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:
parent
cc3d83268f
commit
507cae4e51
@ -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 }) {
|
||||
return (
|
||||
<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
|
||||
// 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' && (
|
||||
<DimForm dimCols={dimCols} setObj={recodeSet} setSet={setRecodeSet}
|
||||
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' && (
|
||||
<DimForm dimCols={dimCols} setObj={cloneSet} setSet={setCloneSet}
|
||||
slices={slices} lookupDerivedCols={lookupDerivedCols} dimMembers={dimMembers} versionId={versionId}
|
||||
extra={
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 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. */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-gray-500 whitespace-nowrap">copy rows from</span>
|
||||
<div className="flex flex-col gap-2 pt-1 border-t border-gray-100 mt-1">
|
||||
<SectionLabel>source</SectionLabel>
|
||||
|
||||
{/* 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. */}
|
||||
<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)}
|
||||
className={`${INPUT} w-auto`}>
|
||||
className={`${TEXT} w-48`}>
|
||||
<option value="">the whole selection</option>
|
||||
{(cloneSources || []).map(s => (
|
||||
<option key={s.id} value={s.id}>
|
||||
@ -804,44 +848,42 @@ export default function OperationPanel({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* 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. */}
|
||||
<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>
|
||||
)}
|
||||
<SectionLabel>changes</SectionLabel>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-500">scale cloned rows by</span>
|
||||
<Field label="shift dates"
|
||||
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}
|
||||
onChange={e => setCloneScale(e.target.value)} className={INPUT} />
|
||||
</div>
|
||||
{!cloneFrom && (
|
||||
onChange={e => setCloneScale(e.target.value)}
|
||||
className={`${INPUT} w-28`} />
|
||||
<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"
|
||||
factor={parseFloat(cloneScale) || 1} />
|
||||
)}
|
||||
factor={parseFloat(cloneScale) || 1}
|
||||
includeExcluded />
|
||||
</div>
|
||||
</div>
|
||||
} />
|
||||
)}
|
||||
@ -852,11 +894,12 @@ export default function OperationPanel({
|
||||
{hasSlice && (
|
||||
<Block horizontal={horizontal}>
|
||||
<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,
|
||||
in the ledger and in the bridge. Completes from initiatives already
|
||||
used on this source; free text is still accepted. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-600 whitespace-nowrap w-9">tag</span>
|
||||
<Field label="tag">
|
||||
<input
|
||||
value={opTag} onChange={e => setOpTag(e.target.value)}
|
||||
list="pf-tag-options" placeholder="initiative, e.g. reduce_spend"
|
||||
@ -865,27 +908,35 @@ export default function OperationPanel({
|
||||
<button onClick={() => setOpTag('')} title="Clear tag"
|
||||
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 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{knownTags.slice(0, 6).map(t => (
|
||||
<button key={t.tag} onClick={() => setOpTag(t.tag)}
|
||||
title={`${t.uses} previous use${t.uses === 1 ? '' : 's'}`}
|
||||
className={`px-2 py-0.5 rounded-full border text-xs whitespace-nowrap ${
|
||||
opTag.trim() === t.tag
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:border-blue-400 hover:text-blue-700'}`}>
|
||||
{t.tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Field label="">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{knownTags.slice(0, 6).map(t => (
|
||||
<button key={t.tag} onClick={() => setOpTag(t.tag)}
|
||||
title={`${t.uses} previous use${t.uses === 1 ? '' : 's'}`}
|
||||
className={`px-2 py-0.5 rounded-full border text-xs whitespace-nowrap ${
|
||||
opTag.trim() === t.tag
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:border-blue-400 hover:text-blue-700'}`}>
|
||||
{t.tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-600 whitespace-nowrap w-9">note</span>
|
||||
<input value={note} onChange={e => setNote(e.target.value)} placeholder="optional" className={TEXT} />
|
||||
<Field label="note">
|
||||
<input value={note} onChange={e => setNote(e.target.value)}
|
||||
placeholder="optional" className={`${TEXT} w-48`} />
|
||||
</Field>
|
||||
|
||||
<div className="pt-1">
|
||||
<Submit onClick={() => submitOp(activeOp)}>{OP_LABEL[activeOp]}</Submit>
|
||||
</div>
|
||||
<Submit onClick={() => submitOp(activeOp)}>{OP_LABEL[activeOp]}</Submit>
|
||||
<RequestPreview payload={buildPayload(activeOp)} />
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user