From ef6c6bbbb8efd53cb99a12b59c169e0005ff0816 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sat, 18 Apr 2026 15:48:42 -0400 Subject: [PATCH] Add Stacks feature: multi-source union with running balance and calibration - database/queries/stacks.sql: tables, functions for create/update/delete/calibrate/generate view - api/routes/stacks.js: REST endpoints for stacks and stack sources - api/server.js: register stacks router - ui/src/api.js: stacks API methods - ui/src/App.jsx: Stacks page route and nav entry - ui/src/pages/Stacks.jsx: full UI for stack management, source mapping, calibration Note: SQL deployment pending fix for balance_offset column and calibrate_balance signature Co-Authored-By: Claude Sonnet 4.6 --- api/routes/stacks.js | 115 ++++++++++ api/server.js | 2 + database/queries/stacks.sql | 335 ++++++++++++++++++++++++++++ ui/src/App.jsx | 3 + ui/src/api.js | 11 + ui/src/pages/Stacks.jsx | 426 ++++++++++++++++++++++++++++++++++++ 6 files changed, 892 insertions(+) create mode 100644 api/routes/stacks.js create mode 100644 database/queries/stacks.sql create mode 100644 ui/src/pages/Stacks.jsx diff --git a/api/routes/stacks.js b/api/routes/stacks.js new file mode 100644 index 0000000..58ee927 --- /dev/null +++ b/api/routes/stacks.js @@ -0,0 +1,115 @@ +/** + * Stacks Routes + * Named unions of multiple sources with field mappings and running balance + */ + +const express = require('express'); +const { lit } = require('../lib/sql'); + +module.exports = (pool) => { + const router = express.Router(); + + // List all stacks + router.get('/', async (req, res, next) => { + try { + const result = await pool.query('SELECT * FROM list_stacks()'); + res.json(result.rows); + } catch (err) { next(err); } + }); + + // Get single stack with sources + router.get('/:name', async (req, res, next) => { + try { + const result = await pool.query(`SELECT * FROM get_stack(${lit(req.params.name)})`); + if (!result.rows.length) return res.status(404).json({ error: 'Stack not found' }); + res.json(result.rows[0]); + } catch (err) { next(err); } + }); + + // Create stack + router.post('/', async (req, res, next) => { + try { + const { name, label, fields, amount_field, date_field, balance_offset } = req.body; + if (!name) return res.status(400).json({ error: 'name is required' }); + const result = await pool.query( + `SELECT * FROM create_stack(${lit(name)}, ${lit(label || null)}, ${lit(JSON.stringify(fields || []))}, ${lit(amount_field || null)}, ${lit(date_field || null)}, ${lit(balance_offset ?? 0)})` + ); + res.status(201).json(result.rows[0]); + } catch (err) { + if (err.code === '23505') return res.status(409).json({ error: 'Stack already exists' }); + next(err); + } + }); + + // Update stack + router.put('/:name', async (req, res, next) => { + try { + const { label, fields, amount_field, date_field, balance_offset } = req.body; + const n = v => v !== undefined ? lit(v) : 'NULL'; + const f = v => v !== undefined ? lit(JSON.stringify(v)) : 'NULL'; + const result = await pool.query( + `SELECT * FROM update_stack(${lit(req.params.name)}, ${n(label)}, ${f(fields)}, ${n(amount_field)}, ${n(date_field)}, ${n(balance_offset)})` + ); + if (!result.rows.length) return res.status(404).json({ error: 'Stack not found' }); + res.json(result.rows[0]); + } catch (err) { next(err); } + }); + + // Delete stack + router.delete('/:name', async (req, res, next) => { + try { + const result = await pool.query(`SELECT * FROM delete_stack(${lit(req.params.name)})`); + if (!result.rows.length) return res.status(404).json({ error: 'Stack not found' }); + res.json({ success: true, deleted: req.params.name }); + } catch (err) { next(err); } + }); + + // Add or update a source in a stack + router.put('/:name/sources/:source', async (req, res, next) => { + try { + const { field_map, amount_sign, balance_offset } = req.body; + const result = await pool.query( + `SELECT * FROM upsert_stack_source(${lit(req.params.name)}, ${lit(req.params.source)}, ${lit(JSON.stringify(field_map || {}))}, ${lit(amount_sign ?? 1)}, ${lit(balance_offset ?? 0)})` + ); + res.json(result.rows[0]); + } catch (err) { + if (err.code === '23503') return res.status(404).json({ error: 'Stack or source not found' }); + next(err); + } + }); + + // Remove a source from a stack + router.delete('/:name/sources/:source', async (req, res, next) => { + try { + const result = await pool.query( + `SELECT * FROM remove_stack_source(${lit(req.params.name)}, ${lit(req.params.source)})` + ); + if (!result.rows.length) return res.status(404).json({ error: 'Source not in stack' }); + res.json({ success: true, removed: req.params.source }); + } catch (err) { next(err); } + }); + + // Generate / refresh the dfv view + router.post('/:name/view', async (req, res, next) => { + try { + const result = await pool.query(`SELECT generate_stack_view(${lit(req.params.name)}) AS result`); + res.json(result.rows[0].result); + } catch (err) { next(err); } + }); + + // Calibrate balance offset given a known good balance at a specific date + router.post('/:name/calibrate', async (req, res, next) => { + try { + const { as_of_date, known_balance, source_name } = req.body; + if (!as_of_date || known_balance === undefined) { + return res.status(400).json({ error: 'as_of_date and known_balance are required' }); + } + const result = await pool.query( + `SELECT calibrate_balance(${lit(req.params.name)}, ${source_name ? lit(source_name) : 'NULL'}, ${lit(as_of_date)}::date, ${lit(known_balance)}::numeric) AS result` + ); + res.json(result.rows[0].result); + } catch (err) { next(err); } + }); + + return router; +}; diff --git a/api/server.js b/api/server.js index 5f42c0a..60a7982 100644 --- a/api/server.js +++ b/api/server.js @@ -54,12 +54,14 @@ const sourcesRoutes = require('./routes/sources'); const rulesRoutes = require('./routes/rules'); const mappingsRoutes = require('./routes/mappings'); const recordsRoutes = require('./routes/records'); +const stacksRoutes = require('./routes/stacks'); // Mount routes app.use('/api/sources', sourcesRoutes(pool)); app.use('/api/rules', rulesRoutes(pool)); app.use('/api/mappings', mappingsRoutes(pool)); app.use('/api/records', recordsRoutes(pool)); +app.use('/api/stacks', stacksRoutes(pool)); // Health check app.get('/health', (req, res) => { diff --git a/database/queries/stacks.sql b/database/queries/stacks.sql new file mode 100644 index 0000000..4002b50 --- /dev/null +++ b/database/queries/stacks.sql @@ -0,0 +1,335 @@ +-- +-- Stacks queries +-- All SQL for api/routes/stacks.js +-- + +SET search_path TO dataflow, public; + +------------------------------------------------------ +-- Tables +------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS dataflow.stacks ( + name TEXT PRIMARY KEY, + label TEXT, + -- Ordered canonical field definitions: [{name, label, type}] + -- type: 'text' | 'numeric' | 'date' + fields JSONB NOT NULL DEFAULT '[]', + -- Running balance config + amount_field TEXT, -- canonical field to sum for running balance + date_field TEXT, -- canonical field to order by + balance_offset NUMERIC DEFAULT 0, -- added to running sum (calibration) + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS dataflow.stack_sources ( + id SERIAL PRIMARY KEY, + stack_name TEXT NOT NULL REFERENCES dataflow.stacks(name) ON DELETE CASCADE, + source_name TEXT NOT NULL REFERENCES dataflow.sources(name) ON DELETE CASCADE, + -- Maps canonical field name → field name in records.transformed + field_map JSONB NOT NULL DEFAULT '{}', + -- Multiply amount by this before summing (1 = as-is, -1 = flip sign) + amount_sign INTEGER NOT NULL DEFAULT 1, + -- Seed balance for this source — added as a constant to the combined running total + balance_offset NUMERIC NOT NULL DEFAULT 0, + UNIQUE (stack_name, source_name) +); + +------------------------------------------------------ +-- Function: list_stacks +------------------------------------------------------ +CREATE OR REPLACE FUNCTION list_stacks() +RETURNS TABLE ( + name TEXT, + label TEXT, + fields JSONB, + amount_field TEXT, + date_field TEXT, + balance_offset NUMERIC, + source_count BIGINT, + created_at TIMESTAMPTZ +) AS $$ + SELECT + s.name, s.label, s.fields, + s.amount_field, s.date_field, s.balance_offset, + count(ss.id) AS source_count, + s.created_at + FROM dataflow.stacks s + LEFT JOIN dataflow.stack_sources ss ON ss.stack_name = s.name + GROUP BY s.name, s.label, s.fields, s.amount_field, s.date_field, s.balance_offset, s.created_at + ORDER BY s.name; +$$ LANGUAGE sql STABLE; + +------------------------------------------------------ +-- Function: get_stack +------------------------------------------------------ +CREATE OR REPLACE FUNCTION get_stack(p_name TEXT) +RETURNS TABLE ( + name TEXT, + label TEXT, + fields JSONB, + amount_field TEXT, + date_field TEXT, + balance_offset NUMERIC, + created_at TIMESTAMPTZ, + sources JSONB +) AS $$ + SELECT + s.name, s.label, s.fields, + s.amount_field, s.date_field, s.balance_offset, + s.created_at, + COALESCE(jsonb_agg( + jsonb_build_object( + 'id', ss.id, + 'source_name', ss.source_name, + 'field_map', ss.field_map, + 'amount_sign', ss.amount_sign, + 'balance_offset', ss.balance_offset + ) ORDER BY ss.source_name + ) FILTER (WHERE ss.id IS NOT NULL), '[]') + FROM dataflow.stacks s + LEFT JOIN dataflow.stack_sources ss ON ss.stack_name = s.name + WHERE s.name = p_name + GROUP BY s.name, s.label, s.fields, s.amount_field, s.date_field, s.balance_offset, s.created_at; +$$ LANGUAGE sql STABLE; + +------------------------------------------------------ +-- Function: create_stack +------------------------------------------------------ +CREATE OR REPLACE FUNCTION create_stack( + p_name TEXT, + p_label TEXT DEFAULT NULL, + p_fields JSONB DEFAULT '[]', + p_amount_field TEXT DEFAULT NULL, + p_date_field TEXT DEFAULT NULL, + p_balance_offset NUMERIC DEFAULT 0 +) RETURNS dataflow.stacks AS $$ + INSERT INTO dataflow.stacks (name, label, fields, amount_field, date_field, balance_offset) + VALUES (p_name, p_label, p_fields, p_amount_field, p_date_field, p_balance_offset) + RETURNING *; +$$ LANGUAGE sql; + +------------------------------------------------------ +-- Function: update_stack +------------------------------------------------------ +CREATE OR REPLACE FUNCTION update_stack( + p_name TEXT, + p_label TEXT DEFAULT NULL, + p_fields JSONB DEFAULT NULL, + p_amount_field TEXT DEFAULT NULL, + p_date_field TEXT DEFAULT NULL, + p_balance_offset NUMERIC DEFAULT NULL +) RETURNS dataflow.stacks AS $$ + UPDATE dataflow.stacks SET + label = COALESCE(p_label, label), + fields = COALESCE(p_fields, fields), + amount_field = COALESCE(p_amount_field, amount_field), + date_field = COALESCE(p_date_field, date_field), + balance_offset = COALESCE(p_balance_offset, balance_offset) + WHERE name = p_name + RETURNING *; +$$ LANGUAGE sql; + +------------------------------------------------------ +-- Function: delete_stack +------------------------------------------------------ +CREATE OR REPLACE FUNCTION delete_stack(p_name TEXT) +RETURNS TABLE (name TEXT) AS $$ + DELETE FROM dataflow.stacks WHERE name = p_name RETURNING name; +$$ LANGUAGE sql; + +------------------------------------------------------ +-- Function: upsert_stack_source +------------------------------------------------------ +CREATE OR REPLACE FUNCTION upsert_stack_source( + p_stack_name TEXT, + p_source_name TEXT, + p_field_map JSONB DEFAULT '{}', + p_amount_sign INTEGER DEFAULT 1, + p_balance_offset NUMERIC DEFAULT 0 +) RETURNS dataflow.stack_sources AS $$ + INSERT INTO dataflow.stack_sources (stack_name, source_name, field_map, amount_sign, balance_offset) + VALUES (p_stack_name, p_source_name, p_field_map, p_amount_sign, p_balance_offset) + ON CONFLICT (stack_name, source_name) DO UPDATE SET + field_map = EXCLUDED.field_map, + amount_sign = EXCLUDED.amount_sign, + balance_offset = EXCLUDED.balance_offset + RETURNING *; +$$ LANGUAGE sql; + +------------------------------------------------------ +-- Function: remove_stack_source +------------------------------------------------------ +CREATE OR REPLACE FUNCTION remove_stack_source(p_stack_name TEXT, p_source_name TEXT) +RETURNS TABLE (source_name TEXT) AS $$ + DELETE FROM dataflow.stack_sources + WHERE stack_name = p_stack_name AND source_name = p_source_name + RETURNING source_name; +$$ LANGUAGE sql; + +------------------------------------------------------ +-- Function: calibrate_balance +-- Given a known good balance at a specific date, compute the offset needed. +-- Returns: {computed_at_date, known_balance, suggested_offset} +------------------------------------------------------ +CREATE OR REPLACE FUNCTION calibrate_balance( + p_stack_name TEXT, + p_source_name TEXT, -- specific source to calibrate, or NULL for combined + p_as_of_date DATE, + p_known_balance NUMERIC +) RETURNS JSON AS $$ +DECLARE + v_stack dataflow.stacks%ROWTYPE; + v_running NUMERIC := 0; + v_other_offsets NUMERIC := 0; +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', 'Stack must have amount_field and date_field set'); + END IF; + + -- Sum amount * sign for the target source(s) up to as_of_date + SELECT COALESCE(SUM( + (rec.transformed ->> (ss.field_map ->> v_stack.amount_field))::numeric + * ss.amount_sign + ), 0) + INTO v_running + FROM dataflow.stack_sources ss + JOIN dataflow.records rec ON rec.source_name = ss.source_name + WHERE ss.stack_name = p_stack_name + AND (p_source_name IS NULL OR ss.source_name = p_source_name) + AND rec.transformed IS NOT NULL + AND (rec.transformed ->> (ss.field_map ->> v_stack.date_field))::date <= p_as_of_date; + + -- For combined calibration, include existing offsets from other sources + IF p_source_name IS NOT NULL THEN + SELECT COALESCE(SUM(balance_offset), 0) INTO v_other_offsets + FROM dataflow.stack_sources + WHERE stack_name = p_stack_name AND source_name != p_source_name; + END IF; + + RETURN json_build_object( + 'success', true, + 'source', p_source_name, + 'as_of_date', p_as_of_date, + 'known_balance', p_known_balance, + 'computed_sum', v_running, + 'other_offsets', v_other_offsets, + 'suggested_offset', p_known_balance - v_running + ); +END; +$$ LANGUAGE plpgsql STABLE; + +------------------------------------------------------ +-- Function: generate_stack_view +-- Builds a UNION ALL view in dfv schema from all member sources. +-- Includes running_balance if amount_field and date_field are set. +------------------------------------------------------ +CREATE OR REPLACE FUNCTION generate_stack_view(p_stack_name TEXT) +RETURNS JSON AS $$ +DECLARE + v_stack dataflow.stacks%ROWTYPE; + v_src dataflow.stack_sources%ROWTYPE; + v_field JSONB; + v_parts TEXT[] := '{}'; + v_select TEXT; + v_col TEXT; + v_src_field TEXT; + v_view TEXT; + v_sql TEXT; + v_has_bal BOOLEAN; +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; + + v_has_bal := v_stack.amount_field IS NOT NULL AND v_stack.date_field IS NOT NULL; + + -- Build one SELECT per source + FOR v_src IN + SELECT * FROM dataflow.stack_sources WHERE stack_name = p_stack_name ORDER BY source_name + LOOP + v_select := format('SELECT %L AS _source, rec.id AS _id', v_src.source_name); + + -- Canonical fields, mapped to source field names + FOR v_field IN SELECT * FROM jsonb_array_elements(v_stack.fields) + LOOP + v_col := v_field->>'name'; + v_src_field := COALESCE(v_src.field_map->>v_col, v_col); + + v_select := v_select || ', ' || CASE v_field->>'type' + WHEN 'numeric' THEN + format('(rec.transformed->>%L)::numeric AS %I', v_src_field, v_col) + WHEN 'date' THEN + format('(rec.transformed->>%L)::date AS %I', v_src_field, v_col) + ELSE + format('rec.transformed->>%L AS %I', v_src_field, v_col) + END; + END LOOP; + + -- amount_sign column for running balance + v_select := v_select || format(', %s::integer AS _sign, %s::numeric AS _src_offset', v_src.amount_sign, v_src.balance_offset); + + v_select := v_select || format( + ' FROM dataflow.records rec WHERE rec.source_name = %L AND rec.transformed IS NOT NULL', + v_src.source_name + ); + + v_parts := v_parts || v_select; + END LOOP; + + IF array_length(v_parts, 1) IS NULL THEN + RETURN json_build_object('success', false, 'error', 'Stack has no sources'); + END IF; + + CREATE SCHEMA IF NOT EXISTS dfv; + v_view := 'dfv.' || quote_ident(p_stack_name); + EXECUTE format('DROP VIEW IF EXISTS %s', v_view); + + -- Wrap in outer SELECT that adds running_balance if configured + IF v_has_bal THEN + -- running_balance = cumulative sum of (amount * sign) + per-source seed offsets + stack-level offset + -- Each row's _src_offset is that source's balance_offset, summed cumulatively so it's added once + -- per source in the window rather than per row. We use a trick: sum(_src_offset / count_of_source_rows) + -- is complex, so instead we add SUM(DISTINCT _src_offset per _source) as a constant via subquery. + -- Simpler: treat each source offset as a lump added to its first row only via ROW_NUMBER trick. + -- Cleanest: add total of all source offsets as a constant (valid when each source is calibrated + -- relative to its own transaction history, not to each other). + v_sql := format( + 'CREATE VIEW %s AS ' + 'SELECT _source, _id, %s, ' + 'SUM((%I)::numeric * _sign) OVER (ORDER BY %I ASC, _id ASC) ' + '+ (SELECT COALESCE(SUM(balance_offset),0) FROM dataflow.stack_sources WHERE stack_name = %L) ' + '+ %s AS running_balance ' + 'FROM (%s) _stacked', + v_view, + (SELECT string_agg(quote_ident(f->>'name'), ', ') + FROM jsonb_array_elements(v_stack.fields) f), + v_stack.amount_field, + v_stack.date_field, + p_stack_name, + v_stack.balance_offset, + array_to_string(v_parts, ' UNION ALL ') + ); + ELSE + v_sql := format( + 'CREATE VIEW %s AS SELECT _source, _id, %s FROM (%s) _stacked', + v_view, + (SELECT string_agg(quote_ident(f->>'name'), ', ') + FROM jsonb_array_elements(v_stack.fields) f), + array_to_string(v_parts, ' UNION ALL ') + ); + END IF; + + EXECUTE v_sql; + + RETURN json_build_object('success', true, 'view', v_view, 'sql', v_sql); +END; +$$ LANGUAGE plpgsql; + +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'; diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 66e34b7..d3323d0 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -10,6 +10,7 @@ import Records from './pages/Records' import Log from './pages/Log' import Pivot from './pages/Pivot' import Remap from './pages/Remap' +import Stacks from './pages/Stacks' const NAV = [ { to: '/sources', label: 'Sources' }, @@ -19,6 +20,7 @@ const NAV = [ { to: '/remap', label: 'Remap' }, { to: '/records', label: 'Records' }, { to: '/pivot', label: 'Pivot' }, + { to: '/stacks', label: 'Stacks' }, { to: '/log', label: 'Log' }, ] @@ -149,6 +151,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/ui/src/api.js b/ui/src/api.js index a9c5a50..0389940 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -113,6 +113,17 @@ export const api = { savePivotLayout: (source, layout_name, config) => request('POST', `/sources/${source}/layouts`, { layout_name, config }), deletePivotLayout: (source, id) => request('DELETE', `/sources/${source}/layouts/${id}`), + // Stacks + getStacks: () => request('GET', '/stacks'), + getStack: (name) => request('GET', `/stacks/${name}`), + createStack: (body) => request('POST', '/stacks', body), + updateStack: (name, body) => request('PUT', `/stacks/${name}`, body), + deleteStack: (name) => request('DELETE', `/stacks/${name}`), + 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`), + calibrateBalance: (name, source, body) => request('POST', `/stacks/${name}/calibrate`, { ...body, source_name: source || null }), + // Records getRecords: (source, limit = 100, offset = 0) => request('GET', `/records/source/${source}?limit=${limit}&offset=${offset}`), diff --git a/ui/src/pages/Stacks.jsx b/ui/src/pages/Stacks.jsx new file mode 100644 index 0000000..b84ee3a --- /dev/null +++ b/ui/src/pages/Stacks.jsx @@ -0,0 +1,426 @@ +import { useState, useEffect } from 'react' +import { api } from '../api' + +const FIELD_TYPES = ['text', 'numeric', 'date'] + +// ── Calibrate modal ──────────────────────────────────────────────────────────── + +function CalibrateModal({ stack, sourceName, onClose, onApply }) { + const [asOf, setAsOf] = useState('') + const [known, setKnown] = useState('') + const [result, setResult] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + async function handleCalc() { + setError(''); setResult(null); setLoading(true) + try { + const r = await api.calibrateBalance(stack.name, sourceName, { as_of_date: asOf, known_balance: parseFloat(known) }) + setResult(r) + } catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + return ( +
+
e.stopPropagation()}> +
+ Calibrate balance + +
+

+ Enter a known good balance at a specific date. The system will compute the offset needed. +

+
+
+ + setAsOf(e.target.value)} /> +
+
+ + setKnown(e.target.value)} placeholder="e.g. 12450.22" /> +
+ + {error &&

{error}

} + {result && ( +
+
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 && ( + + )} +
+
+
+ ) +} + +// ── Source member row ────────────────────────────────────────────────────────── + +function SourceRow({ stack, member, stackFields, onSave, onRemove }) { + const [map, setMap] = useState(member.field_map || {}) + const [sign, setSign] = useState(member.amount_sign ?? 1) + const [offset, setOffset] = useState(member.balance_offset ?? 0) + const [dirty, setDirty] = useState(false) + const [srcFields, setSrcFields] = useState([]) + const [calibrating, setCalibrating] = useState(false) + + useEffect(() => { + api.getFields(member.source_name).then(f => setSrcFields(f.map(x => x.key))).catch(() => {}) + }, [member.source_name]) + + function setMapping(canonical, srcField) { setMap(m => ({ ...m, [canonical]: srcField })); setDirty(true) } + function handleSign(v) { setSign(v); setDirty(true) } + function handleOffset(v) { setOffset(v); setDirty(true) } + + function save() { onSave(member.source_name, map, sign, offset); setDirty(false) } + + function applyCalibration(suggestedOffset) { + setOffset(suggestedOffset) + setDirty(true) + setCalibrating(false) + } + + return ( +
+
+ {member.source_name} +
+ {dirty && ( + + )} + +
+
+ + {/* Amount sign + balance offset */} +
+
+ + +
+
+ + 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" /> + +
+
+ + {/* Field mappings */} +
+ Stack field + Source field + {stackFields.map(f => ( + <> + {f.name} + + + ))} +
+ + {calibrating && ( + setCalibrating(false)} onApply={applyCalibration} /> + )} +
+ ) +} + +// ── 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 [fields, setFields] = useState(stack.fields || []) + const [members, setMembers] = useState(stack.sources || []) + const [newField, setNewField] = useState({ name: '', type: 'text' }) + const [addingSrc, setAddingSrc] = useState('') + const [viewResult, setViewResult] = useState(null) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + async function saveStack() { + setSaving(true); setError('') + try { + await api.updateStack(stack.name, { ...form, fields }) + onUpdated() + } catch (e) { setError(e.message) } + finally { setSaving(false) } + } + + async function addField() { + if (!newField.name) return + const updated = [...fields, { name: newField.name, type: newField.type }] + setFields(updated) + setNewField({ name: '', type: 'text' }) + await api.updateStack(stack.name, { fields: updated }) + onUpdated() + } + + function removeField(name) { + const updated = fields.filter(f => f.name !== name) + setFields(updated) + 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 }) + setMembers(m => [...m.filter(x => x.source_name !== addingSrc), { source_name: addingSrc, field_map: {}, amount_sign: 1 }]) + setAddingSrc('') + } + + async function saveSource(src, field_map, amount_sign, balance_offset) { + await api.upsertStackSource(stack.name, src, { field_map, amount_sign, balance_offset }) + } + + async function removeSource(src) { + await api.removeStackSource(stack.name, src) + setMembers(m => m.filter(x => x.source_name !== src)) + } + + async function generateView() { + try { + const r = await api.generateStackView(stack.name) + setViewResult(r) + } catch (e) { setError(e.message) } + } + + const availableSources = sources.filter(s => !members.find(m => m.source_name === s.name)) + + return ( +
+ {/* Basic config */} +
+

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" /> +
+
+ {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()} /> + + +
+
+ + {/* Sources */} +
+

Sources

+
+ {members.map(m => ( + + ))} +
+ {availableSources.length > 0 && ( +
+ + +
+ )} +
+ + {/* Generate view */} +
+
+

View

+ +
+ {viewResult && ( +
+ {viewResult.success + ?

View created: {viewResult.view}

+ :

{viewResult.error}

+ } + {viewResult.sql && ( +
{viewResult.sql}
+ )} +
+ )} +
+ +
+ ) +} + +// ── Main page ────────────────────────────────────────────────────────────────── + +export default function Stacks({ sources }) { + const [stacks, setStacks] = useState([]) + const [selected, setSelected] = useState(null) + const [stackDetail, setStackDetail] = useState(null) + const [creating, setCreating] = useState(false) + const [newName, setNewName] = useState('') + const [error, setError] = useState('') + + async function load() { + const s = await api.getStacks() + setStacks(s) + } + + async function loadDetail(name) { + const s = await api.getStack(name) + setStackDetail(s) + setSelected(name) + } + + useEffect(() => { load() }, []) + useEffect(() => { if (selected) loadDetail(selected) }, [selected]) + + async function createStack() { + if (!newName) return + setError('') + try { + await api.createStack({ name: newName, fields: [], balance_offset: 0 }) + setNewName(''); setCreating(false) + await load() + loadDetail(newName) + } catch (e) { setError(e.message) } + } + + async function deleteStack(name) { + if (!confirm(`Delete stack "${name}"?`)) return + await api.deleteStack(name) + if (selected === name) { setSelected(null); setStackDetail(null) } + load() + } + + return ( +
+ {/* Stack list */} +
+
+

Stacks

+ +
+ + {creating && ( +
+ setNewName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') createStack(); if (e.key === 'Escape') setCreating(false) }} /> + {error &&

{error}

} +
+ + +
+
+ )} + +
+ {stacks.map(s => ( +
loadDetail(s.name)} + className={`flex items-center justify-between px-2 py-1.5 rounded cursor-pointer text-sm group ${selected === s.name ? 'bg-blue-50 text-blue-700' : 'text-gray-600 hover:bg-gray-50'}`}> +
+
{s.label || s.name}
+
{s.source_count} source{s.source_count !== 1 ? 's' : ''}
+
+ +
+ ))} + {stacks.length === 0 && !creating && ( +

No stacks yet.

+ )} +
+
+ + {/* Detail panel */} +
+ {stackDetail ? ( + <> +

+ {stackDetail.label || stackDetail.name} + {stackDetail.label && {stackDetail.name}} +

+ { load(); loadDetail(stackDetail.name) }} + onDeleted={() => { setSelected(null); setStackDetail(null); load() }} + /> + + ) : ( +

Select a stack or create one.

+ )} +
+
+ ) +}