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
-
-
-
+
- {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