pf_app/ui/src/views/Baseline.jsx
Paul Trowbridge af52845523 Unify baseline/reference into one form; fix timeline for both types
- Baseline.jsx: merge Reference section into Add Segment form with baseline/reference toggle; segment rows now clickable to expand stored WHERE clause + timeline; date filter inputs use type="date" for date-role columns
- Timeline.jsx: add type prop ('baseline'|'reference'); reference band uses purple; single-band height shrinks to 52px; canvas uses requestAnimationFrame to fix offsetWidth=0 on mount
- operations.js: reference route now accepts where_clause like baseline (drops date_from/date_to)
- sql_generator.js: reference SQL template uses {{filter_clause}} instead of hardcoded BETWEEN

Note: existing sources need Generate SQL re-run to pick up the new reference template.

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

521 lines
25 KiB
JavaScript

import { useState, useEffect } from 'react'
import Timeline from '../components/Timeline.jsx'
const OPERATORS = ['BETWEEN', '=', '!=', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL']
function buildFilterClause(filters) {
if (!filters.length) return null
const parts = filters.map(f => {
const col = `"${f.col}"`
const op = f.op
if (op === 'IS NULL') return `${col} IS NULL`
if (op === 'IS NOT NULL') return `${col} IS NOT NULL`
if (op === 'BETWEEN') {
const [a, b] = f.values
return `${col} BETWEEN '${a}' AND '${b}'`
}
if (op === 'IN' || op === 'NOT IN') {
const vals = f.values.join("','")
return `${col} ${op} ('${vals}')`
}
return `${col} ${op} '${f.values[0]}'`
})
return parts.join(' AND ')
}
function getDateRange(filters) {
for (const f of filters) {
if (f.op === 'BETWEEN' && f.values[0] && f.values[1]) {
return { from: f.values[0], to: f.values[1] }
}
if (f.op === '=' && f.values[0]) {
return { from: f.values[0], to: f.values[0] }
}
}
return null
}
function parseDateRangeFromClause(clause) {
if (!clause) return null
const m = clause.match(/BETWEEN '(\d{4}-\d{2}-\d{2})' AND '(\d{4}-\d{2}-\d{2})'/)
if (m) return { from: m[1], to: m[2] }
const m2 = clause.match(/>= ?'(\d{4}-\d{2}-\d{2})'.+<= ?'(\d{4}-\d{2}-\d{2})'/)
if (m2) return { from: m2[1], to: m2[2] }
return null
}
function parseOffset(offsetStr) {
if (!offsetStr || offsetStr === '0 days') return { yr: 0, mo: 0 }
const yr = parseInt(offsetStr.match(/(\d+)\s+year/)?.[1] || 0)
const mo = parseInt(offsetStr.match(/(\d+)\s+month/)?.[1] || 0)
return { yr, mo }
}
function emptyFilter(cols) {
return { col: cols[0]?.cname || '', op: 'BETWEEN', values: ['', ''] }
}
export default function Baseline() {
const [sources, setSources] = useState([])
const [sourceId, setSourceId] = useState('')
const [versions, setVersions] = useState([])
const [versionId, setVersionId] = useState('')
const [filterCols, setFilterCols] = useState([])
const [log, setLog] = useState([])
// new version form
const [showNewVersion, setShowNewVersion] = useState(false)
const [newVerName, setNewVerName] = useState('')
const [newVerDesc, setNewVerDesc] = useState('')
const [creatingVer, setCreatingVer] = useState(false)
// add segment form
const [segType, setSegType] = useState('baseline')
const [description, setDescription] = useState('')
const [filters, setFilters] = useState([])
const [offsetYr, setOffsetYr] = useState(0)
const [offsetMo, setOffsetMo] = useState(0)
const [segNote, setSegNote] = useState('')
const [submitting, setSubmitting] = useState(false)
const [expandedId, setExpandedId] = useState(null)
const [msg, setMsg] = useState(null)
useEffect(() => {
fetch('/api/sources').then(r => r.json()).then(data => {
setSources(data)
if (data.length > 0) setSourceId(String(data[0].id))
})
}, [])
useEffect(() => {
if (!sourceId) return
fetch(`/api/sources/${sourceId}/versions`).then(r => r.json()).then(data => {
setVersions(data)
if (data.length > 0) setVersionId(String(data[0].id))
else setVersionId('')
})
fetch(`/api/sources/${sourceId}/cols`).then(r => r.json()).then(cols => {
const fc = cols.filter(c => c.role === 'date' || c.role === 'filter')
setFilterCols(fc)
setFilters(fc.length > 0 ? [emptyFilter(fc)] : [])
})
}, [sourceId])
useEffect(() => {
if (!versionId) { setLog([]); return }
loadLog()
}, [versionId])
function loadLog() {
fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => {
setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference'))
})
}
async function createVersion() {
if (!newVerName.trim()) return
setCreatingVer(true)
try {
const res = await fetch(`/api/sources/${sourceId}/versions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newVerName.trim(), description: newVerDesc, created_by: 'admin' })
})
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
setVersions(updated)
setVersionId(String(data.id))
setShowNewVersion(false)
setNewVerName('')
setNewVerDesc('')
flash(`Version "${data.name}" created`)
} catch (err) {
flash(err.message, 'error')
} finally {
setCreatingVer(false)
}
}
function addFilter() {
setFilters(f => [...f, emptyFilter(filterCols)])
}
function removeFilter(i) {
setFilters(f => f.filter((_, idx) => idx !== i))
}
function updateFilter(i, field, value) {
setFilters(f => f.map((row, idx) => {
if (idx !== i) return row
if (field === 'op') {
const needsTwo = value === 'BETWEEN'
const needsOne = ['=', '!='].includes(value)
const needsMany = ['IN', 'NOT IN'].includes(value)
const needsNone = ['IS NULL', 'IS NOT NULL'].includes(value)
return { ...row, op: value, values: needsNone ? [] : needsTwo ? ['', ''] : needsMany ? [''] : [''] }
}
return { ...row, [field]: value }
}))
}
function updateFilterValue(i, vi, value) {
setFilters(f => f.map((row, idx) => {
if (idx !== i) return row
const vals = [...row.values]
vals[vi] = value
return { ...row, values: vals }
}))
}
async function loadSegment() {
const clause = buildFilterClause(filters)
if (!clause) { flash('Add at least one filter', 'error'); return }
const isRef = segType === 'reference'
const offsetStr = isRef ? '0 days' : ([offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days')
const endpoint = isRef ? 'reference' : 'baseline'
const body = isRef
? { where_clause: clause, pf_user: 'admin', note: description || segNote }
: { where_clause: clause, date_offset: offsetStr, pf_user: 'admin', note: description || segNote }
setSubmitting(true)
try {
const res = await fetch(`/api/versions/${versionId}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
flash(`Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`)
loadLog()
setDescription(''); setSegNote(''); setOffsetYr(0); setOffsetMo(0)
setFilters(filterCols.length > 0 ? [emptyFilter(filterCols)] : [])
} catch (err) {
flash(err.message, 'error')
} finally {
setSubmitting(false)
}
}
async function undoSegment(logid) {
await fetch(`/api/log/${logid}`, { method: 'DELETE' })
loadLog()
flash('Segment undone')
}
async function clearBaseline() {
if (!confirm('Delete all baseline rows for this version?')) return
const res = await fetch(`/api/versions/${versionId}/baseline`, { method: 'DELETE' })
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
loadLog()
flash(`Cleared ${data.rows_deleted} rows`)
}
async function closeVersion() {
const res = await fetch(`/api/versions/${versionId}/close`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pf_user: 'admin' })
})
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
setVersions(updated)
flash('Version closed')
}
async function reopenVersion() {
const res = await fetch(`/api/versions/${versionId}/reopen`, { method: 'POST' })
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
setVersions(updated)
flash('Version reopened')
}
async function deleteVersion() {
if (!confirm(`Delete version "${selectedVersion?.name}"? This drops the forecast table and cannot be undone.`)) return
const res = await fetch(`/api/versions/${versionId}`, { method: 'DELETE' })
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
setVersions(updated)
setVersionId(updated.length > 0 ? String(updated[0].id) : '')
flash('Version deleted')
}
function flash(text, type = 'ok') {
setMsg({ text, type })
setTimeout(() => setMsg(null), 3000)
}
const dateRange = getDateRange(filters)
const selectedVersion = versions.find(v => String(v.id) === versionId)
return (
<div className="h-full overflow-y-auto bg-gray-50">
<div className="p-4 flex flex-col gap-4 max-w-4xl">
{/* Flash */}
{msg && (
<div className={`px-3 py-2 text-xs rounded font-medium ${msg.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
{msg.text}
</div>
)}
{/* Source + Version bar */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Source</span>
<select value={sourceId} onChange={e => setSourceId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
{sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Version</span>
<select value={versionId} onChange={e => setVersionId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white" disabled={versions.length === 0}>
{versions.length === 0
? <option value=""> no versions </option>
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)
}
</select>
{versionId && (
<span className={`text-xs font-medium ${selectedVersion?.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}>
{selectedVersion?.status}
</span>
)}
</div>
<button onClick={() => setShowNewVersion(v => !v)} className="text-xs text-blue-600 hover:text-blue-700 border border-blue-200 px-2 py-1 rounded">
+ New version
</button>
{versionId && (
<div className="flex items-center gap-2 ml-2">
{selectedVersion?.status === 'open'
? <button onClick={closeVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Close</button>
: <button onClick={reopenVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Reopen</button>
}
<button onClick={deleteVersion} className="text-xs text-red-400 hover:text-red-600 border border-red-200 px-2 py-1 rounded">Delete</button>
</div>
)}
</div>
{/* New version inline form */}
{showNewVersion && (
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-3">
<div className="flex items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Name</label>
<input value={newVerName} onChange={e => setNewVerName(e.target.value)} placeholder="e.g. FY2026 Plan" className="border border-gray-200 rounded px-2 py-1 text-sm w-48" />
</div>
<div className="flex flex-col gap-1 flex-1">
<label className="text-xs text-gray-500">Description</label>
<input value={newVerDesc} onChange={e => setNewVerDesc(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1 text-sm" />
</div>
<button onClick={createVersion} disabled={creatingVer || !newVerName.trim()} className="bg-blue-600 text-white text-xs px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50 shrink-0">
{creatingVer ? 'Creating table…' : 'Create'}
</button>
<button onClick={() => setShowNewVersion(false)} className="text-gray-400 hover:text-gray-600 text-xs shrink-0">Cancel</button>
</div>
<div className="text-xs text-gray-400 border-t border-gray-100 pt-2">
Creates a forecast table <span className="font-mono text-gray-500">pf.fc_{sources.find(s=>String(s.id)===sourceId)?.tname}_&lt;id&gt;</span> in the database from the current col meta. If col meta changes after creation the table and SQL will be out of sync delete and recreate the version to realign.
</div>
</div>
)}
{versionId && <>
{/* Segments loaded */}
<div className="bg-white border border-gray-200 rounded">
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
<span>Segments loaded</span>
<button onClick={clearBaseline} className="text-red-400 hover:text-red-600 text-xs normal-case font-normal">Clear all baseline</button>
</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 w-6"></th>
<th className="px-3 py-1.5 font-medium">#</th>
<th className="px-3 py-1.5 font-medium">note</th>
<th className="px-3 py-1.5 font-medium">by</th>
<th className="px-3 py-1.5 font-medium">when</th>
<th className="px-3 py-1.5 font-medium"></th>
</tr>
</thead>
<tbody>
{log.length === 0 && (
<tr><td colSpan={6} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
)}
{log.map((entry, i) => {
const isOpen = expandedId === entry.id
const params = entry.params || {}
const dr = parseDateRangeFromClause(params.where_clause)
const off = parseOffset(params.date_offset)
return (
<>
<tr
key={entry.id}
onClick={() => setExpandedId(isOpen ? null : entry.id)}
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${isOpen ? 'bg-blue-50' : ''}`}
>
<td className="px-3 py-2 text-gray-400 w-6">
<span className="text-gray-300 text-xs">{isOpen ? '▾' : '▸'}</span>
</td>
<td className="px-3 py-2 text-gray-400">{log.length - i}</td>
<td className="px-3 py-2">
<span className={`inline-block mr-2 px-1.5 py-0.5 rounded text-xs font-medium ${entry.operation === 'reference' ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600'}`}>
{entry.operation}
</span>
{entry.note || <span className="text-gray-300"></span>}
</td>
<td className="px-3 py-2 text-gray-500">{entry.pf_user}</td>
<td className="px-3 py-2 text-gray-400">{new Date(entry.stamp).toLocaleDateString()}</td>
<td className="px-3 py-2 text-right">
<button onClick={e => { e.stopPropagation(); undoSegment(entry.id) }} className="text-gray-400 hover:text-red-500 text-xs">Undo</button>
</td>
</tr>
{isOpen && (
<tr key={`${entry.id}-detail`} className="bg-blue-50 border-t border-blue-100">
<td colSpan={6} className="px-4 py-3">
<div className="flex flex-col gap-2">
<div className="flex items-start gap-2">
<span className="text-xs text-gray-400 w-24 shrink-0 pt-0.5">WHERE</span>
<code className="text-xs font-mono text-gray-700 bg-white border border-gray-200 rounded px-2 py-1 break-all">{params.where_clause || '—'}</code>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400 w-24 shrink-0">offset</span>
<span className="text-xs font-mono text-gray-600">{params.date_offset || '0 days'}</span>
</div>
{dr && (
<div className="mt-1">
<Timeline dateFrom={dr.from} dateTo={dr.to} offsetYr={off.yr} offsetMo={off.mo} type={entry.operation === 'reference' ? 'reference' : 'baseline'} />
</div>
)}
</div>
</td>
</tr>
)}
</>
)
})}
</tbody>
</table>
</div>
{/* Add Segment */}
<div className="bg-white border border-gray-200 rounded">
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide">
Add Segment
</div>
<div className="p-4 flex flex-col gap-4">
{/* Type toggle */}
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Type</label>
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
{['baseline', 'reference'].map(t => (
<button
key={t}
onClick={() => { setSegType(t); if (t === 'reference') { setOffsetYr(0); setOffsetMo(0) } }}
className={`px-3 py-1 capitalize ${segType === t ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}
>{t}</button>
))}
</div>
{segType === 'reference' && (
<span className="text-xs text-gray-400">dates land verbatim no offset applied</span>
)}
</div>
{/* Description */}
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Description</label>
<input value={description} onChange={e => setDescription(e.target.value)} placeholder="e.g. FY25 actuals +1yr" className="border border-gray-200 rounded px-2 py-1.5 text-sm flex-1 max-w-sm" />
</div>
{/* Filters */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs text-gray-500 w-28 shrink-0">Filters</label>
<button onClick={addFilter} className="text-blue-600 hover:text-blue-700 text-xs font-medium">+ Add filter</button>
</div>
<div className="flex flex-col gap-1.5 ml-28">
{filters.map((f, i) => {
const isDateCol = filterCols.find(c => c.cname === f.col)?.role === 'date'
return (
<div key={i} className="flex items-center gap-2 flex-wrap">
<select value={f.col} onChange={e => updateFilter(i, 'col', e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
{filterCols.map(c => <option key={c.cname} value={c.cname}>{c.cname}</option>)}
</select>
<select value={f.op} onChange={e => updateFilter(i, 'op', e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
{OPERATORS.map(op => <option key={op} value={op}>{op}</option>)}
</select>
{f.op === 'BETWEEN' && <>
<input type={isDateCol ? 'date' : 'text'} value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="from" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
<span className="text-gray-400 text-xs">and</span>
<input type={isDateCol ? 'date' : 'text'} value={f.values[1]} onChange={e => updateFilterValue(i, 1, e.target.value)} placeholder="to" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
</>}
{(f.op === '=' || f.op === '!=') && (
<input type={isDateCol ? 'date' : 'text'} value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="value" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
)}
{(f.op === 'IN' || f.op === 'NOT IN') && (
<input value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="val1, val2, …" className="border border-gray-200 rounded px-2 py-1 text-xs w-48 font-mono bg-white" />
)}
<button onClick={() => removeFilter(i)} className="text-gray-300 hover:text-red-400 text-xs"></button>
</div>
)
})}
{filters.length === 0 && (
<span className="text-xs text-gray-300 italic">No filters at least one is required</span>
)}
</div>
</div>
{/* Date offset — baseline only */}
{segType === 'baseline' && (
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Date offset</label>
<div className="flex items-center gap-2">
<input type="number" value={offsetYr} min={0} onChange={e => setOffsetYr(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
<span className="text-xs text-gray-500">yr</span>
<input type="number" value={offsetMo} min={0} max={11} onChange={e => setOffsetMo(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
<span className="text-xs text-gray-500">mo</span>
</div>
</div>
)}
{/* Timeline */}
{dateRange && (
<div className="ml-28">
<div className="bg-gray-50 border border-gray-200 rounded p-3">
<Timeline
dateFrom={dateRange.from}
dateTo={dateRange.to}
offsetYr={segType === 'baseline' ? offsetYr : 0}
offsetMo={segType === 'baseline' ? offsetMo : 0}
type={segType}
/>
</div>
</div>
)}
{/* Note + submit */}
<div className="flex items-end gap-3">
<div className="flex flex-col gap-1 flex-1 max-w-xs">
<label className="text-xs text-gray-500">Note</label>
<input value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1.5 text-sm" />
</div>
<button onClick={loadSegment} disabled={submitting || filters.length === 0} className="bg-blue-600 text-white text-xs px-5 py-2 rounded hover:bg-blue-700 disabled:opacity-50 shrink-0">
{submitting ? 'Loading…' : `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`}
</button>
</div>
</div>
</div>
</>}
</div>
</div>
)
}