diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index f616267..4ae9ce6 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -237,15 +237,6 @@ const FIELDS = [ ['pct', '% change'], ] -// Exactly one box per measure is live -- scaleInputs is keyed by measure, so -// focusing another row hands that measure's edit over rather than adding a -// second one. The other two are the same number said differently, so they are -// drawn as output: no border, no ground, muted. They are still editable, and a -// border on hover is what says so. -// -// Both states used to be full input boxes differing by a border colour, which -// made three typed figures out of one typed figure and two calculations. -// // given the active edit for a measure, what do the three rows read? // dp is the measure's own precision, so the editable rows read the same way as // the lines above them -- whole dollars against whole dollars, five places @@ -337,16 +328,11 @@ function LedgerInput({ value, active, onChange, onFocus, suffix }) { onChange(el.value.replace(/,/g, '')) }} onFocus={onFocus} placeholder="—" - title={active ? undefined : 'Derived — click to enter the number this way instead'} className={`border rounded py-0.5 text-xs w-24 text-right font-mono tabular-nums - transition-colors ${suffix ? 'pl-2 pr-5' : 'px-2'} - ${active - ? 'border-blue-400 bg-blue-50/40 text-gray-900 font-medium' - : 'border-transparent bg-transparent text-gray-400 hover:border-gray-300 hover:bg-white'}`} /> + ${active ? 'border-blue-400 bg-blue-50/40 text-gray-800' : 'border-gray-200 bg-white text-gray-700'}`} /> {suffix && ( - {suffix} )} @@ -501,6 +487,30 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se } })() + // The same three readings for a measure nobody typed into, taken from where + // the edit actually lands it. Scaling dollars with price holding moves qty, + // and leaving that column blank until the Result row meant the number you had + // just implied was the one thing not shown next to the one you typed. + const backfill = (current, next, dp) => { + if (next == null || !isFinite(next) || current == null) return { new: '', change: '', pct: '' } + const change = next - current + const pct = current === 0 ? null : (change / Math.abs(current)) * 100 + return { + new: fmtNum(next, dp), + change: fmtNum(change, dp), + pct: pct == null ? '—' : fmtNum(pct, 1), + n: { new: next, change, pct }, + } + } + + // What the rows render. derived stays the edit-driven map -- it is what + // outcome is computed from, so back-filling into it would feed the result + // back into its own inputs. + const shown = Object.fromEntries(measures.map(m => [ + m.key, + (filled(m.key) || !outcome) ? derived[m.key] : backfill(basisOf(m.key), outcome[m.key], m.dp), + ])) + const setEdit = (key, field, raw) => setScaleInputs(prev => ({ ...prev, [key]: { field, raw } })) @@ -510,9 +520,12 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se setScaleInputs(prev => { const cur = prev[key] if (cur && cur.field === field) return prev - const shown = derived[key]?.[field] ?? '' - const raw = shown === '—' ? '' : String(shown).replace(/,/g, '') - return { ...prev, [key]: { field, raw: cur ? raw : '' } } + // Whatever the box reads is what you get, including a back-filled + // number: clicking into an implied figure is how you pin it, which is + // the difference between "units may move" and "units are this". + const text = shown[key]?.[field] ?? '' + const raw = text === '—' ? '' : String(text).replace(/,/g, '') + return { ...prev, [key]: { field, raw } } }) // A running total across the walk, in the primary measure. The lines sum to @@ -661,7 +674,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se return ( setEdit(m.key, field, raw)} onFocus={() => focusRow(m.key, field)}