diff --git a/routes/operations.js b/routes/operations.js index 385e6c0..b2b01aa 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -123,16 +123,16 @@ module.exports = function(pool) { // load baseline rows from source table — additive, no delete router.post('/versions/:id/baseline', async (req, res) => { - const { where_clause, date_offset, pf_user, note, filters } = req.body; + const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body; const dateOffset = date_offset || '0 days'; - const filterClause = (where_clause || '').trim() || 'TRUE'; + const filterClause = (raw_where || where_clause || '').trim() || 'TRUE'; try { const ctx = await getContext(parseInt(req.params.id), 'baseline'); if (!guardOpen(ctx.version, res)) return; const paramsJson = JSON.stringify({ where_clause: filterClause, date_offset: dateOffset, - ...(filters ? { filters } : {}) + ...(raw_where ? { raw_where } : (filters ? { filters } : {})) }); const sql = applyTokens(ctx.sql, { fc_table: ctx.table, @@ -158,9 +158,9 @@ module.exports = function(pool) { router.put('/versions/:id/baseline/:logid', async (req, res) => { const versionId = parseInt(req.params.id); const logid = parseInt(req.params.logid); - const { where_clause, date_offset, pf_user, note, filters } = req.body; + const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body; const dateOffset = date_offset || '0 days'; - const filterClause = (where_clause || '').trim() || 'TRUE'; + const filterClause = (raw_where || where_clause || '').trim() || 'TRUE'; const client = await pool.connect(); try { @@ -193,7 +193,7 @@ module.exports = function(pool) { const paramsJson = JSON.stringify({ where_clause: filterClause, ...(oldLog.operation === 'baseline' ? { date_offset: dateOffset } : {}), - ...(filters ? { filters } : {}) + ...(raw_where ? { raw_where } : (filters ? { filters } : {})) }); const tokens = oldLog.operation === 'baseline' ? { @@ -274,14 +274,14 @@ module.exports = function(pool) { // load reference rows from source table (additive — does not clear prior reference rows) router.post('/versions/:id/reference', async (req, res) => { - const { where_clause, pf_user, note, filters } = req.body; - const filterClause = (where_clause || '').trim() || 'TRUE'; + const { where_clause, pf_user, note, filters, raw_where } = req.body; + const filterClause = (raw_where || where_clause || '').trim() || 'TRUE'; try { const ctx = await getContext(parseInt(req.params.id), 'reference'); if (!guardOpen(ctx.version, res)) return; const paramsJson = JSON.stringify({ where_clause: filterClause, - ...(filters ? { filters } : {}) + ...(raw_where ? { raw_where } : (filters ? { filters } : {})) }); const sql = applyTokens(ctx.sql, { fc_table: ctx.table, @@ -436,19 +436,36 @@ module.exports = function(pool) { const versionId = parseInt(req.params.id); try { const verResult = await pool.query( - `SELECT v.*, s.tname FROM pf.version v JOIN pf.source s ON s.id = v.source_id WHERE v.id = $1`, + `SELECT v.*, s.tname, s.id AS source_id FROM pf.version v JOIN pf.source s ON s.id = v.source_id WHERE v.id = $1`, [versionId] ); if (!verResult.rows.length) return res.status(404).json({ error: 'Version not found' }); - const table = fcTable(verResult.rows[0].tname, versionId); + const { tname, source_id } = verResult.rows[0]; + const table = fcTable(tname, versionId); + + const colMeta = await pool.query( + `SELECT cname, role FROM pf.col_meta WHERE source_id = $1 AND role IN ('value', 'units')`, + [source_id] + ); + const valueCol = colMeta.rows.find(c => c.role === 'value')?.cname; + const unitsCol = colMeta.rows.find(c => c.role === 'units')?.cname; + + const aggCols = [ + `count(f.pf_id)::int AS row_count`, + valueCol ? `sum(f."${valueCol}")::float8 AS value_total` : `NULL::float8 AS value_total`, + unitsCol ? `sum(f."${unitsCol}")::float8 AS units_total` : `NULL::float8 AS units_total` + ].join(', '); + const result = await pool.query(` - SELECT l.*, count(f.pf_id)::int AS row_count + SELECT l.*, ${aggCols}, + $2::text AS value_col, + $3::text AS units_col FROM pf.log l LEFT JOIN ${table} f ON f.pf_logid = l.id WHERE l.version_id = $1 GROUP BY l.id ORDER BY l.id DESC - `, [versionId]); + `, [versionId, valueCol || null, unitsCol || null]); res.json(result.rows); } catch (err) { console.error(err); diff --git a/ui/src/views/Baseline.jsx b/ui/src/views/Baseline.jsx index 745297d..bfb19fc 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -3,44 +3,48 @@ 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 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 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 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] } - 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 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 } @@ -51,9 +55,18 @@ function parseOffset(offsetStr) { return { yr, mo } } -function emptyFilter(cols) { +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([]) @@ -65,15 +78,18 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio const [newVerDesc, setNewVerDesc] = useState('') const [creatingVer, setCreatingVer] = useState(false) - // add segment form + // segment form const [segType, setSegType] = useState('baseline') const [description, setDescription] = useState('') - const [filters, setFilters] = 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) @@ -84,7 +100,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio 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)] : []) + setFilters(fc.length > 0 ? [emptyGroup(fc)] : []) }) }, [sourceId]) @@ -124,46 +140,21 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio } } - 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 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 = isRef ? '0 days' : ([offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days') + 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, filters } - : { where_clause: clause, date_offset: offsetStr, pf_user: 'admin', note: description || segNote, filters } + const body = { + where_clause: clause, + pf_user: 'admin', + note: description || segNote, + ...(isRef ? {} : { date_offset: offsetStr }), + ...(useRaw ? { raw_where: clause } : { filters }), + } setSubmitting(true) try { const url = editingLogId @@ -205,29 +196,38 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio } else { setOffsetYr(0); setOffsetMo(0) } - if (Array.isArray(params.filters) && params.filters.length > 0) { - setFilters(params.filters) - } else if (filterCols.length > 0) { - // Pre-existing segment without structured filters — fall back to a blank row. - // The original WHERE clause is shown read-only on the segment detail row. - setFilters([emptyFilter(filterCols)]) - flash('Filters were not stored on this segment — rebuild them, or undo and re-add', 'error') + 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(() => { - const form = document.getElementById('add-segment') - form?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + document.getElementById('add-segment')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, 0) } function cancelEdit() { setEditingLogId(null) + setShowAddForm(false) setDescription('') setSegNote('') setOffsetYr(0) setOffsetMo(0) - setFilters(filterCols.length > 0 ? [emptyFilter(filterCols)] : []) + setUseRaw(false) + setRawSql('') + setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : []) } async function undoSegment(logid) { @@ -279,14 +279,12 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio setTimeout(() => setMsg(null), 3000) } - const dateRange = getDateRange(filters) const selectedVersion = versions.find(v => String(v.id) === versionId) return (
- {/* Flash */} {msg && (
{msg.text} @@ -309,7 +307,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio )}
- {/* New version inline form */} {showNewVersion && (
@@ -346,6 +343,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio # note + rows + {log[0]?.value_col || 'value'} by when @@ -353,13 +352,23 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio {log.length === 0 && ( - No segments loaded yet + No segments loaded yet + )} + {!showAddForm && !editingLogId && ( + + + + + )} {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) + const view = segmentValuesFor(entry, filterCols) return ( <> setExpandedId(isOpen ? null : entry.id)} className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${isOpen ? 'bg-blue-50' : ''}`} > - - {isOpen ? '▾' : '▸'} - + {isOpen ? '▾' : '▸'} {log.length - i} @@ -377,6 +384,12 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio {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()} @@ -388,21 +401,9 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio {isOpen && ( - -
-
- WHERE - {params.where_clause || '—'} -
-
- offset - {params.date_offset || '0 days'} -
- {dr && ( -
- -
- )} + +
+
@@ -415,127 +416,37 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
{/* Add / Edit Segment */} -
-
- {(() => { - if (!editingLogId) return 'Add Segment' - const entry = log.find(e => e.id === editingLogId) - if (!entry) return 'Edit Segment' - const label = entry.operation === 'reference' ? 'reference' : 'baseline' - return entry.note ? `Edit ${label} — ${entry.note}` : `Edit ${label} segment` - })()} - {editingLogId && ( - - )} -
-
- - {/* 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" /> -
-
- +
-
+ )} } @@ -543,3 +454,253 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
) } + +// 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 => ( + + ))} +
+ {segType === 'reference' && ( + dates land verbatim — no offset applied + )} +
+ + {/* 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 && ( +
+