Lay the adjustment panel out on a grid, and count what a clone will copy

The clone form was four controls whose labels ran to different lengths --
"copy rows from", "scale cloned rows by", "tag", "note" -- each row starting
wherever its label happened to end, with a preview sentence floating after
them and a row of unexplained chips between the tag and the note. Nothing
separated where the rows come from, what happens to them, and what the
change is called.

Every row now shares a label column, so the controls line up, and the three
groups carry headers in the same 10px uppercase the ledger headers already
use. The tag suggestions sit inside the tag's own column, where they read as
values for it. Hints appear under the control they qualify and only when
relevant.

Also: the preview said "Copying 0.00 sales_usd" while five AOP rows worth
68,904 were selected. It reported the adjustable total, which is zero when
the selection is entirely prior year or plan -- the case clone exists for,
now that it can read reference rows. It counts them for clone, and still
does not for recode, which cannot touch them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-17 02:46:05 -04:00
parent cc3d83268f
commit 507cae4e51

View File

@ -61,6 +61,34 @@ function Button({ onClick, active, children, title }) {
) )
} }
// 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 }) { function Segmented({ options, value, onChange }) {
return ( return (
<div className="inline-flex rounded border border-gray-200 overflow-hidden w-auto self-start"> <div className="inline-flex rounded border border-gray-200 overflow-hidden w-auto self-start">
@ -639,9 +667,16 @@ function DimForm({ dimCols, setObj, setSet, slices, lookupDerivedCols, dimMember
// Recode and clone change dimensions rather than amounts, but you still want to // 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. // see how much is on the move and for clone, what it becomes after scaling.
function MovingTotal({ currentTotals, verb, factor }) { // includeExcluded: clone reads reference rows too, so its preview has to count
const t = currentTotals?.total // them. Reporting the adjustable total alone said "Copying 0.00" for a selection
if (!t) return null // 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 { valueCol, unitsCol } = currentTotals
const scaled = factor != null && factor !== 1 const scaled = factor != null && factor !== 1
return ( return (
@ -725,6 +760,7 @@ export default function OperationPanel({
const horizontal = dock === 'bottom' const horizontal = dock === 'bottom'
const note = activeOp === 'scale' ? scaleNote : activeOp === 'recode' ? recodeNote : cloneNote 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 setNote = activeOp === 'scale' ? setScaleNote : activeOp === 'recode' ? setRecodeNote : setCloneNote
const OP_LABEL = { scale: 'Apply Scale', recode: 'Apply Recode', clone: 'Apply Clone' } const OP_LABEL = { scale: 'Apply Scale', recode: 'Apply Recode', clone: 'Apply Clone' }
@ -779,24 +815,32 @@ export default function OperationPanel({
{activeOp === 'recode' && ( {activeOp === 'recode' && (
<DimForm dimCols={dimCols} setObj={recodeSet} setSet={setRecodeSet} <DimForm dimCols={dimCols} setObj={recodeSet} setSet={setRecodeSet}
slices={slices} lookupDerivedCols={lookupDerivedCols} dimMembers={dimMembers} versionId={versionId} slices={slices} lookupDerivedCols={lookupDerivedCols} dimMembers={dimMembers} versionId={versionId}
extra={<MovingTotal currentTotals={currentTotals} verb="Moving" />} /> extra={
<div className="pt-1 border-t border-gray-100 mt-1">
<MovingTotal currentTotals={currentTotals} verb="Moving" />
</div>
} />
)} )}
{activeOp === 'clone' && ( {activeOp === 'clone' && (
<DimForm dimCols={dimCols} setObj={cloneSet} setSet={setCloneSet} <DimForm dimCols={dimCols} setObj={cloneSet} setSet={setCloneSet}
slices={slices} lookupDerivedCols={lookupDerivedCols} dimMembers={dimMembers} versionId={versionId} slices={slices} lookupDerivedCols={lookupDerivedCols} dimMembers={dimMembers} versionId={versionId}
extra={ extra={
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2 pt-1 border-t border-gray-100 mt-1">
{/* The selection is the SOURCE, not the destination: pick <SectionLabel>source</SectionLabel>
the cells you want to copy -- last December, say -- and
the shift below is what lands them in the target period. {/* The selection is the SOURCE, not the destination: pick the
Naming a segment narrows that selection to one entry, cells you want to copy -- last December, say -- and the
including the reference ones operations are normally kept shift is what lands them in the target period. Naming a
away from, which is the point: a period with no baseline segment narrows that selection to one entry, including the
borrows its shape from prior year or plan. */} reference ones operations are normally kept away from,
<div className="flex items-center gap-2 flex-wrap"> which is the point: a period with no baseline borrows its
<span className="text-gray-500 whitespace-nowrap">copy rows from</span> 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)} <select value={cloneFrom} onChange={e => setCloneFrom(e.target.value)}
className={`${INPUT} w-auto`}> className={`${TEXT} w-48`}>
<option value="">the whole selection</option> <option value="">the whole selection</option>
{(cloneSources || []).map(s => ( {(cloneSources || []).map(s => (
<option key={s.id} value={s.id}> <option key={s.id} value={s.id}>
@ -804,44 +848,42 @@ export default function OperationPanel({
</option> </option>
))} ))}
</select> </select>
</div> </Field>
{/* Always shown. This was gated on picking a segment, from <SectionLabel>changes</SectionLabel>
when naming one was the only way to reach reference rows
-- once clone could read them directly, the offset became
unreachable in the ordinary case. */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-gray-500 whitespace-nowrap">shift dates by</span>
<input value={cloneOffset} list="pf-clone-offsets"
onChange={e => setCloneOffset(e.target.value)}
placeholder="0 days" className={`${INPUT} w-28`} />
<datalist id="pf-clone-offsets">
<option value="12 months" />
<option value="24 months" />
<option value="-90 days" />
<option value="-12 months" />
<option value="0 days" />
</datalist>
</div>
{cloneOffset.trim() && cloneOffset.trim() !== '0 days' && (
<p className="text-gray-500 text-[11px] leading-snug max-w-sm">
The cells you have selected are what gets copied. Shifting
moves every date column, and the season and month
dimensions are re-derived from the calendar to match so
select the period you are copying <em>from</em>, not the
one you are filling.
</p>
)}
<div className="flex items-center gap-2"> <Field label="shift dates"
<span className="text-gray-500">scale cloned rows by</span> 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} <input type="number" step="any" value={cloneScale}
onChange={e => setCloneScale(e.target.value)} className={INPUT} /> onChange={e => setCloneScale(e.target.value)}
</div> className={`${INPUT} w-28`} />
{!cloneFrom && ( <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" <MovingTotal currentTotals={currentTotals} verb="Copying"
factor={parseFloat(cloneScale) || 1} /> factor={parseFloat(cloneScale) || 1}
)} includeExcluded />
</div>
</div> </div>
} /> } />
)} )}
@ -852,11 +894,12 @@ export default function OperationPanel({
{hasSlice && ( {hasSlice && (
<Block horizontal={horizontal}> <Block horizontal={horizontal}>
<div className="flex flex-col gap-2.5 min-w-0"> <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, {/* Tag first: it is the field that gives an adjustment meaning later,
in the ledger and in the bridge. Completes from initiatives already in the ledger and in the bridge. Completes from initiatives already
used on this source; free text is still accepted. */} used on this source; free text is still accepted. */}
<div className="flex items-center gap-2"> <Field label="tag">
<span className="text-gray-600 whitespace-nowrap w-9">tag</span>
<input <input
value={opTag} onChange={e => setOpTag(e.target.value)} value={opTag} onChange={e => setOpTag(e.target.value)}
list="pf-tag-options" placeholder="initiative, e.g. reduce_spend" list="pf-tag-options" placeholder="initiative, e.g. reduce_spend"
@ -865,27 +908,35 @@ export default function OperationPanel({
<button onClick={() => setOpTag('')} title="Clear tag" <button onClick={() => setOpTag('')} title="Clear tag"
className="text-gray-500 hover:text-red-500 leading-none px-1">×</button> className="text-gray-500 hover:text-red-500 leading-none px-1">×</button>
)} )}
</div> </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 && ( {knownTags.length > 0 && (
<div className="flex items-center gap-1 flex-wrap"> <Field label="">
{knownTags.slice(0, 6).map(t => ( <div className="flex items-center gap-1 flex-wrap">
<button key={t.tag} onClick={() => setOpTag(t.tag)} {knownTags.slice(0, 6).map(t => (
title={`${t.uses} previous use${t.uses === 1 ? '' : 's'}`} <button key={t.tag} onClick={() => setOpTag(t.tag)}
className={`px-2 py-0.5 rounded-full border text-xs whitespace-nowrap ${ title={`${t.uses} previous use${t.uses === 1 ? '' : 's'}`}
opTag.trim() === t.tag className={`px-2 py-0.5 rounded-full border text-xs whitespace-nowrap ${
? 'bg-blue-600 border-blue-600 text-white' opTag.trim() === t.tag
: 'bg-white border-gray-300 text-gray-700 hover:border-blue-400 hover:text-blue-700'}`}> ? 'bg-blue-600 border-blue-600 text-white'
{t.tag} : 'bg-white border-gray-300 text-gray-700 hover:border-blue-400 hover:text-blue-700'}`}>
</button> {t.tag}
))} </button>
</div> ))}
</div>
</Field>
)} )}
<div className="flex items-center gap-2"> <Field label="note">
<span className="text-gray-600 whitespace-nowrap w-9">note</span> <input value={note} onChange={e => setNote(e.target.value)}
<input value={note} onChange={e => setNote(e.target.value)} placeholder="optional" className={TEXT} /> placeholder="optional" className={`${TEXT} w-48`} />
</Field>
<div className="pt-1">
<Submit onClick={() => submitOp(activeOp)}>{OP_LABEL[activeOp]}</Submit>
</div> </div>
<Submit onClick={() => submitOp(activeOp)}>{OP_LABEL[activeOp]}</Submit>
<RequestPreview payload={buildPayload(activeOp)} /> <RequestPreview payload={buildPayload(activeOp)} />
</div> </div>
</Block> </Block>