Add baseline workbench — multi-segment additive baseline with filter builder
- New Baseline nav view replaces the simple Load Baseline modal - Baseline loads are now additive; each segment is independently undoable - Filter builder: any date/filter-role column, full operator set - Timeline preview shows source → projected period bars for date BETWEEN filters - Clear Baseline action deletes all baseline rows and log entries - DELETE /api/versions/:id/baseline route - buildFilterClause() added to sql_generator - filter role added to col_meta editor - Reminder: re-run generate-sql for each source after this change Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7e9ea456b6
commit
5550a57f97
@ -59,14 +59,11 @@ ilog AS (
|
|||||||
VALUES ({{version_id}}, '{{pf_user}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}')
|
VALUES ({{version_id}}, '{{pf_user}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}')
|
||||||
RETURNING id
|
RETURNING id
|
||||||
)
|
)
|
||||||
,del AS (
|
|
||||||
DELETE FROM {{fc_table}} WHERE iter = 'baseline'
|
|
||||||
)
|
|
||||||
,ins AS (
|
,ins AS (
|
||||||
INSERT INTO {{fc_table}} (${insertCols})
|
INSERT INTO {{fc_table}} (${insertCols})
|
||||||
SELECT ${baselineSelect}, 'baseline', (SELECT id FROM ilog), '{{pf_user}}', now()
|
SELECT ${baselineSelect}, 'baseline', (SELECT id FROM ilog), '{{pf_user}}', now()
|
||||||
FROM ${srcTable}
|
FROM ${srcTable}
|
||||||
WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
|
WHERE {{filter_clause}}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT count(*) AS rows_affected FROM ins`.trim();
|
SELECT count(*) AS rows_affected FROM ins`.trim();
|
||||||
@ -248,10 +245,44 @@ function buildSetClause(dimCols, setObj) {
|
|||||||
}).join(', ');
|
}).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
|
// escape a value for safe SQL string substitution
|
||||||
function esc(val) {
|
function esc(val) {
|
||||||
if (val === null || val === undefined) return '';
|
if (val === null || val === undefined) return '';
|
||||||
return String(val).replace(/'/g, "''");
|
return String(val).replace(/'/g, "''");
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc };
|
module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, buildFilterClause, esc };
|
||||||
|
|||||||
311
public/app.js
311
public/app.js
@ -9,6 +9,7 @@ const state = {
|
|||||||
colMeta: [], // col_meta for selected source
|
colMeta: [], // col_meta for selected source
|
||||||
slice: {}, // current pivot selection
|
slice: {}, // current pivot selection
|
||||||
loadDataOp: null, // 'baseline' | 'reference'
|
loadDataOp: null, // 'baseline' | 'reference'
|
||||||
|
baselineFilterCols: [],
|
||||||
previewSchema: null,
|
previewSchema: null,
|
||||||
previewTname: null,
|
previewTname: null,
|
||||||
grids: {
|
grids: {
|
||||||
@ -50,7 +51,7 @@ function showStatus(msg, type = 'info') {
|
|||||||
NAVIGATION
|
NAVIGATION
|
||||||
============================================================ */
|
============================================================ */
|
||||||
function switchView(name) {
|
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');
|
showStatus('Select a version first', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -68,6 +69,7 @@ function switchView(name) {
|
|||||||
if (name === 'versions') renderVersions();
|
if (name === 'versions') renderVersions();
|
||||||
if (name === 'forecast') loadForecastData();
|
if (name === 'forecast') loadForecastData();
|
||||||
if (name === 'log') loadLogData();
|
if (name === 'log') loadLogData();
|
||||||
|
if (name === 'baseline') openBaselineWorkbench();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSource(source) {
|
function setSource(source) {
|
||||||
@ -245,9 +247,9 @@ function renderColMetaGrid(colMeta) {
|
|||||||
{
|
{
|
||||||
field: 'role', headerName: 'Role', width: 110, editable: true,
|
field: 'role', headerName: 'Role', width: 110, editable: true,
|
||||||
cellEditor: 'agSelectCellEditor',
|
cellEditor: 'agSelectCellEditor',
|
||||||
cellEditorParams: { values: ['dimension', 'value', 'units', 'date', 'ignore'] },
|
cellEditorParams: { values: ['dimension', 'value', 'units', 'date', 'filter', 'ignore'] },
|
||||||
cellStyle: p => {
|
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] || '' };
|
return { background: colors[p.value] || '' };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -528,6 +530,290 @@ function openForecast() {
|
|||||||
switchView('forecast');
|
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 = '<div class="segments-empty">No segments loaded yet.</div>';
|
||||||
|
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 `
|
||||||
|
<div class="segment-card">
|
||||||
|
<div class="segment-card-header">
|
||||||
|
<span class="segment-card-note">${s.note || '(no description)'}</span>
|
||||||
|
<span class="segment-card-meta">${s.pf_user} — ${stamp}</span>
|
||||||
|
<button class="btn btn-sm btn-danger" data-logid="${s.id}">Undo</button>
|
||||||
|
</div>
|
||||||
|
<div class="segment-card-params">${filters}${offset}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFilterRow() {
|
||||||
|
const container = document.getElementById('seg-filter-rows');
|
||||||
|
const idx = container.children.length;
|
||||||
|
const colOptions = state.baselineFilterCols
|
||||||
|
.map(c => `<option value="${c.cname}">${c.label || c.cname}</option>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'filter-row';
|
||||||
|
div.dataset.index = idx;
|
||||||
|
div.innerHTML = `
|
||||||
|
<select class="filter-col-select">
|
||||||
|
<option value="">— column —</option>
|
||||||
|
${colOptions}
|
||||||
|
</select>
|
||||||
|
<select class="filter-op-select">
|
||||||
|
<option value="=">=</option>
|
||||||
|
<option value="!=">!=</option>
|
||||||
|
<option value="IN">IN</option>
|
||||||
|
<option value="NOT IN">NOT IN</option>
|
||||||
|
<option value="BETWEEN" selected>BETWEEN</option>
|
||||||
|
<option value="IS NULL">IS NULL</option>
|
||||||
|
<option value="IS NOT NULL">IS NOT NULL</option>
|
||||||
|
</select>
|
||||||
|
<div class="filter-value-container"></div>
|
||||||
|
<button class="btn btn-sm btn-danger filter-remove-btn">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<input type="text" class="filter-val-from" placeholder="from" />
|
||||||
|
<span class="filter-between-sep">and</span>
|
||||||
|
<input type="text" class="filter-val-to" placeholder="to" />
|
||||||
|
`;
|
||||||
|
container.querySelectorAll('input').forEach(i => i.addEventListener('input', updateTimelinePreview));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (op === 'IN' || op === 'NOT IN') {
|
||||||
|
container.innerHTML = `<input type="text" class="filter-val-list" placeholder="a, b, c" />`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// = or !=
|
||||||
|
container.innerHTML = `<input type="text" class="filter-val-single" placeholder="value" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="timeline-row">
|
||||||
|
<div class="timeline-row-label">Source</div>
|
||||||
|
<div class="timeline-bar-wrap">
|
||||||
|
<div class="timeline-bar"></div>
|
||||||
|
<div class="timeline-bar-labels">
|
||||||
|
<span>${fmt.format(from)}</span>
|
||||||
|
<span>${months} month${months !== 1 ? 's' : ''}</span>
|
||||||
|
<span>${fmt.format(to)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (offsetYears || offsetMonths) {
|
||||||
|
const parts = [];
|
||||||
|
if (offsetYears) parts.push(`${offsetYears} yr`);
|
||||||
|
if (offsetMonths) parts.push(`${offsetMonths} mo`);
|
||||||
|
html += `
|
||||||
|
<div class="timeline-offset-indicator">+ ${parts.join(' ')} →</div>
|
||||||
|
<div class="timeline-row">
|
||||||
|
<div class="timeline-row-label">Projected</div>
|
||||||
|
<div class="timeline-bar-wrap">
|
||||||
|
<div class="timeline-bar timeline-bar-projected"></div>
|
||||||
|
<div class="timeline-bar-labels">
|
||||||
|
<span>${fmt.format(projFrom)}</span>
|
||||||
|
<span>${months} month${months !== 1 ? 's' : ''}</span>
|
||||||
|
<span>${fmt.format(projTo)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
FORECAST VIEW — data loading
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@ -956,7 +1242,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.getElementById('new-version-form').classList.add('hidden');
|
document.getElementById('new-version-form').classList.add('hidden');
|
||||||
});
|
});
|
||||||
document.getElementById('vbtn-forecast').addEventListener('click', openForecast);
|
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-reference').addEventListener('click', () => showLoadForm('reference'));
|
||||||
document.getElementById('vbtn-toggle').addEventListener('click', toggleVersionStatus);
|
document.getElementById('vbtn-toggle').addEventListener('click', toggleVersionStatus);
|
||||||
document.getElementById('vbtn-delete').addEventListener('click', deleteVersion);
|
document.getElementById('vbtn-delete').addEventListener('click', deleteVersion);
|
||||||
@ -988,6 +1274,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (btn) undoOperation(parseInt(btn.dataset.logid));
|
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
|
// init sources view
|
||||||
initSourcesView().catch(err => showStatus(err.message, 'error'));
|
initSourcesView().catch(err => showStatus(err.message, 'error'));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li data-view="sources" class="active">Sources</li>
|
<li data-view="sources" class="active">Sources</li>
|
||||||
<li data-view="versions">Versions</li>
|
<li data-view="versions">Versions</li>
|
||||||
|
<li data-view="baseline">Baseline</li>
|
||||||
<li data-view="forecast">Forecast</li>
|
<li data-view="forecast">Forecast</li>
|
||||||
<li data-view="log">Log</li>
|
<li data-view="log">Log</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -96,6 +97,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== BASELINE WORKBENCH VIEW ===== -->
|
||||||
|
<div id="view-baseline" class="view hidden">
|
||||||
|
<div class="workbench-toolbar">
|
||||||
|
<span id="baseline-label">No version selected</span>
|
||||||
|
<button id="btn-clear-baseline" class="btn btn-danger">Clear Baseline</button>
|
||||||
|
</div>
|
||||||
|
<div class="baseline-layout">
|
||||||
|
<div class="baseline-form-panel">
|
||||||
|
<div class="panel-section-title">Add Segment</div>
|
||||||
|
<div class="baseline-form">
|
||||||
|
<label class="baseline-field-label">Description
|
||||||
|
<input type="text" id="seg-description" placeholder="e.g. All orders FY2024" />
|
||||||
|
</label>
|
||||||
|
<div class="offset-row">
|
||||||
|
<label class="baseline-field-label">Offset Years
|
||||||
|
<input type="number" id="seg-offset-years" min="0" value="0" />
|
||||||
|
</label>
|
||||||
|
<label class="baseline-field-label">Offset Months
|
||||||
|
<input type="number" id="seg-offset-months" min="0" value="0" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="filter-section">
|
||||||
|
<div class="filter-section-label">Filters <span class="required-star">*</span></div>
|
||||||
|
<div id="seg-filter-rows"></div>
|
||||||
|
<button id="btn-add-filter-row" class="btn btn-sm">+ Add Filter</button>
|
||||||
|
</div>
|
||||||
|
<div id="seg-timeline" class="timeline-preview hidden"></div>
|
||||||
|
<button id="btn-load-segment" class="btn btn-primary">Load Segment</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="baseline-segments-panel">
|
||||||
|
<div class="panel-section-title">Loaded Segments</div>
|
||||||
|
<div id="baseline-segments-list">
|
||||||
|
<div class="segments-empty">No segments loaded yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===== FORECAST VIEW ===== -->
|
<!-- ===== FORECAST VIEW ===== -->
|
||||||
<div id="view-forecast" class="view hidden">
|
<div id="view-forecast" class="view hidden">
|
||||||
<div class="forecast-toolbar">
|
<div class="forecast-toolbar">
|
||||||
|
|||||||
@ -411,3 +411,191 @@ body {
|
|||||||
.preview-table th, .preview-table td { border: 1px solid #e0e0e0; padding: 4px 8px; text-align: left; }
|
.preview-table th, .preview-table td { border: 1px solid #e0e0e0; padding: 4px 8px; text-align: left; }
|
||||||
.preview-table th { background: #f5f7f9; font-weight: 600; }
|
.preview-table th { background: #f5f7f9; font-weight: 600; }
|
||||||
.preview-table tr:hover td { background: #fafbfc; }
|
.preview-table tr:hover td { background: #fafbfc; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
BASELINE WORKBENCH
|
||||||
|
============================================================ */
|
||||||
|
.workbench-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#baseline-label { font-weight: 600; font-size: 13px; flex: 1; }
|
||||||
|
|
||||||
|
.baseline-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseline-form-panel {
|
||||||
|
width: 360px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseline-segments-panel {
|
||||||
|
flex: 1;
|
||||||
|
background: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-section-title {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #7f8c8d;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseline-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseline-field-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseline-field-label input[type=text],
|
||||||
|
.baseline-field-label input[type=number] {
|
||||||
|
border: 1px solid #dce1e7;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offset-row { display: flex; gap: 10px; }
|
||||||
|
.offset-row label { flex: 1; }
|
||||||
|
|
||||||
|
.filter-section { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.filter-section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.required-star { color: #e74c3c; }
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e8ecf0;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row select,
|
||||||
|
.filter-row input[type=text] {
|
||||||
|
border: 1px solid #dce1e7;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #333;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-col-select { min-width: 120px; }
|
||||||
|
.filter-op-select { min-width: 90px; }
|
||||||
|
.filter-val-single,
|
||||||
|
.filter-val-list { min-width: 120px; flex: 1; }
|
||||||
|
.filter-val-from,
|
||||||
|
.filter-val-to { min-width: 90px; flex: 1; }
|
||||||
|
.filter-between-sep { font-size: 11px; color: #aaa; padding: 5px 2px; }
|
||||||
|
.filter-value-container { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
/* Timeline preview */
|
||||||
|
.timeline-preview { display: flex; flex-direction: column; gap: 6px; padding: 10px 0 4px; }
|
||||||
|
.timeline-preview.hidden { display: none; }
|
||||||
|
|
||||||
|
.timeline-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.timeline-row-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #7f8c8d;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
width: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.timeline-bar-wrap { flex: 1; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.timeline-bar {
|
||||||
|
height: 14px;
|
||||||
|
background: #2980b9;
|
||||||
|
border-radius: 3px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.timeline-bar-projected { background: #27ae60; }
|
||||||
|
.timeline-bar-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.timeline-offset-indicator {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #aaa;
|
||||||
|
padding: 2px 0 2px 68px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segment cards */
|
||||||
|
.segment-card {
|
||||||
|
border: 1px solid #e8ecf0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fafbfc;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.segment-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.segment-card-note {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.segment-card-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.segment-card-params {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.segments-empty { font-size: 12px; color: #aaa; font-style: italic; }
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc } = require('../lib/sql_generator');
|
const { applyTokens, buildWhere, buildExcludeClause, buildSetClause, buildFilterClause, esc } = require('../lib/sql_generator');
|
||||||
const { fcTable } = require('../lib/utils');
|
const { fcTable } = require('../lib/utils');
|
||||||
|
|
||||||
module.exports = function(pool) {
|
module.exports = function(pool) {
|
||||||
@ -74,12 +74,11 @@ module.exports = function(pool) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// load baseline rows from source table for a date range
|
// load baseline rows from source table — additive, no delete
|
||||||
// deletes existing iter='baseline' rows before inserting (handled inside stored SQL)
|
|
||||||
router.post('/versions/:id/baseline', async (req, res) => {
|
router.post('/versions/:id/baseline', async (req, res) => {
|
||||||
const { date_from, date_to, date_offset, pf_user, note, replay } = req.body;
|
const { filters, date_offset, pf_user, note, replay } = req.body;
|
||||||
if (!date_from || !date_to) {
|
if (!filters || filters.length === 0) {
|
||||||
return res.status(400).json({ error: 'date_from and date_to are required' });
|
return res.status(400).json({ error: 'filters are required' });
|
||||||
}
|
}
|
||||||
if (replay) {
|
if (replay) {
|
||||||
return res.status(501).json({ error: 'replay is not yet implemented' });
|
return res.status(501).json({ error: 'replay is not yet implemented' });
|
||||||
@ -89,19 +88,50 @@ module.exports = function(pool) {
|
|||||||
const ctx = await getContext(parseInt(req.params.id), 'baseline');
|
const ctx = await getContext(parseInt(req.params.id), 'baseline');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
|
|
||||||
|
const filterClause = buildFilterClause(filters, ctx.colMeta);
|
||||||
|
|
||||||
const sql = applyTokens(ctx.sql, {
|
const sql = applyTokens(ctx.sql, {
|
||||||
fc_table: ctx.table,
|
fc_table: ctx.table,
|
||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
params: esc(JSON.stringify({ date_from, date_to, date_offset: dateOffset })),
|
params: esc(JSON.stringify({ filters, date_offset: dateOffset })),
|
||||||
date_from: esc(date_from),
|
filter_clause: filterClause,
|
||||||
date_to: esc(date_to),
|
date_offset: esc(dateOffset)
|
||||||
date_offset: esc(dateOffset)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSQL(sql);
|
const result = await runSQL(sql);
|
||||||
res.json({ rows_affected: result.rows.length });
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(err.status || 500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// delete all baseline rows and log entries for a version
|
||||||
|
router.delete('/versions/:id/baseline', async (req, res) => {
|
||||||
|
const versionId = parseInt(req.params.id);
|
||||||
|
try {
|
||||||
|
const ctx = await getContext(versionId, 'baseline');
|
||||||
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const delRows = await client.query(
|
||||||
|
`DELETE FROM ${ctx.table} WHERE iter = 'baseline' RETURNING id`
|
||||||
|
);
|
||||||
|
const delLog = await client.query(
|
||||||
|
`DELETE FROM pf.log WHERE version_id = $1 AND operation = 'baseline'`,
|
||||||
|
[versionId]
|
||||||
|
);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ rows_deleted: delRows.rowCount, log_entries_deleted: delLog.rowCount });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(err.status || 500).json({ error: err.message });
|
res.status(err.status || 500).json({ error: err.message });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user