From 507cae4e51c369f4132271ad3c0bbe80a9a0cb67 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Thu, 17 Sep 2026 02:46:05 -0400 Subject: [PATCH] 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) --- ui/src/components/OperationPanel.jsx | 187 +++++++++++++++++---------- 1 file changed, 119 insertions(+), 68 deletions(-) diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index b0f8c56..566ff1a 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -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 ( +
+ {label} +
+
{children}
+ {hint &&

{hint}

} +
+
+ ) +} + +// 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 ( +
+ {children} +
+ ) +} + function Segmented({ options, value, onChange }) { return (
@@ -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 // see how much is on the move — and for clone, what it becomes after scaling. -function MovingTotal({ currentTotals, verb, factor }) { - const t = currentTotals?.total - if (!t) return null +// 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 ( @@ -725,6 +760,7 @@ export default function OperationPanel({ 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' } @@ -779,24 +815,32 @@ export default function OperationPanel({ {activeOp === 'recode' && ( } /> + extra={ +
+ +
+ } /> )} {activeOp === 'clone' && ( - {/* The selection is the SOURCE, not the destination: pick - the cells you want to copy -- last December, say -- and - the shift below is what lands them in the target period. - Naming a segment narrows that selection to one entry, - including the reference ones operations are normally kept - away from, which is the point: a period with no baseline - borrows its shape from prior year or plan. */} -
- copy rows from +
+ source + + {/* 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. */} + -
+ - {/* Always shown. This was gated on picking a segment, from - when naming one was the only way to reach reference rows - -- once clone could read them directly, the offset became - unreachable in the ordinary case. */} -
- shift dates by - setCloneOffset(e.target.value)} - placeholder="0 days" className={`${INPUT} w-28`} /> - - -
- {cloneOffset.trim() && cloneOffset.trim() !== '0 days' && ( -

- 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 from, not the - one you are filling. -

- )} + changes -
- scale cloned rows by + + setCloneOffset(e.target.value)} + placeholder="0 days" className={`${TEXT} w-28`} /> + + + + + setCloneScale(e.target.value)} className={INPUT} /> -
- {!cloneFrom && ( + onChange={e => setCloneScale(e.target.value)} + className={`${INPUT} w-28`} /> + × + + + {/* 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. */} +
- )} + factor={parseFloat(cloneScale) || 1} + includeExcluded /> +
} /> )} @@ -852,11 +894,12 @@ export default function OperationPanel({ {hasSlice && (
+ label this change + {/* 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. */} -
- tag + setOpTag(e.target.value)} list="pf-tag-options" placeholder="initiative, e.g. reduce_spend" @@ -865,27 +908,35 @@ export default function OperationPanel({ )} -
+ + + {/* 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.slice(0, 6).map(t => ( - - ))} -
+ +
+ {knownTags.slice(0, 6).map(t => ( + + ))} +
+
)} -
- note - setNote(e.target.value)} placeholder="optional" className={TEXT} /> + + setNote(e.target.value)} + placeholder="optional" className={`${TEXT} w-48`} /> + + +
+ submitOp(activeOp)}>{OP_LABEL[activeOp]}
- submitOp(activeOp)}>{OP_LABEL[activeOp]}