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) => {
|
||||
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); }
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 (
|
||||
<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">
|
||||
<span className="text-sm font-semibold text-gray-700">Calibrate — {sourceName}</span>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">✕</button>
|
||||
</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.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<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"
|
||||
value={asOf} onChange={e => setAsOf(e.target.value)} />
|
||||
|
||||
{/* Date */}
|
||||
<div className="mb-4">
|
||||
<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"
|
||||
value={asOf} onChange={e => setAsOf(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<label className="text-xs text-gray-500 block mb-1">Known balance</label>
|
||||
<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"
|
||||
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">Known balance</span>
|
||||
<input
|
||||
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>
|
||||
<button onClick={handleCalc} disabled={!asOf || !known || loading}
|
||||
className="w-full text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||
{loading ? 'Calculating…' : 'Calculate'}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
||||
<span className="text-gray-500 text-xs">Current offset</span>
|
||||
<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>
|
||||
{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>
|
||||
@ -135,15 +162,31 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
||||
})
|
||||
}, [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)
|
||||
useEffect(() => {
|
||||
clearTimeout(previewTimer.current)
|
||||
previewTimer.current = setTimeout(() => {
|
||||
api.previewStackSql(stack.name)
|
||||
.then(r => { if (r.success) onSqlGenerated?.(r.sql) })
|
||||
.catch(() => {})
|
||||
}, 400)
|
||||
previewTimer.current = setTimeout(async () => {
|
||||
try {
|
||||
for (const m of members) {
|
||||
const cfg = srcCfg[m.source_name] || {}
|
||||
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)
|
||||
}, [
|
||||
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))
|
||||
if (addingSrc === '' && availableSources.length === 1) setAddingSrc(availableSources[0].name)
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@ -589,6 +633,7 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
|
||||
<CalibrateModal
|
||||
stack={stack}
|
||||
sourceName={calibratingSource}
|
||||
currentOffset={srcCfg[calibratingSource]?.offset ?? 0}
|
||||
onClose={() => setCalibratingSource(null)}
|
||||
onApply={offset => applyCalibration(calibratingSource, offset)}
|
||||
/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user