From f63a0ec0e515f530d47479616f7dbf1afef58734 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sat, 18 Apr 2026 16:20:49 -0400 Subject: [PATCH] Stacks UI: reorder flow, balance dropdowns, current balance display, calibration editable offset Co-Authored-By: Claude Sonnet 4.6 --- api/routes/stacks.js | 8 ++ database/queries/stacks.sql | 43 +++++++ ui/src/api.js | 1 + ui/src/pages/Stacks.jsx | 216 ++++++++++++++++++++++++------------ 4 files changed, 198 insertions(+), 70 deletions(-) diff --git a/api/routes/stacks.js b/api/routes/stacks.js index 58ee927..e4b56e3 100644 --- a/api/routes/stacks.js +++ b/api/routes/stacks.js @@ -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 { diff --git a/database/queries/stacks.sql b/database/queries/stacks.sql index 4002b50..d805060 100644 --- a/database/queries/stacks.sql +++ b/database/queries/stacks.sql @@ -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'; diff --git a/ui/src/api.js b/ui/src/api.js index 0389940..339f9ac 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -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 diff --git a/ui/src/pages/Stacks.jsx b/ui/src/pages/Stacks.jsx index b84ee3a..990059a 100644 --- a/ui/src/pages/Stacks.jsx +++ b/ui/src/pages/Stacks.jsx @@ -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'} {error &&

{error}

} - {result && ( + {result && !result.success && ( +

{result.error}

+ )} + {result && result.success && (
Computed sum at date{Number(result.computed_sum).toLocaleString(undefined, { minimumFractionDigits: 2 })}
Known balance{Number(result.known_balance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
Suggested offset{Number(result.suggested_offset).toLocaleString(undefined, { minimumFractionDigits: 2 })}
)} - {result && ( - + {result && result.success && ( +
+
+ + setApplyOffset(e.target.value)} /> +
+ +
)} @@ -119,7 +133,10 @@ function SourceRow({ stack, member, stackFields, onSave, onRemove }) { 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" /> - + @@ -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 (
- {/* Basic config */} + + {/* Label */}

Configuration

-
-
- +
+
+ setForm(f => ({ ...f, label: e.target.value }))} /> -
-
- - setForm(f => ({ ...f, amount_field: e.target.value }))} - placeholder="e.g. amount" /> -
-
- - 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()} />
+
- {error &&

{error}

} - -
- - {/* Canonical fields */} -
-

Canonical fields

- {fields.length > 0 && ( -
- {fields.map(f => ( -
- {f.name} - {f.type} - -
- ))} -
- )} -
- setNewField(f => ({ ...f, name: e.target.value }))} - onKeyDown={e => e.key === 'Enter' && addField()} /> - - -
+ {error &&

{error}

}
{/* Sources */} @@ -278,6 +281,7 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) { ))} + {members.length === 0 &&

No sources added yet.

}
{availableSources.length > 0 && (
@@ -292,15 +296,87 @@ function StackPanel({ stack, sources, onUpdated, onDeleted }) { )}
- {/* Generate view */} + {/* Output fields */}
-
-

View

- +

Output fields

+ {fields.length > 0 && ( +
+ {fields.map(f => ( +
+ {f.name} + {f.type} + +
+ ))} +
+ )} + {fields.length === 0 &&

No output fields defined yet.

} +
+ setNewField(f => ({ ...f, name: e.target.value }))} + onKeyDown={e => e.key === 'Enter' && addField()} /> + +
+
+ + {/* Running balance config */} +
+

Running balance

+

Select which output fields drive the running balance. Define output fields above first.

+
+
+ + +
+
+ + +
+
+ +
+ + {/* Generate view + current balance */} +
+
+

View

+
+ + +
+
+ + {currentBalance !== null && ( +
+ Current running balance + + {Number(currentBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })} + +
+ )} + {balanceError &&

{balanceError}

} + {viewResult && (
{viewResult.success