diff --git a/lib/sql_generator.js b/lib/sql_generator.js index 90ea433..2b9040b 100644 --- a/lib/sql_generator.js +++ b/lib/sql_generator.js @@ -59,14 +59,11 @@ ilog AS ( VALUES ({{version_id}}, '{{pf_user}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}') RETURNING id ) -,del AS ( - DELETE FROM {{fc_table}} WHERE iter = 'baseline' -) ,ins AS ( INSERT INTO {{fc_table}} (${insertCols}) SELECT ${baselineSelect}, 'baseline', (SELECT id FROM ilog), '{{pf_user}}', now() FROM ${srcTable} - WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}' + WHERE {{filter_clause}} RETURNING * ) SELECT count(*) AS rows_affected FROM ins`.trim(); @@ -248,10 +245,44 @@ function buildSetClause(dimCols, setObj) { }).join(', '); } +// build a SQL WHERE clause from an array of filter objects { col, op, values } +// only allows columns with role 'date' or 'filter' +function buildFilterClause(filters, colMeta) { + if (!filters || filters.length === 0) { + const err = new Error('At least one filter is required'); + err.status = 400; throw err; + } + const allowed = new Set( + colMeta.filter(c => c.role === 'date' || c.role === 'filter').map(c => c.cname) + ); + const parts = filters.map(({ col, op, values = [] }) => { + if (!allowed.has(col)) { + const err = new Error(`Column "${col}" is not available for baseline filtering`); + err.status = 400; throw err; + } + const c = `"${col}"`; + const v = values.map(x => `'${esc(String(x))}'`); + switch (op) { + case '=': return `${c} = ${v[0]}`; + case '!=': return `${c} != ${v[0]}`; + case 'IN': return `${c} IN (${v.join(', ')})`; + case 'NOT IN': return `${c} NOT IN (${v.join(', ')})`; + case 'BETWEEN': return `${c} BETWEEN ${v[0]} AND ${v[1]}`; + case 'IS NULL': return `${c} IS NULL`; + case 'IS NOT NULL': return `${c} IS NOT NULL`; + default: { + const err = new Error(`Unsupported operator "${op}"`); + err.status = 400; throw err; + } + } + }); + return parts.join('\nAND '); +} + // escape a value for safe SQL string substitution function esc(val) { if (val === null || val === undefined) return ''; return String(val).replace(/'/g, "''"); } -module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc }; +module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, buildFilterClause, esc }; diff --git a/public/app.js b/public/app.js index 71ecacf..9975f70 100644 --- a/public/app.js +++ b/public/app.js @@ -9,6 +9,7 @@ const state = { colMeta: [], // col_meta for selected source slice: {}, // current pivot selection loadDataOp: null, // 'baseline' | 'reference' + baselineFilterCols: [], previewSchema: null, previewTname: null, grids: { @@ -50,7 +51,7 @@ function showStatus(msg, type = 'info') { NAVIGATION ============================================================ */ function switchView(name) { - if ((name === 'forecast' || name === 'log') && !state.version) { + if ((name === 'forecast' || name === 'log' || name === 'baseline') && !state.version) { showStatus('Select a version first', 'error'); return; } @@ -68,6 +69,7 @@ function switchView(name) { if (name === 'versions') renderVersions(); if (name === 'forecast') loadForecastData(); if (name === 'log') loadLogData(); + if (name === 'baseline') openBaselineWorkbench(); } function setSource(source) { @@ -245,9 +247,9 @@ function renderColMetaGrid(colMeta) { { field: 'role', headerName: 'Role', width: 110, editable: true, cellEditor: 'agSelectCellEditor', - cellEditorParams: { values: ['dimension', 'value', 'units', 'date', 'ignore'] }, + cellEditorParams: { values: ['dimension', 'value', 'units', 'date', 'filter', 'ignore'] }, cellStyle: p => { - const colors = { dimension: '#eaf4fb', value: '#eafaf1', units: '#fef9e7', date: '#fdf2f8', ignore: '#f9f9f9' }; + const colors = { dimension: '#eaf4fb', value: '#eafaf1', units: '#fef9e7', date: '#fdf2f8', filter: '#fef5e4', ignore: '#f9f9f9' }; return { background: colors[p.value] || '' }; } }, @@ -528,6 +530,290 @@ function openForecast() { switchView('forecast'); } +function openVersionBaseline() { + if (!state.selectedVersionId) return; + let v = null; + state.grids.versions.forEachNode(n => { + if (n.data.id === state.selectedVersionId) v = n.data; + }); + if (!v) return; + setVersion(v); + switchView('baseline'); +} + +/* ============================================================ + BASELINE WORKBENCH + ============================================================ */ +async function openBaselineWorkbench() { + if (!state.version) return; + document.getElementById('baseline-label').textContent = + `${state.source?.tname || ''} — ${state.version.name} [${state.version.status}]`; + + // ensure colMeta loaded + if (!state.colMeta.length && state.source) { + state.colMeta = await api('GET', `/sources/${state.source.id}/cols`); + } + state.baselineFilterCols = state.colMeta.filter(c => c.role === 'date' || c.role === 'filter'); + + // reset form + document.getElementById('seg-description').value = ''; + document.getElementById('seg-offset-years').value = '0'; + document.getElementById('seg-offset-months').value = '0'; + document.getElementById('seg-filter-rows').innerHTML = ''; + document.getElementById('seg-timeline').classList.add('hidden'); + addFilterRow(); // start with one empty filter row + + await loadBaselineSegments(); +} + +async function loadBaselineSegments() { + if (!state.version) return; + try { + const logs = await api('GET', `/versions/${state.version.id}/log`); + const segments = logs.filter(l => l.operation === 'baseline'); + renderBaselineSegments(segments); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +function renderBaselineSegments(segments) { + const el = document.getElementById('baseline-segments-list'); + if (segments.length === 0) { + el.innerHTML = '
No segments loaded yet.
'; + return; + } + el.innerHTML = segments.map(s => { + const params = s.params || {}; + const filters = (params.filters || []) + .map(f => { + if (f.op === 'IS NULL' || f.op === 'IS NOT NULL') return `${f.col} ${f.op}`; + if (f.op === 'BETWEEN') return `${f.col} BETWEEN ${(f.values||[]).join(' AND ')}`; + if (f.op === 'IN' || f.op === 'NOT IN') return `${f.col} ${f.op} (${(f.values||[]).join(', ')})`; + return `${f.col} ${f.op} ${(f.values||[])[0] || ''}`; + }).join('\n'); + const offset = params.date_offset && params.date_offset !== '0 days' ? ` · offset: ${params.date_offset}` : ''; + const stamp = s.stamp ? new Date(s.stamp).toLocaleString() : ''; + return ` +
+
+ ${s.note || '(no description)'} + ${s.pf_user} — ${stamp} + +
+
${filters}${offset}
+
`; + }).join(''); +} + +function addFilterRow() { + const container = document.getElementById('seg-filter-rows'); + const idx = container.children.length; + const colOptions = state.baselineFilterCols + .map(c => ``) + .join(''); + + const div = document.createElement('div'); + div.className = 'filter-row'; + div.dataset.index = idx; + div.innerHTML = ` + + +
+ + `; + + div.querySelector('.filter-op-select').addEventListener('change', () => { + updateFilterValueInputs(div); + updateTimelinePreview(); + }); + div.querySelector('.filter-col-select').addEventListener('change', () => { + updateTimelinePreview(); + }); + div.querySelector('.filter-remove-btn').addEventListener('click', () => { + div.remove(); + updateTimelinePreview(); + }); + + updateFilterValueInputs(div); + container.appendChild(div); +} + +function updateFilterValueInputs(row) { + const op = row.querySelector('.filter-op-select').value; + const container = row.querySelector('.filter-value-container'); + + if (op === 'IS NULL' || op === 'IS NOT NULL') { + container.innerHTML = ''; + return; + } + if (op === 'BETWEEN') { + container.innerHTML = ` + + and + + `; + container.querySelectorAll('input').forEach(i => i.addEventListener('input', updateTimelinePreview)); + return; + } + if (op === 'IN' || op === 'NOT IN') { + container.innerHTML = ``; + return; + } + // = or != + container.innerHTML = ``; +} + +function getFilterRows() { + const rows = []; + document.querySelectorAll('#seg-filter-rows .filter-row').forEach(row => { + const col = row.querySelector('.filter-col-select').value; + const op = row.querySelector('.filter-op-select').value; + if (!col) return; + + const filter = { col, op, values: [] }; + + if (op === 'BETWEEN') { + const from = row.querySelector('.filter-val-from')?.value.trim(); + const to = row.querySelector('.filter-val-to')?.value.trim(); + if (from) filter.values.push(from); + if (to) filter.values.push(to); + } else if (op === 'IN' || op === 'NOT IN') { + const raw = row.querySelector('.filter-val-list')?.value.trim(); + if (raw) filter.values = raw.split(',').map(s => s.trim()).filter(Boolean); + } else if (op !== 'IS NULL' && op !== 'IS NOT NULL') { + const val = row.querySelector('.filter-val-single')?.value.trim(); + if (val) filter.values.push(val); + } + + rows.push(filter); + }); + return rows; +} + +function updateTimelinePreview() { + const el = document.getElementById('seg-timeline'); + + // find first BETWEEN filter on a date-role column + let dateFrom = null, dateTo = null; + document.querySelectorAll('#seg-filter-rows .filter-row').forEach(row => { + if (dateFrom) return; + const col = row.querySelector('.filter-col-select').value; + const op = row.querySelector('.filter-op-select').value; + if (op !== 'BETWEEN') return; + const meta = state.baselineFilterCols.find(c => c.cname === col); + if (!meta || meta.role !== 'date') return; + const from = row.querySelector('.filter-val-from')?.value.trim(); + const to = row.querySelector('.filter-val-to')?.value.trim(); + if (from && to) { dateFrom = from; dateTo = to; } + }); + + if (!dateFrom || !dateTo) { el.classList.add('hidden'); return; } + + const from = new Date(dateFrom + 'T00:00:00'); + const to = new Date(dateTo + 'T00:00:00'); + if (isNaN(from) || isNaN(to) || from > to) { el.classList.add('hidden'); return; } + + const months = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth()) + 1; + const fmt = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' }); + const offsetYears = parseInt(document.getElementById('seg-offset-years').value) || 0; + const offsetMonths = parseInt(document.getElementById('seg-offset-months').value) || 0; + + const projFrom = new Date(from); projFrom.setFullYear(projFrom.getFullYear() + offsetYears); projFrom.setMonth(projFrom.getMonth() + offsetMonths); + const projTo = new Date(to); projTo.setFullYear(projTo.getFullYear() + offsetYears); projTo.setMonth(projTo.getMonth() + offsetMonths); + + let html = ` +
+
Source
+
+
+
+ ${fmt.format(from)} + ${months} month${months !== 1 ? 's' : ''} + ${fmt.format(to)} +
+
+
`; + + if (offsetYears || offsetMonths) { + const parts = []; + if (offsetYears) parts.push(`${offsetYears} yr`); + if (offsetMonths) parts.push(`${offsetMonths} mo`); + html += ` +
+ ${parts.join(' ')} →
+
+
Projected
+
+
+
+ ${fmt.format(projFrom)} + ${months} month${months !== 1 ? 's' : ''} + ${fmt.format(projTo)} +
+
+
`; + } + + el.innerHTML = html; + el.classList.remove('hidden'); +} + +async function submitBaselineSegment() { + if (!state.version) return; + const filters = getFilterRows(); + if (filters.length === 0) { showStatus('Add at least one filter', 'error'); return; } + + const years = parseInt(document.getElementById('seg-offset-years').value) || 0; + const months = parseInt(document.getElementById('seg-offset-months').value) || 0; + const parts = []; + if (years) parts.push(`${years} year${years !== 1 ? 's' : ''}`); + if (months) parts.push(`${months} month${months !== 1 ? 's' : ''}`); + const date_offset = parts.length ? parts.join(' ') : '0 days'; + + const body = { + filters, + date_offset, + pf_user: getPfUser(), + note: document.getElementById('seg-description').value.trim() || undefined + }; + + try { + showStatus('Loading segment...', 'info'); + const result = await api('POST', `/versions/${state.version.id}/baseline`, body); + showStatus(`Segment loaded — ${result.rows_affected} rows`, 'success'); + document.getElementById('seg-description').value = ''; + document.getElementById('seg-filter-rows').innerHTML = ''; + document.getElementById('seg-timeline').classList.add('hidden'); + addFilterRow(); + await loadBaselineSegments(); + } catch (err) { + showStatus(err.message, 'error'); + } +} + +async function clearBaseline() { + if (!state.version) return; + if (!confirm('Delete all baseline rows and segment history for this version? This cannot be undone.')) return; + try { + const result = await api('DELETE', `/versions/${state.version.id}/baseline`); + showStatus(`Baseline cleared — ${result.rows_deleted} rows removed`, 'success'); + await loadBaselineSegments(); + } catch (err) { + showStatus(err.message, 'error'); + } +} + /* ============================================================ FORECAST VIEW — data loading ============================================================ */ @@ -956,7 +1242,7 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('new-version-form').classList.add('hidden'); }); document.getElementById('vbtn-forecast').addEventListener('click', openForecast); - document.getElementById('vbtn-baseline').addEventListener('click', () => showLoadForm('baseline')); + document.getElementById('vbtn-baseline').addEventListener('click', openVersionBaseline); document.getElementById('vbtn-reference').addEventListener('click', () => showLoadForm('reference')); document.getElementById('vbtn-toggle').addEventListener('click', toggleVersionStatus); document.getElementById('vbtn-delete').addEventListener('click', deleteVersion); @@ -988,6 +1274,23 @@ document.addEventListener('DOMContentLoaded', () => { if (btn) undoOperation(parseInt(btn.dataset.logid)); }); + // baseline workbench + document.getElementById('btn-add-filter-row').addEventListener('click', addFilterRow); + document.getElementById('btn-load-segment').addEventListener('click', submitBaselineSegment); + document.getElementById('btn-clear-baseline').addEventListener('click', clearBaseline); + document.getElementById('seg-offset-years').addEventListener('input', updateTimelinePreview); + document.getElementById('seg-offset-months').addEventListener('input', updateTimelinePreview); + + // undo in baseline segments list + document.getElementById('baseline-segments-list').addEventListener('click', async e => { + const btn = e.target.closest('[data-logid]'); + if (!btn) return; + const logid = parseInt(btn.dataset.logid); + const result = await api('DELETE', `/log/${logid}`); + showStatus(`Segment undone — ${result.rows_deleted} rows removed`, 'success'); + await loadBaselineSegments(); + }); + // init sources view initSourcesView().catch(err => showStatus(err.message, 'error')); }); diff --git a/public/index.html b/public/index.html index 6be4881..a9d7e62 100644 --- a/public/index.html +++ b/public/index.html @@ -17,6 +17,7 @@ @@ -96,6 +97,45 @@ + + +