pf_app/ui/src/views/Setup.jsx
Paul Trowbridge dc090fe394 Scaffold React/Vite/Tailwind UI with 3-step Setup → Baseline → Forecast flow
- ui/: React + Vite + Tailwind app (Setup, Baseline, Forecast views, collapsible sidebar, status bar, canvas timeline)
- server.js: serve built UI from public/app/
- package.json: add build script (cd ui && npm run build)
- routes/sources.js: default new col_meta role to 'dimension' instead of 'ignore'
- .gitignore: exclude public/app/ build output
- pf_spec.md: update tech stack, nav, frontend section, and project status to reflect current implementation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:28:45 -04:00

411 lines
18 KiB
JavaScript

import { useState, useEffect } from 'react'
const ROLES = ['ignore', 'dimension', 'value', 'units', 'date', 'filter']
const ROLE_STYLE = {
dimension: 'bg-blue-50 text-blue-700',
value: 'bg-green-50 text-green-700',
units: 'bg-green-50 text-green-700',
date: 'bg-purple-50 text-purple-700',
filter: 'bg-yellow-50 text-yellow-700',
ignore: 'bg-gray-100 text-gray-400',
}
export default function Setup() {
const [tables, setTables] = useState([])
const [sources, setSources] = useState([])
const [selectedSource, setSelectedSource] = useState(null)
const [cols, setCols] = useState([])
const [editedCols, setEditedCols] = useState([])
const [colsDirty, setColsDirty] = useState(false)
const [preview, setPreview] = useState(null) // { schema, tname, columns, rows }
const [previewLoading, setPreviewLoading] = useState(false)
const [sqlStatus, setSqlStatus] = useState({}) // sourceId -> bool
const [saving, setSaving] = useState(false)
const [generating, setGenerating] = useState(false)
const [msg, setMsg] = useState(null)
useEffect(() => {
fetch('/api/tables').then(r => r.json()).then(setTables).catch(console.error)
loadSources()
}, [])
function loadSources() {
fetch('/api/sources').then(r => r.json()).then(data => {
setSources(data)
// check sql status for each source
data.forEach(s => {
fetch(`/api/sources/${s.id}/sql`).then(r => r.json()).then(sqls => {
setSqlStatus(prev => ({ ...prev, [s.id]: sqls.length > 0 }))
})
})
}).catch(console.error)
}
function selectSource(source) {
setSelectedSource(source)
setColsDirty(false)
fetch(`/api/sources/${source.id}/cols`).then(r => r.json()).then(data => {
setCols(data)
setEditedCols(data.map(c => ({ ...c })))
})
}
async function openPreview(schema, tname, e) {
e.stopPropagation()
setPreviewLoading(true)
setPreview({ schema, tname, loading: true })
try {
const data = await fetch(`/api/tables/${schema}/${tname}/preview`).then(r => r.json())
setPreview({ schema, tname, ...data })
} catch {
setPreview(null)
} finally {
setPreviewLoading(false)
}
}
async function registerSource(schema, tname) {
try {
const res = await fetch('/api/sources', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema, tname, created_by: 'admin' })
})
if (!res.ok) {
const err = await res.json()
flash(err.error, 'error')
return
}
const source = await res.json()
loadSources()
flash(`Registered ${schema}.${tname}`)
// auto-select new source and load its cols
fetch(`/api/sources/${source.id}/cols`).then(r => r.json()).then(data => {
setSelectedSource(source)
setCols(data)
setEditedCols(data.map(c => ({ ...c })))
setColsDirty(false)
})
} catch (err) {
flash(err.message, 'error')
}
}
function updateCol(idx, field, value) {
setEditedCols(prev => {
const next = prev.map((c, i) => i === idx ? { ...c, [field]: value } : c)
return next
})
setColsDirty(true)
}
async function saveCols() {
setSaving(true)
try {
const res = await fetch(`/api/sources/${selectedSource.id}/cols`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editedCols)
})
if (!res.ok) { const e = await res.json(); flash(e.error, 'error'); return }
const saved = await res.json()
setCols(saved)
setEditedCols(saved.map(c => ({ ...c })))
setColsDirty(false)
flash('Saved')
} catch (err) {
flash(err.message, 'error')
} finally {
setSaving(false)
}
}
async function generateSQL() {
setGenerating(true)
try {
const res = await fetch(`/api/sources/${selectedSource.id}/generate-sql`, { method: 'POST' })
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
setSqlStatus(prev => ({ ...prev, [selectedSource.id]: true }))
flash(`SQL generated: ${data.operations.join(', ')}`)
} catch (err) {
flash(err.message, 'error')
} finally {
setGenerating(false)
}
}
async function deleteSource(id, e) {
e.stopPropagation()
if (!confirm('Deregister this source? Existing forecast tables are not affected.')) return
await fetch(`/api/sources/${id}`, { method: 'DELETE' })
if (selectedSource?.id === id) { setSelectedSource(null); setCols([]); setEditedCols([]) }
loadSources()
}
function flash(text, type = 'ok') {
setMsg({ text, type })
setTimeout(() => setMsg(null), 3000)
}
const registeredKeys = new Set(sources.map(s => `${s.schema}.${s.tname}`))
return (
<div className="h-full flex overflow-hidden text-sm">
{/* All Tables */}
<div className="w-64 bg-white border-r border-gray-200 flex flex-col shrink-0">
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide">
All Tables
</div>
<div className="overflow-y-auto flex-1">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-gray-50">
<tr className="text-left text-gray-400 border-b border-gray-100">
<th className="px-3 py-1.5 font-medium">schema</th>
<th className="px-3 py-1.5 font-medium">table</th>
<th className="px-3 py-1.5 font-medium text-right">rows</th>
</tr>
</thead>
<tbody>
{tables.map(t => {
const key = `${t.schema}.${t.tname}`
const registered = registeredKeys.has(key)
return (
<tr
key={key}
onClick={e => openPreview(t.schema, t.tname, e)}
className="border-t border-gray-50 hover:bg-blue-50 cursor-pointer group"
>
<td className="px-3 py-1.5 text-gray-400">{t.schema}</td>
<td className="px-3 py-1.5 font-medium">
<div className="flex items-center gap-1">
<span className={registered ? 'text-green-600' : ''}>{t.tname}</span>
{registered && <span className="text-green-400 text-xs"></span>}
</div>
</td>
<td className="px-3 py-1.5 text-right text-gray-500">
{Number(t.row_estimate).toLocaleString()}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
{/* Right side */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* Flash message */}
{msg && (
<div className={`px-4 py-2 text-xs font-medium ${msg.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
{msg.text}
</div>
)}
<div className="flex-1 flex flex-col gap-0 overflow-hidden">
{/* Registered Sources */}
<div className="bg-white border-b border-gray-200 shrink-0">
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide">
Registered Sources
</div>
<table className="w-full text-xs">
<thead className="bg-gray-50">
<tr className="text-left text-gray-400 border-b border-gray-100">
<th className="px-3 py-1.5 font-medium">source</th>
<th className="px-3 py-1.5 font-medium">schema</th>
<th className="px-3 py-1.5 font-medium">sql</th>
<th className="px-3 py-1.5 font-medium">created</th>
<th className="px-3 py-1.5"></th>
</tr>
</thead>
<tbody>
{sources.length === 0 && (
<tr><td colSpan={5} className="px-3 py-3 text-gray-300 italic">No sources registered click a table to preview, then register it</td></tr>
)}
{sources.map(s => (
<tr
key={s.id}
onClick={() => selectSource(s)}
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${selectedSource?.id === s.id ? 'bg-blue-50' : ''}`}
>
<td className={`px-3 py-2 font-medium ${selectedSource?.id === s.id ? 'text-blue-700' : ''}`}>{s.tname}</td>
<td className="px-3 py-2 text-gray-400">{s.schema}</td>
<td className="px-3 py-2">
{sqlStatus[s.id]
? <span className="text-green-600 font-medium"> ready</span>
: <span className="text-gray-300"></span>}
</td>
<td className="px-3 py-2 text-gray-400">{s.created_by || '—'}</td>
<td className="px-3 py-2 text-right">
<button onClick={e => deleteSource(s.id, e)} className="text-gray-300 hover:text-red-500 text-xs"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Col Meta Editor */}
{selectedSource ? (
<div className="flex-1 flex flex-col overflow-hidden bg-white">
<div className="px-3 py-2 border-b border-gray-100 flex items-center justify-between shrink-0">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Col Meta <span className="text-gray-700 normal-case">{selectedSource.schema}.{selectedSource.tname}</span>
</span>
<div className="flex items-center gap-2">
{colsDirty && (
<button onClick={saveCols} disabled={saving} className="text-xs border border-gray-200 px-3 py-1 rounded hover:bg-gray-50 disabled:opacity-50">
{saving ? 'Saving…' : 'Save'}
</button>
)}
<button
onClick={generateSQL}
disabled={generating || colsDirty}
className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 disabled:opacity-50"
title={colsDirty ? 'Save col meta first' : ''}
>
{generating ? 'Generating…' : 'Generate SQL'}
</button>
</div>
</div>
<div className="overflow-y-auto flex-1">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-gray-50">
<tr className="text-left text-gray-400 border-b border-gray-100">
<th className="px-3 py-1.5 font-medium">column</th>
<th className="px-3 py-1.5 font-medium">role</th>
<th className="px-3 py-1.5 font-medium text-center">key</th>
<th className="px-3 py-1.5 font-medium">label</th>
</tr>
</thead>
<tbody>
{editedCols.map((col, i) => (
<tr key={col.cname} className="border-t border-gray-50 hover:bg-gray-50">
<td className="px-3 py-1.5 font-mono text-gray-700">{col.cname}</td>
<td className="px-3 py-1.5">
<select
value={col.role}
onChange={e => updateCol(i, 'role', e.target.value)}
className={`text-xs px-1.5 py-0.5 rounded border-0 font-medium cursor-pointer ${ROLE_STYLE[col.role] || ''}`}
>
{ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</td>
<td className="px-3 py-1.5 text-center">
<input
type="checkbox"
checked={!!col.is_key}
onChange={e => updateCol(i, 'is_key', e.target.checked)}
disabled={col.role !== 'dimension'}
className="cursor-pointer disabled:opacity-20"
/>
</td>
<td className="px-3 py-1.5">
<input
type="text"
value={col.label || ''}
onChange={e => updateCol(i, 'label', e.target.value)}
placeholder={col.cname}
className="border border-transparent hover:border-gray-200 focus:border-gray-300 rounded px-1.5 py-0.5 w-full outline-none bg-transparent"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-gray-300 text-xs italic">
Select a source to edit col meta
</div>
)}
</div>
</div>
{/* Table preview modal */}
{preview && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/30" onClick={() => setPreview(null)} />
<div className="relative bg-white rounded-lg shadow-2xl flex flex-col z-10 text-xs" style={{ width: 720, maxWidth: '90vw', maxHeight: '80vh' }}>
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 shrink-0">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-gray-800">{preview.schema}.{preview.tname}</span>
{preview.columns && (
<span className="text-gray-400">{preview.columns.length} columns</span>
)}
{!registeredKeys.has(`${preview.schema}.${preview.tname}`) && (
<button
onClick={() => { registerSource(preview.schema, preview.tname); setPreview(null) }}
className="bg-blue-600 text-white text-xs px-3 py-1 rounded hover:bg-blue-700"
>
+ Register source
</button>
)}
</div>
<button onClick={() => setPreview(null)} className="text-gray-300 hover:text-gray-600 text-lg leading-none ml-4"></button>
</div>
{preview.loading ? (
<div className="p-8 text-center text-gray-400">Loading</div>
) : (
<div className="overflow-y-auto flex-1">
{/* Columns */}
<div className="px-4 pt-3 pb-1 text-gray-400 uppercase tracking-wide font-medium text-xs">Columns</div>
<table className="w-full mb-2">
<thead>
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50">
<th className="px-4 py-1 font-medium">name</th>
<th className="px-4 py-1 font-medium">type</th>
<th className="px-4 py-1 font-medium">nullable</th>
</tr>
</thead>
<tbody>
{(preview.columns || []).map(c => (
<tr key={c.column_name} className="border-t border-gray-50">
<td className="px-4 py-1 font-mono text-gray-700">{c.column_name}</td>
<td className="px-4 py-1 text-gray-400">{c.data_type}</td>
<td className="px-4 py-1 text-gray-400">{c.is_nullable}</td>
</tr>
))}
</tbody>
</table>
{/* Sample rows */}
<div className="px-4 py-1 text-gray-400 uppercase tracking-wide font-medium text-xs border-t border-gray-100">Sample rows</div>
<div className="overflow-x-auto">
<table className="text-xs" style={{ minWidth: '100%' }}>
<thead>
<tr className="text-left text-gray-400 bg-gray-50 border-b border-gray-100">
{(preview.columns || []).map(c => (
<th key={c.column_name} className="px-4 py-1 font-medium whitespace-nowrap">{c.column_name}</th>
))}
</tr>
</thead>
<tbody>
{(preview.rows || []).map((row, i) => (
<tr key={i} className="border-t border-gray-50">
{(preview.columns || []).map(c => (
<td key={c.column_name} className={`px-4 py-1 font-mono whitespace-nowrap ${row[c.column_name] == null ? 'text-gray-300' : 'text-gray-600'}`}>
{row[c.column_name] == null ? 'null' : String(row[c.column_name])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}