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 (
{params.where_clause || '—'}
-
+ {compiled || — add a condition —}
+
+