Stacks UI: reorder flow, balance dropdowns, current balance display, calibration editable offset

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-18 16:20:49 -04:00
parent ef6c6bbbb8
commit f63a0ec0e5
4 changed files with 198 additions and 70 deletions

View File

@ -89,6 +89,14 @@ module.exports = (pool) => {
} catch (err) { next(err); }
});
// Get current running balance from the generated view
router.get('/:name/balance', async (req, res, next) => {
try {
const result = await pool.query(`SELECT get_stack_balance(${lit(req.params.name)}) AS result`);
res.json(result.rows[0].result);
} catch (err) { next(err); }
});
// Generate / refresh the dfv view
router.post('/:name/view', async (req, res, next) => {
try {

View File

@ -35,6 +35,12 @@ CREATE TABLE IF NOT EXISTS dataflow.stack_sources (
UNIQUE (stack_name, source_name)
);
-- Migrations: add columns that may be missing from earlier deploys
ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS balance_offset NUMERIC NOT NULL DEFAULT 0;
-- Drop old 3-arg calibrate_balance signature if it exists before recreating with 4 args
DROP FUNCTION IF EXISTS calibrate_balance(TEXT, DATE, NUMERIC);
------------------------------------------------------
-- Function: list_stacks
------------------------------------------------------
@ -331,5 +337,42 @@ BEGIN
END;
$$ LANGUAGE plpgsql;
------------------------------------------------------
-- Function: get_stack_balance
-- Returns the current running balance (last row of the generated view)
------------------------------------------------------
CREATE OR REPLACE FUNCTION get_stack_balance(p_stack_name TEXT)
RETURNS JSON AS $$
DECLARE
v_stack dataflow.stacks%ROWTYPE;
v_balance NUMERIC;
v_view TEXT;
v_sql TEXT;
BEGIN
SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name;
IF NOT FOUND THEN
RETURN json_build_object('success', false, 'error', 'Stack not found');
END IF;
IF v_stack.amount_field IS NULL OR v_stack.date_field IS NULL THEN
RETURN json_build_object('success', false, 'error', 'amount_field and date_field must be set');
END IF;
v_view := 'dfv.' || quote_ident(p_stack_name);
BEGIN
v_sql := format(
'SELECT running_balance FROM %s ORDER BY %I DESC, _id DESC LIMIT 1',
v_view, v_stack.date_field
);
EXECUTE v_sql INTO v_balance;
EXCEPTION WHEN undefined_table THEN
RETURN json_build_object('success', false, 'error', 'View not generated yet — click Generate first');
END;
RETURN json_build_object('success', true, 'balance', v_balance);
END;
$$ LANGUAGE plpgsql STABLE;
COMMENT ON FUNCTION generate_stack_view IS 'Generate a UNION ALL view in dfv schema combining multiple sources with optional running balance';
COMMENT ON FUNCTION calibrate_balance IS 'Given a known good balance at a date, compute the offset to add to balance_offset';
COMMENT ON FUNCTION get_stack_balance IS 'Return the current running balance (last row) from the generated dfv view';

View File

@ -122,6 +122,7 @@ export const api = {
upsertStackSource: (name, source, body) => request('PUT', `/stacks/${name}/sources/${source}`, body),
removeStackSource: (name, source) => request('DELETE', `/stacks/${name}/sources/${source}`),
generateStackView: (name) => request('POST', `/stacks/${name}/view`),
getStackBalance: (name) => request('GET', `/stacks/${name}/balance`),
calibrateBalance: (name, source, body) => request('POST', `/stacks/${name}/calibrate`, { ...body, source_name: source || null }),
// Records

View File

@ -9,6 +9,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
const [asOf, setAsOf] = useState('')
const [known, setKnown] = useState('')
const [result, setResult] = useState(null)
const [applyOffset, setApplyOffset] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@ -17,6 +18,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
try {
const r = await api.calibrateBalance(stack.name, sourceName, { as_of_date: asOf, known_balance: parseFloat(known) })
setResult(r)
if (r.success) setApplyOffset(Number(r.suggested_offset).toFixed(2))
} catch (e) { setError(e.message) }
finally { setLoading(false) }
}
@ -47,18 +49,30 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
{loading ? 'Calculating…' : 'Calculate'}
</button>
{error && <p className="text-xs text-red-500">{error}</p>}
{result && (
{result && !result.success && (
<p className="text-xs text-red-500">{result.error}</p>
)}
{result && result.success && (
<div className="bg-gray-50 rounded p-3 text-xs space-y-1">
<div className="flex justify-between"><span className="text-gray-500">Computed sum at date</span><span className="font-mono">{Number(result.computed_sum).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Known balance</span><span className="font-mono">{Number(result.known_balance).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
<div className="flex justify-between font-medium"><span className="text-gray-700">Suggested offset</span><span className="font-mono text-blue-700">{Number(result.suggested_offset).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
</div>
)}
{result && (
<button onClick={() => onApply(result.suggested_offset)}
className="w-full text-sm bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700">
Apply offset ({Number(result.suggested_offset).toFixed(2)})
</button>
{result && result.success && (
<div className="space-y-2">
<div>
<label className="text-xs text-gray-500 block mb-1">Offset to apply</label>
<input type="number" step="0.01"
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
value={applyOffset} onChange={e => setApplyOffset(e.target.value)} />
</div>
<button onClick={() => onApply(parseFloat(applyOffset))}
disabled={applyOffset === ''}
className="w-full text-sm bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700 disabled:opacity-50">
Apply offset
</button>
</div>
)}
</div>
</div>
@ -119,7 +133,10 @@ function SourceRow({ stack, member, stackFields, onSave, onRemove }) {
<input type="number" step="0.01" value={offset}
onChange={e => handleOffset(parseFloat(e.target.value) || 0)}
className="w-28 border border-gray-200 rounded px-2 py-0.5 font-mono focus:outline-none focus:border-blue-400" />
<button onClick={() => setCalibrating(true)} className="text-gray-400 hover:text-blue-500 underline">Calibrate</button>
<button onClick={() => setCalibrating(true)}
disabled={!stack.amount_field || !stack.date_field}
title={(!stack.amount_field || !stack.date_field) ? 'Set amount and date fields in Running Balance first' : 'Calibrate balance'}
className="text-gray-400 hover:text-blue-500 underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline">Calibrate</button>
</div>
</div>
@ -151,28 +168,36 @@ function SourceRow({ stack, member, stackFields, onSave, onRemove }) {
// Stack detail / edit panel
function StackPanel({ stack, sources, onUpdated, onDeleted }) {
const [form, setForm] = useState({
label: stack.label || '',
amount_field: stack.amount_field || '',
date_field: stack.date_field || '',
})
const [label, setLabel] = useState(stack.label || '')
const [fields, setFields] = useState(stack.fields || [])
const [amountField, setAmountField] = useState(stack.amount_field || '')
const [dateField, setDateField] = useState(stack.date_field || '')
const [members, setMembers] = useState(stack.sources || [])
const [newField, setNewField] = useState({ name: '', type: 'text' })
const [addingSrc, setAddingSrc] = useState('')
const [viewResult, setViewResult] = useState(null)
const [currentBalance, setCurrentBalance] = useState(null)
const [balanceError, setBalanceError] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
async function saveStack() {
async function saveStack(updates) {
setSaving(true); setError('')
try {
await api.updateStack(stack.name, { ...form, fields })
await api.updateStack(stack.name, updates)
onUpdated()
} catch (e) { setError(e.message) }
finally { setSaving(false) }
}
async function saveLabel() {
await saveStack({ label, amount_field: amountField, date_field: dateField, fields })
}
async function saveBalance() {
await saveStack({ label, amount_field: amountField, date_field: dateField, fields })
}
async function addField() {
if (!newField.name) return
const updated = [...fields, { name: newField.name, type: newField.type }]
@ -185,12 +210,14 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
function removeField(name) {
const updated = fields.filter(f => f.name !== name)
setFields(updated)
if (amountField === name) setAmountField('')
if (dateField === name) setDateField('')
api.updateStack(stack.name, { fields: updated }).then(onUpdated)
}
async function addSource() {
if (!addingSrc) return
const member = await api.upsertStackSource(stack.name, addingSrc, { field_map: {}, amount_sign: 1 })
await api.upsertStackSource(stack.name, addingSrc, { field_map: {}, amount_sign: 1 })
setMembers(m => [...m.filter(x => x.source_name !== addingSrc), { source_name: addingSrc, field_map: {}, amount_sign: 1 }])
setAddingSrc('')
}
@ -205,69 +232,45 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
}
async function generateView() {
setViewResult(null); setCurrentBalance(null); setBalanceError('')
try {
const r = await api.generateStackView(stack.name)
setViewResult(r)
if (r.success) fetchBalance()
} catch (e) { setError(e.message) }
}
async function fetchBalance() {
setBalanceError('')
try {
const r = await api.getStackBalance(stack.name)
if (r.success) setCurrentBalance(r.balance)
else setBalanceError(r.error)
} catch (e) { setBalanceError(e.message) }
}
const availableSources = sources.filter(s => !members.find(m => m.source_name === s.name))
const fieldNames = fields.map(f => f.name)
return (
<div className="space-y-5">
{/* Basic config */}
{/* Label */}
<div className="bg-white border border-gray-200 rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Configuration</h3>
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<label className="text-xs text-gray-500 block mb-1">Label</label>
<div className="flex gap-3 items-end">
<div className="flex-1">
<label className="text-xs text-gray-500 block mb-1">Label <span className="text-gray-400">(optional)</span></label>
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
value={form.label} onChange={e => setForm(f => ({ ...f, label: e.target.value }))} />
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Amount field (for balance)</label>
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
value={form.amount_field} onChange={e => setForm(f => ({ ...f, amount_field: e.target.value }))}
placeholder="e.g. amount" />
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Date field (for ordering)</label>
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
value={form.date_field} onChange={e => setForm(f => ({ ...f, date_field: e.target.value }))}
placeholder="e.g. date" />
value={label} onChange={e => setLabel(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveLabel()} />
</div>
<button onClick={saveLabel} disabled={saving}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{error && <p className="text-xs text-red-500 mb-2">{error}</p>}
<button onClick={saveStack} disabled={saving}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{/* Canonical fields */}
<div className="bg-white border border-gray-200 rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Canonical fields</h3>
{fields.length > 0 && (
<div className="space-y-1 mb-3">
{fields.map(f => (
<div key={f.name} className="flex items-center gap-2 text-xs">
<span className="font-mono text-gray-700 w-32">{f.name}</span>
<span className="text-gray-400">{f.type}</span>
<button onClick={() => removeField(f.name)} className="text-red-400 hover:text-red-600 ml-auto"></button>
</div>
))}
</div>
)}
<div className="flex gap-2">
<input className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
placeholder="field name" value={newField.name} onChange={e => setNewField(f => ({ ...f, name: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && addField()} />
<select className="border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value }))}>
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<button onClick={addField} className="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700">Add</button>
</div>
{error && <p className="text-xs text-red-500 mt-2">{error}</p>}
</div>
{/* Sources */}
@ -278,6 +281,7 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
<SourceRow key={m.source_name} stack={stack} member={m} stackFields={fields}
onSave={saveSource} onRemove={removeSource} />
))}
{members.length === 0 && <p className="text-xs text-gray-400">No sources added yet.</p>}
</div>
{availableSources.length > 0 && (
<div className="flex gap-2">
@ -292,15 +296,87 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) {
)}
</div>
{/* Generate view */}
{/* Output fields */}
<div className="bg-white border border-gray-200 rounded p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-gray-700">View</h3>
<button onClick={generateView}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700">
Generate / refresh
</button>
<h3 className="text-sm font-semibold text-gray-700 mb-3">Output fields</h3>
{fields.length > 0 && (
<div className="space-y-1 mb-3">
{fields.map(f => (
<div key={f.name} className="flex items-center gap-2 text-xs">
<span className="font-mono text-gray-700 w-32">{f.name}</span>
<span className="text-gray-400">{f.type}</span>
<button onClick={() => removeField(f.name)} className="text-red-400 hover:text-red-600 ml-auto"></button>
</div>
))}
</div>
)}
{fields.length === 0 && <p className="text-xs text-gray-400 mb-3">No output fields defined yet.</p>}
<div className="flex gap-2">
<input className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
placeholder="field name" value={newField.name} onChange={e => setNewField(f => ({ ...f, name: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && addField()} />
<select className="border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value }))}>
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<button onClick={addField} className="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700">Add</button>
</div>
</div>
{/* Running balance config */}
<div className="bg-white border border-gray-200 rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-1">Running balance</h3>
<p className="text-xs text-gray-400 mb-3">Select which output fields drive the running balance. Define output fields above first.</p>
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<label className="text-xs text-gray-500 block mb-1">Amount field</label>
<select className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
value={amountField} onChange={e => setAmountField(e.target.value)}>
<option value=""> none </option>
{fieldNames.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Date field</label>
<select className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
value={dateField} onChange={e => setDateField(e.target.value)}>
<option value=""> none </option>
{fieldNames.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
</div>
<button onClick={saveBalance} disabled={saving}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{/* Generate view + current balance */}
<div className="bg-white border border-gray-200 rounded p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">View</h3>
<div className="flex gap-2">
<button onClick={fetchBalance}
className="text-sm bg-gray-100 text-gray-700 px-3 py-1.5 rounded hover:bg-gray-200">
Refresh balance
</button>
<button onClick={generateView}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700">
Generate / refresh
</button>
</div>
</div>
{currentBalance !== null && (
<div className="mb-3 flex items-center gap-3">
<span className="text-xs text-gray-500">Current running balance</span>
<span className="text-lg font-mono font-semibold text-gray-800">
{Number(currentBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
</div>
)}
{balanceError && <p className="text-xs text-gray-400 mb-3">{balanceError}</p>}
{viewResult && (
<div className="text-xs">
{viewResult.success