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 <noreply@anthropic.com>
This commit is contained in:
parent
fd67bb03af
commit
ef6c6bbbb8
115
api/routes/stacks.js
Normal file
115
api/routes/stacks.js
Normal file
@ -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;
|
||||
};
|
||||
@ -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) => {
|
||||
|
||||
335
database/queries/stacks.sql
Normal file
335
database/queries/stacks.sql
Normal file
@ -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';
|
||||
@ -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() {
|
||||
<Route path="/remap" element={<Remap />} />
|
||||
<Route path="/records" element={<Records source={source} />} />
|
||||
<Route path="/pivot" element={<Pivot source={source} />} />
|
||||
<Route path="/stacks" element={<Stacks sources={sources} />} />
|
||||
<Route path="/log" element={<Log />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@ -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}`),
|
||||
|
||||
426
ui/src/pages/Stacks.jsx
Normal file
426
ui/src/pages/Stacks.jsx
Normal file
@ -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 (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-96 p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-semibold text-gray-700">Calibrate balance</span>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">✕</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Enter a known good balance at a specific date. The system will compute the offset needed.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">As-of date</label>
|
||||
<input type="date" className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={asOf} onChange={e => setAsOf(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">Known balance</label>
|
||||
<input type="number" step="0.01" className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={known} onChange={e => setKnown(e.target.value)} placeholder="e.g. 12450.22" />
|
||||
</div>
|
||||
<button onClick={handleCalc} disabled={!asOf || !known || loading}
|
||||
className="w-full text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||
{loading ? 'Calculating…' : 'Calculate'}
|
||||
</button>
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
{result && (
|
||||
<div className="bg-gray-50 rounded p-3 text-xs space-y-1">
|
||||
<div className="flex justify-between"><span className="text-gray-500">Computed sum at date</span><span className="font-mono">{Number(result.computed_sum).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Known balance</span><span className="font-mono">{Number(result.known_balance).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
|
||||
<div className="flex justify-between font-medium"><span className="text-gray-700">Suggested offset</span><span className="font-mono text-blue-700">{Number(result.suggested_offset).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<button onClick={() => onApply(result.suggested_offset)}
|
||||
className="w-full text-sm bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700">
|
||||
Apply offset ({Number(result.suggested_offset).toFixed(2)})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="border border-gray-200 rounded p-3 text-xs">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-gray-700">{member.source_name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{dirty && (
|
||||
<button onClick={save} className="text-xs bg-blue-600 text-white px-2 py-0.5 rounded hover:bg-blue-700">Save</button>
|
||||
)}
|
||||
<button onClick={() => onRemove(member.source_name)} className="text-red-400 hover:text-red-600">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount sign + balance offset */}
|
||||
<div className="flex items-center gap-4 mb-3 pb-2 border-b border-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-gray-500">Amount sign</label>
|
||||
<select value={sign} onChange={e => handleSign(parseInt(e.target.value))}
|
||||
className="border border-gray-200 rounded px-2 py-0.5 focus:outline-none focus:border-blue-400">
|
||||
<option value={1}>+1 (as-is)</option>
|
||||
<option value={-1}>−1 (flip)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-gray-500">Balance offset</label>
|
||||
<input type="number" step="0.01" value={offset}
|
||||
onChange={e => 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" />
|
||||
<button onClick={() => setCalibrating(true)} className="text-gray-400 hover:text-blue-500 underline">Calibrate</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Field mappings */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<span className="text-gray-400 font-medium">Stack field</span>
|
||||
<span className="text-gray-400 font-medium">Source field</span>
|
||||
{stackFields.map(f => (
|
||||
<>
|
||||
<span key={f.name + '_l'} className="text-gray-600 self-center">{f.name}</span>
|
||||
<select key={f.name + '_s'} value={map[f.name] || ''}
|
||||
onChange={e => setMapping(f.name, e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-0.5 focus:outline-none focus:border-blue-400">
|
||||
<option value="">— same name —</option>
|
||||
{srcFields.map(sf => <option key={sf} value={sf}>{sf}</option>)}
|
||||
</select>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{calibrating && (
|
||||
<CalibrateModal stack={stack} sourceName={member.source_name}
|
||||
onClose={() => setCalibrating(false)} onApply={applyCalibration} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="space-y-5">
|
||||
{/* Basic config */}
|
||||
<div className="bg-white border border-gray-200 rounded p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Configuration</h3>
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">Label</label>
|
||||
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={form.label} onChange={e => setForm(f => ({ ...f, label: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">Amount field (for balance)</label>
|
||||
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={form.amount_field} onChange={e => setForm(f => ({ ...f, amount_field: e.target.value }))}
|
||||
placeholder="e.g. amount" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">Date field (for ordering)</label>
|
||||
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={form.date_field} onChange={e => setForm(f => ({ ...f, date_field: e.target.value }))}
|
||||
placeholder="e.g. date" />
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mb-2">{error}</p>}
|
||||
<button onClick={saveStack} disabled={saving}
|
||||
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Canonical fields */}
|
||||
<div className="bg-white border border-gray-200 rounded p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Canonical fields</h3>
|
||||
{fields.length > 0 && (
|
||||
<div className="space-y-1 mb-3">
|
||||
{fields.map(f => (
|
||||
<div key={f.name} className="flex items-center gap-2 text-xs">
|
||||
<span className="font-mono text-gray-700 w-32">{f.name}</span>
|
||||
<span className="text-gray-400">{f.type}</span>
|
||||
<button onClick={() => removeField(f.name)} className="text-red-400 hover:text-red-600 ml-auto">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="field name" value={newField.name} onChange={e => setNewField(f => ({ ...f, name: e.target.value }))}
|
||||
onKeyDown={e => e.key === 'Enter' && addField()} />
|
||||
<select className="border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value }))}>
|
||||
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<button onClick={addField} className="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sources */}
|
||||
<div className="bg-white border border-gray-200 rounded p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Sources</h3>
|
||||
<div className="space-y-3 mb-3">
|
||||
{members.map(m => (
|
||||
<SourceRow key={m.source_name} stack={stack} member={m} stackFields={fields}
|
||||
onSave={saveSource} onRemove={removeSource} />
|
||||
))}
|
||||
</div>
|
||||
{availableSources.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<select className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
|
||||
value={addingSrc} onChange={e => setAddingSrc(e.target.value)}>
|
||||
<option value="">— add source —</option>
|
||||
{availableSources.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
|
||||
</select>
|
||||
<button onClick={addSource} disabled={!addingSrc}
|
||||
className="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700 disabled:opacity-40">Add</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generate view */}
|
||||
<div className="bg-white border border-gray-200 rounded p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700">View</h3>
|
||||
<button onClick={generateView}
|
||||
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700">
|
||||
Generate / refresh
|
||||
</button>
|
||||
</div>
|
||||
{viewResult && (
|
||||
<div className="text-xs">
|
||||
{viewResult.success
|
||||
? <p className="text-green-600 mb-2">View created: <span className="font-mono">{viewResult.view}</span></p>
|
||||
: <p className="text-red-500">{viewResult.error}</p>
|
||||
}
|
||||
{viewResult.sql && (
|
||||
<pre className="bg-gray-50 p-2 rounded overflow-auto text-gray-500 text-xs leading-relaxed">{viewResult.sql}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="p-6 flex gap-6 max-w-5xl">
|
||||
{/* Stack list */}
|
||||
<div className="w-52 flex-shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h1 className="text-sm font-semibold text-gray-800">Stacks</h1>
|
||||
<button onClick={() => setCreating(true)} className="text-xs text-blue-500 hover:text-blue-700">+ New</button>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="mb-3">
|
||||
<input autoFocus className="w-full border border-blue-400 rounded px-2 py-1 text-sm focus:outline-none mb-1"
|
||||
placeholder="stack name" value={newName} onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') createStack(); if (e.key === 'Escape') setCreating(false) }} />
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
<div className="flex gap-1">
|
||||
<button onClick={createStack} className="text-xs bg-blue-600 text-white px-2 py-0.5 rounded">Create</button>
|
||||
<button onClick={() => setCreating(false)} className="text-xs text-gray-400 px-2 py-0.5">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{stacks.map(s => (
|
||||
<div key={s.name}
|
||||
onClick={() => 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'}`}>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{s.label || s.name}</div>
|
||||
<div className="text-xs text-gray-400">{s.source_count} source{s.source_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<button onClick={e => { e.stopPropagation(); deleteStack(s.name) }}
|
||||
className="opacity-0 group-hover:opacity-100 text-red-300 hover:text-red-500 text-xs ml-1 flex-shrink-0">✕</button>
|
||||
</div>
|
||||
))}
|
||||
{stacks.length === 0 && !creating && (
|
||||
<p className="text-xs text-gray-400 px-2">No stacks yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail panel */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{stackDetail ? (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
{stackDetail.label || stackDetail.name}
|
||||
{stackDetail.label && <span className="text-sm text-gray-400 font-normal ml-2">{stackDetail.name}</span>}
|
||||
</h2>
|
||||
<StackPanel
|
||||
key={stackDetail.name}
|
||||
stack={stackDetail}
|
||||
sources={sources}
|
||||
onUpdated={() => { load(); loadDetail(stackDetail.name) }}
|
||||
onDeleted={() => { setSelected(null); setStackDetail(null); load() }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">Select a stack or create one.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user