import { useState, useEffect } from 'react' import Timeline from '../components/Timeline.jsx' const OPERATORS = ['BETWEEN', '=', '!=', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL'] function buildCondition(c) { const col = `"${c.col}"` if (c.op === 'IS NULL') return `${col} IS NULL` if (c.op === 'IS NOT NULL') return `${col} IS NOT NULL` if (c.op === 'BETWEEN') { const [a, b] = c.values if (!a || !b) return null return `${col} BETWEEN '${a}' AND '${b}'` } if (c.op === 'IN' || c.op === 'NOT IN') { const v = (c.values[0] || '').split(',').map(s => s.trim()).filter(Boolean) if (!v.length) return null return `${col} ${c.op} ('${v.join("','")}')` } if (!c.values[0]) return null return `${col} ${c.op} '${c.values[0]}'` } function buildFilterClause(groups) { if (!groups?.length) return null const parts = groups .map(g => g.map(buildCondition).filter(Boolean).join(' AND ')) .filter(s => s.length > 0) .map(s => `(${s})`) if (!parts.length) return null return parts.join(' OR ') } 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] } return null } function getDateRange(groups) { for (const g of groups || []) { for (const c of g) { if (c.op === 'BETWEEN' && c.values[0] && c.values[1]) return { from: c.values[0], to: c.values[1] } if (c.op === '=' && c.values[0]) return { from: c.values[0], to: c.values[0] } } } 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 emptyCondition(cols) { return { col: cols[0]?.cname || '', op: 'BETWEEN', values: ['', ''] } } function emptyGroup(cols) { return [emptyCondition(cols)] } function normalizeFilters(stored) { // accept legacy flat shape and wrap as one group; fall back to a blank group if (!Array.isArray(stored) || stored.length === 0) return null if (Array.isArray(stored[0])) return stored if (stored[0]?.col != null) return [stored] return null } export default function Baseline({ sources = [], sourceId, versions = [], versionId, setVersionId, refreshVersions }) { 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) // segment form const [segType, setSegType] = useState('baseline') const [description, setDescription] = useState('') const [filters, setFilters] = useState([]) // [[cond,...], [cond,...]] const [useRaw, setUseRaw] = useState(false) const [rawSql, setRawSql] = useState('') const [offsetYr, setOffsetYr] = useState(0) const [offsetMo, setOffsetMo] = useState(0) const [segNote, setSegNote] = useState('') const [submitting, setSubmitting] = useState(false) const [editingLogId, setEditingLogId] = useState(null) const [showAddForm, setShowAddForm] = useState(false) const [hasForecastOps, setHasForecastOps] = useState(false) const [expandedId, setExpandedId] = useState(null) const [msg, setMsg] = useState(null) useEffect(() => { if (!sourceId) return 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 ? [emptyGroup(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')) setHasForecastOps(data.some(e => ['scale', 'recode', 'clone'].includes(e.operation))) }) } 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 } await refreshVersions(sourceId) setVersionId(String(data.id)) setShowNewVersion(false) setNewVerName('') setNewVerDesc('') flash(`Version "${data.name}" created`) } catch (err) { flash(err.message, 'error') } finally { setCreatingVer(false) } } async function loadSegment() { const clause = useRaw ? rawSql.trim() : buildFilterClause(filters) if (!clause) { flash(useRaw ? 'Enter a WHERE clause' : 'Add at least one filter', 'error'); return } const isRef = segType === 'reference' const offsetStr = [offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days' const endpoint = isRef ? 'reference' : 'baseline' const body = { where_clause: clause, pf_user: 'admin', note: description || segNote, date_offset: offsetStr, ...(useRaw ? { raw_where: clause } : { filters }), } setSubmitting(true) try { const url = editingLogId ? `/api/versions/${versionId}/baseline/${editingLogId}` : `/api/versions/${versionId}/${endpoint}` const method = editingLogId ? 'PUT' : 'POST' const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) const data = await res.json() if (!res.ok) { flash(data.error, 'error'); return } flash(editingLogId ? `Updated — ${data.rows_deleted} rows replaced with ${data.rows_affected}` : `Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`) loadLog() cancelEdit() } catch (err) { flash(err.message, 'error') } finally { setSubmitting(false) } } function startEdit(entry) { if (hasForecastOps) { flash('Undo forecast operations first to edit segments', 'error') return } const params = entry.params || {} setSegType(entry.operation) setSegNote(entry.note || '') setDescription('') const off = parseOffset(params.date_offset) setOffsetYr(off.yr) setOffsetMo(off.mo) const groups = normalizeFilters(params.filters) if (groups) { setUseRaw(false) setRawSql('') setFilters(groups) } else if (params.where_clause) { // legacy: only the compiled WHERE was stored. Open in raw mode. setUseRaw(true) setRawSql(params.where_clause) setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : []) } else { setUseRaw(false) setRawSql('') setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : []) } setEditingLogId(entry.id) setExpandedId(null) setTimeout(() => { document.getElementById('add-segment')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, 0) } function cancelEdit() { setEditingLogId(null) setShowAddForm(false) setDescription('') setSegNote('') setOffsetYr(0) setOffsetMo(0) setUseRaw(false) setRawSql('') setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : []) } 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 } await refreshVersions(sourceId) 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 } await refreshVersions(sourceId) 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 refreshVersions(sourceId) setVersionId(updated.length > 0 ? String(updated[0].id) : '') flash('Version deleted') } function flash(text, type = 'ok') { setMsg({ text, type }) setTimeout(() => setMsg(null), 3000) } const selectedVersion = versions.find(v => String(v.id) === versionId) return (
{msg && (
{msg.text}
)} {/* Version actions */}
{versionId && (
{selectedVersion?.status === 'open' ? : }
)}
{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 && ( )} {!showAddForm && !editingLogId && ( )} {log.map((entry, i) => { const isOpen = expandedId === entry.id const view = segmentValuesFor(entry, filterCols) return ( <> setExpandedId(isOpen ? null : entry.id)} className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${editingLogId === entry.id ? 'bg-amber-50 ring-1 ring-amber-300 ring-inset' : isOpen ? 'bg-blue-50' : ''}`} > {isOpen && ( )} ) })}
# note rows {log[0]?.value_col || 'value'} by when
No segments loaded yet
{isOpen ? '▾' : '▸'} {log.length - i} {entry.operation} {entry.note || } {entry.row_count != null ? entry.row_count.toLocaleString() : '—'} {entry.value_total != null ? entry.value_total.toLocaleString(undefined, { maximumFractionDigits: 0 }) : '—'} {entry.pf_user} {new Date(entry.stamp).toLocaleDateString()} {!hasForecastOps && ( )}
{/* Add / Edit Segment */} {(showAddForm || editingLogId) && (
{(() => { if (!editingLogId) return 'Add Segment' const idx = log.findIndex(e => e.id === editingLogId) if (idx < 0) return 'Edit Segment' const entry = log[idx] const segNum = log.length - idx const label = entry.operation === 'reference' ? 'reference' : 'baseline' return entry.note ? `Edit segment #${segNum} — ${label} — ${entry.note}` : `Edit segment #${segNum} — ${label}` })()}
)} }
) } // derive view-mode props for a saved segment function segmentValuesFor(entry, filterCols) { const params = entry.params || {} const off = parseOffset(params.date_offset) const groups = normalizeFilters(params.filters) return { segType: entry.operation === 'reference' ? 'reference' : 'baseline', filters: groups || (filterCols.length > 0 ? [emptyGroup(filterCols)] : []), useRaw: !groups && !!params.where_clause, rawSql: params.where_clause || '', description: '', segNote: entry.note || '', offsetYr: off.yr, offsetMo: off.mo, } } function SegmentForm({ mode, // 'view' | 'edit' segType, setSegType, filters, setFilters, useRaw, setUseRaw, rawSql, setRawSql, description, setDescription, segNote, setSegNote, offsetYr, setOffsetYr, offsetMo, setOffsetMo, filterCols, onSubmit, submitting, editing, }) { const disabled = mode === 'view' const compiled = useRaw ? rawSql : (buildFilterClause(filters) || '') const dateRange = useRaw ? parseDateRangeFromClause(rawSql) : getDateRange(filters) function setGroup(gi, fn) { setFilters(prev => prev.map((g, i) => i === gi ? fn(g) : g)) } function addCondition(gi) { setGroup(gi, g => [...g, emptyCondition(filterCols)]) } function removeCondition(gi, ci) { setFilters(prev => { const next = prev.map((g, i) => i === gi ? g.filter((_, j) => j !== ci) : g) return next.filter(g => g.length > 0) }) } function updateCondition(gi, ci, field, value) { setGroup(gi, g => g.map((c, j) => { if (j !== ci) return c if (field === 'op') { const needsTwo = value === 'BETWEEN' const needsNone = ['IS NULL', 'IS NOT NULL'].includes(value) return { ...c, op: value, values: needsNone ? [] : needsTwo ? ['', ''] : [''] } } return { ...c, [field]: value } })) } function updateConditionValue(gi, ci, vi, value) { setGroup(gi, g => g.map((c, j) => { if (j !== ci) return c const vals = [...c.values]; vals[vi] = value return { ...c, values: vals } })) } function addGroup() { setFilters(prev => [...prev, emptyGroup(filterCols)]) } function removeGroup(gi) { setFilters(prev => prev.filter((_, i) => i !== gi)) } function toggleRaw() { if (useRaw) { setUseRaw(false) setRawSql('') } else { setRawSql(compiled) setUseRaw(true) } } const baseInp = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white disabled:bg-gray-50 disabled:text-gray-500' return (
{/* Type */}
{['baseline', 'reference'].map(t => ( ))}
{/* Description (edit only) */} {mode === 'edit' && (
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 */}
{!disabled && ( )}
{!useRaw && (
{filters.map((group, gi) => (
{gi === 0 ? 'Group 1' : `Group ${gi + 1} — OR`} {!disabled && (
{filters.length > 1 && ( )}
)}
{group.map((c, ci) => { const isDateCol = filterCols.find(fc => fc.cname === c.col)?.role === 'date' return (
{c.op === 'BETWEEN' && <> updateConditionValue(gi, ci, 0, e.target.value)} placeholder="from" className={`${baseInp} w-36 font-mono`} /> and updateConditionValue(gi, ci, 1, e.target.value)} placeholder="to" className={`${baseInp} w-36 font-mono`} /> } {(c.op === '=' || c.op === '!=') && ( updateConditionValue(gi, ci, 0, e.target.value)} placeholder="value" className={`${baseInp} w-36 font-mono`} /> )} {(c.op === 'IN' || c.op === 'NOT IN') && ( updateConditionValue(gi, ci, 0, e.target.value)} placeholder="val1, val2, …" className={`${baseInp} w-48 font-mono`} /> )} {!disabled && group.length > 1 && ( )}
) })}
))} {!disabled && ( )} {filters.length === 0 && ( No filters — at least one is required )}
)} {useRaw && (