diff --git a/api/routes/stacks.js b/api/routes/stacks.js index 3f0a1aa..4f38cc2 100644 --- a/api/routes/stacks.js +++ b/api/routes/stacks.js @@ -149,11 +149,12 @@ module.exports = (pool) => { 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' }); + if (known_balance === undefined) { + return res.status(400).json({ error: 'known_balance is required' }); } + const dateExpr = as_of_date ? `${lit(as_of_date)}::date` : 'NULL'; 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` + `SELECT calibrate_balance(${lit(req.params.name)}, ${source_name ? lit(source_name) : 'NULL'}, ${dateExpr}, ${lit(known_balance)}::numeric) AS result` ); res.json(result.rows[0].result); } catch (err) { next(err); } diff --git a/database/queries/stacks.sql b/database/queries/stacks.sql index b7519a2..3382a69 100644 --- a/database/queries/stacks.sql +++ b/database/queries/stacks.sql @@ -214,10 +214,17 @@ BEGIN END IF; BEGIN - v_sql := format( - 'SELECT COALESCE(SUM(%I * %s), 0) FROM dfv.%I WHERE %I <= %L::date', - v_src.amount_field, v_src.amount_sign, p_source_name, v_src.date_field, p_as_of_date - ); + IF p_as_of_date IS NULL THEN + v_sql := format( + 'SELECT COALESCE(SUM(%I * %s), 0) FROM dfv.%I', + v_src.amount_field, v_src.amount_sign, p_source_name + ); + ELSE + v_sql := format( + 'SELECT COALESCE(SUM(%I * %s), 0) FROM dfv.%I WHERE %I <= %L::date', + v_src.amount_field, v_src.amount_sign, p_source_name, v_src.date_field, p_as_of_date + ); + END IF; EXECUTE v_sql INTO v_running; EXCEPTION WHEN undefined_table THEN RETURN json_build_object('success', false, 'error', 'Source view not found — generate the source view first'); diff --git a/ui/src/pages/Pivot.jsx b/ui/src/pages/Pivot.jsx index 275a143..bb5c1b1 100644 --- a/ui/src/pages/Pivot.jsx +++ b/ui/src/pages/Pivot.jsx @@ -168,6 +168,18 @@ export default function Pivot({ source }) { tableRef.current = table const viewer = viewerRef.current + const validCols = new Set(Object.keys(rows[0] || {})) + + function cleanLayout(cfg) { + if (!cfg) return cfg + const clean = { ...cfg } + if (clean.columns) clean.columns = clean.columns.filter(c => c == null || validCols.has(c)) + if (clean.group_by) clean.group_by = clean.group_by.filter(c => validCols.has(c)) + if (clean.split_by) clean.split_by = clean.split_by.filter(c => validCols.has(c)) + if (clean.sort) clean.sort = clean.sort.filter(([c]) => validCols.has(c)) + if (clean.filter) clean.filter = clean.filter.filter(([c]) => validCols.has(c)) + return clean + } viewer.addEventListener('perspective-click', async (e) => { const detail = e.detail || {} @@ -208,7 +220,7 @@ export default function Pivot({ source }) { const plugin = await viewer.getPlugin() const savedLayout = localStorage.getItem(LAYOUT_KEY(selectedView)) if (savedLayout) { - const parsed = JSON.parse(savedLayout) + const parsed = cleanLayout(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) @@ -240,14 +252,38 @@ export default function Pivot({ source }) { async function applyLayout(layout) { const viewer = viewerRef.current if (!viewer) return - await viewer.restore(layout.config) - if (layout.config.plugin_config) { - const plugin = await viewer.getPlugin() - await plugin.restore(layout.config.plugin_config) + try { + const validCols = new Set(Object.keys(allRowsRef.current[0] || {})) + function cleanLayout(cfg) { + if (!cfg) return cfg + const clean = { ...cfg } + if (clean.columns) clean.columns = clean.columns.filter(c => c == null || validCols.has(c)) + if (clean.group_by) clean.group_by = clean.group_by.filter(c => validCols.has(c)) + if (clean.split_by) clean.split_by = clean.split_by.filter(c => validCols.has(c)) + if (clean.sort) clean.sort = clean.sort.filter(([c]) => validCols.has(c)) + if (clean.filter) clean.filter = clean.filter.filter(([c]) => validCols.has(c)) + return clean + } + const cleaned = cleanLayout(layout.config) + await viewer.restore(cleaned) + if (cleaned.plugin_config) { + const plugin = await viewer.getPlugin() + await plugin.restore(cleaned.plugin_config) + } + await applyExpandDepth(viewer, cleaned.expand_depth ?? null) + setActiveLayoutId(layout.id) + localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(cleaned)) + } catch { + // Layout references columns that no longer exist — remove it + if (viewType === 'stack') { + const updated = layouts.filter(l => l.id !== layout.id) + setLayouts(updated) + localStorage.setItem(`psp_layouts_stack_${selectedView}`, JSON.stringify(updated)) + } + localStorage.removeItem(LAYOUT_KEY(selectedView)) + setActiveLayoutId(null) + await viewer.restore({ table: selectedView, settings: false }) } - await applyExpandDepth(viewer, layout.config.expand_depth ?? null) - setActiveLayoutId(layout.id) - localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(layout.config)) } async function captureConfig() { diff --git a/ui/src/pages/Stacks.jsx b/ui/src/pages/Stacks.jsx index fd6da27..89a19db 100644 --- a/ui/src/pages/Stacks.jsx +++ b/ui/src/pages/Stacks.jsx @@ -14,72 +14,99 @@ const FIELD_TYPES = ['text', 'numeric', 'date'] // ── Calibrate modal ──────────────────────────────────────────────────────────── -function CalibrateModal({ stack, sourceName, onClose, onApply }) { +function fmt(n) { + return Number(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} + +function CalibrateModal({ stack, sourceName, currentOffset, onClose, onApply }) { const [asOf, setAsOf] = useState('') const [known, setKnown] = useState('') - const [result, setResult] = useState(null) - const [applyOffset, setApplyOffset] = useState('') + const [computed, setComputed] = useState(null) // raw sum from DB (no offset) const [loading, setLoading] = useState(false) const [error, setError] = useState('') + const [applyOffset, setApplyOffset] = useState('') + const debounceRef = useRef(null) - 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) - if (r.success) setApplyOffset(Number(r.suggested_offset).toFixed(2)) - } catch (e) { setError(e.message) } - finally { setLoading(false) } - } + const knownNum = parseFloat(known) + const hasKnown = known !== '' && !isNaN(knownNum) + const plug = hasKnown && computed !== null ? knownNum - computed : null + + // Auto-fetch computed sum on mount (all transactions) and whenever date changes + useEffect(() => { + clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(async () => { + setLoading(true); setError('') + try { + const r = await api.calibrateBalance(stack.name, sourceName, { as_of_date: asOf || null, known_balance: 0 }) + if (r.success) setComputed(Number(r.computed_sum)) + else setError(r.error) + } catch (e) { setError(e.message) } + finally { setLoading(false) } + }, asOf ? 400 : 0) + return () => clearTimeout(debounceRef.current) + }, [asOf]) + + // Keep applyOffset in sync with plug + useEffect(() => { + if (plug !== null) setApplyOffset(plug.toFixed(2)) + }, [plug]) return (
- Enter a known good balance at a specific date. The system will compute the offset needed to make the running balance match. -
-{error}
} + + {/* Apply */} +{error}
} - {result && !result.success &&{result.error}
} - {result && result.success && ( -