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:
parent
95e63679ef
commit
a89bd36f40
@ -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); }
|
||||||
|
|||||||
@ -214,10 +214,17 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
BEGIN
|
BEGIN
|
||||||
v_sql := format(
|
IF p_as_of_date IS NULL THEN
|
||||||
'SELECT COALESCE(SUM(%I * %s), 0) FROM dfv.%I WHERE %I <= %L::date',
|
v_sql := format(
|
||||||
v_src.amount_field, v_src.amount_sign, p_source_name, v_src.date_field, p_as_of_date
|
'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;
|
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');
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
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() {
|
async function captureConfig() {
|
||||||
|
|||||||
@ -14,72 +14,99 @@ 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)
|
||||||
try {
|
const plug = hasKnown && computed !== null ? knownNum - computed : null
|
||||||
const r = await api.calibrateBalance(stack.name, sourceName, { as_of_date: asOf, known_balance: parseFloat(known) })
|
|
||||||
setResult(r)
|
// Auto-fetch computed sum on mount (all transactions) and whenever date changes
|
||||||
if (r.success) setApplyOffset(Number(r.suggested_offset).toFixed(2))
|
useEffect(() => {
|
||||||
} catch (e) { setError(e.message) }
|
clearTimeout(debounceRef.current)
|
||||||
finally { setLoading(false) }
|
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 (
|
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">
|
<label className="text-xs text-gray-500 block mb-1">As-of date</label>
|
||||||
<div>
|
<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"
|
||||||
<label className="text-xs text-gray-500 block mb-1">As-of date</label>
|
value={asOf} onChange={e => setAsOf(e.target.value)} />
|
||||||
<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"
|
</div>
|
||||||
value={asOf} onChange={e => setAsOf(e.target.value)} />
|
|
||||||
|
{/* Reconciliation table */}
|
||||||
|
<div className="bg-gray-50 rounded border border-gray-200 mb-4 text-sm">
|
||||||
|
<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>
|
||||||
<div>
|
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
||||||
<label className="text-xs text-gray-500 block mb-1">Known balance</label>
|
<span className="text-gray-500 text-xs">Known balance</span>
|
||||||
<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"
|
<input
|
||||||
value={known} onChange={e => setKnown(e.target.value)} placeholder="e.g. 12450.22" />
|
type="number" step="0.01"
|
||||||
|
className="font-mono text-right bg-transparent border-0 focus:outline-none w-36 text-sm text-gray-700 placeholder-gray-300"
|
||||||
|
placeholder="enter balance"
|
||||||
|
value={known} onChange={e => setKnown(e.target.value)}
|
||||||
|
/>
|
||||||
</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">Current offset</span>
|
||||||
{loading ? 'Calculating…' : 'Calculate'}
|
<span className="font-mono text-gray-400">{fmt(currentOffset ?? 0)}</span>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
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)} />
|
||||||
|
<button onClick={() => onApply(parseFloat(applyOffset))} disabled={applyOffset === '' || isNaN(parseFloat(applyOffset))}
|
||||||
|
className="text-sm bg-green-600 text-white px-4 py-1.5 rounded hover:bg-green-700 disabled:opacity-40">
|
||||||
|
Apply
|
||||||
</button>
|
</button>
|
||||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
|
||||||
{result && !result.success && <p className="text-xs text-red-500">{result.error}</p>}
|
|
||||||
{result && result.success && (
|
|
||||||
<div className="bg-gray-50 rounded p-3 text-xs space-y-1">
|
|
||||||
<div className="flex justify-between"><span className="text-gray-500">Computed sum at date</span><span className="font-mono">{Number(result.computed_sum).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-gray-500">Known balance</span><span className="font-mono">{Number(result.known_balance).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
|
|
||||||
<div className="flex justify-between font-medium"><span className="text-gray-700">Suggested offset</span><span className="font-mono text-blue-700">{Number(result.suggested_offset).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{result && result.success && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500 block mb-1">Offset to apply</label>
|
|
||||||
<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"
|
|
||||||
value={applyOffset} onChange={e => setApplyOffset(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<button onClick={() => onApply(parseFloat(applyOffset))} disabled={applyOffset === ''}
|
|
||||||
className="w-full text-sm bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700 disabled:opacity-50">
|
|
||||||
Apply offset
|
|
||||||
</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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user