Status bar, stale tracking, Pivot stack selector, stack view fixes
- Add get_status() SQL and /api/status route; load stale state on login - Replace polling with immediate client-side stale tracking via callbacks - Amber status bar with per-item Generate buttons for sources and stacks - Pivot: add stack selector to view any dfv.stack view via Perspective - Stack views: DROP CASCADE, add id to source views, per-source balance columns - net_balance = sum(all amounts) + total_offset guarantees chase+dcard=net per row - CLAUDE.md: document correct dedup spec (within-batch duplicates always allowed) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f941c5ae4a
commit
7c63a2ac29
12
CLAUDE.md
12
CLAUDE.md
@ -124,9 +124,15 @@ records.data → apply_transformations() →
|
|||||||
|
|
||||||
### Deduplication
|
### Deduplication
|
||||||
- `constraint_key` is a JSONB object of the constraint field values (readable, no hashing)
|
- `constraint_key` is a JSONB object of the constraint field values (readable, no hashing)
|
||||||
- Dedup is enforced at import time via CTE — no unique DB constraint
|
- Dedup is enforced at import time via CTE — NO unique DB constraint on constraint_key
|
||||||
- Intra-file duplicate rows are allowed (bank may send identical rows); they all insert
|
- **The constraint key is for cross-batch re-import protection, NOT record uniqueness**
|
||||||
- On re-import, all rows whose constraint_key already exists in the DB are skipped
|
- Within a single import batch, ALL rows insert regardless of duplicate constraint keys
|
||||||
|
- Banks legitimately send multiple identical-looking transactions (same date, description, amount)
|
||||||
|
- Example: 11 Cedar Point merchandise charges on one day — all should insert in one batch
|
||||||
|
- On re-import of overlapping date range, rows whose constraint_key already exists in DB are skipped
|
||||||
|
- This prevents double-counting when you re-run a month-to-date export the next day
|
||||||
|
- NEVER use `ON CONFLICT (constraint_key)` — there is no unique constraint and it would wrongly
|
||||||
|
drop legitimate duplicate transactions from the same batch
|
||||||
- Deleting an import log entry cascades to all records from that batch (import_id FK)
|
- Deleting an import log entry cascades to all records from that batch (import_id FK)
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|||||||
@ -187,7 +187,11 @@ module.exports = (pool) => {
|
|||||||
router.post('/:name/view', async (req, res, next) => {
|
router.post('/:name/view', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`SELECT generate_source_view(${lit(req.params.name)}) as result`);
|
const result = await pool.query(`SELECT generate_source_view(${lit(req.params.name)}) as result`);
|
||||||
res.json(result.rows[0].result);
|
const data = result.rows[0].result;
|
||||||
|
if (data && data.success) {
|
||||||
|
await pool.query(`UPDATE dataflow.sources SET view_generated_at = NOW() WHERE name = ${lit(req.params.name)}`);
|
||||||
|
}
|
||||||
|
res.json(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -102,7 +102,11 @@ module.exports = (pool) => {
|
|||||||
router.post('/:name/view', async (req, res, next) => {
|
router.post('/:name/view', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`SELECT generate_stack_view(${lit(req.params.name)}) AS result`);
|
const result = await pool.query(`SELECT generate_stack_view(${lit(req.params.name)}) AS result`);
|
||||||
res.json(result.rows[0].result);
|
const data = result.rows[0].result;
|
||||||
|
if (data && data.success) {
|
||||||
|
await pool.query(`UPDATE dataflow.stacks SET view_generated_at = NOW() WHERE name = ${lit(req.params.name)}`);
|
||||||
|
}
|
||||||
|
res.json(data);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
14
api/routes/status.js
Normal file
14
api/routes/status.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
module.exports = (pool) => {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT get_status() AS result');
|
||||||
|
res.json(result.rows[0].result);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@ -55,6 +55,7 @@ const rulesRoutes = require('./routes/rules');
|
|||||||
const mappingsRoutes = require('./routes/mappings');
|
const mappingsRoutes = require('./routes/mappings');
|
||||||
const recordsRoutes = require('./routes/records');
|
const recordsRoutes = require('./routes/records');
|
||||||
const stacksRoutes = require('./routes/stacks');
|
const stacksRoutes = require('./routes/stacks');
|
||||||
|
const statusRoutes = require('./routes/status');
|
||||||
|
|
||||||
// Mount routes
|
// Mount routes
|
||||||
app.use('/api/sources', sourcesRoutes(pool));
|
app.use('/api/sources', sourcesRoutes(pool));
|
||||||
@ -62,6 +63,7 @@ app.use('/api/rules', rulesRoutes(pool));
|
|||||||
app.use('/api/mappings', mappingsRoutes(pool));
|
app.use('/api/mappings', mappingsRoutes(pool));
|
||||||
app.use('/api/records', recordsRoutes(pool));
|
app.use('/api/records', recordsRoutes(pool));
|
||||||
app.use('/api/stacks', stacksRoutes(pool));
|
app.use('/api/stacks', stacksRoutes(pool));
|
||||||
|
app.use('/api/status', statusRoutes(pool));
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
|
|||||||
@ -506,10 +506,10 @@ BEGIN
|
|||||||
|
|
||||||
v_view := 'dfv.' || quote_ident(p_source_name);
|
v_view := 'dfv.' || quote_ident(p_source_name);
|
||||||
|
|
||||||
EXECUTE format('DROP VIEW IF EXISTS %s', v_view);
|
EXECUTE format('DROP VIEW IF EXISTS %s CASCADE', v_view);
|
||||||
|
|
||||||
v_sql := format(
|
v_sql := format(
|
||||||
'CREATE VIEW %s AS SELECT %s FROM dataflow.records WHERE source_name = %L AND transformed IS NOT NULL',
|
'CREATE VIEW %s AS SELECT id, %s FROM dataflow.records WHERE source_name = %L AND transformed IS NOT NULL',
|
||||||
v_view, v_cols, p_source_name
|
v_view, v_cols, p_source_name
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -256,6 +256,8 @@ DECLARE
|
|||||||
v_sql TEXT;
|
v_sql TEXT;
|
||||||
v_has_bal BOOLEAN;
|
v_has_bal BOOLEAN;
|
||||||
v_canon_cols TEXT;
|
v_canon_cols TEXT;
|
||||||
|
v_src_bal_cols TEXT;
|
||||||
|
v_total_offset NUMERIC := 0;
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name;
|
SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name;
|
||||||
IF NOT FOUND THEN
|
IF NOT FOUND THEN
|
||||||
@ -304,20 +306,21 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END LOOP;
|
END LOOP;
|
||||||
|
|
||||||
-- Per-source running balance with calibration offset baked in
|
|
||||||
IF v_has_bal AND v_src.amount_field IS NOT NULL AND v_src.date_field IS NOT NULL THEN
|
|
||||||
v_select := v_select || format(
|
|
||||||
', SUM(%I * %s) OVER (ORDER BY %I ASC, id ASC) + %s AS source_balance',
|
|
||||||
v_src.amount_field, v_src.amount_sign, v_src.date_field, v_src.balance_offset
|
|
||||||
);
|
|
||||||
ELSE
|
|
||||||
v_select := v_select || ', NULL::numeric AS source_balance';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
v_select := v_select || format(' FROM dfv.%I', v_src.source_name);
|
v_select := v_select || format(' FROM dfv.%I', v_src.source_name);
|
||||||
|
|
||||||
v_ctes := v_ctes || format('%I AS (%s)', v_src.source_name, v_select);
|
v_ctes := v_ctes || format('%I AS (%s)', v_src.source_name, v_select);
|
||||||
v_cte_names := v_cte_names || quote_ident(v_src.source_name);
|
v_cte_names := v_cte_names || quote_ident(v_src.source_name);
|
||||||
|
|
||||||
|
-- Accumulate carried-forward source balance column and total offset
|
||||||
|
IF v_has_bal THEN
|
||||||
|
IF v_src_bal_cols IS NOT NULL THEN v_src_bal_cols := v_src_bal_cols || ', '; END IF;
|
||||||
|
v_src_bal_cols := COALESCE(v_src_bal_cols, '') || format(
|
||||||
|
'SUM(CASE WHEN _source = %L THEN %I END) OVER (ORDER BY %I ASC, _id ASC) + %s AS %I',
|
||||||
|
v_src.source_name, v_stack.amount_field, v_stack.date_field,
|
||||||
|
v_src.balance_offset, v_src.source_name || '_balance'
|
||||||
|
);
|
||||||
|
v_total_offset := v_total_offset + v_src.balance_offset;
|
||||||
|
END IF;
|
||||||
END LOOP;
|
END LOOP;
|
||||||
|
|
||||||
IF array_length(v_ctes, 1) IS NULL THEN
|
IF array_length(v_ctes, 1) IS NULL THEN
|
||||||
@ -326,7 +329,7 @@ BEGIN
|
|||||||
|
|
||||||
CREATE SCHEMA IF NOT EXISTS dfv;
|
CREATE SCHEMA IF NOT EXISTS dfv;
|
||||||
v_view := 'dfv.' || quote_ident(p_stack_name);
|
v_view := 'dfv.' || quote_ident(p_stack_name);
|
||||||
EXECUTE format('DROP VIEW IF EXISTS %s', v_view);
|
EXECUTE format('DROP VIEW IF EXISTS %s CASCADE', v_view);
|
||||||
|
|
||||||
v_canon_cols := (
|
v_canon_cols := (
|
||||||
SELECT string_agg(quote_ident(f->>'name'), ', ')
|
SELECT string_agg(quote_ident(f->>'name'), ', ')
|
||||||
@ -334,20 +337,24 @@ BEGIN
|
|||||||
);
|
);
|
||||||
|
|
||||||
IF v_has_bal THEN
|
IF v_has_bal THEN
|
||||||
-- net_balance: cumulative signed amount across all sources + stack-level offset
|
-- net_balance = sum of all signed amounts + total of all source offsets
|
||||||
|
-- This ensures net_balance = sum of per-source carried-forward balances on every row
|
||||||
v_sql := format(
|
v_sql := format(
|
||||||
'CREATE VIEW %s AS '
|
'CREATE VIEW %s AS '
|
||||||
'WITH %s, _stacked AS (SELECT * FROM %s) '
|
'WITH %s, _stacked AS (SELECT * FROM %s) '
|
||||||
'SELECT _source, _id, %s, source_balance, '
|
'SELECT _source, _id, %s, '
|
||||||
'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance '
|
'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance, '
|
||||||
'FROM _stacked',
|
'%s '
|
||||||
|
'FROM _stacked ORDER BY %I DESC, _id DESC',
|
||||||
v_view,
|
v_view,
|
||||||
array_to_string(v_ctes, ', '),
|
array_to_string(v_ctes, ', '),
|
||||||
array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '),
|
array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '),
|
||||||
v_canon_cols,
|
v_canon_cols,
|
||||||
v_stack.amount_field,
|
v_stack.amount_field,
|
||||||
v_stack.date_field,
|
v_stack.date_field,
|
||||||
v_stack.balance_offset
|
v_total_offset,
|
||||||
|
v_src_bal_cols,
|
||||||
|
v_stack.date_field
|
||||||
);
|
);
|
||||||
ELSE
|
ELSE
|
||||||
v_sql := format(
|
v_sql := format(
|
||||||
|
|||||||
85
database/queries/status.sql
Normal file
85
database/queries/status.sql
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
--
|
||||||
|
-- Status tracking: view_generated_at on sources and stacks
|
||||||
|
-- Cleared by triggers when definitions change; set by API when views are generated.
|
||||||
|
--
|
||||||
|
|
||||||
|
SET search_path TO dataflow, public;
|
||||||
|
|
||||||
|
-- Add view_generated_at columns
|
||||||
|
ALTER TABLE dataflow.sources ADD COLUMN IF NOT EXISTS view_generated_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE dataflow.stacks ADD COLUMN IF NOT EXISTS view_generated_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
------------------------------------------------------
|
||||||
|
-- Trigger: clear source view_generated_at when rules change
|
||||||
|
------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION dataflow.rules_changed()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE dataflow.sources SET view_generated_at = NULL
|
||||||
|
WHERE name = COALESCE(NEW.source_name, OLD.source_name);
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_rules_changed ON dataflow.rules;
|
||||||
|
CREATE TRIGGER trg_rules_changed
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON dataflow.rules
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION dataflow.rules_changed();
|
||||||
|
|
||||||
|
------------------------------------------------------
|
||||||
|
-- Trigger: clear source view_generated_at when mappings change
|
||||||
|
------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION dataflow.mappings_changed()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE dataflow.sources SET view_generated_at = NULL
|
||||||
|
WHERE name = COALESCE(NEW.source_name, OLD.source_name);
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_mappings_changed ON dataflow.mappings;
|
||||||
|
CREATE TRIGGER trg_mappings_changed
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON dataflow.mappings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION dataflow.mappings_changed();
|
||||||
|
|
||||||
|
------------------------------------------------------
|
||||||
|
-- Trigger: clear stack view_generated_at when sources change
|
||||||
|
------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION dataflow.stack_sources_changed()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE dataflow.stacks SET view_generated_at = NULL
|
||||||
|
WHERE name = COALESCE(NEW.stack_name, OLD.stack_name);
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_stack_sources_changed ON dataflow.stack_sources;
|
||||||
|
CREATE TRIGGER trg_stack_sources_changed
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON dataflow.stack_sources
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION dataflow.stack_sources_changed();
|
||||||
|
|
||||||
|
------------------------------------------------------
|
||||||
|
-- Function: get_status
|
||||||
|
-- Returns sources and stacks whose view is stale (null or never generated)
|
||||||
|
------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION get_status()
|
||||||
|
RETURNS JSON AS $$
|
||||||
|
DECLARE
|
||||||
|
v_sources JSON;
|
||||||
|
v_stacks JSON;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(json_agg(json_build_object('name', name, 'view_generated_at', view_generated_at) ORDER BY name), '[]'::json)
|
||||||
|
INTO v_sources
|
||||||
|
FROM dataflow.sources
|
||||||
|
WHERE view_generated_at IS NULL;
|
||||||
|
|
||||||
|
SELECT COALESCE(json_agg(json_build_object('name', name, 'view_generated_at', view_generated_at) ORDER BY name), '[]'::json)
|
||||||
|
INTO v_stacks
|
||||||
|
FROM dataflow.stacks
|
||||||
|
WHERE view_generated_at IS NULL;
|
||||||
|
|
||||||
|
RETURN json_build_object('stale_sources', v_sources, 'stale_stacks', v_stacks);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
@ -30,6 +30,10 @@ export default function App() {
|
|||||||
const [sources, setSources] = useState([])
|
const [sources, setSources] = useState([])
|
||||||
const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
|
const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
// Sets of names whose dfv view is out of sync with current definitions
|
||||||
|
const [staleSources, setStaleSources] = useState(new Set())
|
||||||
|
const [staleStacks, setStaleStacks] = useState(new Set())
|
||||||
|
const [generating, setGenerating] = useState({}) // { 'source:name': true }
|
||||||
|
|
||||||
async function handleLogin(user, pass) {
|
async function handleLogin(user, pass) {
|
||||||
setCredentials(user, pass)
|
setCredentials(user, pass)
|
||||||
@ -50,6 +54,45 @@ export default function App() {
|
|||||||
setAuthed(false)
|
setAuthed(false)
|
||||||
setLoginUser('')
|
setLoginUser('')
|
||||||
setSources([])
|
setSources([])
|
||||||
|
setStaleSources(new Set())
|
||||||
|
setStaleStacks(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial stale state from DB once on login
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authed) return
|
||||||
|
api.getStatus().then(s => {
|
||||||
|
setStaleSources(new Set((s.stale_sources || []).map(x => x.name)))
|
||||||
|
setStaleStacks(new Set((s.stale_stacks || []).map(x => x.name)))
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [authed])
|
||||||
|
|
||||||
|
function markSourceStale(name) {
|
||||||
|
setStaleSources(prev => new Set([...prev, name]))
|
||||||
|
}
|
||||||
|
function markStackStale(name) {
|
||||||
|
setStaleStacks(prev => new Set([...prev, name]))
|
||||||
|
}
|
||||||
|
function clearStackStale(name) {
|
||||||
|
setStaleStacks(prev => { const n = new Set(prev); n.delete(name); return n })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerateSource(name) {
|
||||||
|
setGenerating(g => ({ ...g, [`src:${name}`]: true }))
|
||||||
|
try {
|
||||||
|
await api.generateView(name)
|
||||||
|
setStaleSources(prev => { const n = new Set(prev); n.delete(name); return n })
|
||||||
|
} catch (e) { alert(e.message) }
|
||||||
|
finally { setGenerating(g => { const n = { ...g }; delete n[`src:${name}`]; return n }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerateStack(name) {
|
||||||
|
setGenerating(g => ({ ...g, [`stk:${name}`]: true }))
|
||||||
|
try {
|
||||||
|
await api.generateStackView(name)
|
||||||
|
setStaleStacks(prev => { const n = new Set(prev); n.delete(name); return n })
|
||||||
|
} catch (e) { alert(e.message) }
|
||||||
|
finally { setGenerating(g => { const n = { ...g }; delete n[`stk:${name}`]; return n }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// On mount, restore session if credentials are saved
|
// On mount, restore session if credentials are saved
|
||||||
@ -141,17 +184,48 @@ export default function App() {
|
|||||||
<span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
|
<span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(staleSources.size > 0 || staleStacks.size > 0) && (
|
||||||
|
<div className="bg-amber-50 border-b border-amber-200 px-4 py-1.5 text-xs text-amber-800 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<span className="font-medium">View out of sync:</span>
|
||||||
|
{[...staleSources].map(name => (
|
||||||
|
<span key={name} className="flex items-center gap-1">
|
||||||
|
{name}
|
||||||
|
<button
|
||||||
|
onClick={() => handleGenerateSource(name)}
|
||||||
|
disabled={generating[`src:${name}`]}
|
||||||
|
className="px-1.5 py-0.5 rounded bg-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium"
|
||||||
|
>
|
||||||
|
{generating[`src:${name}`] ? '…' : 'Generate'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{staleSources.size > 0 && staleStacks.size > 0 && <span className="text-amber-400">|</span>}
|
||||||
|
{[...staleStacks].map(name => (
|
||||||
|
<span key={name} className="flex items-center gap-1">
|
||||||
|
stack: {name}
|
||||||
|
<button
|
||||||
|
onClick={() => handleGenerateStack(name)}
|
||||||
|
disabled={generating[`stk:${name}`]}
|
||||||
|
className="px-1.5 py-0.5 rounded bg-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium"
|
||||||
|
>
|
||||||
|
{generating[`stk:${name}`] ? '…' : 'Generate'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/sources" replace />} />
|
<Route path="/" element={<Navigate to="/sources" replace />} />
|
||||||
<Route path="/sources" element={<Sources source={source} sources={sources} setSources={setSources} setSource={setSource} />} />
|
<Route path="/sources" element={<Sources source={source} sources={sources} setSources={setSources} setSource={setSource} />} />
|
||||||
<Route path="/import" element={<Import source={source} />} />
|
<Route path="/import" element={<Import source={source} />} />
|
||||||
<Route path="/rules" element={<Rules source={source} />} />
|
<Route path="/rules" element={<Rules source={source} onStale={markSourceStale} />} />
|
||||||
<Route path="/mappings" element={<Mappings source={source} />} />
|
<Route path="/mappings" element={<Mappings source={source} onStale={markSourceStale} />} />
|
||||||
<Route path="/remap" element={<Remap />} />
|
<Route path="/remap" element={<Remap />} />
|
||||||
<Route path="/records" element={<Records source={source} />} />
|
<Route path="/records" element={<Records source={source} />} />
|
||||||
<Route path="/pivot" element={<Pivot source={source} />} />
|
<Route path="/pivot" element={<Pivot source={source} />} />
|
||||||
<Route path="/stacks" element={<Stacks sources={sources} />} />
|
<Route path="/stacks" element={<Stacks sources={sources} onStackStale={markStackStale} onStackViewGenerated={clearStackStale} />} />
|
||||||
<Route path="/log" element={<Log />} />
|
<Route path="/log" element={<Log />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -125,6 +125,9 @@ export const api = {
|
|||||||
getStackBalance: (name) => request('GET', `/stacks/${name}/balance`),
|
getStackBalance: (name) => request('GET', `/stacks/${name}/balance`),
|
||||||
calibrateBalance: (name, source, body) => request('POST', `/stacks/${name}/calibrate`, { ...body, source_name: source || null }),
|
calibrateBalance: (name, source, body) => request('POST', `/stacks/${name}/calibrate`, { ...body, source_name: source || null }),
|
||||||
|
|
||||||
|
// Status
|
||||||
|
getStatus: () => request('GET', '/status'),
|
||||||
|
|
||||||
// Records
|
// Records
|
||||||
getRecords: (source, limit = 100, offset = 0) =>
|
getRecords: (source, limit = 100, offset = 0) =>
|
||||||
request('GET', `/records/source/${source}?limit=${limit}&offset=${offset}`),
|
request('GET', `/records/source/${source}?limit=${limit}&offset=${offset}`),
|
||||||
|
|||||||
@ -104,7 +104,7 @@ function SortHeader({ col, label, sortBy, onSort, className = '' }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Mappings({ source }) {
|
export default function Mappings({ source, onStale }) {
|
||||||
const [rules, setRules] = useState([])
|
const [rules, setRules] = useState([])
|
||||||
const [selectedRule, setSelectedRule] = useState('')
|
const [selectedRule, setSelectedRule] = useState('')
|
||||||
const [allValues, setAllValues] = useState([])
|
const [allValues, setAllValues] = useState([])
|
||||||
@ -263,6 +263,7 @@ export default function Mappings({ source }) {
|
|||||||
valueKey(x.extracted_value) === k ? { ...x, is_mapped: true, mapping_id: created.id, output } : x
|
valueKey(x.extracted_value) === k ? { ...x, is_mapped: true, mapping_id: created.id, output } : x
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
onStale?.(source)
|
||||||
setDrafts(d => { const n = { ...d }; delete n[k]; return n })
|
setDrafts(d => { const n = { ...d }; delete n[k]; return n })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message)
|
alert(err.message)
|
||||||
@ -309,6 +310,7 @@ export default function Mappings({ source }) {
|
|||||||
setSaving(s => ({ ...s, [k]: false }))
|
setSaving(s => ({ ...s, [k]: false }))
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
onStale?.(source)
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
setBulkDraft({})
|
setBulkDraft({})
|
||||||
}
|
}
|
||||||
@ -317,6 +319,7 @@ export default function Mappings({ source }) {
|
|||||||
if (!row.mapping_id) return
|
if (!row.mapping_id) return
|
||||||
try {
|
try {
|
||||||
await api.deleteMapping(row.mapping_id)
|
await api.deleteMapping(row.mapping_id)
|
||||||
|
onStale?.(source)
|
||||||
setAllValues(av => av.map(x =>
|
setAllValues(av => av.map(x =>
|
||||||
valueKey(x.extracted_value) === valueKey(row.extracted_value)
|
valueKey(x.extracted_value) === valueKey(row.extracted_value)
|
||||||
? { ...x, is_mapped: false, mapping_id: null, output: null }
|
? { ...x, is_mapped: false, mapping_id: null, output: null }
|
||||||
|
|||||||
@ -92,7 +92,22 @@ export default function Pivot({ source }) {
|
|||||||
const [clickDetail, setClickDetail] = useState(null)
|
const [clickDetail, setClickDetail] = useState(null)
|
||||||
const [decimals, setDecimals] = useState(2)
|
const [decimals, setDecimals] = useState(2)
|
||||||
|
|
||||||
// Named layouts
|
// View selector: source or a stack
|
||||||
|
const [stacks, setStacks] = useState([])
|
||||||
|
const [selectedView, setSelectedView] = useState(source) // name of active dfv view
|
||||||
|
const [viewType, setViewType] = useState('source') // 'source' | 'stack'
|
||||||
|
|
||||||
|
useEffect(() => { api.getStacks().then(setStacks).catch(() => {}) }, [])
|
||||||
|
|
||||||
|
// When sidebar source changes, reset to that source
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewType === 'source') setSelectedView(source)
|
||||||
|
}, [source])
|
||||||
|
|
||||||
|
function selectSource() { setViewType('source'); setSelectedView(source) }
|
||||||
|
function selectStack(name) { setViewType('stack'); setSelectedView(name) }
|
||||||
|
|
||||||
|
// Named layouts — stacks use localStorage only (no server FK to sources)
|
||||||
const [layouts, setLayouts] = useState([])
|
const [layouts, setLayouts] = useState([])
|
||||||
const [activeLayoutId, setActiveLayoutId] = useState(null)
|
const [activeLayoutId, setActiveLayoutId] = useState(null)
|
||||||
const [saveAsName, setSaveAsName] = useState('')
|
const [saveAsName, setSaveAsName] = useState('')
|
||||||
@ -105,15 +120,21 @@ export default function Pivot({ source }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadLayouts = useCallback(async () => {
|
const loadLayouts = useCallback(async () => {
|
||||||
if (!source) return
|
if (!selectedView) return
|
||||||
try {
|
try {
|
||||||
const rows = await api.getPivotLayouts(source)
|
if (viewType === 'source') {
|
||||||
|
const rows = await api.getPivotLayouts(selectedView)
|
||||||
setLayouts(rows)
|
setLayouts(rows)
|
||||||
|
} else {
|
||||||
|
// Stacks: localStorage only
|
||||||
|
const stored = localStorage.getItem(`psp_layouts_stack_${selectedView}`)
|
||||||
|
setLayouts(stored ? JSON.parse(stored) : [])
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [source])
|
}, [selectedView, viewType])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!source) return
|
if (!selectedView) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setInspectedRows(null)
|
setInspectedRows(null)
|
||||||
setClickDetail(null)
|
setClickDetail(null)
|
||||||
@ -129,7 +150,7 @@ export default function Pivot({ source }) {
|
|||||||
try {
|
try {
|
||||||
const [perspective, rows] = await Promise.all([
|
const [perspective, rows] = await Promise.all([
|
||||||
loadPerspective(),
|
loadPerspective(),
|
||||||
fetchAllRows(source),
|
fetchAllRows(selectedView),
|
||||||
])
|
])
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (!rows.length) { setStatus('noview'); return }
|
if (!rows.length) { setStatus('noview'); return }
|
||||||
@ -142,7 +163,7 @@ export default function Pivot({ source }) {
|
|||||||
if (cancelled) { worker.terminate(); return }
|
if (cancelled) { worker.terminate(); return }
|
||||||
workerRef.current = worker
|
workerRef.current = worker
|
||||||
|
|
||||||
const table = await worker.table(rows, { name: source })
|
const table = await worker.table(rows, { name: selectedView })
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
tableRef.current = table
|
tableRef.current = table
|
||||||
|
|
||||||
@ -185,14 +206,14 @@ export default function Pivot({ source }) {
|
|||||||
await viewer.load(worker)
|
await viewer.load(worker)
|
||||||
|
|
||||||
const plugin = await viewer.getPlugin()
|
const plugin = await viewer.getPlugin()
|
||||||
const savedLayout = localStorage.getItem(LAYOUT_KEY(source))
|
const savedLayout = localStorage.getItem(LAYOUT_KEY(selectedView))
|
||||||
if (savedLayout) {
|
if (savedLayout) {
|
||||||
const parsed = JSON.parse(savedLayout)
|
const parsed = JSON.parse(savedLayout)
|
||||||
await viewer.restore(parsed)
|
await viewer.restore(parsed)
|
||||||
await plugin.restore(parsed.plugin_config || DEFAULT_PLUGIN_CONFIG)
|
await plugin.restore(parsed.plugin_config || DEFAULT_PLUGIN_CONFIG)
|
||||||
if (parsed.expand_depth != null) await applyExpandDepth(viewer, parsed.expand_depth)
|
if (parsed.expand_depth != null) await applyExpandDepth(viewer, parsed.expand_depth)
|
||||||
} else {
|
} else {
|
||||||
await viewer.restore({ table: source, settings: false, plugin_config: DEFAULT_PLUGIN_CONFIG })
|
await viewer.restore({ table: selectedView, settings: false, plugin_config: DEFAULT_PLUGIN_CONFIG })
|
||||||
await plugin.restore(DEFAULT_PLUGIN_CONFIG)
|
await plugin.restore(DEFAULT_PLUGIN_CONFIG)
|
||||||
}
|
}
|
||||||
await viewer.flush()
|
await viewer.flush()
|
||||||
@ -205,7 +226,7 @@ export default function Pivot({ source }) {
|
|||||||
|
|
||||||
init()
|
init()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [source])
|
}, [selectedView])
|
||||||
|
|
||||||
async function applyExpandDepth(viewer, depth) {
|
async function applyExpandDepth(viewer, depth) {
|
||||||
if (depth == null) return
|
if (depth == null) return
|
||||||
@ -226,8 +247,7 @@ export default function Pivot({ source }) {
|
|||||||
}
|
}
|
||||||
await applyExpandDepth(viewer, layout.config.expand_depth ?? null)
|
await applyExpandDepth(viewer, layout.config.expand_depth ?? null)
|
||||||
setActiveLayoutId(layout.id)
|
setActiveLayoutId(layout.id)
|
||||||
// also persist to localStorage so it survives refresh
|
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(layout.config))
|
||||||
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout.config))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function captureConfig() {
|
async function captureConfig() {
|
||||||
@ -244,10 +264,15 @@ export default function Pivot({ source }) {
|
|||||||
const config = await captureConfig()
|
const config = await captureConfig()
|
||||||
if (!config) return
|
if (!config) return
|
||||||
try {
|
try {
|
||||||
const saved = await api.savePivotLayout(source, layout.layout_name, config)
|
if (viewType === 'source') {
|
||||||
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(config))
|
const saved = await api.savePivotLayout(selectedView, layout.layout_name, config)
|
||||||
await loadLayouts()
|
|
||||||
setActiveLayoutId(saved.id)
|
setActiveLayoutId(saved.id)
|
||||||
|
} else {
|
||||||
|
const updated = layouts.map(l => l.id === activeLayoutId ? { ...l, config } : l)
|
||||||
|
localStorage.setItem(`psp_layouts_stack_${selectedView}`, JSON.stringify(updated))
|
||||||
|
}
|
||||||
|
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(config))
|
||||||
|
await loadLayouts()
|
||||||
flashMsg('Saved!')
|
flashMsg('Saved!')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
flashMsg(err.message)
|
flashMsg(err.message)
|
||||||
@ -260,10 +285,18 @@ export default function Pivot({ source }) {
|
|||||||
const config = await captureConfig()
|
const config = await captureConfig()
|
||||||
if (!config) return
|
if (!config) return
|
||||||
try {
|
try {
|
||||||
const saved = await api.savePivotLayout(source, name, config)
|
let newId
|
||||||
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(config))
|
if (viewType === 'source') {
|
||||||
|
const saved = await api.savePivotLayout(selectedView, name, config)
|
||||||
|
newId = saved.id
|
||||||
|
} else {
|
||||||
|
newId = Date.now()
|
||||||
|
const updated = [...layouts, { id: newId, layout_name: name, config }]
|
||||||
|
localStorage.setItem(`psp_layouts_stack_${selectedView}`, JSON.stringify(updated))
|
||||||
|
}
|
||||||
|
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(config))
|
||||||
await loadLayouts()
|
await loadLayouts()
|
||||||
setActiveLayoutId(saved.id)
|
setActiveLayoutId(newId)
|
||||||
setShowSaveAs(false)
|
setShowSaveAs(false)
|
||||||
setSaveAsName('')
|
setSaveAsName('')
|
||||||
flashMsg('Saved!')
|
flashMsg('Saved!')
|
||||||
@ -275,7 +308,12 @@ export default function Pivot({ source }) {
|
|||||||
async function handleDelete(layout, e) {
|
async function handleDelete(layout, e) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
try {
|
try {
|
||||||
await api.deletePivotLayout(source, layout.id)
|
if (viewType === 'source') {
|
||||||
|
await api.deletePivotLayout(selectedView, layout.id)
|
||||||
|
} else {
|
||||||
|
const updated = layouts.filter(l => l.id !== layout.id)
|
||||||
|
localStorage.setItem(`psp_layouts_stack_${selectedView}`, JSON.stringify(updated))
|
||||||
|
}
|
||||||
if (activeLayoutId === layout.id) setActiveLayoutId(null)
|
if (activeLayoutId === layout.id) setActiveLayoutId(null)
|
||||||
await loadLayouts()
|
await loadLayouts()
|
||||||
flashMsg('Deleted')
|
flashMsg('Deleted')
|
||||||
@ -287,9 +325,9 @@ export default function Pivot({ source }) {
|
|||||||
function handleResetToDefault() {
|
function handleResetToDefault() {
|
||||||
const viewer = viewerRef.current
|
const viewer = viewerRef.current
|
||||||
if (!viewer) return
|
if (!viewer) return
|
||||||
localStorage.removeItem(LAYOUT_KEY(source))
|
localStorage.removeItem(LAYOUT_KEY(selectedView))
|
||||||
setActiveLayoutId(null)
|
setActiveLayoutId(null)
|
||||||
viewer.restore({ table: source, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
|
viewer.restore({ table: selectedView, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
|
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
|
||||||
@ -316,6 +354,21 @@ export default function Pivot({ source }) {
|
|||||||
|
|
||||||
{/* Layout toolbar */}
|
{/* Layout toolbar */}
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-white border-b border-gray-200 flex-shrink-0">
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-white border-b border-gray-200 flex-shrink-0">
|
||||||
|
|
||||||
|
{/* View selector */}
|
||||||
|
<div className="flex items-center gap-1 mr-2 border-r border-gray-200 pr-3">
|
||||||
|
<button
|
||||||
|
onClick={selectSource}
|
||||||
|
className={`text-xs rounded px-2 py-0.5 border transition-colors ${viewType === 'source' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-500 hover:border-gray-400'}`}
|
||||||
|
>{source}</button>
|
||||||
|
{stacks.map(s => (
|
||||||
|
<button key={s.name}
|
||||||
|
onClick={() => selectStack(s.name)}
|
||||||
|
className={`text-xs rounded px-2 py-0.5 border transition-colors ${viewType === 'stack' && selectedView === s.name ? 'bg-purple-50 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-500 hover:border-gray-400'}`}
|
||||||
|
>{s.name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<span className="text-xs text-gray-400 uppercase tracking-wide mr-1">Layouts</span>
|
<span className="text-xs text-gray-400 uppercase tracking-wide mr-1">Layouts</span>
|
||||||
|
|
||||||
{layouts.map(l => (
|
{layouts.map(l => (
|
||||||
|
|||||||
@ -213,7 +213,7 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Rules({ source }) {
|
export default function Rules({ source, onStale }) {
|
||||||
const [rules, setRules] = useState([])
|
const [rules, setRules] = useState([])
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [editing, setEditing] = useState(null)
|
const [editing, setEditing] = useState(null)
|
||||||
@ -266,6 +266,7 @@ export default function Rules({ source }) {
|
|||||||
} else {
|
} else {
|
||||||
await api.createRule({ ...form, source_name: source })
|
await api.createRule({ ...form, source_name: source })
|
||||||
}
|
}
|
||||||
|
onStale?.(source)
|
||||||
const updated = await api.getRules(source)
|
const updated = await api.getRules(source)
|
||||||
setRules(updated)
|
setRules(updated)
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
@ -282,6 +283,7 @@ export default function Rules({ source }) {
|
|||||||
if (!confirm('Delete this rule and all its mappings?')) return
|
if (!confirm('Delete this rule and all its mappings?')) return
|
||||||
try {
|
try {
|
||||||
await api.deleteRule(id)
|
await api.deleteRule(id)
|
||||||
|
onStale?.(source)
|
||||||
setRules(r => r.filter(x => x.id !== id))
|
setRules(r => r.filter(x => x.id !== id))
|
||||||
setTestResults(t => { const n = { ...t }; delete n[id]; return n })
|
setTestResults(t => { const n = { ...t }; delete n[id]; return n })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -301,6 +303,7 @@ export default function Rules({ source }) {
|
|||||||
async function handleToggle(rule) {
|
async function handleToggle(rule) {
|
||||||
try {
|
try {
|
||||||
await api.updateRule(rule.id, { enabled: !rule.enabled })
|
await api.updateRule(rule.id, { enabled: !rule.enabled })
|
||||||
|
onStale?.(source)
|
||||||
setRules(r => r.map(x => x.id === rule.id ? { ...x, enabled: !x.enabled } : x))
|
setRules(r => r.map(x => x.id === rule.id ? { ...x, enabled: !x.enabled } : x))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message)
|
alert(err.message)
|
||||||
|
|||||||
@ -79,7 +79,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
|
|||||||
|
|
||||||
// ── Stack panel ────────────────────────────────────────────────────────────────
|
// ── Stack panel ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StackPanel({ stack, sources, onUpdated }) {
|
function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) {
|
||||||
const members = stack.sources || []
|
const members = stack.sources || []
|
||||||
|
|
||||||
const [label, setLabel] = useState(stack.label || '')
|
const [label, setLabel] = useState(stack.label || '')
|
||||||
@ -226,6 +226,7 @@ function StackPanel({ stack, sources, onUpdated }) {
|
|||||||
date_field: dateCanonical || null,
|
date_field: dateCanonical || null,
|
||||||
})
|
})
|
||||||
setMappingsDirty(false)
|
setMappingsDirty(false)
|
||||||
|
onStale?.(stack.name)
|
||||||
onUpdated()
|
onUpdated()
|
||||||
} catch (e) { setError(e.message) }
|
} catch (e) { setError(e.message) }
|
||||||
finally { setSaving(false) }
|
finally { setSaving(false) }
|
||||||
@ -242,6 +243,7 @@ function StackPanel({ stack, sources, onUpdated }) {
|
|||||||
setSrcFields(prev => ({ ...prev, [addingSrc]: f.map(x => x.key) }))
|
setSrcFields(prev => ({ ...prev, [addingSrc]: f.map(x => x.key) }))
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
setAddingSrc('')
|
setAddingSrc('')
|
||||||
|
onStale?.(stack.name)
|
||||||
onUpdated()
|
onUpdated()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,6 +278,7 @@ function StackPanel({ stack, sources, onUpdated }) {
|
|||||||
async function removeSource(src) {
|
async function removeSource(src) {
|
||||||
await api.removeStackSource(stack.name, src)
|
await api.removeStackSource(stack.name, src)
|
||||||
setSrcCfg(prev => { const n = { ...prev }; delete n[src]; return n })
|
setSrcCfg(prev => { const n = { ...prev }; delete n[src]; return n })
|
||||||
|
onStale?.(stack.name)
|
||||||
onUpdated()
|
onUpdated()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -303,6 +306,7 @@ function StackPanel({ stack, sources, onUpdated }) {
|
|||||||
})
|
})
|
||||||
setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], offset } }))
|
setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], offset } }))
|
||||||
setCalibratingSource(null)
|
setCalibratingSource(null)
|
||||||
|
onStale?.(stack.name)
|
||||||
onUpdated()
|
onUpdated()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -312,7 +316,7 @@ function StackPanel({ stack, sources, onUpdated }) {
|
|||||||
try {
|
try {
|
||||||
const r = await api.generateStackView(stack.name)
|
const r = await api.generateStackView(stack.name)
|
||||||
setViewResult(r)
|
setViewResult(r)
|
||||||
if (r.success) fetchBalance()
|
if (r.success) { fetchBalance(); onViewGenerated?.(stack.name) }
|
||||||
} catch (e) { setError(e.message) }
|
} catch (e) { setError(e.message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -570,7 +574,7 @@ function StackPanel({ stack, sources, onUpdated }) {
|
|||||||
|
|
||||||
// ── Main page ──────────────────────────────────────────────────────────────────
|
// ── Main page ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function Stacks({ sources }) {
|
export default function Stacks({ sources, onStackStale, onStackViewGenerated }) {
|
||||||
const [stacks, setStacks] = useState([])
|
const [stacks, setStacks] = useState([])
|
||||||
const [selected, setSelected] = useState(null)
|
const [selected, setSelected] = useState(null)
|
||||||
const [stackDetail, setStackDetail] = useState(null)
|
const [stackDetail, setStackDetail] = useState(null)
|
||||||
@ -651,6 +655,8 @@ export default function Stacks({ sources }) {
|
|||||||
stack={stackDetail}
|
stack={stackDetail}
|
||||||
sources={sources}
|
sources={sources}
|
||||||
onUpdated={() => { load(); loadDetail(stackDetail.name) }}
|
onUpdated={() => { load(); loadDetail(stackDetail.name) }}
|
||||||
|
onStale={onStackStale}
|
||||||
|
onViewGenerated={onStackViewGenerated}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user