Stacks: source ordering via seq field with drag-to-reorder

- Add seq column to stack_sources; existing rows seeded by insertion order
- New sources auto-assigned max(seq)+1 so they always append to the end
- get_stack and generate_stack_view now order by seq instead of source_name
- Add reorder_stack_sources() function and PUT /:name/sources/reorder endpoint
- Source cards have drag handles matching the output columns grid behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-19 17:11:44 -04:00
parent 4e477420ad
commit 9e6d184bd8
4 changed files with 80 additions and 8 deletions

View File

@ -4,7 +4,7 @@
*/ */
const express = require('express'); const express = require('express');
const { lit } = require('../lib/sql'); const { lit, arr } = require('../lib/sql');
module.exports = (pool) => { module.exports = (pool) => {
const router = express.Router(); const router = express.Router();
@ -90,6 +90,16 @@ module.exports = (pool) => {
} catch (err) { next(err); } } 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 // Get current running balance from the generated view
router.get('/:name/balance', async (req, res, next) => { router.get('/:name/balance', async (req, res, next) => {
try { try {

View File

@ -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 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 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 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 old signatures before recreating
DROP FUNCTION IF EXISTS calibrate_balance(TEXT, DATE, NUMERIC); DROP FUNCTION IF EXISTS calibrate_balance(TEXT, DATE, NUMERIC);
@ -99,8 +109,9 @@ RETURNS TABLE (
'amount_field', ss.amount_field, 'amount_field', ss.amount_field,
'amount_sign', ss.amount_sign, 'amount_sign', ss.amount_sign,
'date_field', ss.date_field, 'date_field', ss.date_field,
'balance_offset', ss.balance_offset 'balance_offset', ss.balance_offset,
) ORDER BY ss.source_name 'seq', ss.seq
) ORDER BY ss.seq, ss.id
) FILTER (WHERE ss.id IS NOT NULL), '[]') ) FILTER (WHERE ss.id IS NOT NULL), '[]')
FROM dataflow.stacks s FROM dataflow.stacks s
LEFT JOIN dataflow.stack_sources ss ON ss.stack_name = s.name 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_amount_field TEXT DEFAULT NULL,
p_date_field TEXT DEFAULT NULL p_date_field TEXT DEFAULT NULL
) RETURNS dataflow.stack_sources AS $$ ) RETURNS dataflow.stack_sources AS $$
INSERT INTO dataflow.stack_sources (stack_name, source_name, field_map, amount_sign, balance_offset, amount_field, 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) 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 ON CONFLICT (stack_name, source_name) DO UPDATE SET
field_map = EXCLUDED.field_map, field_map = EXCLUDED.field_map,
amount_sign = EXCLUDED.amount_sign, amount_sign = EXCLUDED.amount_sign,
@ -277,7 +291,7 @@ BEGIN
-- Build one CTE per source querying dfv.{source} directly -- Build one CTE per source querying dfv.{source} directly
FOR v_src IN 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 LOOP
v_select := format('SELECT %L AS _source, id AS _id', v_src.source_name); 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 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 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'; 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;

View File

@ -120,6 +120,7 @@ export const api = {
updateStack: (name, body) => request('PUT', `/stacks/${name}`, body), updateStack: (name, body) => request('PUT', `/stacks/${name}`, body),
deleteStack: (name) => request('DELETE', `/stacks/${name}`), deleteStack: (name) => request('DELETE', `/stacks/${name}`),
upsertStackSource: (name, source, body) => request('PUT', `/stacks/${name}/sources/${source}`, body), 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}`), removeStackSource: (name, source) => request('DELETE', `/stacks/${name}/sources/${source}`),
previewStackSql: (name) => request('GET', `/stacks/${name}/view-sql`), previewStackSql: (name) => request('GET', `/stacks/${name}/view-sql`),
generateStackView: (name) => request('POST', `/stacks/${name}/view`), generateStackView: (name) => request('POST', `/stacks/${name}/view`),

View File

@ -140,6 +140,8 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
// Drag-to-reorder state // Drag-to-reorder state
const [dragIdx, setDragIdx] = useState(null) const [dragIdx, setDragIdx] = useState(null)
const [dragOverIdx, setDragOverIdx] = useState(null) const [dragOverIdx, setDragOverIdx] = useState(null)
const [srcDragIdx, setSrcDragIdx] = useState(null)
const [srcDragOverIdx, setSrcDragOverIdx] = useState(null)
// Calibrate // Calibrate
const [calibratingSource, setCalibratingSource] = useState(null) const [calibratingSource, setCalibratingSource] = useState(null)
@ -246,6 +248,28 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
onUpdated() 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 // Mapping grid
function getMappingValue(srcName, canonicalName) { function getMappingValue(srcName, canonicalName) {
const cfg = srcCfg[srcName] || {} const cfg = srcCfg[srcName] || {}
@ -431,13 +455,20 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
<h3 className="text-sm font-semibold text-gray-700 mb-1">Sources</h3> <h3 className="text-sm font-semibold text-gray-700 mb-1">Sources</h3>
<p className="text-xs text-gray-400 mb-3">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.</p> <p className="text-xs text-gray-400 mb-3">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.</p>
<div className="space-y-2 mb-3"> <div className="space-y-2 mb-3">
{members.map(m => { {members.map((m, idx) => {
const cfg = srcCfg[m.source_name] || {} const cfg = srcCfg[m.source_name] || {}
const sf = srcFields[m.source_name] || [] const sf = srcFields[m.source_name] || []
const canCalibrate = !!cfg.amount_field && !!cfg.date_field const canCalibrate = !!cfg.amount_field && !!cfg.date_field
return ( return (
<div key={m.source_name} className="border border-gray-100 rounded px-3 py-2 text-xs space-y-2"> <div key={m.source_name}
draggable
onDragStart={e => handleSrcDragStart(e, idx)}
onDragOver={e => handleSrcDragOver(e, idx)}
onDrop={e => handleSrcDrop(e, idx)}
onDragEnd={() => { setSrcDragIdx(null); setSrcDragOverIdx(null) }}
className={`border border-gray-100 rounded px-3 py-2 text-xs space-y-2 ${srcDragOverIdx === idx && srcDragIdx !== idx ? 'bg-blue-50' : ''}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-gray-300 cursor-grab select-none"></span>
<span className="font-medium text-gray-700 flex-1">{m.source_name}</span> <span className="font-medium text-gray-700 flex-1">{m.source_name}</span>
<button onClick={() => removeSource(m.source_name)} className="text-red-300 hover:text-red-500">Remove</button> <button onClick={() => removeSource(m.source_name)} className="text-red-300 hover:text-red-500">Remove</button>
</div> </div>