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} +
)}
)