Name every section, and give the buttons some weight
- Button labels are capitalised and font-medium, the segmented groups carry a border and a shadow of their own. An inactive segment used to be white text-on-white inside a group border, which is why none of them read as something you could press. - Each section says what it is: the operation strip, LEDGER (or RECODE TO / CLONE TO, which is what the form under those two operations is), SETTINGS over the toggles, LABEL THIS CHANGE. - Together/Each moves down beside Price/Volume and Forecast/Total, in a wrapping row. Three one-line settings stacked cost three rows of a dock that is 260px tall to begin with, and they are read together anyway: what absorbs the change, what it is measured against, and whether it applies once or per slice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4bce3bbd17
commit
91b88cd809
@ -78,10 +78,10 @@ function TextButton({ onClick, children, title, danger }) {
|
|||||||
function Button({ onClick, active, children, title, disabled }) {
|
function Button({ onClick, active, children, title, disabled }) {
|
||||||
return (
|
return (
|
||||||
<button onClick={onClick} title={title} disabled={disabled}
|
<button onClick={onClick} title={title} disabled={disabled}
|
||||||
className={`px-3 py-1 rounded text-xs whitespace-nowrap transition-colors ${
|
className={`px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
|
||||||
disabled
|
disabled
|
||||||
? (active ? 'bg-gray-300 text-white cursor-default' : 'bg-white text-gray-400 cursor-default')
|
? (active ? 'bg-gray-300 text-white cursor-default' : 'bg-white text-gray-400 cursor-default')
|
||||||
: (active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50')}`}>
|
: (active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-100')}`}>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@ -119,9 +119,9 @@ function Field({ label, children, hint, stack }) {
|
|||||||
|
|
||||||
// Same 10px uppercase as the ledger table headers, so the groupings read as part
|
// Same 10px uppercase as the ledger table headers, so the groupings read as part
|
||||||
// of the same family rather than as a second style.
|
// of the same family rather than as a second style.
|
||||||
function SectionLabel({ children }) {
|
function SectionLabel({ children, className = '' }) {
|
||||||
return (
|
return (
|
||||||
<div className="text-gray-400 uppercase tracking-wide pt-1" style={{ fontSize: '10px' }}>
|
<div className={`text-gray-500 uppercase tracking-wide pt-1 ${className}`} style={{ fontSize: '10px' }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -132,8 +132,8 @@ function SectionLabel({ children }) {
|
|||||||
// says the choice has been made elsewhere.
|
// says the choice has been made elsewhere.
|
||||||
function Segmented({ options, value, onChange, disabled }) {
|
function Segmented({ options, value, onChange, disabled }) {
|
||||||
return (
|
return (
|
||||||
<div className={`inline-flex rounded border divide-x divide-gray-200 overflow-hidden w-auto self-start ${
|
<div className={`inline-flex rounded border divide-x shadow-sm overflow-hidden w-auto self-start ${
|
||||||
disabled ? 'border-gray-200 bg-gray-50' : 'border-gray-200'}`}>
|
disabled ? 'border-gray-200 divide-gray-200 bg-gray-50' : 'border-gray-300 divide-gray-300'}`}>
|
||||||
{options.map(([val, label, title]) => (
|
{options.map(([val, label, title]) => (
|
||||||
<Button key={val} onClick={() => onChange(val)} active={value === val}
|
<Button key={val} onClick={() => onChange(val)} active={value === val}
|
||||||
title={title} disabled={disabled}>{label}</Button>
|
title={title} disabled={disabled}>{label}</Button>
|
||||||
@ -145,7 +145,7 @@ function Segmented({ options, value, onChange, disabled }) {
|
|||||||
function Submit({ onClick, children, disabled, busy }) {
|
function Submit({ onClick, children, disabled, busy }) {
|
||||||
return (
|
return (
|
||||||
<button onClick={onClick} disabled={disabled || busy}
|
<button onClick={onClick} disabled={disabled || busy}
|
||||||
className="self-start px-4 py-1.5 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700
|
className="self-start px-4 py-1.5 rounded text-xs font-semibold shadow-sm bg-blue-600 text-white hover:bg-blue-700
|
||||||
disabled:bg-gray-200 disabled:text-gray-600 disabled:cursor-not-allowed whitespace-nowrap
|
disabled:bg-gray-200 disabled:text-gray-600 disabled:cursor-not-allowed whitespace-nowrap
|
||||||
inline-flex items-center gap-2">
|
inline-flex items-center gap-2">
|
||||||
{busy && (
|
{busy && (
|
||||||
@ -254,7 +254,7 @@ function IterBreakdown({ currentTotals }) {
|
|||||||
return (
|
return (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<TextButton onClick={() => setOpen(o => !o)}>
|
<TextButton onClick={() => setOpen(o => !o)}>
|
||||||
{open ? '▾' : '▸'} breakdown by iter
|
{open ? '▾' : '▸'} Breakdown by iter
|
||||||
</TextButton>
|
</TextButton>
|
||||||
{open && (
|
{open && (
|
||||||
<table className="mt-1 text-gray-500">
|
<table className="mt-1 text-gray-500">
|
||||||
@ -496,7 +496,7 @@ function TagInput({ value, onChange, tags = [], className }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode }) {
|
function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode, settingsExtra }) {
|
||||||
const valueCol = currentTotals?.valueCol
|
const valueCol = currentTotals?.valueCol
|
||||||
const unitsCol = currentTotals?.unitsCol
|
const unitsCol = currentTotals?.unitsCol
|
||||||
const total = currentTotals?.total || { value: 0, units: 0 }
|
const total = currentTotals?.total || { value: 0, units: 0 }
|
||||||
@ -884,7 +884,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
|||||||
{showAllLines && adjLines.length > MAX_ADJ_LINES + 1 && (
|
{showAllLines && adjLines.length > MAX_ADJ_LINES + 1 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={measures.length + 1 + (showRunning ? 1 : 0)} className="pr-3">
|
<td colSpan={measures.length + 1 + (showRunning ? 1 : 0)} className="pr-3">
|
||||||
<TextButton onClick={() => setShowAllLines(false)}>▾ collapse the smaller changes</TextButton>
|
<TextButton onClick={() => setShowAllLines(false)}>▾ Collapse the smaller changes</TextButton>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@ -1013,11 +1013,17 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
|||||||
They used to hand-roll their own label row and hint, with two
|
They used to hand-roll their own label row and hint, with two
|
||||||
different type sizes and no shared label width, so the two stacked
|
different type sizes and no shared label width, so the two stacked
|
||||||
into a paragraph of loose words rather than reading as two settings. */}
|
into a paragraph of loose words rather than reading as two settings. */}
|
||||||
{(plugState || hasExcl || perSlice) && (
|
{(plugState || hasExcl || perSlice || settingsExtra) && (
|
||||||
<div className="flex flex-col gap-2 mt-3 pt-3 border-t border-gray-100">
|
// Side by side, not stacked: three one-line settings in a column cost
|
||||||
|
// three rows of a dock that is 260px tall to begin with, and they are
|
||||||
|
// read together anyway -- what absorbs the change, what it is measured
|
||||||
|
// against, and whether it applies once or per slice.
|
||||||
|
<div className="flex flex-wrap items-start gap-x-8 gap-y-2 mt-3 pt-3 border-t border-gray-200">
|
||||||
|
<SectionLabel className="w-full">settings</SectionLabel>
|
||||||
|
{settingsExtra}
|
||||||
|
|
||||||
{plugState && (
|
{plugState && (
|
||||||
<Field label="absorbs" hint={plugState.why}>
|
<Field label="Absorbs" hint={plugState.why}>
|
||||||
<Segmented
|
<Segmented
|
||||||
value={plugState.value}
|
value={plugState.value}
|
||||||
onChange={setScalePlug}
|
onChange={setScalePlug}
|
||||||
@ -1037,7 +1043,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasExcl && (
|
{hasExcl && (
|
||||||
<Field label="target"
|
<Field label="Target"
|
||||||
hint={onTotal
|
hint={onTotal
|
||||||
? `${exclName} can't move — the forecast absorbs it`
|
? `${exclName} can't move — the forecast absorbs it`
|
||||||
: `${exclName} left out — the pivot will show your number plus ${fmtNum(excl.value)}`}>
|
: `${exclName} left out — the pivot will show your number plus ${fmtNum(excl.value)}`}>
|
||||||
@ -1058,7 +1064,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{perSlice && (
|
{perSlice && (
|
||||||
<Field label="per slice">
|
<Field label="Per slice">
|
||||||
<span className="text-gray-500">figures above are combined totals</span>
|
<span className="text-gray-500">figures above are combined totals</span>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@ -1272,7 +1278,7 @@ function RequestPreview({ payload, op, versionId }) {
|
|||||||
<span className="font-medium text-gray-700 text-sm">Request</span>
|
<span className="font-medium text-gray-700 text-sm">Request</span>
|
||||||
<span className="font-mono text-gray-600 text-xs truncate" title={line}>{line}</span>
|
<span className="font-mono text-gray-600 text-xs truncate" title={line}>{line}</span>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<TextButton onClick={copy}>{copied ? 'copied' : 'copy'}</TextButton>
|
<TextButton onClick={copy}>{copied ? 'Copied' : 'Copy'}</TextButton>
|
||||||
<button onClick={() => setOpen(false)}
|
<button onClick={() => setOpen(false)}
|
||||||
className="text-gray-600 hover:text-gray-800 text-lg leading-none">×</button>
|
className="text-gray-600 hover:text-gray-800 text-lg leading-none">×</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1332,6 +1338,15 @@ export default function OperationPanel({
|
|||||||
// operation is named by the tabs beside it.
|
// operation is named by the tabs beside it.
|
||||||
const POST = 'Post', POSTING = 'Posting…'
|
const POST = 'Post', POSTING = 'Posting…'
|
||||||
|
|
||||||
|
// Sits with the ledger's own settings rather than under the operation tabs:
|
||||||
|
// it is one line, they are one line, and stacked they cost three rows of a
|
||||||
|
// dock that is 260px tall to begin with.
|
||||||
|
const applyField = multi ? (
|
||||||
|
<Field label="Apply">
|
||||||
|
<ApplyModeChooser op={activeOp} applyMode={applyMode} setApplyMode={setApplyMode} count={slices.length} />
|
||||||
|
</Field>
|
||||||
|
) : null
|
||||||
|
|
||||||
const ledger = (
|
const ledger = (
|
||||||
<>
|
<>
|
||||||
{activeOp === 'scale' && (
|
{activeOp === 'scale' && (
|
||||||
@ -1342,6 +1357,7 @@ export default function OperationPanel({
|
|||||||
targetBasis={targetBasis} setTargetBasis={setTargetBasis}
|
targetBasis={targetBasis} setTargetBasis={setTargetBasis}
|
||||||
logMeta={logMeta}
|
logMeta={logMeta}
|
||||||
multi={multi} applyMode={applyMode}
|
multi={multi} applyMode={applyMode}
|
||||||
|
settingsExtra={applyField}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeOp === 'recode' && (
|
{activeOp === 'recode' && (
|
||||||
@ -1367,7 +1383,7 @@ export default function OperationPanel({
|
|||||||
reference ones operations are normally kept away from,
|
reference ones operations are normally kept away from,
|
||||||
which is the point: a period with no baseline borrows its
|
which is the point: a period with no baseline borrows its
|
||||||
shape from prior year or plan. */}
|
shape from prior year or plan. */}
|
||||||
<Field label="rows from"
|
<Field label="Rows from"
|
||||||
hint={cloneFrom ? 'only that segment, out of the selection' : undefined}>
|
hint={cloneFrom ? 'only that segment, out of the selection' : undefined}>
|
||||||
<select value={cloneFrom} onChange={e => setCloneFrom(e.target.value)}
|
<select value={cloneFrom} onChange={e => setCloneFrom(e.target.value)}
|
||||||
className={`${TEXT} w-48`}>
|
className={`${TEXT} w-48`}>
|
||||||
@ -1382,7 +1398,7 @@ export default function OperationPanel({
|
|||||||
|
|
||||||
<SectionLabel>changes</SectionLabel>
|
<SectionLabel>changes</SectionLabel>
|
||||||
|
|
||||||
<Field label="shift dates"
|
<Field label="Shift dates"
|
||||||
hint={shifting
|
hint={shifting
|
||||||
? 'every date moves — select the period you copy FROM'
|
? 'every date moves — select the period you copy FROM'
|
||||||
: undefined}>
|
: undefined}>
|
||||||
@ -1398,7 +1414,7 @@ export default function OperationPanel({
|
|||||||
</datalist>
|
</datalist>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="scale by">
|
<Field label="Scale by">
|
||||||
<input type="number" step="any" value={cloneScale}
|
<input type="number" step="any" value={cloneScale}
|
||||||
onChange={e => setCloneScale(e.target.value)}
|
onChange={e => setCloneScale(e.target.value)}
|
||||||
className={`${INPUT} w-28`} />
|
className={`${INPUT} w-28`} />
|
||||||
@ -1417,6 +1433,12 @@ export default function OperationPanel({
|
|||||||
</div>
|
</div>
|
||||||
} />
|
} />
|
||||||
)}
|
)}
|
||||||
|
{activeOp !== 'scale' && applyField && (
|
||||||
|
<div className="flex flex-wrap items-start gap-x-8 gap-y-2 mt-3 pt-3 border-t border-gray-200">
|
||||||
|
<SectionLabel className="w-full">settings</SectionLabel>
|
||||||
|
{applyField}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1425,7 +1447,7 @@ export default function OperationPanel({
|
|||||||
{/* Tag first: it is the field that gives an adjustment meaning later,
|
{/* Tag first: it is the field that gives an adjustment meaning later,
|
||||||
in the ledger and in the bridge. Completes from initiatives already
|
in the ledger and in the bridge. Completes from initiatives already
|
||||||
used on this source; free text is still accepted. */}
|
used on this source; free text is still accepted. */}
|
||||||
<Field label="tag" stack>
|
<Field label="Tag" stack>
|
||||||
<TagInput value={opTag} onChange={setOpTag} tags={knownTags}
|
<TagInput value={opTag} onChange={setOpTag} tags={knownTags}
|
||||||
className={`${TEXT} w-full`} />
|
className={`${TEXT} w-full`} />
|
||||||
{opTag.trim() && (
|
{opTag.trim() && (
|
||||||
@ -1441,7 +1463,7 @@ export default function OperationPanel({
|
|||||||
border. The same tags complete from the input's datalist, which
|
border. The same tags complete from the input's datalist, which
|
||||||
costs no space and is not capped at six. */}
|
costs no space and is not capped at six. */}
|
||||||
|
|
||||||
<Field label="note" stack>
|
<Field label="Note" stack>
|
||||||
<input value={note} onChange={e => setNote(e.target.value)}
|
<input value={note} onChange={e => setNote(e.target.value)}
|
||||||
placeholder="optional" className={`${TEXT} w-full`} />
|
placeholder="optional" className={`${TEXT} w-full`} />
|
||||||
</Field>
|
</Field>
|
||||||
@ -1472,10 +1494,9 @@ export default function OperationPanel({
|
|||||||
The operation used to be a row of tabs buried in the middle column
|
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
|
with no heading over it, so the panel never said what it was about
|
||||||
to do until you read the button. */}
|
to do until you read the button. */}
|
||||||
<div className="flex flex-col gap-2 px-3 py-2 border-b border-gray-200 bg-gray-50/60">
|
<div className="flex items-start gap-4 flex-wrap px-3 py-2 border-b border-gray-200 bg-gray-50/60">
|
||||||
<div className="flex items-start gap-4 flex-wrap">
|
<Field label="Change">
|
||||||
<Field label="change">
|
<div className="inline-flex rounded border border-gray-300 divide-x divide-gray-300 shadow-sm overflow-hidden">
|
||||||
<div className="inline-flex rounded border border-gray-200 divide-x divide-gray-200 overflow-hidden">
|
|
||||||
{['scale', 'recode', 'clone'].map(op => (
|
{['scale', 'recode', 'clone'].map(op => (
|
||||||
<Button key={op} onClick={() => setActiveOp(op)} active={activeOp === op}>
|
<Button key={op} onClick={() => setActiveOp(op)} active={activeOp === op}>
|
||||||
<span className="capitalize">{op}</span>
|
<span className="capitalize">{op}</span>
|
||||||
@ -1493,16 +1514,6 @@ export default function OperationPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Under the operation it qualifies, on the same label column as every
|
|
||||||
other button row -- it used to sit loose in the middle of the strip,
|
|
||||||
where it read as a peer of the operation rather than a setting on it. */}
|
|
||||||
{hasSlice && multi && (
|
|
||||||
<Field label="apply">
|
|
||||||
<ApplyModeChooser op={activeOp} applyMode={applyMode} setApplyMode={setApplyMode} count={slices.length} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{peek && hasSlice && (
|
{peek && hasSlice && (
|
||||||
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50/60">
|
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50/60">
|
||||||
<SelectionList
|
<SelectionList
|
||||||
@ -1535,7 +1546,8 @@ export default function OperationPanel({
|
|||||||
style={horizontal
|
style={horizontal
|
||||||
? { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(15rem, 20rem)', alignItems: 'start' }
|
? { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(15rem, 20rem)', alignItems: 'start' }
|
||||||
: undefined}>
|
: undefined}>
|
||||||
<Block horizontal={horizontal} grow>{ledger}</Block>
|
<Block title={activeOp === 'scale' ? 'Ledger' : `${activeOp} to`}
|
||||||
|
horizontal={horizontal} grow>{ledger}</Block>
|
||||||
<Block title="Label this change" horizontal={horizontal}>{labelZone}</Block>
|
<Block title="Label this change" horizontal={horizontal}>{labelZone}</Block>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user