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
|
||||
- `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
|
||||
- Intra-file duplicate rows are allowed (bank may send identical rows); they all insert
|
||||
- On re-import, all rows whose constraint_key already exists in the DB are skipped
|
||||
- Dedup is enforced at import time via CTE — NO unique DB constraint on constraint_key
|
||||
- **The constraint key is for cross-batch re-import protection, NOT record uniqueness**
|
||||
- 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)
|
||||
|
||||
### Error Handling
|
||||
|
||||
@ -187,7 +187,11 @@ module.exports = (pool) => {
|
||||
router.post('/:name/view', async (req, res, next) => {
|
||||
try {
|
||||
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) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
@ -102,7 +102,11 @@ module.exports = (pool) => {
|
||||
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);
|
||||
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); }
|
||||
});
|
||||
|
||||
|
||||
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 recordsRoutes = require('./routes/records');
|
||||
const stacksRoutes = require('./routes/stacks');
|
||||
const statusRoutes = require('./routes/status');
|
||||
|
||||
// Mount routes
|
||||
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/records', recordsRoutes(pool));
|
||||
app.use('/api/stacks', stacksRoutes(pool));
|
||||
app.use('/api/status', statusRoutes(pool));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
|
||||
@ -506,10 +506,10 @@ BEGIN
|
||||
|
||||
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(
|
||||
'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
|
||||
);
|
||||
|
||||
|
||||
@ -256,6 +256,8 @@ DECLARE
|
||||
v_sql TEXT;
|
||||
v_has_bal BOOLEAN;
|
||||
v_canon_cols TEXT;
|
||||
v_src_bal_cols TEXT;
|
||||
v_total_offset NUMERIC := 0;
|
||||
BEGIN
|
||||
SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name;
|
||||
IF NOT FOUND THEN
|
||||
@ -304,20 +306,21 @@ BEGIN
|
||||
END IF;
|
||||
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_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);
|
||||
|
||||
-- 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;
|
||||
|
||||
IF array_length(v_ctes, 1) IS NULL THEN
|
||||
@ -326,7 +329,7 @@ BEGIN
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS dfv;
|
||||
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 := (
|
||||
SELECT string_agg(quote_ident(f->>'name'), ', ')
|
||||
@ -334,20 +337,24 @@ BEGIN
|
||||
);
|
||||
|
||||
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(
|
||||
'CREATE VIEW %s AS '
|
||||
'WITH %s, _stacked AS (SELECT * FROM %s) '
|
||||
'SELECT _source, _id, %s, source_balance, '
|
||||
'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance '
|
||||
'FROM _stacked',
|
||||
'SELECT _source, _id, %s, '
|
||||
'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance, '
|
||||
'%s '
|
||||
'FROM _stacked ORDER BY %I DESC, _id DESC',
|
||||
v_view,
|
||||
array_to_string(v_ctes, ', '),
|
||||
array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '),
|
||||
v_canon_cols,
|
||||
v_stack.amount_field,
|
||||
v_stack.date_field,
|
||||
v_stack.balance_offset
|
||||
v_total_offset,
|
||||
v_src_bal_cols,
|
||||
v_stack.date_field
|
||||
);
|
||||
ELSE
|
||||
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 [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
|
||||
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) {
|
||||
setCredentials(user, pass)
|
||||
@ -50,6 +54,45 @@ export default function App() {
|
||||
setAuthed(false)
|
||||
setLoginUser('')
|
||||
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
|
||||
@ -141,17 +184,48 @@ export default function App() {
|
||||
<span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
|
||||
</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">
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/sources" replace />} />
|
||||
<Route path="/sources" element={<Sources source={source} sources={sources} setSources={setSources} setSource={setSource} />} />
|
||||
<Route path="/import" element={<Import source={source} />} />
|
||||
<Route path="/rules" element={<Rules source={source} />} />
|
||||
<Route path="/mappings" element={<Mappings source={source} />} />
|
||||
<Route path="/rules" element={<Rules source={source} onStale={markSourceStale} />} />
|
||||
<Route path="/mappings" element={<Mappings source={source} onStale={markSourceStale} />} />
|
||||
<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="/stacks" element={<Stacks sources={sources} onStackStale={markStackStale} onStackViewGenerated={clearStackStale} />} />
|
||||
<Route path="/log" element={<Log />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@ -125,6 +125,9 @@ export const api = {
|
||||
getStackBalance: (name) => request('GET', `/stacks/${name}/balance`),
|
||||
calibrateBalance: (name, source, body) => request('POST', `/stacks/${name}/calibrate`, { ...body, source_name: source || null }),
|
||||
|
||||
// Status
|
||||
getStatus: () => request('GET', '/status'),
|
||||
|
||||
// Records
|
||||
getRecords: (source, limit = 100, offset = 0) =>
|
||||
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 [selectedRule, setSelectedRule] = 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
|
||||
))
|
||||
}
|
||||
onStale?.(source)
|
||||
setDrafts(d => { const n = { ...d }; delete n[k]; return n })
|
||||
} catch (err) {
|
||||
alert(err.message)
|
||||
@ -309,6 +310,7 @@ export default function Mappings({ source }) {
|
||||
setSaving(s => ({ ...s, [k]: false }))
|
||||
}
|
||||
}))
|
||||
onStale?.(source)
|
||||
setSelected(new Set())
|
||||
setBulkDraft({})
|
||||
}
|
||||
@ -317,6 +319,7 @@ export default function Mappings({ source }) {
|
||||
if (!row.mapping_id) return
|
||||
try {
|
||||
await api.deleteMapping(row.mapping_id)
|
||||
onStale?.(source)
|
||||
setAllValues(av => av.map(x =>
|
||||
valueKey(x.extracted_value) === valueKey(row.extracted_value)
|
||||
? { ...x, is_mapped: false, mapping_id: null, output: null }
|
||||
|
||||
@ -92,7 +92,22 @@ export default function Pivot({ source }) {
|
||||
const [clickDetail, setClickDetail] = useState(null)
|
||||
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 [activeLayoutId, setActiveLayoutId] = useState(null)
|
||||
const [saveAsName, setSaveAsName] = useState('')
|
||||
@ -105,15 +120,21 @@ export default function Pivot({ source }) {
|
||||
}
|
||||
|
||||
const loadLayouts = useCallback(async () => {
|
||||
if (!source) return
|
||||
if (!selectedView) return
|
||||
try {
|
||||
const rows = await api.getPivotLayouts(source)
|
||||
if (viewType === 'source') {
|
||||
const rows = await api.getPivotLayouts(selectedView)
|
||||
setLayouts(rows)
|
||||
} else {
|
||||
// Stacks: localStorage only
|
||||
const stored = localStorage.getItem(`psp_layouts_stack_${selectedView}`)
|
||||
setLayouts(stored ? JSON.parse(stored) : [])
|
||||
}
|
||||
} catch {}
|
||||
}, [source])
|
||||
}, [selectedView, viewType])
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) return
|
||||
if (!selectedView) return
|
||||
let cancelled = false
|
||||
setInspectedRows(null)
|
||||
setClickDetail(null)
|
||||
@ -129,7 +150,7 @@ export default function Pivot({ source }) {
|
||||
try {
|
||||
const [perspective, rows] = await Promise.all([
|
||||
loadPerspective(),
|
||||
fetchAllRows(source),
|
||||
fetchAllRows(selectedView),
|
||||
])
|
||||
if (cancelled) return
|
||||
if (!rows.length) { setStatus('noview'); return }
|
||||
@ -142,7 +163,7 @@ export default function Pivot({ source }) {
|
||||
if (cancelled) { worker.terminate(); return }
|
||||
workerRef.current = worker
|
||||
|
||||
const table = await worker.table(rows, { name: source })
|
||||
const table = await worker.table(rows, { name: selectedView })
|
||||
if (cancelled) return
|
||||
tableRef.current = table
|
||||
|
||||
@ -185,14 +206,14 @@ export default function Pivot({ source }) {
|
||||
await viewer.load(worker)
|
||||
|
||||
const plugin = await viewer.getPlugin()
|
||||
const savedLayout = localStorage.getItem(LAYOUT_KEY(source))
|
||||
const savedLayout = localStorage.getItem(LAYOUT_KEY(selectedView))
|
||||
if (savedLayout) {
|
||||
const parsed = JSON.parse(savedLayout)
|
||||
await viewer.restore(parsed)
|
||||
await plugin.restore(parsed.plugin_config || DEFAULT_PLUGIN_CONFIG)
|
||||
if (parsed.expand_depth != null) await applyExpandDepth(viewer, parsed.expand_depth)
|
||||
} 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 viewer.flush()
|
||||
@ -205,7 +226,7 @@ export default function Pivot({ source }) {
|
||||
|
||||
init()
|
||||
return () => { cancelled = true }
|
||||
}, [source])
|
||||
}, [selectedView])
|
||||
|
||||
async function applyExpandDepth(viewer, depth) {
|
||||
if (depth == null) return
|
||||
@ -226,8 +247,7 @@ export default function Pivot({ source }) {
|
||||
}
|
||||
await applyExpandDepth(viewer, layout.config.expand_depth ?? null)
|
||||
setActiveLayoutId(layout.id)
|
||||
// also persist to localStorage so it survives refresh
|
||||
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout.config))
|
||||
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(layout.config))
|
||||
}
|
||||
|
||||
async function captureConfig() {
|
||||
@ -244,10 +264,15 @@ export default function Pivot({ source }) {
|
||||
const config = await captureConfig()
|
||||
if (!config) return
|
||||
try {
|
||||
const saved = await api.savePivotLayout(source, layout.layout_name, config)
|
||||
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(config))
|
||||
await loadLayouts()
|
||||
if (viewType === 'source') {
|
||||
const saved = await api.savePivotLayout(selectedView, layout.layout_name, config)
|
||||
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!')
|
||||
} catch (err) {
|
||||
flashMsg(err.message)
|
||||
@ -260,10 +285,18 @@ export default function Pivot({ source }) {
|
||||
const config = await captureConfig()
|
||||
if (!config) return
|
||||
try {
|
||||
const saved = await api.savePivotLayout(source, name, config)
|
||||
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(config))
|
||||
let newId
|
||||
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()
|
||||
setActiveLayoutId(saved.id)
|
||||
setActiveLayoutId(newId)
|
||||
setShowSaveAs(false)
|
||||
setSaveAsName('')
|
||||
flashMsg('Saved!')
|
||||
@ -275,7 +308,12 @@ export default function Pivot({ source }) {
|
||||
async function handleDelete(layout, e) {
|
||||
e.stopPropagation()
|
||||
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)
|
||||
await loadLayouts()
|
||||
flashMsg('Deleted')
|
||||
@ -287,9 +325,9 @@ export default function Pivot({ source }) {
|
||||
function handleResetToDefault() {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer) return
|
||||
localStorage.removeItem(LAYOUT_KEY(source))
|
||||
localStorage.removeItem(LAYOUT_KEY(selectedView))
|
||||
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>
|
||||
@ -316,6 +354,21 @@ export default function Pivot({ source }) {
|
||||
|
||||
{/* Layout toolbar */}
|
||||
<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>
|
||||
|
||||
{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 [creating, setCreating] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
@ -266,6 +266,7 @@ export default function Rules({ source }) {
|
||||
} else {
|
||||
await api.createRule({ ...form, source_name: source })
|
||||
}
|
||||
onStale?.(source)
|
||||
const updated = await api.getRules(source)
|
||||
setRules(updated)
|
||||
setCreating(false)
|
||||
@ -282,6 +283,7 @@ export default function Rules({ source }) {
|
||||
if (!confirm('Delete this rule and all its mappings?')) return
|
||||
try {
|
||||
await api.deleteRule(id)
|
||||
onStale?.(source)
|
||||
setRules(r => r.filter(x => x.id !== id))
|
||||
setTestResults(t => { const n = { ...t }; delete n[id]; return n })
|
||||
} catch (err) {
|
||||
@ -301,6 +303,7 @@ export default function Rules({ source }) {
|
||||
async function handleToggle(rule) {
|
||||
try {
|
||||
await api.updateRule(rule.id, { enabled: !rule.enabled })
|
||||
onStale?.(source)
|
||||
setRules(r => r.map(x => x.id === rule.id ? { ...x, enabled: !x.enabled } : x))
|
||||
} catch (err) {
|
||||
alert(err.message)
|
||||
|
||||
@ -79,7 +79,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
|
||||
|
||||
// ── Stack panel ────────────────────────────────────────────────────────────────
|
||||
|
||||
function StackPanel({ stack, sources, onUpdated }) {
|
||||
function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) {
|
||||
const members = stack.sources || []
|
||||
|
||||
const [label, setLabel] = useState(stack.label || '')
|
||||
@ -226,6 +226,7 @@ function StackPanel({ stack, sources, onUpdated }) {
|
||||
date_field: dateCanonical || null,
|
||||
})
|
||||
setMappingsDirty(false)
|
||||
onStale?.(stack.name)
|
||||
onUpdated()
|
||||
} catch (e) { setError(e.message) }
|
||||
finally { setSaving(false) }
|
||||
@ -242,6 +243,7 @@ function StackPanel({ stack, sources, onUpdated }) {
|
||||
setSrcFields(prev => ({ ...prev, [addingSrc]: f.map(x => x.key) }))
|
||||
} catch (e) {}
|
||||
setAddingSrc('')
|
||||
onStale?.(stack.name)
|
||||
onUpdated()
|
||||
}
|
||||
|
||||
@ -276,6 +278,7 @@ function StackPanel({ stack, sources, onUpdated }) {
|
||||
async function removeSource(src) {
|
||||
await api.removeStackSource(stack.name, src)
|
||||
setSrcCfg(prev => { const n = { ...prev }; delete n[src]; return n })
|
||||
onStale?.(stack.name)
|
||||
onUpdated()
|
||||
}
|
||||
|
||||
@ -303,6 +306,7 @@ function StackPanel({ stack, sources, onUpdated }) {
|
||||
})
|
||||
setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], offset } }))
|
||||
setCalibratingSource(null)
|
||||
onStale?.(stack.name)
|
||||
onUpdated()
|
||||
}
|
||||
|
||||
@ -312,7 +316,7 @@ function StackPanel({ stack, sources, onUpdated }) {
|
||||
try {
|
||||
const r = await api.generateStackView(stack.name)
|
||||
setViewResult(r)
|
||||
if (r.success) fetchBalance()
|
||||
if (r.success) { fetchBalance(); onViewGenerated?.(stack.name) }
|
||||
} catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
@ -570,7 +574,7 @@ function StackPanel({ stack, sources, onUpdated }) {
|
||||
|
||||
// ── Main page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Stacks({ sources }) {
|
||||
export default function Stacks({ sources, onStackStale, onStackViewGenerated }) {
|
||||
const [stacks, setStacks] = useState([])
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [stackDetail, setStackDetail] = useState(null)
|
||||
@ -651,6 +655,8 @@ export default function Stacks({ sources }) {
|
||||
stack={stackDetail}
|
||||
sources={sources}
|
||||
onUpdated={() => { load(); loadDetail(stackDetail.name) }}
|
||||
onStale={onStackStale}
|
||||
onViewGenerated={onStackViewGenerated}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user