diff --git a/api/routes/stacks.js b/api/routes/stacks.js index 4f38cc2..6dc8e33 100644 --- a/api/routes/stacks.js +++ b/api/routes/stacks.js @@ -4,7 +4,7 @@ */ const express = require('express'); -const { lit } = require('../lib/sql'); +const { lit, arr } = require('../lib/sql'); module.exports = (pool) => { const router = express.Router(); @@ -90,6 +90,16 @@ module.exports = (pool) => { } catch (err) { next(err); } }); + // Reorder sources within a stack + router.put('/:name/sources/reorder', async (req, res, next) => { + try { + const { source_names } = req.body; + if (!Array.isArray(source_names)) return res.status(400).json({ error: 'source_names array required' }); + await pool.query(`SELECT reorder_stack_sources(${lit(req.params.name)}, ${arr(source_names)})`); + res.json({ success: true }); + } catch (err) { next(err); } + }); + // Get current running balance from the generated view router.get('/:name/balance', async (req, res, next) => { try { diff --git a/database/queries/stacks.sql b/database/queries/stacks.sql index 3382a69..78a07bc 100644 --- a/database/queries/stacks.sql +++ b/database/queries/stacks.sql @@ -42,6 +42,16 @@ CREATE TABLE IF NOT EXISTS dataflow.stack_sources ( ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS balance_offset NUMERIC NOT NULL DEFAULT 0; ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS amount_field TEXT; ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS date_field TEXT; +ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS seq INTEGER NOT NULL DEFAULT 0; + +-- Seed seq from insertion order for existing rows +UPDATE dataflow.stack_sources ss +SET seq = sub.rn +FROM ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY stack_name ORDER BY id) AS rn + FROM dataflow.stack_sources WHERE seq = 0 +) sub +WHERE ss.id = sub.id AND ss.seq = 0; -- Drop old signatures before recreating DROP FUNCTION IF EXISTS calibrate_balance(TEXT, DATE, NUMERIC); @@ -99,8 +109,9 @@ RETURNS TABLE ( 'amount_field', ss.amount_field, 'amount_sign', ss.amount_sign, 'date_field', ss.date_field, - 'balance_offset', ss.balance_offset - ) ORDER BY ss.source_name + 'balance_offset', ss.balance_offset, + 'seq', ss.seq + ) ORDER BY ss.seq, ss.id ) FILTER (WHERE ss.id IS NOT NULL), '[]') FROM dataflow.stacks s LEFT JOIN dataflow.stack_sources ss ON ss.stack_name = s.name @@ -165,8 +176,11 @@ CREATE OR REPLACE FUNCTION upsert_stack_source( p_amount_field TEXT DEFAULT NULL, p_date_field TEXT DEFAULT NULL ) RETURNS dataflow.stack_sources AS $$ - INSERT INTO dataflow.stack_sources (stack_name, source_name, field_map, amount_sign, balance_offset, amount_field, date_field) - VALUES (p_stack_name, p_source_name, p_field_map, p_amount_sign, p_balance_offset, p_amount_field, p_date_field) + INSERT INTO dataflow.stack_sources (stack_name, source_name, field_map, amount_sign, balance_offset, amount_field, date_field, seq) + VALUES ( + p_stack_name, p_source_name, p_field_map, p_amount_sign, p_balance_offset, p_amount_field, p_date_field, + (SELECT COALESCE(MAX(seq), 0) + 1 FROM dataflow.stack_sources WHERE stack_name = p_stack_name) + ) ON CONFLICT (stack_name, source_name) DO UPDATE SET field_map = EXCLUDED.field_map, amount_sign = EXCLUDED.amount_sign, @@ -277,7 +291,7 @@ BEGIN -- Build one CTE per source querying dfv.{source} directly FOR v_src IN - SELECT * FROM dataflow.stack_sources WHERE stack_name = p_stack_name ORDER BY source_name + SELECT * FROM dataflow.stack_sources WHERE stack_name = p_stack_name ORDER BY seq, id LOOP v_select := format('SELECT %L AS _source, id AS _id', v_src.source_name); @@ -440,3 +454,19 @@ $$ LANGUAGE plpgsql STABLE; COMMENT ON FUNCTION generate_stack_view(TEXT, BOOLEAN) IS 'Generate a UNION ALL view in dfv schema combining multiple sources with optional running balance; p_dry_run=true returns SQL without executing'; 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'; + +------------------------------------------------------ +-- Function: reorder_stack_sources +------------------------------------------------------ +CREATE OR REPLACE FUNCTION reorder_stack_sources(p_stack_name TEXT, p_source_names TEXT[]) +RETURNS VOID AS $$ +DECLARE + i INTEGER; +BEGIN + FOR i IN 1..array_length(p_source_names, 1) LOOP + UPDATE dataflow.stack_sources + SET seq = i + WHERE stack_name = p_stack_name AND source_name = p_source_names[i]; + END LOOP; +END; +$$ LANGUAGE plpgsql; diff --git a/ui/src/api.js b/ui/src/api.js index d37313b..1a2d0b0 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -120,6 +120,7 @@ export const api = { 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), + reorderStackSources: (name, source_names) => request('PUT', `/stacks/${name}/sources/reorder`, { source_names }), removeStackSource: (name, source) => request('DELETE', `/stacks/${name}/sources/${source}`), previewStackSql: (name) => request('GET', `/stacks/${name}/view-sql`), generateStackView: (name) => request('POST', `/stacks/${name}/view`), diff --git a/ui/src/pages/Stacks.jsx b/ui/src/pages/Stacks.jsx index df2eecf..75ed9be 100644 --- a/ui/src/pages/Stacks.jsx +++ b/ui/src/pages/Stacks.jsx @@ -140,6 +140,8 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql // Drag-to-reorder state const [dragIdx, setDragIdx] = useState(null) const [dragOverIdx, setDragOverIdx] = useState(null) + const [srcDragIdx, setSrcDragIdx] = useState(null) + const [srcDragOverIdx, setSrcDragOverIdx] = useState(null) // Calibrate const [calibratingSource, setCalibratingSource] = useState(null) @@ -246,6 +248,28 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql onUpdated() } + // ── Source drag-to-reorder ── + function handleSrcDragStart(e, idx) { + setSrcDragIdx(idx) + e.dataTransfer.effectAllowed = 'move' + } + + function handleSrcDragOver(e, idx) { + e.preventDefault() + setSrcDragOverIdx(idx) + } + + async function handleSrcDrop(e, toIdx) { + e.preventDefault() + if (srcDragIdx === null || srcDragIdx === toIdx) { setSrcDragIdx(null); setSrcDragOverIdx(null); return } + const updated = [...members] + const [moved] = updated.splice(srcDragIdx, 1) + updated.splice(toIdx, 0, moved) + setSrcDragIdx(null); setSrcDragOverIdx(null) + await api.reorderStackSources(stack.name, updated.map(m => m.source_name)) + onUpdated() + } + // ── Mapping grid ── function getMappingValue(srcName, canonicalName) { const cfg = srcCfg[srcName] || {} @@ -431,13 +455,20 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
Each source contributes rows to the combined view. Set the sign to flip the direction of amounts (e.g. credit card charges are positive in the source but should subtract from your balance). The offset adjusts the running balance — use Calibrate to compute it from a known good balance.