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}}')
|
||||
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 };
|
||||
|
||||
311
public/app.js
311
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 = '<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
|
||||
============================================================ */
|
||||
@ -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'));
|
||||
});
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
<ul class="nav-links">
|
||||
<li data-view="sources" class="active">Sources</li>
|
||||
<li data-view="versions">Versions</li>
|
||||
<li data-view="baseline">Baseline</li>
|
||||
<li data-view="forecast">Forecast</li>
|
||||
<li data-view="log">Log</li>
|
||||
</ul>
|
||||
@ -96,6 +97,45 @@
|
||||
</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 ===== -->
|
||||
<div id="view-forecast" class="view hidden">
|
||||
<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 { background: #f5f7f9; font-weight: 600; }
|
||||
.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 { 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');
|
||||
|
||||
module.exports = function(pool) {
|
||||
@ -74,12 +74,11 @@ module.exports = function(pool) {
|
||||
}
|
||||
});
|
||||
|
||||
// load baseline rows from source table for a date range
|
||||
// deletes existing iter='baseline' rows before inserting (handled inside stored SQL)
|
||||
// load baseline rows from source table — additive, no delete
|
||||
router.post('/versions/:id/baseline', async (req, res) => {
|
||||
const { date_from, date_to, date_offset, pf_user, note, replay } = req.body;
|
||||
if (!date_from || !date_to) {
|
||||
return res.status(400).json({ error: 'date_from and date_to are required' });
|
||||
const { filters, date_offset, pf_user, note, replay } = req.body;
|
||||
if (!filters || filters.length === 0) {
|
||||
return res.status(400).json({ error: 'filters are required' });
|
||||
}
|
||||
if (replay) {
|
||||
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');
|
||||
if (!guardOpen(ctx.version, res)) return;
|
||||
|
||||
const filterClause = buildFilterClause(filters, ctx.colMeta);
|
||||
|
||||
const sql = applyTokens(ctx.sql, {
|
||||
fc_table: ctx.table,
|
||||
version_id: ctx.version.id,
|
||||
pf_user: esc(pf_user || ''),
|
||||
note: esc(note || ''),
|
||||
params: esc(JSON.stringify({ date_from, date_to, date_offset: dateOffset })),
|
||||
date_from: esc(date_from),
|
||||
date_to: esc(date_to),
|
||||
params: esc(JSON.stringify({ filters, date_offset: dateOffset })),
|
||||
filter_clause: filterClause,
|
||||
date_offset: esc(dateOffset)
|
||||
});
|
||||
|
||||
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) {
|
||||
console.error(err);
|
||||
res.status(err.status || 500).json({ error: err.message });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user