dataflow/ui/src/pages/Stacks.jsx
Paul Trowbridge 8753719db8 Make the stack pivot link visible and move it clear of delete
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
2026-08-07 22:51:04 -04:00

835 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 { 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>
)
}