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 (
{/* Flash */} {msg && (
{msg.text}
)} {/* Source + Version bar */}
Source
Version {versionId && ( {selectedVersion?.status} )}
{versionId && (
{selectedVersion?.status === 'open' ? : }
)}
{/* New version inline form */} {showNewVersion && (
setNewVerName(e.target.value)} placeholder="e.g. FY2026 Plan" className="border border-gray-200 rounded px-2 py-1 text-sm w-48" />
setNewVerDesc(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1 text-sm" />
Creates a forecast table pf.fc_{sources.find(s=>String(s.id)===sourceId)?.tname}_<id> 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.
)} {versionId && <> {/* Segments loaded */}
Segments loaded
{log.length === 0 && ( )} {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 ( <> setExpandedId(isOpen ? null : entry.id)} className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${isOpen ? 'bg-blue-50' : ''}`} > {isOpen && ( )} ) })}
# note by when
No segments loaded yet
{isOpen ? '▾' : '▸'} {log.length - i} {entry.operation} {entry.note || } {entry.pf_user} {new Date(entry.stamp).toLocaleDateString()}
WHERE {params.where_clause || '—'}
offset {params.date_offset || '0 days'}
{dr && (
)}
{/* Add Segment */}
Add Segment
{/* Type toggle */}
{['baseline', 'reference'].map(t => ( ))}
{segType === 'reference' && ( dates land verbatim — no offset applied )}
{/* Description */}
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" />
{/* Filters */}
{filters.map((f, i) => { const isDateCol = filterCols.find(c => c.cname === f.col)?.role === 'date' return (
{f.op === 'BETWEEN' && <> 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" /> and 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 === '!=') && ( 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') && ( 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" /> )}
) })} {filters.length === 0 && ( No filters — at least one is required )}
{/* Date offset — baseline only */} {segType === 'baseline' && (
setOffsetYr(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" /> yr setOffsetMo(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" /> mo
)} {/* Timeline */} {dateRange && (
)} {/* Note + submit */}
setSegNote(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1.5 text-sm" />
}
) }