+ {/* 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 && (
- setShowAllLines(false)}
- className="text-gray-400 hover:text-gray-600">▾ collapse the smaller changes
+ setShowAllLines(false)}>▾ collapse the smaller changes
)}
@@ -875,7 +948,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
- {hasExcl ? 'Adjustable' : 'Current'}{perSlice ? ' (all)' : ''}
+ {hasExcl ? 'Forecast' : 'Current'}{perSlice ? ' (all)' : ''}
{measures.map(m => (
{fmtNum(m.current, m.dp)}
@@ -886,7 +959,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
{hasExcl && (
- Selected total
+ Total selected
{measures.map(m => (
{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 && (
-
clearEdit(plugState.unpin)}
- title={`Stop pinning ${plugState.unpin === 'units' ? unitsCol : 'price'} and choose here instead`}
- className="text-blue-600 hover:underline">
+ clearEdit(plugState.unpin)}
+ title={`Stop pinning ${plugState.unpin === 'units' ? unitsCol : 'price'} and choose here instead`}>
unpin {plugState.unpin === 'units' ? unitsCol : 'price'}
-
+
)}
)}
@@ -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 (
-
setOpen(o => !o)} className="text-gray-600 hover:text-gray-600">
+ setOpen(o => !o)}>
{open ? '▾' : '▸'} request
-
+
{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. */}
+
+ setCloneFrom(e.target.value)}
+ className={`${TEXT} w-48`}>
+ the whole selection
+ {(cloneSources || []).map(s => (
+
+ {s.label || `${s.operation} #${s.id}`}
+
+ ))}
+
+
+
+ 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() && (
+ setOpTag('')} title="Clear tag"
+ className="text-gray-500 hover:text-red-500 leading-none px-1">×
+ )}
+
+
+ {/* 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 => (
+ setActiveOp(op)} active={activeOp === op}>
+ {op}
+
+ ))}
+
+
+ {hasSlice && multi && (
+
)}
-
setSlices(prev => prev.filter((_, x) => x !== i))}
- onClear={() => setSlices([])}
- />
-
- {hasSlice && (
-
-
-
-
- {['scale', 'recode', 'clone'].map(op => (
- setActiveOp(op)} active={activeOp === op}>
- {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. */}
-
- setCloneFrom(e.target.value)}
- className={`${TEXT} w-48`}>
- the whole selection
- {(cloneSources || []).map(s => (
-
- {s.label || `${s.operation} #${s.id}`}
-
- ))}
-
-
-
- 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() && (
- setOpTag('')} title="Clear tag"
- className="text-gray-500 hover:text-red-500 leading-none px-1">×
- )}
-
-
- {/* 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}
+
)}
)