import { useState, useEffect, useRef } from 'react' import { api } from '../api' import { format as formatSql } from 'sql-formatter' function prettySql(sql) { try { return formatSql(sql, { language: 'postgresql', tabWidth: 4, keywordCase: 'upper' }) } catch { return sql } } const FIELD_TYPES = ['text', 'numeric', 'date'] // ── Calibrate modal ──────────────────────────────────────────────────────────── 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 [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) 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 (
{ if (e.target === e.currentTarget) onClose() }}>
e.stopPropagation()}>
Calibrate — {sourceName}
{/* Date */}
setAsOf(e.target.value)} />
{/* Reconciliation table */}
Data sum at date {loading ? : computed !== null ? fmt(computed) : }
Known balance setKnown(e.target.value)} />
Current offset {fmt(currentOffset ?? 0)}
Plug (offset needed) {plug !== null ? fmt(plug) : '—'}
{error &&

{error}

} {/* Apply */}
setApplyOffset(e.target.value)} />
) } // ── Stack panel ──────────────────────────────────────────────────────────────── function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSqlGenerated }) { const members = stack.sources || [] const [label, setLabel] = useState(stack.label || '') const [fields, setFields] = useState(stack.fields || []) const [newField, setNewField] = useState({ name: '', type: 'text' }) const [addingSrc, setAddingSrc] = useState('') // Per-source config: sign, offset, amount_field, date_field, field_map const [srcCfg, setSrcCfg] = useState(() => Object.fromEntries(members.map(m => [m.source_name, { sign: m.amount_sign ?? 1, offset: m.balance_offset ?? 0, amount_field: m.amount_field || '', date_field: m.date_field || '', field_map: { ...(m.field_map || {}) }, }])) ) // Available columns from each source's dfv view const [srcFields, setSrcFields] = useState({}) // Drag-to-reorder state const [dragIdx, setDragIdx] = useState(null) const [dragOverIdx, setDragOverIdx] = useState(null) const [srcDragIdx, setSrcDragIdx] = useState(null) const [srcDragOverIdx, setSrcDragOverIdx] = useState(null) // Calibrate const [calibratingSource, setCalibratingSource] = useState(null) // View / balance const [viewResult, setViewResult] = useState(null) const [netBalance, setNetBalance] = useState(null) const [balanceError, setBalanceError] = useState('') const [saving, setSaving] = useState(false) const [mappingsDirty, setMappingsDirty] = useState(false) const [error, setError] = useState('') // Fetch source columns whenever members change useEffect(() => { members.forEach(m => { api.getFields(m.source_name) .then(f => setSrcFields(prev => ({ ...prev, [m.source_name]: f.map(x => x.key) }))) .catch(() => {}) }) }, [members.map(m => m.source_name).join(',')]) // 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(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), JSON.stringify(srcCfg), members.map(m => m.source_name).join(','), ]) // Auto-detect canonical amount/date field from field types const amountCanonical = fields.find(f => f.type === 'numeric')?.name || stack.amount_field const dateCanonical = fields.find(f => f.type === 'date')?.name || stack.date_field // ── Label ── async function saveLabel() { setSaving(true); setError('') try { await api.updateStack(stack.name, { label }); onUpdated() } catch (e) { setError(e.message) } finally { setSaving(false) } } // ── Fields ── async function addField() { if (!newField.name) return const updated = [...fields, { name: newField.name, type: newField.type }] setFields(updated) setNewField({ name: '', type: 'text' }) await api.updateStack(stack.name, { fields: updated }) onUpdated() } async function removeField(name) { const updated = fields.filter(f => f.name !== name) setFields(updated) await api.updateStack(stack.name, { fields: updated }) onUpdated() } // ── Drag reorder ── function handleDragStart(e, idx) { setDragIdx(idx) e.dataTransfer.effectAllowed = 'move' } function handleDragOver(e, idx) { e.preventDefault() setDragOverIdx(idx) } async function handleDrop(e, toIdx) { e.preventDefault() if (dragIdx === null || dragIdx === toIdx) { setDragIdx(null); setDragOverIdx(null); return } const updated = [...fields] const [moved] = updated.splice(dragIdx, 1) updated.splice(toIdx, 0, moved) setFields(updated) setDragIdx(null); setDragOverIdx(null) await api.updateStack(stack.name, { fields: updated }) onUpdated() } // ── Source drag-to-reorder ── function handleSrcDragStart(e, idx) { setSrcDragIdx(idx) e.dataTransfer.effectAllowed = 'move' } function handleSrcDragOver(e, idx) { e.preventDefault() setSrcDragOverIdx(idx) } async function handleSrcDrop(e, toIdx) { e.preventDefault() if (srcDragIdx === null || srcDragIdx === toIdx) { setSrcDragIdx(null); setSrcDragOverIdx(null); return } const updated = [...members] const [moved] = updated.splice(srcDragIdx, 1) updated.splice(toIdx, 0, moved) setSrcDragIdx(null); setSrcDragOverIdx(null) await api.reorderStackSources(stack.name, updated.map(m => m.source_name)) onUpdated() } // ── Mapping grid ── function getMappingValue(srcName, canonicalName) { const cfg = srcCfg[srcName] || {} if (canonicalName === amountCanonical) return cfg.amount_field || '' if (canonicalName === dateCanonical) return cfg.date_field || '' return cfg.field_map?.[canonicalName] || '' } function setMappingValue(srcName, canonicalName, value) { setSrcCfg(prev => { const cfg = { ...prev[srcName] } if (canonicalName === amountCanonical) cfg.amount_field = value else if (canonicalName === dateCanonical) cfg.date_field = value else cfg.field_map = { ...cfg.field_map, [canonicalName]: value } return { ...prev, [srcName]: cfg } }) setMappingsDirty(true) } function setSrcSign(srcName, sign) { setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], sign } })) setMappingsDirty(true) } function setSrcOffset(srcName, offset) { setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], offset } })) setMappingsDirty(true) } async function saveMappings() { setSaving(true); setError('') 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, }) } // Persist the auto-detected canonical field names on the stack await api.updateStack(stack.name, { amount_field: amountCanonical || null, date_field: dateCanonical || null, }) setMappingsDirty(false) onStale?.(stack.name) onUpdated() } catch (e) { setError(e.message) } finally { setSaving(false) } } // ── Sources ── async function addSource() { if (!addingSrc) return await api.upsertStackSource(stack.name, addingSrc, { field_map: {}, amount_sign: 1 }) setSrcCfg(prev => ({ ...prev, [addingSrc]: { sign: 1, offset: 0, amount_field: '', date_field: '', field_map: {} } })) // Load fields immediately so dropdowns are ready try { const f = await api.getFields(addingSrc) setSrcFields(prev => ({ ...prev, [addingSrc]: f.map(x => x.key) })) } catch (e) {} setAddingSrc('') onStale?.(stack.name) onUpdated() } function handleSrcAmountField(srcName, value) { setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], amount_field: value } })) // Update column type to numeric if a column with this name exists setFields(prev => prev.map(f => f.name === value ? { ...f, type: 'numeric' } : f)) setMappingsDirty(true) maybeAutoPopulate(srcName, value, srcCfg[srcName]?.date_field) } function handleSrcDateField(srcName, value) { setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], date_field: value } })) setFields(prev => prev.map(f => f.name === value ? { ...f, type: 'date' } : f)) setMappingsDirty(true) maybeAutoPopulate(srcName, srcCfg[srcName]?.amount_field, value) } function maybeAutoPopulate(srcName, amtField, dtField) { if (!amtField || !dtField) return if (fields.length > 0) return // don't overwrite existing columns const sourceFields = srcFields[srcName] || [] if (sourceFields.length === 0) return const newFields = sourceFields.map(sf => ({ name: sf, type: sf === amtField ? 'numeric' : sf === dtField ? 'date' : 'text', })) setFields(newFields) api.updateStack(stack.name, { fields: newFields, amount_field: amtField, date_field: dtField }) } async function removeSource(src) { await api.removeStackSource(stack.name, src) setSrcCfg(prev => { const n = { ...prev }; delete n[src]; return n }) onStale?.(stack.name) onUpdated() } async function handleCalibrate(srcName) { // Save this source's current config before opening modal const cfg = srcCfg[srcName] || {} await api.upsertStackSource(stack.name, srcName, { 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, }) setCalibratingSource(srcName) } async function applyCalibration(srcName, offset) { const cfg = srcCfg[srcName] || {} await api.upsertStackSource(stack.name, srcName, { field_map: cfg.field_map || {}, amount_sign: cfg.sign ?? 1, balance_offset: offset, amount_field: cfg.amount_field || null, date_field: cfg.date_field || null, }) setSrcCfg(prev => ({ ...prev, [srcName]: { ...prev[srcName], offset } })) setCalibratingSource(null) onStale?.(stack.name) onUpdated() } // ── View ── async function generateView() { setViewResult(null); setNetBalance(null); setBalanceError('') try { const r = await api.generateStackView(stack.name) setViewResult(r) if (r.success) { fetchBalance() onViewGenerated?.(stack.name) onSqlGenerated?.(r.sql || '') ;(r.cascade_stale || []).forEach(n => onStale?.(n)) } } catch (e) { setError(e.message) } } async function fetchBalance() { setBalanceError('') try { const r = await api.getStackBalance(stack.name) if (r.success) setNetBalance(r.balance) else setBalanceError(r.error) } catch (e) { setBalanceError(e.message) } } const availableSources = sources.filter(s => !members.find(m => m.source_name === s.name)) if (addingSrc === '' && availableSources.length === 1) setAddingSrc(availableSources[0].name) return (
{/* Label */}

Configuration

setLabel(e.target.value)} onKeyDown={e => e.key === 'Enter' && saveLabel()} />
{error &&

{error}

}
{/* Sources */}

Sources

Each source contributes rows to the combined view. Set the sign to flip the direction of amounts (e.g. credit card charges are positive in the source but should subtract from your balance). The offset adjusts the running balance — use Calibrate to compute it from a known good balance.

{members.map((m, idx) => { const cfg = srcCfg[m.source_name] || {} const sf = srcFields[m.source_name] || [] const canCalibrate = !!cfg.amount_field && !!cfg.date_field return (
handleSrcDragStart(e, idx)} onDragOver={e => handleSrcDragOver(e, idx)} onDrop={e => handleSrcDrop(e, idx)} onDragEnd={() => { setSrcDragIdx(null); setSrcDragOverIdx(null) }} className={`border border-gray-100 rounded px-3 py-2 text-xs space-y-2 ${srcDragOverIdx === idx && srcDragIdx !== idx ? 'bg-blue-50' : ''}`}>
{m.source_name}
{ setSrcOffset(m.source_name, parseFloat(e.target.value) || 0); setMappingsDirty(true) }} className="flex-1 border border-gray-200 rounded px-1.5 py-0.5 font-mono focus:outline-none focus:border-blue-400" />
) })} {members.length === 0 &&

No sources added yet.

}
{availableSources.length > 0 && (
)}
{/* Output columns mapping grid */}

Output columns

Each row is a column in the combined view. Each source column shows which field from that source maps to it. The first numeric field drives the running balance; the first date field drives the ordering. Both source_balance (per-source) and net_balance (combined) are always included in the generated view. Drag rows to reorder.

{members.length === 0 ? (

Add sources above first.

) : (
{members.map(m => ( ))} {fields.map((f, idx) => { const isAmount = f.name === amountCanonical const isDate = f.name === dateCanonical return ( handleDragStart(e, idx)} onDragOver={e => handleDragOver(e, idx)} onDrop={e => handleDrop(e, idx)} onDragEnd={() => { setDragIdx(null); setDragOverIdx(null) }} className={`border-b border-gray-50 ${dragOverIdx === idx && dragIdx !== idx ? 'bg-blue-50' : ''}`}> {members.map(m => ( ))} ) })} {fields.length === 0 && ( )}
Column Type{m.source_name}
{f.name} {isAmount && amount} {isDate && date} {f.type}
No columns defined yet — add one below.
)} {/* Add field */}
setNewField(f => ({ ...f, name: e.target.value }))} onKeyDown={e => e.key === 'Enter' && addField()} />
{mappingsDirty && ( )}
{/* Generate view + balance */}

View

{netBalance !== null && (
Current net balance {Number(netBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
)} {balanceError &&

{balanceError}

} {viewResult && !viewResult.success && (

{viewResult.error}

)} {viewResult && viewResult.success && (

View created: {viewResult.view}

)}
{calibratingSource && ( setCalibratingSource(null)} onApply={offset => applyCalibration(calibratingSource, offset)} /> )}
) } // ── Main page ────────────────────────────────────────────────────────────────── export default function Stacks({ sources, onStackStale, onStackViewGenerated, onStacksChange }) { const [stacks, setStacks] = useState([]) const [selected, setSelected] = useState(null) const [stackDetail, setStackDetail] = useState(null) const [creating, setCreating] = useState(false) const [newName, setNewName] = useState('') const [error, setError] = useState('') const [sqlDraft, setSqlDraft] = useState('') const [sqlRunning, setSqlRunning] = useState(false) const [sqlResult, setSqlResult] = useState(null) async function load() { const s = await api.getStacks() setStacks(s) return s } async function loadDetail(name) { const s = await api.getStack(name) setStackDetail(s) setSelected(name) localStorage.setItem('stacks_last_selected', name) setSqlDraft('') setSqlResult(null) } useEffect(() => { load().then(s => { const last = localStorage.getItem('stacks_last_selected') if (last && s.find(x => x.name === last)) loadDetail(last) }) }, []) useEffect(() => { if (selected) loadDetail(selected) }, [selected]) async function createStack() { if (!newName) return setError('') try { await api.createStack({ name: newName, fields: [] }) setNewName(''); setCreating(false) await load() onStacksChange?.() loadDetail(newName) } catch (e) { setError(e.message) } } async function deleteStack(name) { if (!confirm(`Delete stack "${name}"?`)) return await api.deleteStack(name) if (selected === name) { setSelected(null); setStackDetail(null); setSqlDraft(''); setSqlResult(null) } load() onStacksChange?.() } async function runSql() { if (!sqlDraft.trim() || !selected) return setSqlRunning(true); setSqlResult(null) try { const r = await api.execStackSql(selected, sqlDraft) setSqlResult(r) if (r.success) { onStackViewGenerated?.(selected) ;(r.cascade_stale || []).forEach(n => onStackStale?.(n)) } } catch (e) { setSqlResult({ success: false, error: e.message }) } finally { setSqlRunning(false) } } return (
{/* Stack list — horizontal row of cards */}

Stacks

{stacks.map(s => (
loadDetail(s.name)} className={`flex items-center gap-2 px-3 py-1.5 rounded border cursor-pointer text-xs group transition-colors ${selected === s.name ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}> {s.label || s.name} {s.source_count}s
))} {creating ? (
setNewName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') createStack(); if (e.key === 'Escape') setCreating(false) }} /> {error &&

{error}

}
) : ( )}
{stackDetail ? (
{/* Left: config panel */}

{stackDetail.label || stackDetail.name} {stackDetail.label && {stackDetail.name}}

{ load(); loadDetail(stackDetail.name) }} onStale={onStackStale} onViewGenerated={onStackViewGenerated} onSqlGenerated={sql => { setSqlDraft(prettySql(sql)); setSqlResult(null) }} />
{/* Right: SQL panel */}

Generated SQL

{sqlDraft ? (