dataflow/ui/src/pages/Stacks.jsx
Paul Trowbridge 1baadaca61 Lift stack state to App; merge Records panel; fix Pivot theme on load
- Stack selection lifted to App.jsx: stacks fetched on login, selectedStack
  state shared via StatusBar (pills) and Pivot (view switching); Stacks page
  calls onStacksChange to keep list fresh
- Pivot: derive selectedView/viewType from props, remove local stack state;
  toolbar replaced with dedicated layouts sub-bar (h-9, layouts only)
- Records panel: merge read-only and override sections into single field list;
  known cols seeded from record's transformed fields; rule-derived fields
  (transformed minus data) will be editable in follow-up refactor
- Pivot theme: setAttribute moved to after flush() so restore() can't reset it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 10:35:34 -04:00

831 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-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>
{/* 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 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>
<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>
</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-white border border-gray-200 rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Configuration</h3>
<div className="flex gap-3 items-end">
<div className="flex-1">
<label className="text-xs text-gray-500 block mb-1">Label <span className="text-gray-400">(optional)</span></label>
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400"
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-red-500 mt-2">{error}</p>}
</div>
{/* Sources */}
<div className="bg-white border border-gray-200 rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-1">Sources</h3>
<p className="text-xs text-gray-400 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-gray-100 rounded px-3 py-2 text-xs space-y-2 ${srcDragOverIdx === idx && srcDragIdx !== idx ? 'bg-blue-50' : ''}`}>
<div className="flex items-center gap-2">
<span className="text-gray-300 cursor-grab select-none"></span>
<span className="font-medium text-gray-700 flex-1">{m.source_name}</span>
<button onClick={() => removeSource(m.source_name)} className="text-red-300 hover:text-red-500">Remove</button>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
<div>
<label className="text-gray-400 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-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
<option value=""> select </option>
{sf.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div>
<label className="text-gray-400 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-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
<option value={1}>+1 (as-is)</option>
<option value={-1}>1 (flip)</option>
</select>
</div>
<div>
<label className="text-gray-400 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-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400">
<option value=""> select </option>
{sf.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div>
<label className="text-gray-400 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-gray-200 rounded px-1.5 py-0.5 font-mono focus:outline-none focus:border-blue-400" />
<button onClick={() => handleCalibrate(m.source_name)}
disabled={!canCalibrate}
title={!canCalibrate ? 'Set amount and date fields first' : 'Calibrate balance'}
className="text-blue-400 hover:text-blue-600 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-gray-400">No sources added yet.</p>}
</div>
{availableSources.length > 0 && (
<div className="flex gap-2">
<select className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
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-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700 disabled:opacity-40">Add</button>
</div>
)}
</div>
{/* Output columns mapping grid */}
<div className="bg-white border border-gray-200 rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-1">Output columns</h3>
<p className="text-xs text-gray-400 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-blue-500">numeric</span> field drives the running balance; the first <span className="text-green-600">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-gray-400 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-gray-200">
<th className="w-5 pb-2"></th>
<th className="text-left text-gray-400 font-normal pb-2 pr-4">Column</th>
<th className="text-left text-gray-400 font-normal pb-2 pr-4">Type</th>
{members.map(m => (
<th key={m.source_name} className="text-left text-gray-400 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-gray-50 ${dragOverIdx === idx && dragIdx !== idx ? 'bg-blue-50' : ''}`}>
<td className="py-1.5 pr-1 text-gray-300 cursor-grab select-none"></td>
<td className="py-1.5 pr-4 font-mono text-gray-700 whitespace-nowrap">
{f.name}
{isAmount && <span className="ml-1.5 text-blue-500 font-sans font-normal">amount</span>}
{isDate && <span className="ml-1.5 text-green-600 font-sans font-normal">date</span>}
</td>
<td className="py-1.5 pr-4 text-gray-400">{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-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400 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-red-300 hover:text-red-500"></button>
</td>
</tr>
)
})}
{fields.length === 0 && (
<tr><td colSpan={3 + members.length} className="py-3 text-gray-400 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-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
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-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400"
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-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700">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-white border border-gray-200 rounded p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">View</h3>
<div className="flex gap-2">
<button onClick={fetchBalance}
className="text-sm bg-gray-100 text-gray-700 px-3 py-1.5 rounded hover:bg-gray-200">
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-gray-500">Current net balance</span>
<span className="text-lg font-mono font-semibold text-gray-800">
{Number(netBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
</div>
)}
{balanceError && <p className="text-xs text-gray-400 mb-3">{balanceError}</p>}
{viewResult && !viewResult.success && (
<p className="text-xs text-red-500">{viewResult.error}</p>
)}
{viewResult && viewResult.success && (
<p className="text-xs text-green-600">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-gray-800 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-blue-300 bg-blue-50 text-blue-700' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}>
<span className="font-medium">{s.label || s.name}</span>
<span className="text-gray-400">{s.source_count}s</span>
<button onClick={e => { e.stopPropagation(); deleteStack(s.name) }}
className="opacity-0 group-hover:opacity-100 text-red-300 hover:text-red-500 leading-none"></button>
</div>
))}
{creating ? (
<div className="flex items-center gap-1">
<input autoFocus className="border border-blue-400 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-gray-400 px-1"></button>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
) : (
<button onClick={() => setCreating(true)} className="text-xs text-blue-500 hover:text-blue-700 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-gray-800 mb-4">
{stackDetail.label || stackDetail.name}
{stackDetail.label && <span className="text-sm text-gray-400 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-white border border-gray-200 rounded p-4 sticky top-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">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-gray-700 bg-gray-50 border border-gray-200 rounded p-2 focus:outline-none focus:border-blue-400 resize-none leading-relaxed"
style={{ minHeight: '60vh' }}
value={sqlDraft}
onChange={e => { setSqlDraft(e.target.value); setSqlResult(null) }}
spellCheck={false}
/>
) : (
<p className="text-xs text-gray-400">Generate a view to see the SQL here.</p>
)}
{sqlResult && (
<p className={`text-xs mt-2 ${sqlResult.success ? 'text-green-600' : 'text-red-500'}`}>
{sqlResult.success ? 'View updated successfully.' : sqlResult.error}
</p>
)}
</div>
</div>
</div>
) : (
<p className="text-sm text-gray-400">Select a stack or create one.</p>
)}
</div>
)
}