The link was copied from the delete button's hover-reveal styling, so the only way to a stack pivot was invisible until you hovered — and then it appeared right beside the button that deletes the stack. Pivot is now always shown; delete stays hover-only, separated by a divider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
835 lines
36 KiB
JavaScript
835 lines
36 KiB
JavaScript
import { Link } from 'react-router-dom'
|
||
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 (
|
||
<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-surface 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-ink-soft">Calibrate — {sourceName}</span>
|
||
<button onClick={onClose} className="text-muted hover:text-ink-soft">✕</button>
|
||
</div>
|
||
|
||
{/* Date */}
|
||
<div className="mb-4">
|
||
<label className="text-xs text-muted block mb-1">As-of date</label>
|
||
<input type="date" className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||
value={asOf} onChange={e => setAsOf(e.target.value)} />
|
||
</div>
|
||
|
||
{/* Reconciliation table */}
|
||
<div className="bg-raised rounded border border-line mb-4 text-sm">
|
||
<div className="flex items-center justify-between px-3 py-2 border-b border-line">
|
||
<span className="text-muted text-xs">Data sum at date</span>
|
||
<span className="font-mono text-ink-soft">
|
||
{loading ? <span className="text-muted">…</span> : computed !== null ? fmt(computed) : <span className="text-muted">—</span>}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center justify-between px-3 py-2 border-b border-line">
|
||
<span className="text-muted 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-ink-soft placeholder-gray-300"
|
||
placeholder="enter balance"
|
||
value={known} onChange={e => setKnown(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between px-3 py-2 border-b border-line">
|
||
<span className="text-muted text-xs">Current offset</span>
|
||
<span className="font-mono text-muted">{fmt(currentOffset ?? 0)}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between px-3 py-2 font-medium">
|
||
<span className="text-ink-soft text-xs">Plug (offset needed)</span>
|
||
<span className={`font-mono ${plug !== null ? 'text-accent' : 'text-muted'}`}>
|
||
{plug !== null ? fmt(plug) : '—'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{error && <p className="text-xs text-danger mb-3">{error}</p>}
|
||
|
||
{/* Apply */}
|
||
<div className="flex gap-2 items-center">
|
||
<input type="number" step="0.01"
|
||
className="flex-1 border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
|
||
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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── 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 (
|
||
<div className="space-y-5">
|
||
|
||
{/* Label */}
|
||
<div className="bg-surface border border-line rounded p-4">
|
||
<h3 className="text-sm font-semibold text-ink-soft mb-3">Configuration</h3>
|
||
<div className="flex gap-3 items-end">
|
||
<div className="flex-1">
|
||
<label className="text-xs text-muted block mb-1">Label <span className="text-muted">(optional)</span></label>
|
||
<input className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
|
||
value={label} onChange={e => setLabel(e.target.value)}
|
||
onKeyDown={e => e.key === 'Enter' && saveLabel()} />
|
||
</div>
|
||
<button onClick={saveLabel} disabled={saving}
|
||
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
||
{saving ? 'Saving…' : 'Save'}
|
||
</button>
|
||
</div>
|
||
{error && <p className="text-xs text-danger mt-2">{error}</p>}
|
||
</div>
|
||
|
||
{/* Sources */}
|
||
<div className="bg-surface border border-line rounded p-4">
|
||
<h3 className="text-sm font-semibold text-ink-soft mb-1">Sources</h3>
|
||
<p className="text-xs text-muted mb-3">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.</p>
|
||
<div className="space-y-2 mb-3">
|
||
{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 (
|
||
<div key={m.source_name}
|
||
draggable
|
||
onDragStart={e => handleSrcDragStart(e, idx)}
|
||
onDragOver={e => handleSrcDragOver(e, idx)}
|
||
onDrop={e => handleSrcDrop(e, idx)}
|
||
onDragEnd={() => { setSrcDragIdx(null); setSrcDragOverIdx(null) }}
|
||
className={`border border-line-soft rounded px-3 py-2 text-xs space-y-2 ${srcDragOverIdx === idx && srcDragIdx !== idx ? 'bg-accent-soft' : ''}`}>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-muted cursor-grab select-none">⠿</span>
|
||
<span className="font-medium text-ink-soft flex-1">{m.source_name}</span>
|
||
<button onClick={() => removeSource(m.source_name)} className="text-danger hover:text-danger">Remove</button>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||
<div>
|
||
<label className="text-muted block mb-0.5">Amount field</label>
|
||
<select value={cfg.amount_field || ''}
|
||
onChange={e => handleSrcAmountField(m.source_name, e.target.value)}
|
||
className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
|
||
<option value="">— select —</option>
|
||
{sf.map(f => <option key={f} value={f}>{f}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-muted block mb-0.5">Sign</label>
|
||
<select value={cfg.sign ?? 1}
|
||
onChange={e => { setSrcSign(m.source_name, parseInt(e.target.value)); setMappingsDirty(true) }}
|
||
className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
|
||
<option value={1}>+1 (as-is)</option>
|
||
<option value={-1}>−1 (flip)</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-muted block mb-0.5">Date field</label>
|
||
<select value={cfg.date_field || ''}
|
||
onChange={e => handleSrcDateField(m.source_name, e.target.value)}
|
||
className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
|
||
<option value="">— select —</option>
|
||
{sf.map(f => <option key={f} value={f}>{f}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-muted block mb-0.5">Balance offset</label>
|
||
<div className="flex items-center gap-1">
|
||
<input type="number" step="0.01" value={cfg.offset ?? 0}
|
||
onChange={e => { setSrcOffset(m.source_name, parseFloat(e.target.value) || 0); setMappingsDirty(true) }}
|
||
className="flex-1 border border-line rounded px-1.5 py-0.5 font-mono focus:outline-none focus:border-accent" />
|
||
<button onClick={() => handleCalibrate(m.source_name)}
|
||
disabled={!canCalibrate}
|
||
title={!canCalibrate ? 'Set amount and date fields first' : 'Calibrate balance'}
|
||
className="text-accent hover:text-accent underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline">
|
||
Calibrate
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
{members.length === 0 && <p className="text-xs text-muted">No sources added yet.</p>}
|
||
</div>
|
||
{availableSources.length > 0 && (
|
||
<div className="flex gap-2">
|
||
<select className="flex-1 border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
|
||
value={addingSrc} onChange={e => setAddingSrc(e.target.value)}>
|
||
<option value="">— add source —</option>
|
||
{availableSources.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
|
||
</select>
|
||
<button onClick={addSource} disabled={!addingSrc}
|
||
className="text-sm bg-raised px-3 py-1 rounded hover:bg-raised text-ink-soft disabled:opacity-40">Add</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Output columns mapping grid */}
|
||
<div className="bg-surface border border-line rounded p-4">
|
||
<h3 className="text-sm font-semibold text-ink-soft mb-1">Output columns</h3>
|
||
<p className="text-xs text-muted mb-3">
|
||
Each row is a column in the combined view. Each source column shows which field from that source maps to it.
|
||
The first <span className="text-accent">numeric</span> field drives the running balance; the first <span className="text-ok">date</span> field drives the ordering.
|
||
Both <span className="font-mono">source_balance</span> (per-source) and <span className="font-mono">net_balance</span> (combined) are always included in the generated view.
|
||
Drag rows to reorder.
|
||
</p>
|
||
|
||
{members.length === 0 ? (
|
||
<p className="text-xs text-muted mb-3">Add sources above first.</p>
|
||
) : (
|
||
<div className="overflow-x-auto mb-3">
|
||
<table className="w-full text-xs border-collapse">
|
||
<thead>
|
||
<tr className="border-b border-line">
|
||
<th className="w-5 pb-2"></th>
|
||
<th className="text-left text-muted font-normal pb-2 pr-4">Column</th>
|
||
<th className="text-left text-muted font-normal pb-2 pr-4">Type</th>
|
||
{members.map(m => (
|
||
<th key={m.source_name} className="text-left text-muted font-normal pb-2 pr-3 min-w-36">{m.source_name}</th>
|
||
))}
|
||
<th className="w-5 pb-2"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{fields.map((f, idx) => {
|
||
const isAmount = f.name === amountCanonical
|
||
const isDate = f.name === dateCanonical
|
||
return (
|
||
<tr key={f.name}
|
||
draggable
|
||
onDragStart={e => handleDragStart(e, idx)}
|
||
onDragOver={e => handleDragOver(e, idx)}
|
||
onDrop={e => handleDrop(e, idx)}
|
||
onDragEnd={() => { setDragIdx(null); setDragOverIdx(null) }}
|
||
className={`border-b border-line-soft ${dragOverIdx === idx && dragIdx !== idx ? 'bg-accent-soft' : ''}`}>
|
||
<td className="py-1.5 pr-1 text-muted cursor-grab select-none">⠿</td>
|
||
<td className="py-1.5 pr-4 font-mono text-ink-soft whitespace-nowrap">
|
||
{f.name}
|
||
{isAmount && <span className="ml-1.5 text-accent font-sans font-normal">amount</span>}
|
||
{isDate && <span className="ml-1.5 text-ok font-sans font-normal">date</span>}
|
||
</td>
|
||
<td className="py-1.5 pr-4 text-muted">{f.type}</td>
|
||
{members.map(m => (
|
||
<td key={m.source_name} className="py-1.5 pr-3">
|
||
<div className="flex items-center gap-1">
|
||
<select
|
||
value={getMappingValue(m.source_name, f.name)}
|
||
onChange={e => setMappingValue(m.source_name, f.name, e.target.value)}
|
||
className="border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent min-w-0 flex-1">
|
||
<option value="">— same name —</option>
|
||
{(srcFields[m.source_name] || []).map(sf => (
|
||
<option key={sf} value={sf}>{sf}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</td>
|
||
))}
|
||
<td className="py-1.5">
|
||
<button onClick={() => removeField(f.name)} className="text-danger hover:text-danger">✕</button>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
{fields.length === 0 && (
|
||
<tr><td colSpan={3 + members.length} className="py-3 text-muted text-center">No columns defined yet — add one below.</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Add field */}
|
||
<div className="flex gap-2 mb-3">
|
||
<input className="flex-1 border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
|
||
placeholder="column name" value={newField.name}
|
||
onChange={e => setNewField(f => ({ ...f, name: e.target.value }))}
|
||
onKeyDown={e => e.key === 'Enter' && addField()} />
|
||
<select className="border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
|
||
value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value }))}>
|
||
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
<button onClick={addField} className="text-sm bg-raised px-3 py-1 rounded hover:bg-raised text-ink-soft">Add</button>
|
||
</div>
|
||
|
||
{mappingsDirty && (
|
||
<button onClick={saveMappings} disabled={saving}
|
||
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
||
{saving ? 'Saving…' : 'Save mappings'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Generate view + balance */}
|
||
<div className="bg-surface border border-line rounded p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h3 className="text-sm font-semibold text-ink-soft">View</h3>
|
||
<div className="flex gap-2">
|
||
<button onClick={fetchBalance}
|
||
className="text-sm bg-raised text-ink-soft px-3 py-1.5 rounded hover:bg-raised">
|
||
Refresh balance
|
||
</button>
|
||
<button onClick={generateView}
|
||
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700">
|
||
Generate / refresh
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{netBalance !== null && (
|
||
<div className="mb-3 flex items-center gap-3">
|
||
<span className="text-xs text-muted">Current net balance</span>
|
||
<span className="text-lg font-mono font-semibold text-ink">
|
||
{Number(netBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{balanceError && <p className="text-xs text-muted mb-3">{balanceError}</p>}
|
||
{viewResult && !viewResult.success && (
|
||
<p className="text-xs text-danger">{viewResult.error}</p>
|
||
)}
|
||
{viewResult && viewResult.success && (
|
||
<p className="text-xs text-ok">View created: <span className="font-mono">{viewResult.view}</span></p>
|
||
)}
|
||
</div>
|
||
|
||
{calibratingSource && (
|
||
<CalibrateModal
|
||
stack={stack}
|
||
sourceName={calibratingSource}
|
||
currentOffset={srcCfg[calibratingSource]?.offset ?? 0}
|
||
onClose={() => setCalibratingSource(null)}
|
||
onApply={offset => applyCalibration(calibratingSource, offset)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── 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 (
|
||
<div className="p-6">
|
||
{/* Stack list — horizontal row of cards */}
|
||
<div className="flex items-center gap-2 mb-5 flex-wrap">
|
||
<h1 className="text-sm font-semibold text-ink mr-1">Stacks</h1>
|
||
{stacks.map(s => (
|
||
<div key={s.name}
|
||
onClick={() => 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-accent-line bg-accent-soft text-accent' : 'border-line bg-surface text-ink-soft hover:border-line hover:bg-raised'}`}>
|
||
<span className="font-medium">{s.label || s.name}</span>
|
||
<span className="text-muted">{s.source_count}s</span>
|
||
<Link to={`/stacks/${encodeURIComponent(s.name)}/pivot`}
|
||
onClick={e => e.stopPropagation()}
|
||
className="text-accent underline decoration-transparent hover:decoration-inherit">pivot</Link>
|
||
<button onClick={e => { e.stopPropagation(); deleteStack(s.name) }}
|
||
className="opacity-0 group-hover:opacity-100 text-danger hover:text-danger leading-none ml-2 pl-2 border-l border-line">✕</button>
|
||
</div>
|
||
))}
|
||
{creating ? (
|
||
<div className="flex items-center gap-1">
|
||
<input autoFocus className="border border-accent rounded px-2 py-1 text-xs focus:outline-none w-32"
|
||
placeholder="stack name" value={newName} onChange={e => setNewName(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') createStack(); if (e.key === 'Escape') setCreating(false) }} />
|
||
<button onClick={createStack} className="text-xs bg-blue-600 text-white px-2 py-1 rounded">Create</button>
|
||
<button onClick={() => setCreating(false)} className="text-xs text-muted px-1">✕</button>
|
||
{error && <p className="text-xs text-danger">{error}</p>}
|
||
</div>
|
||
) : (
|
||
<button onClick={() => setCreating(true)} className="text-xs text-accent hover:text-accent px-2 py-1.5">+ New</button>
|
||
)}
|
||
</div>
|
||
|
||
{stackDetail ? (
|
||
<div className="flex gap-6 items-start">
|
||
{/* Left: config panel */}
|
||
<div className="flex-1 min-w-0">
|
||
<h2 className="text-base font-semibold text-ink mb-4">
|
||
{stackDetail.label || stackDetail.name}
|
||
{stackDetail.label && <span className="text-sm text-muted font-normal ml-2">{stackDetail.name}</span>}
|
||
</h2>
|
||
<StackPanel
|
||
key={stackDetail.name}
|
||
stack={stackDetail}
|
||
sources={sources}
|
||
onUpdated={() => { load(); loadDetail(stackDetail.name) }}
|
||
onStale={onStackStale}
|
||
onViewGenerated={onStackViewGenerated}
|
||
onSqlGenerated={sql => { setSqlDraft(prettySql(sql)); setSqlResult(null) }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Right: SQL panel */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="bg-surface border border-line rounded p-4 sticky top-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h3 className="text-sm font-semibold text-ink-soft">Generated SQL</h3>
|
||
<button
|
||
onClick={runSql}
|
||
disabled={!sqlDraft.trim() || sqlRunning}
|
||
className="text-sm bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 disabled:opacity-40">
|
||
{sqlRunning ? 'Running…' : 'Run'}
|
||
</button>
|
||
</div>
|
||
{sqlDraft ? (
|
||
<textarea
|
||
className="w-full font-mono text-xs text-ink-soft bg-raised border border-line rounded p-2 focus:outline-none focus:border-accent resize-none leading-relaxed"
|
||
style={{ minHeight: '60vh' }}
|
||
value={sqlDraft}
|
||
onChange={e => { setSqlDraft(e.target.value); setSqlResult(null) }}
|
||
spellCheck={false}
|
||
/>
|
||
) : (
|
||
<p className="text-xs text-muted">Generate a view to see the SQL here.</p>
|
||
)}
|
||
{sqlResult && (
|
||
<p className={`text-xs mt-2 ${sqlResult.success ? 'text-ok' : 'text-danger'}`}>
|
||
{sqlResult.success ? 'View updated successfully.' : sqlResult.error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-muted">Select a stack or create one.</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|