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 } // The offset is stored and sent as a Postgres interval, so it is typed as one -- // "4 months", "1 year", "-90 days". This only parses it far enough to draw the // timeline preview; Postgres remains the authority on what is valid, and the // server rejects anything it will not accept. // // It replaced a pair of year/month number spinners, which could not express days // at all and were clamped at zero, so a segment could only ever be shifted // forward in whole months. export function parseInterval(str) { let months = 0, days = 0 if (!str) return { months, days } const re = /([+-]?\d+(?:\.\d+)?)\s*(years?|yrs?|y|months?|mons?|mo|weeks?|wks?|w|days?|d)\b/gi for (const [, n, unit] of str.matchAll(re)) { const v = parseFloat(n) const u = unit.toLowerCase() if (u.startsWith('y')) months += v * 12 else if (u.startsWith('mo') || u === 'mons' || u === 'mon') months += v else if (u.startsWith('w')) days += v * 7 else if (u.startsWith('d')) days += v else months += v // 'm' alone reads as months here } return { months, days } } 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 [filters, setFilters] = useState([]) // [[cond,...], [cond,...]] const [useRaw, setUseRaw] = useState(false) const [rawSql, setRawSql] = useState('') const [offset, setOffset] = useState('0 days') const [segNote, setSegNote] = useState('') // Presentation, not definition: what the segment counts toward and how it is // labelled in the pivot. Safe to set at any time, unlike its filters. const [segBucket, setSegBucket] = useState('') const [segLabel, setSegLabel] = 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 => { // What a load is filtered by and what the pivot groups by are separate // concerns: a dimension is exactly the sort of thing a segment is cut on // (sseas, channel_new), and forcing it to role 'filter' to get it here // would cost it its place on the pivot. Only measures are excluded. const fc = cols.filter(c => ['date', 'filter', 'dimension'].includes(c.role)) setFilterCols(fc) setFilters(fc.length > 0 ? [emptyGroup(fc)] : []) }) }, [sourceId]) useEffect(() => { if (!versionId) { setLog([]); return } loadLog() }, [versionId]) // A segment's banner: what it counts toward, independent of pf_iter. Held // locally while typing so the field does not fight the fetched value, and // written on blur. const [buckets, setBuckets] = useState({}) // Buckets already in use on this version, for the datalist. There is no stored // bucket order any more: the order is whatever the typed text sorts as, so a // bucket is named "02 - Forecast" and that is the whole mechanism. const [bucketsInUse, setBucketsInUse] = useState([]) // Label and bucket are presentation, not definition: they change what the pivot // shows and what the segment counts toward, never which rows were loaded. So // they stay editable after adjustments exist, unlike the filters and offset, // where an edit would silently recalibrate scales sized against the old rows. // // Both are only read at load time, hence the reload in the confirmation: the // label is part of the aggregated row the pivot holds, not something it can // re-derive in place. async function saveLogField(entry, field, value) { const next = value.trim() if (next === (entry[field] || '')) return try { const res = await fetch(`/api/log/${entry.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [field]: next }), }) if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return } loadLog() flash(next ? `Saved — reload the Forecast view to see it` : 'Cleared') } catch (err) { flash(err.message, 'error') } } // Column widths read off the content rather than guessed. ch is the width of a // '0', so for proportional text it runs slightly generous -- which is what is // wanted for an input you are about to type a longer name into. The floors keep // an empty table from collapsing its headers; the ceilings keep one long note // from pushing the numbers off the side. function widthCh(values, min, max) { const longest = values.reduce((n, v) => Math.max(n, String(v || '').length), 0) return `${Math.min(max, Math.max(min, longest + 2))}ch` } const labelW = widthCh(log.map(e => e.label || e.tag || e.note), 18, 34) const bucketW = widthCh(log.map(e => e.bucket), 16, 24) const noteW = widthCh(log.map(e => e.note), 22, 44) 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))) // Every bucket in use, adjustments included: typing one on a new segment // should offer the ones already there rather than inviting a near-miss // spelling, which would silently split the column in two. setBucketsInUse([...new Set(data.map(e => (e.bucket || '').trim()).filter(Boolean))].sort()) }) } 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 = offset.trim() || '0 days' const endpoint = isRef ? 'reference' : 'baseline' const body = { where_clause: clause, note: segNote, date_offset: offsetStr, label: segLabel.trim(), bucket: segBucket.trim(), ...(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 || '') setSegLabel(entry.label || '') setSegBucket(entry.bucket || '') setOffset(params.date_offset || '0 days') 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) setSegNote('') setSegLabel('') setSegBucket('') 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({}) }) 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) } // The names a row falls back to when nobody has named it. Blank means "use the // built-in", so these save on blur like the segment fields and an empty box is // a meaningful value rather than a missing one. async function saveVersionName(field, value) { const next = value.trim() if (next === (selectedVersion?.[field] || '')) return try { const res = await fetch(`/api/versions/${versionId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [field]: next }), }) if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return } await refreshVersions(sourceId) flash('Saved — reload the Forecast view to see it') } catch (err) { flash(err.message, 'error') } } const selectedVersion = versions.find(v => String(v.id) === versionId) return (
{/* No page-wide cap and no stretching: at max-w-4xl (896px) the eleven-column segment table always had something smashed, and uncapped it ran to the window. items-start makes each block as wide as its own content needs, which for the table is the measured column widths below. */}
{msg && (
{msg.text}
)} {/* Version actions */}
{versionId && (
{selectedVersion?.status === 'open' ? : }
)}
{/* Fallback names. Not part of any segment -- they are what the pivot shows for rows nobody has named, so they belong to the version rather than to a log entry. Blank falls back to the built-in in sql_generator's DISPLAY DEFAULTS block. */} {versionId && (
Fallback names {[ ['adjustment_segment', 'Adjustment segment', '99 - Adjustments'], ['adjustment_bucket', 'Adjustment bucket', '04 - Forecast'], ['unlabeled_load', 'Unlabeled load', 'Unlabeled'], ].map(([field, label, builtin]) => (
saveVersionName(field, e.target.value)} placeholder={builtin} className="border border-gray-200 rounded px-2 py-1 text-sm w-48" />
))}
)} {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
{[...new Set([...bucketsInUse, '01 - Prior Prior Year', '02 - Prior Year', '03 - Plan', '04 - Forecast'])].map(b => ( {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' : ''}`} > {/* The operation badge gets its own column. Sharing one with the note put "reference" hard against "YTD Sales" as soon as the note column lost width to label and bucket. */} {/* One line, clipped against the measured width above. The note is provenance and can run long, so left to itself it wrapped and pushed every row to two or three lines. Expanding the row shows it in full. The cap is on the div, not the cell: a max-width on a cell in an auto-layout table is only a hint, and the column can still collapse to min-content or grow past it. */} {isOpen && ( )} ) })}
# kind label note counts toward rows {log[0]?.value_col || 'value'} by when
No segments loaded yet
{isOpen ? '▾' : '▸'} {log.length - i} {entry.operation} e.stopPropagation()}> saveLogField(entry, 'label', e.target.value)} placeholder={entry.tag || entry.note || '—'} className="w-full border border-transparent hover:border-gray-200 focus:border-blue-400 rounded px-1 py-0.5 text-xs focus:outline-none bg-transparent" /> {entry.note ?
{entry.note}
: }
e.stopPropagation()}> setBuckets(b => ({ ...b, [entry.id]: e.target.value }))} onBlur={e => saveLogField(entry, 'bucket', e.target.value)} placeholder="—" className="w-full border border-transparent hover:border-gray-200 focus:border-blue-400 rounded px-1 py-0.5 text-xs focus:outline-none bg-transparent" /> {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 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 || '', segNote: entry.note || '', segBucket: entry.bucket || '', segLabel: entry.label || '', offset: params.date_offset || '0 days', } } function SegmentForm({ mode, // 'view' | 'edit' segType, setSegType, filters, setFilters, useRaw, setUseRaw, rawSql, setRawSql, segNote, setSegNote, segBucket, setSegBucket, segLabel, setSegLabel, offset, setOffset, 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 => ( ))}
{/* 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 && (