From d01dd2dbc805d06c903cf271cc63db2e9eaf501e Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 23 Sep 2026 21:55:34 -0400 Subject: [PATCH] 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 --- ui/src/components/BridgeView.jsx | 13 +- ui/src/components/OperationPanel.jsx | 490 +++++++++++++++++---------- 2 files changed, 313 insertions(+), 190 deletions(-) diff --git a/ui/src/components/BridgeView.jsx b/ui/src/components/BridgeView.jsx index c8d5c26..acb228d 100644 --- a/ui/src/components/BridgeView.jsx +++ b/ui/src/components/BridgeView.jsx @@ -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({ · {scope === 'selection' ? `${slices.length} selected slice${slices.length === 1 ? '' : 's'}` - : scope === 'filtered' ? "pivot's filters" : 'whole version'} + : "pivot's filters"} @@ -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]) => ( + ) +} + function Button({ onClick, active, children, title, disabled }) { return ( +
Clear selection
) } @@ -214,9 +282,9 @@ function IterBreakdown({ currentTotals }) { const { valueCol, unitsCol } = currentTotals return (
- + {open && ( @@ -785,6 +853,12 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se return (
+ {/* 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. */} +
@@ -839,8 +913,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se {showAllLines && adjLines.length > MAX_ADJ_LINES + 1 && ( )} @@ -875,7 +948,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se {measures.map(m => ( @@ -886,7 +959,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se {hasExcl && ( - + {measures.map(m => (
- + setShowAllLines(false)}>▾ collapse the smaller changes
- {hasExcl ? 'Adjustable' : 'Current'}{perSlice ? ' (all)' : ''} + {hasExcl ? 'Forecast' : 'Current'}{perSlice ? ' (all)' : ''} {fmtNum(m.current, m.dp)}
Selected totalTotal selected {m.key === 'price' @@ -957,6 +1030,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se )}
+
{/* 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 && ( - + )} )} @@ -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. */} @@ -1080,6 +1158,7 @@ function DimForm({ dimCols, setObj, setSet, slices, lookupDerivedCols, dimMember const first = slices[0] || {} return (
+
@@ -1115,6 +1194,7 @@ function DimForm({ dimCols, setObj, setSet, slices, lookupDerivedCols, dimMember })}
+
{extra}
) @@ -1178,9 +1258,9 @@ function RequestPreview({ payload }) { if (!payload) return null return (
- + {open && (
           {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' && (
+        
+      )}
+      {activeOp === 'recode' && (
+        
+                     
+                   
+ } /> + )} + {activeOp === 'clone' && ( + + 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. */} + + + + + changes + + + setCloneOffset(e.target.value)} + placeholder="0 days" className={`${TEXT} w-28`} /> + + + + + + 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. */} +
+ +
+ + } /> + )} + + ) + + const labelZone = ( +
+ {/* 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. */} + + + {opTag.trim() && ( + + )} + + + {/* 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. */} + + + setNote(e.target.value)} + placeholder="optional" className={`${TEXT} w-full`} /> + + + +
+ ) return ( -
+
- - {/* 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 && ( -

- {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. -

+ {/* ── 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. */} +
+
+ {['scale', 'recode', 'clone'].map(op => ( + + ))} +
+ + {hasSlice && multi && ( + )} - setSlices(prev => prev.filter((_, x) => x !== i))} - onClear={() => setSlices([])} - /> - - {hasSlice && ( - -
-
-
- {['scale', 'recode', 'clone'].map(op => ( - - ))} -
- {multi && ( - - )} -
+ {hasSlice && ( + setPeek(o => !o)} /> + )} - {activeOp === 'scale' && ( - - )} - {activeOp === 'recode' && ( - - -
- } /> - )} - {activeOp === 'clone' && ( - - 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. */} - - - - - changes - - - setCloneOffset(e.target.value)} - placeholder="0 days" className={`${TEXT} w-28`} /> - - - - - - 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. */} -
- -
-
- } /> + {hasSlice && ( +
+ {opBusy && ( + + Writing rows — the pivot updates when it finishes. + )} + submitOp(activeOp)} busy={!!opBusy}> + {opBusy ? POSTING : POST} +
-
+ )} +
+ + {peek && hasSlice && ( +
+ setSlices(prev => prev.filter((_, x) => x !== i))} + onClear={() => setSlices([])} + /> +
)} + {!hasSlice && ( +

+ Click a pivot row to select a slice.
+ Ctrl/⌘-click to add more. +

+ )} + + {/* ── 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 && ( - -
- 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. */} - - - {opTag.trim() && ( - - )} - - - {/* 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. */} - - - setNote(e.target.value)} - placeholder="optional" className={`${TEXT} w-48`} /> - - -
- submitOp(activeOp)} busy={!!opBusy}> - {opBusy ? OP_BUSY_LABEL[opBusy] : OP_LABEL[activeOp]} - - {opBusy && ( - - Writing rows — the pivot updates when it finishes. - - )} -
- -
-
+
+ {ledger} + {labelZone} +
)}
)