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:
parent
4e477420ad
commit
9e6d184bd8
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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`),
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user