Stacks: calibrate modal redesign, layout column cleanup, SQL preview sync

- Calibrate modal now auto-fetches computed sum and shows live reconciliation table (data sum, known balance, plug) without requiring a button click
- as_of_date is now optional in calibrate — omitting it sums all transactions
- SQL preview syncs current UI state to DB before fetching so preview is always accurate
- Pivot cleanLayout strips stale columns from saved layouts when switching stack views

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-19 15:36:34 -04:00
parent 95e63679ef
commit a89bd36f40
4 changed files with 161 additions and 72 deletions

View File

@ -149,11 +149,12 @@ module.exports = (pool) => {
router.post('/:name/calibrate', async (req, res, next) => { router.post('/:name/calibrate', async (req, res, next) => {
try { try {
const { as_of_date, known_balance, source_name } = req.body; const { as_of_date, known_balance, source_name } = req.body;
if (!as_of_date || known_balance === undefined) { if (known_balance === undefined) {
return res.status(400).json({ error: 'as_of_date and known_balance are required' }); 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( 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); res.json(result.rows[0].result);
} catch (err) { next(err); } } catch (err) { next(err); }

View File

@ -214,10 +214,17 @@ BEGIN
END IF; END IF;
BEGIN BEGIN
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( v_sql := format(
'SELECT COALESCE(SUM(%I * %s), 0) FROM dfv.%I WHERE %I <= %L::date', '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 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; EXECUTE v_sql INTO v_running;
EXCEPTION WHEN undefined_table THEN EXCEPTION WHEN undefined_table THEN
RETURN json_build_object('success', false, 'error', 'Source view not found — generate the source view first'); RETURN json_build_object('success', false, 'error', 'Source view not found — generate the source view first');

View File

@ -168,6 +168,18 @@ export default function Pivot({ source }) {
tableRef.current = table tableRef.current = table
const viewer = viewerRef.current 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) => { viewer.addEventListener('perspective-click', async (e) => {
const detail = e.detail || {} const detail = e.detail || {}
@ -208,7 +220,7 @@ export default function Pivot({ source }) {
const plugin = await viewer.getPlugin() const plugin = await viewer.getPlugin()
const savedLayout = localStorage.getItem(LAYOUT_KEY(selectedView)) const savedLayout = localStorage.getItem(LAYOUT_KEY(selectedView))
if (savedLayout) { if (savedLayout) {
const parsed = JSON.parse(savedLayout) const parsed = cleanLayout(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)
@ -240,14 +252,38 @@ export default function Pivot({ source }) {
async function applyLayout(layout) { async function applyLayout(layout) {
const viewer = viewerRef.current const viewer = viewerRef.current
if (!viewer) return if (!viewer) return
await viewer.restore(layout.config) try {
if (layout.config.plugin_config) { const validCols = new Set(Object.keys(allRowsRef.current[0] || {}))
const plugin = await viewer.getPlugin() function cleanLayout(cfg) {
await plugin.restore(layout.config.plugin_config) 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
} }
await applyExpandDepth(viewer, layout.config.expand_depth ?? null) 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) setActiveLayoutId(layout.id)
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(layout.config)) 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 })
}
} }
async function captureConfig() { async function captureConfig() {

View File

@ -14,73 +14,100 @@ const FIELD_TYPES = ['text', 'numeric', 'date']
// Calibrate modal // 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 [asOf, setAsOf] = useState('')
const [known, setKnown] = useState('') const [known, setKnown] = useState('')
const [result, setResult] = useState(null) const [computed, setComputed] = useState(null) // raw sum from DB (no offset)
const [applyOffset, setApplyOffset] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [applyOffset, setApplyOffset] = useState('')
const debounceRef = useRef(null)
async function handleCalc() { const knownNum = parseFloat(known)
setError(''); setResult(null); setLoading(true) 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 { try {
const r = await api.calibrateBalance(stack.name, sourceName, { as_of_date: asOf, known_balance: parseFloat(known) }) const r = await api.calibrateBalance(stack.name, sourceName, { as_of_date: asOf || null, known_balance: 0 })
setResult(r) if (r.success) setComputed(Number(r.computed_sum))
if (r.success) setApplyOffset(Number(r.suggested_offset).toFixed(2)) else setError(r.error)
} catch (e) { setError(e.message) } } catch (e) { setError(e.message) }
finally { setLoading(false) } 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 ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-lg shadow-xl w-96 p-5" onClick={e => e.stopPropagation()}> <div className="bg-white rounded-lg shadow-xl w-[420px] p-5" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span className="text-sm font-semibold text-gray-700">Calibrate {sourceName}</span> <span className="text-sm font-semibold text-gray-700">Calibrate {sourceName}</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button> <button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button>
</div> </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 to make the running balance match. {/* Date */}
</p> <div className="mb-4">
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500 block mb-1">As-of date</label> <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" <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)} /> value={asOf} onChange={e => setAsOf(e.target.value)} />
</div> </div>
<div>
<label className="text-xs text-gray-500 block mb-1">Known balance</label> {/* Reconciliation table */}
<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" <div className="bg-gray-50 rounded border border-gray-200 mb-4 text-sm">
value={known} onChange={e => setKnown(e.target.value)} placeholder="e.g. 12450.22" /> <div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<span className="text-gray-500 text-xs">Data sum at date</span>
<span className="font-mono text-gray-700">
{loading ? <span className="text-gray-300"></span> : computed !== null ? fmt(computed) : <span className="text-gray-300"></span>}
</span>
</div> </div>
<button onClick={handleCalc} disabled={!asOf || !known || loading} <div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
className="w-full text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50"> <span className="text-gray-500 text-xs">Known balance</span>
{loading ? 'Calculating…' : 'Calculate'} <input
</button> type="number" step="0.01"
{error && <p className="text-xs text-red-500">{error}</p>} className="font-mono text-right bg-transparent border-0 focus:outline-none w-36 text-sm text-gray-700 placeholder-gray-300"
{result && !result.success && <p className="text-xs text-red-500">{result.error}</p>} placeholder="enter balance"
{result && result.success && ( value={known} onChange={e => setKnown(e.target.value)}
<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> </div>
)} <div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
{result && result.success && ( <span className="text-gray-500 text-xs">Current offset</span>
<div className="space-y-2"> <span className="font-mono text-gray-400">{fmt(currentOffset ?? 0)}</span>
<div> </div>
<label className="text-xs text-gray-500 block mb-1">Offset to apply</label> <div className="flex items-center justify-between px-3 py-2 font-medium">
<span className="text-gray-700 text-xs">Plug (offset needed)</span>
<span className={`font-mono ${plug !== null ? 'text-blue-700' : 'text-gray-300'}`}>
{plug !== null ? fmt(plug) : '—'}
</span>
</div>
</div>
{error && <p className="text-xs text-red-500 mb-3">{error}</p>}
{/* Apply */}
<div className="flex gap-2 items-center">
<input type="number" step="0.01" <input type="number" step="0.01"
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400" className="flex-1 border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400"
placeholder="offset to apply"
value={applyOffset} onChange={e => setApplyOffset(e.target.value)} /> value={applyOffset} onChange={e => setApplyOffset(e.target.value)} />
</div> <button onClick={() => onApply(parseFloat(applyOffset))} disabled={applyOffset === '' || isNaN(parseFloat(applyOffset))}
<button onClick={() => onApply(parseFloat(applyOffset))} disabled={applyOffset === ''} className="text-sm bg-green-600 text-white px-4 py-1.5 rounded hover:bg-green-700 disabled:opacity-40">
className="w-full text-sm bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700 disabled:opacity-50"> Apply
Apply offset
</button> </button>
</div> </div>
)}
</div>
</div> </div>
</div> </div>
) )
@ -135,15 +162,31 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
}) })
}, [members.map(m => m.source_name).join(',')]) }, [members.map(m => m.source_name).join(',')])
// Live SQL preview debounced whenever fields, sources, or mappings change // Live SQL preview debounced; syncs current UI state to DB first so preview is accurate
const previewTimer = useRef(null) const previewTimer = useRef(null)
useEffect(() => { useEffect(() => {
clearTimeout(previewTimer.current) clearTimeout(previewTimer.current)
previewTimer.current = setTimeout(() => { previewTimer.current = setTimeout(async () => {
api.previewStackSql(stack.name) try {
.then(r => { if (r.success) onSqlGenerated?.(r.sql) }) for (const m of members) {
.catch(() => {}) const cfg = srcCfg[m.source_name] || {}
}, 400) await api.upsertStackSource(stack.name, m.source_name, {
field_map: cfg.field_map || {},
amount_sign: cfg.sign ?? 1,
balance_offset: cfg.offset ?? 0,
amount_field: cfg.amount_field || null,
date_field: cfg.date_field || null,
})
}
await api.updateStack(stack.name, {
fields,
amount_field: amountCanonical || null,
date_field: dateCanonical || null,
})
const r = await api.previewStackSql(stack.name)
if (r.success) onSqlGenerated?.(r.sql)
} catch {}
}, 600)
return () => clearTimeout(previewTimer.current) return () => clearTimeout(previewTimer.current)
}, [ }, [
JSON.stringify(fields), JSON.stringify(fields),
@ -360,6 +403,7 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
} }
const availableSources = sources.filter(s => !members.find(m => m.source_name === s.name)) const availableSources = sources.filter(s => !members.find(m => m.source_name === s.name))
if (addingSrc === '' && availableSources.length === 1) setAddingSrc(availableSources[0].name)
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -589,6 +633,7 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
<CalibrateModal <CalibrateModal
stack={stack} stack={stack}
sourceName={calibratingSource} sourceName={calibratingSource}
currentOffset={srcCfg[calibratingSource]?.offset ?? 0}
onClose={() => setCalibratingSource(null)} onClose={() => setCalibratingSource(null)}
onApply={offset => applyCalibration(calibratingSource, offset)} onApply={offset => applyCalibration(calibratingSource, offset)}
/> />