- 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>
1297 lines
52 KiB
JavaScript
1297 lines
52 KiB
JavaScript
/* ============================================================
|
||
STATE
|
||
============================================================ */
|
||
const state = {
|
||
view: 'sources',
|
||
source: null, // selected pf.source row
|
||
version: null, // selected pf.version row
|
||
selectedVersionId: null, // in versions grid
|
||
colMeta: [], // col_meta for selected source
|
||
slice: {}, // current pivot selection
|
||
loadDataOp: null, // 'baseline' | 'reference'
|
||
baselineFilterCols: [],
|
||
previewSchema: null,
|
||
previewTname: null,
|
||
grids: {
|
||
tables: null,
|
||
sources: null,
|
||
colMeta: null,
|
||
versions: null,
|
||
pivot: null,
|
||
log: null
|
||
}
|
||
};
|
||
|
||
/* ============================================================
|
||
API
|
||
============================================================ */
|
||
async function api(method, path, body) {
|
||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||
const res = await fetch('/api' + path, opts);
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||
return data;
|
||
}
|
||
|
||
/* ============================================================
|
||
STATUS BAR
|
||
============================================================ */
|
||
let statusTimer = null;
|
||
function showStatus(msg, type = 'info') {
|
||
const bar = document.getElementById('status-bar');
|
||
bar.textContent = msg;
|
||
bar.className = `status-${type}`;
|
||
bar.classList.remove('hidden');
|
||
clearTimeout(statusTimer);
|
||
if (type !== 'error') statusTimer = setTimeout(() => bar.classList.add('hidden'), 4000);
|
||
}
|
||
|
||
/* ============================================================
|
||
NAVIGATION
|
||
============================================================ */
|
||
function switchView(name) {
|
||
if ((name === 'forecast' || name === 'log' || name === 'baseline') && !state.version) {
|
||
showStatus('Select a version first', 'error');
|
||
return;
|
||
}
|
||
document.querySelectorAll('.view').forEach(el => {
|
||
el.classList.add('hidden');
|
||
el.classList.remove('active');
|
||
});
|
||
const target = document.getElementById(`view-${name}`);
|
||
target.classList.remove('hidden');
|
||
target.classList.add('active');
|
||
document.querySelectorAll('.nav-links li').forEach(li =>
|
||
li.classList.toggle('active', li.dataset.view === name)
|
||
);
|
||
state.view = name;
|
||
if (name === 'versions') renderVersions();
|
||
if (name === 'forecast') loadForecastData();
|
||
if (name === 'log') loadLogData();
|
||
if (name === 'baseline') openBaselineWorkbench();
|
||
}
|
||
|
||
function setSource(source) {
|
||
state.source = source;
|
||
const el = document.getElementById('ctx-source');
|
||
if (source) {
|
||
el.classList.remove('hidden');
|
||
document.getElementById('ctx-source-name').textContent = `${source.schema}.${source.tname}`;
|
||
} else {
|
||
el.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function setVersion(version) {
|
||
state.version = version;
|
||
const el = document.getElementById('ctx-version');
|
||
if (version) {
|
||
el.classList.remove('hidden');
|
||
document.getElementById('ctx-version-name').textContent = version.name;
|
||
const badge = document.getElementById('ctx-version-status');
|
||
badge.textContent = version.status;
|
||
badge.className = `status-badge ${version.status}`;
|
||
} else {
|
||
el.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function getPfUser() {
|
||
return document.getElementById('input-pf-user').value.trim() || 'unknown';
|
||
}
|
||
|
||
/* ============================================================
|
||
SOURCES VIEW — table browser
|
||
============================================================ */
|
||
async function initSourcesView() {
|
||
const tables = await api('GET', '/tables');
|
||
renderTablesGrid(tables);
|
||
const sources = await api('GET', '/sources');
|
||
renderSourcesGrid(sources);
|
||
}
|
||
|
||
function renderTablesGrid(tables) {
|
||
const el = document.getElementById('tables-grid');
|
||
if (state.grids.tables) { state.grids.tables.setGridOption('rowData', tables); return; }
|
||
|
||
state.grids.tables = agGrid.createGrid(el, {
|
||
columnDefs: [
|
||
{ field: 'schema', headerName: 'Schema', width: 90 },
|
||
{ field: 'tname', headerName: 'Table', flex: 1 },
|
||
{ field: 'row_estimate', headerName: 'Rows', width: 80,
|
||
valueFormatter: p => p.value != null ? Number(p.value).toLocaleString() : '—' }
|
||
],
|
||
rowData: tables,
|
||
rowSelection: 'single',
|
||
onRowClicked: onTableRowClicked,
|
||
onRowDoubleClicked: e => showTablePreview(e.data.schema, e.data.tname),
|
||
defaultColDef: { resizable: true, sortable: true },
|
||
headerHeight: 32, rowHeight: 28
|
||
});
|
||
}
|
||
|
||
function onTableRowClicked(e) {
|
||
const { schema, tname } = e.data;
|
||
state.previewSchema = schema;
|
||
state.previewTname = tname;
|
||
document.getElementById('btn-register').classList.remove('hidden');
|
||
}
|
||
|
||
async function showTablePreview(schema, tname) {
|
||
try {
|
||
const data = await api('GET', `/tables/${schema}/${tname}/preview`);
|
||
const title = document.getElementById('modal-title');
|
||
const body = document.getElementById('modal-body');
|
||
title.textContent = `${schema}.${tname}`;
|
||
|
||
// columns table
|
||
let colHtml = `<div class="preview-section"><h4>Columns</h4><table class="preview-table">
|
||
<tr><th>Column</th><th>Type</th><th>Nullable</th></tr>`;
|
||
data.columns.forEach(c => {
|
||
colHtml += `<tr><td>${c.column_name}</td><td>${c.data_type}</td><td>${c.is_nullable}</td></tr>`;
|
||
});
|
||
colHtml += '</table></div>';
|
||
|
||
// sample rows
|
||
let rowHtml = '';
|
||
if (data.rows.length > 0) {
|
||
const cols = Object.keys(data.rows[0]);
|
||
rowHtml = `<div class="preview-section"><h4>Sample rows</h4><table class="preview-table"><tr>`;
|
||
cols.forEach(c => rowHtml += `<th>${c}</th>`);
|
||
rowHtml += '</tr>';
|
||
data.rows.forEach(row => {
|
||
rowHtml += '<tr>';
|
||
cols.forEach(c => rowHtml += `<td>${row[c] ?? ''}</td>`);
|
||
rowHtml += '</tr>';
|
||
});
|
||
rowHtml += '</table></div>';
|
||
}
|
||
|
||
body.innerHTML = colHtml + rowHtml;
|
||
state.previewSchema = schema;
|
||
state.previewTname = tname;
|
||
document.getElementById('modal-overlay').classList.remove('hidden');
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function registerTable(schema, tname) {
|
||
try {
|
||
await api('POST', '/sources', { schema, tname, created_by: getPfUser() });
|
||
showStatus(`Registered ${schema}.${tname}`, 'success');
|
||
const sources = await api('GET', '/sources');
|
||
renderSourcesGrid(sources);
|
||
document.getElementById('modal-overlay').classList.add('hidden');
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
SOURCES VIEW — registered sources + col_meta
|
||
============================================================ */
|
||
function renderSourcesGrid(sources) {
|
||
const el = document.getElementById('sources-list-grid');
|
||
if (state.grids.sources) { state.grids.sources.setGridOption('rowData', sources); return; }
|
||
|
||
state.grids.sources = agGrid.createGrid(el, {
|
||
columnDefs: [
|
||
{ field: 'schema', headerName: 'Schema', width: 90 },
|
||
{ field: 'tname', headerName: 'Table', flex: 1 },
|
||
{ field: 'label', headerName: 'Label', flex: 1 },
|
||
{ field: 'status', headerName: 'Status', width: 70 }
|
||
],
|
||
rowData: sources,
|
||
rowSelection: 'single',
|
||
onRowClicked: e => selectSource(e.data),
|
||
defaultColDef: { resizable: true, sortable: true },
|
||
headerHeight: 32, rowHeight: 28
|
||
});
|
||
}
|
||
|
||
async function selectSource(source) {
|
||
setSource(source);
|
||
try {
|
||
state.colMeta = await api('GET', `/sources/${source.id}/cols`);
|
||
renderColMetaGrid(state.colMeta);
|
||
document.getElementById('sources-list-grid').classList.add('hidden');
|
||
document.getElementById('col-meta-grid').classList.remove('hidden');
|
||
document.getElementById('right-panel-title').textContent = `${source.schema}.${source.tname} — Columns`;
|
||
document.getElementById('btn-back-sources').classList.remove('hidden');
|
||
document.getElementById('btn-save-cols').classList.remove('hidden');
|
||
document.getElementById('btn-generate-sql').classList.remove('hidden');
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function backToSources() {
|
||
document.getElementById('sources-list-grid').classList.remove('hidden');
|
||
document.getElementById('col-meta-grid').classList.add('hidden');
|
||
document.getElementById('right-panel-title').textContent = 'Registered Sources';
|
||
document.getElementById('btn-back-sources').classList.add('hidden');
|
||
document.getElementById('btn-save-cols').classList.add('hidden');
|
||
document.getElementById('btn-generate-sql').classList.add('hidden');
|
||
}
|
||
|
||
function renderColMetaGrid(colMeta) {
|
||
const el = document.getElementById('col-meta-grid');
|
||
if (state.grids.colMeta) { state.grids.colMeta.setGridOption('rowData', colMeta); return; }
|
||
|
||
state.grids.colMeta = agGrid.createGrid(el, {
|
||
columnDefs: [
|
||
{ field: 'opos', headerName: '#', width: 45, sortable: true },
|
||
{ field: 'cname', headerName: 'Column', flex: 1 },
|
||
{
|
||
field: 'role', headerName: 'Role', width: 110, editable: true,
|
||
cellEditor: 'agSelectCellEditor',
|
||
cellEditorParams: { values: ['dimension', 'value', 'units', 'date', 'filter', 'ignore'] },
|
||
cellStyle: p => {
|
||
const colors = { dimension: '#eaf4fb', value: '#eafaf1', units: '#fef9e7', date: '#fdf2f8', filter: '#fef5e4', ignore: '#f9f9f9' };
|
||
return { background: colors[p.value] || '' };
|
||
}
|
||
},
|
||
{
|
||
field: 'is_key', headerName: 'Key', width: 55, editable: true,
|
||
cellRenderer: p => p.value ? '✓' : '',
|
||
cellEditor: 'agCheckboxCellEditor'
|
||
},
|
||
{ field: 'label', headerName: 'Label', flex: 1, editable: true,
|
||
cellEditorParams: { useFormatter: true } }
|
||
],
|
||
rowData: colMeta,
|
||
defaultColDef: { resizable: true },
|
||
headerHeight: 32, rowHeight: 28,
|
||
singleClickEdit: true,
|
||
stopEditingWhenCellsLoseFocus: true
|
||
});
|
||
}
|
||
|
||
async function saveColMeta() {
|
||
if (!state.source) return;
|
||
try {
|
||
const rows = [];
|
||
state.grids.colMeta.forEachNode(n => rows.push(n.data));
|
||
state.colMeta = await api('PUT', `/sources/${state.source.id}/cols`, rows);
|
||
state.grids.colMeta.setGridOption('rowData', state.colMeta);
|
||
showStatus('Columns saved', 'success');
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function generateSQL() {
|
||
if (!state.source) return;
|
||
try {
|
||
const result = await api('POST', `/sources/${state.source.id}/generate-sql`);
|
||
showStatus(`SQL generated: ${result.operations.join(', ')}`, 'success');
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
VERSIONS VIEW
|
||
============================================================ */
|
||
async function renderVersions() {
|
||
if (!state.source) {
|
||
document.getElementById('versions-source-label').textContent = 'No source selected — go to Sources first';
|
||
return;
|
||
}
|
||
document.getElementById('versions-source-label').textContent = `${state.source.schema}.${state.source.tname}`;
|
||
try {
|
||
const versions = await api('GET', `/sources/${state.source.id}/versions`);
|
||
renderVersionsGrid(versions);
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function renderVersionsGrid(versions) {
|
||
const el = document.getElementById('versions-grid');
|
||
|
||
const colDefs = [
|
||
{ field: 'id', headerName: 'ID', width: 55 },
|
||
{ field: 'name', headerName: 'Name', flex: 1 },
|
||
{ field: 'description', headerName: 'Desc', flex: 1 },
|
||
{ field: 'status', headerName: 'Status', width: 75,
|
||
cellStyle: p => ({ color: p.value === 'open' ? '#27ae60' : '#e74c3c', fontWeight: 600 }) },
|
||
{ field: 'created_by', headerName: 'Created by', width: 100 },
|
||
{ field: 'created_at', headerName: 'Created', width: 140,
|
||
valueFormatter: p => p.value ? new Date(p.value).toLocaleString() : '' }
|
||
];
|
||
|
||
if (state.grids.versions) {
|
||
state.grids.versions.setGridOption('rowData', versions);
|
||
state.grids.versions.setGridOption('columnDefs', colDefs);
|
||
return;
|
||
}
|
||
|
||
state.grids.versions = agGrid.createGrid(el, {
|
||
columnDefs: colDefs,
|
||
rowData: versions,
|
||
rowSelection: 'single',
|
||
onRowClicked: onVersionRowClicked,
|
||
defaultColDef: { resizable: true, sortable: true },
|
||
headerHeight: 32, rowHeight: 28
|
||
});
|
||
}
|
||
|
||
function onVersionRowClicked(e) {
|
||
const v = e.data;
|
||
state.selectedVersionId = v.id;
|
||
const panel = document.getElementById('version-actions');
|
||
panel.classList.remove('hidden');
|
||
document.getElementById('version-actions-label').textContent = v.name;
|
||
document.getElementById('vbtn-toggle').textContent = v.status === 'open' ? 'Close Version' : 'Reopen Version';
|
||
document.getElementById('load-data-form').classList.add('hidden');
|
||
}
|
||
|
||
function showNewVersionForm() {
|
||
document.getElementById('new-version-form').classList.remove('hidden');
|
||
document.getElementById('ver-name').focus();
|
||
}
|
||
|
||
async function createVersion() {
|
||
const name = document.getElementById('ver-name').value.trim();
|
||
if (!name) { showStatus('Version name is required', 'error'); return; }
|
||
try {
|
||
await api('POST', `/sources/${state.source.id}/versions`, {
|
||
name,
|
||
description: document.getElementById('ver-desc').value.trim() || undefined,
|
||
created_by: getPfUser()
|
||
});
|
||
document.getElementById('new-version-form').classList.add('hidden');
|
||
document.getElementById('ver-name').value = '';
|
||
document.getElementById('ver-desc').value = '';
|
||
showStatus(`Version "${name}" created`, 'success');
|
||
await renderVersions();
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function showLoadForm(op) {
|
||
state.loadDataOp = op;
|
||
document.getElementById('load-data-title').textContent =
|
||
op === 'baseline' ? 'Load Baseline' : 'Load Reference';
|
||
document.getElementById('load-date-from').value = '';
|
||
document.getElementById('load-date-to').value = '';
|
||
document.getElementById('load-offset-years').value = '0';
|
||
document.getElementById('load-offset-months').value = '0';
|
||
document.getElementById('load-note').value = '';
|
||
document.getElementById('load-date-preview').classList.add('hidden');
|
||
|
||
const showOffset = op === 'baseline';
|
||
document.getElementById('load-offset-fields').classList.toggle('hidden', !showOffset);
|
||
|
||
document.getElementById('load-data-modal').classList.remove('hidden');
|
||
document.getElementById('load-date-from').focus();
|
||
}
|
||
|
||
function hideLoadModal() {
|
||
document.getElementById('load-data-modal').classList.add('hidden');
|
||
}
|
||
|
||
function buildMonthList(fromVal, toVal) {
|
||
const from = new Date(fromVal + 'T00:00:00');
|
||
const to = new Date(toVal + 'T00:00:00');
|
||
if (isNaN(from) || isNaN(to) || from > to) return null;
|
||
const months = [];
|
||
const cur = new Date(from.getFullYear(), from.getMonth(), 1);
|
||
const end = new Date(to.getFullYear(), to.getMonth(), 1);
|
||
while (cur <= end) { months.push(new Date(cur)); cur.setMonth(cur.getMonth() + 1); }
|
||
return months;
|
||
}
|
||
|
||
function renderChips(months, fmt) {
|
||
if (months.length <= 36) {
|
||
return months.map(m => `<span class="date-chip">${fmt.format(m)}</span>`).join('');
|
||
}
|
||
return `<span class="date-chip-summary">${months.length} months — ${fmt.format(months[0])} → ${fmt.format(months[months.length - 1])}</span>`;
|
||
}
|
||
|
||
function updateDatePreview() {
|
||
const fromVal = document.getElementById('load-date-from').value;
|
||
const toVal = document.getElementById('load-date-to').value;
|
||
const preview = document.getElementById('load-date-preview');
|
||
const simple = document.getElementById('load-preview-simple');
|
||
const offset = document.getElementById('load-preview-offset');
|
||
|
||
if (!fromVal || !toVal) { preview.classList.add('hidden'); return; }
|
||
|
||
const months = buildMonthList(fromVal, toVal);
|
||
if (!months) { preview.classList.add('hidden'); return; }
|
||
|
||
const fmt = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' });
|
||
|
||
if (state.loadDataOp === 'baseline') {
|
||
const years = parseInt(document.getElementById('load-offset-years').value) || 0;
|
||
const mths = parseInt(document.getElementById('load-offset-months').value) || 0;
|
||
const projected = months.map(d => {
|
||
const p = new Date(d);
|
||
p.setFullYear(p.getFullYear() + years);
|
||
p.setMonth(p.getMonth() + mths);
|
||
return p;
|
||
});
|
||
|
||
document.getElementById('load-chips-source').innerHTML = renderChips(months, fmt);
|
||
document.getElementById('load-chips-projected').innerHTML = renderChips(projected, fmt);
|
||
simple.classList.add('hidden');
|
||
offset.classList.remove('hidden');
|
||
} else {
|
||
simple.querySelector('.load-preview-label').textContent =
|
||
`${months.length} month${months.length !== 1 ? 's' : ''} covered`;
|
||
document.getElementById('load-date-chips').innerHTML = renderChips(months, fmt);
|
||
offset.classList.add('hidden');
|
||
simple.classList.remove('hidden');
|
||
}
|
||
|
||
preview.classList.remove('hidden');
|
||
}
|
||
|
||
async function submitLoadData() {
|
||
const date_from = document.getElementById('load-date-from').value;
|
||
const date_to = document.getElementById('load-date-to').value;
|
||
if (!date_from || !date_to) { showStatus('Both dates are required', 'error'); return; }
|
||
if (!state.selectedVersionId) { showStatus('No version selected', 'error'); return; }
|
||
|
||
const body = {
|
||
date_from, date_to,
|
||
pf_user: getPfUser(),
|
||
note: document.getElementById('load-note').value.trim() || undefined
|
||
};
|
||
|
||
if (state.loadDataOp === 'baseline') {
|
||
const years = parseInt(document.getElementById('load-offset-years').value) || 0;
|
||
const months = parseInt(document.getElementById('load-offset-months').value) || 0;
|
||
const parts = [];
|
||
if (years) parts.push(`${years} year${years !== 1 ? 's' : ''}`);
|
||
if (months) parts.push(`${months} month${months !== 1 ? 's' : ''}`);
|
||
body.date_offset = parts.length ? parts.join(' ') : '0 days';
|
||
}
|
||
|
||
try {
|
||
showStatus(`Loading ${state.loadDataOp}...`, 'info');
|
||
const result = await api('POST', `/versions/${state.selectedVersionId}/${state.loadDataOp}`, body);
|
||
hideLoadModal();
|
||
showStatus(`${state.loadDataOp} loaded — ${result.rows_affected} rows`, 'success');
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function toggleVersionStatus() {
|
||
if (!state.selectedVersionId) return;
|
||
// find current status from grid
|
||
let currentStatus = null;
|
||
state.grids.versions.forEachNode(n => {
|
||
if (n.data.id === state.selectedVersionId) currentStatus = n.data.status;
|
||
});
|
||
try {
|
||
const route = currentStatus === 'open'
|
||
? `/versions/${state.selectedVersionId}/close`
|
||
: `/versions/${state.selectedVersionId}/reopen`;
|
||
const result = await api('POST', route, { pf_user: getPfUser() });
|
||
showStatus(`Version ${result.status}`, 'success');
|
||
await renderVersions();
|
||
document.getElementById('version-actions').classList.add('hidden');
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function deleteVersion() {
|
||
if (!state.selectedVersionId) return;
|
||
if (!confirm('Delete this version and its forecast table? This cannot be undone.')) return;
|
||
try {
|
||
await api('DELETE', `/versions/${state.selectedVersionId}`);
|
||
showStatus('Version deleted', 'success');
|
||
if (state.version?.id === state.selectedVersionId) setVersion(null);
|
||
state.selectedVersionId = null;
|
||
document.getElementById('version-actions').classList.add('hidden');
|
||
await renderVersions();
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function openForecast() {
|
||
if (!state.selectedVersionId) return;
|
||
// find the version object from grid
|
||
let v = null;
|
||
state.grids.versions.forEachNode(n => {
|
||
if (n.data.id === state.selectedVersionId) v = n.data;
|
||
});
|
||
if (!v) return;
|
||
setVersion(v);
|
||
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
|
||
============================================================ */
|
||
function parseNumericRows(rows) {
|
||
const numericCols = state.colMeta
|
||
.filter(c => c.role === 'value' || c.role === 'units')
|
||
.map(c => c.cname);
|
||
return rows.map(row => {
|
||
const r = { ...row };
|
||
numericCols.forEach(col => { if (r[col] != null) r[col] = parseFloat(r[col]); });
|
||
return r;
|
||
});
|
||
}
|
||
|
||
async function loadForecastData() {
|
||
if (!state.version) return;
|
||
document.getElementById('forecast-label').textContent =
|
||
`${state.source?.tname || ''} — ${state.version.name} [${state.version.status}]`;
|
||
try {
|
||
// ensure col_meta is loaded (may not be if user navigated directly)
|
||
if (!state.colMeta.length && state.source) {
|
||
state.colMeta = await api('GET', `/sources/${state.source.id}/cols`);
|
||
}
|
||
showStatus('Loading forecast data...', 'info');
|
||
const rawData = await api('GET', `/versions/${state.version.id}/data`);
|
||
const data = parseNumericRows(rawData);
|
||
initPivotGrid(data);
|
||
showStatus(`Loaded ${data.length.toLocaleString()} rows`, 'success');
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
FORECAST VIEW — pivot grid
|
||
============================================================ */
|
||
function buildPivotColDefs() {
|
||
const defs = [];
|
||
|
||
state.colMeta.forEach((c) => {
|
||
if (c.role === 'ignore') return;
|
||
const needsGetter = /\W/.test(c.cname);
|
||
const def = {
|
||
field: c.cname,
|
||
headerName: c.label || c.cname,
|
||
resizable: true,
|
||
sortable: true,
|
||
...(needsGetter ? { valueGetter: p => p.data ? p.data[c.cname] : undefined } : {})
|
||
};
|
||
if (c.role === 'dimension' || c.role === 'date') {
|
||
def.enableRowGroup = true;
|
||
def.enablePivot = true;
|
||
}
|
||
if (c.role === 'value' || c.role === 'units') {
|
||
def.enableValue = true;
|
||
def.aggFunc = 'sum';
|
||
def.type = 'numericColumn';
|
||
def.valueFormatter = p => p.value != null
|
||
? Number(p.value).toLocaleString(undefined, { maximumFractionDigits: 2 })
|
||
: '';
|
||
}
|
||
defs.push(def);
|
||
|
||
if (c.role === 'date') {
|
||
defs.push({
|
||
colId: c.cname + '__month',
|
||
headerName: (c.label || c.cname) + ' (Month)',
|
||
enableRowGroup: true,
|
||
enablePivot: true,
|
||
valueGetter: p => {
|
||
const v = p.data ? p.data[c.cname] : undefined;
|
||
if (!v) return undefined;
|
||
const d = new Date(v);
|
||
return isNaN(d) ? undefined : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// always include iter for grouping context
|
||
defs.push({ field: 'iter', headerName: 'Iter', enableRowGroup: true, enablePivot: true, width: 90 });
|
||
defs.push({ field: 'pf_user', headerName: 'User', width: 90, hide: true });
|
||
defs.push({ field: 'created_at', headerName: 'Created', width: 130, hide: true,
|
||
valueFormatter: p => p.value ? new Date(p.value).toLocaleDateString() : '' });
|
||
|
||
return defs;
|
||
}
|
||
|
||
function initPivotGrid(data) {
|
||
const el = document.getElementById('pivot-grid');
|
||
|
||
if (state.grids.pivot) {
|
||
state.grids.pivot.setGridOption('rowData', data);
|
||
return;
|
||
}
|
||
|
||
state.grids.pivot = agGrid.createGrid(el, {
|
||
columnDefs: buildPivotColDefs(),
|
||
rowData: data,
|
||
rowSelection: 'single',
|
||
groupDisplayType: 'singleColumn',
|
||
rowGroupPanelShow: 'always',
|
||
groupDefaultExpanded: 1,
|
||
suppressAggFuncInHeader: true,
|
||
animateRows: true,
|
||
sideBar: {
|
||
toolPanels: [{
|
||
id: 'columns',
|
||
labelDefault: 'Columns',
|
||
labelKey: 'columns',
|
||
iconKey: 'columns',
|
||
toolPanel: 'agColumnsToolPanel',
|
||
toolPanelParams: {}
|
||
}],
|
||
defaultToolPanel: 'columns'
|
||
},
|
||
defaultColDef: { resizable: true, sortable: true },
|
||
autoGroupColumnDef: {
|
||
headerName: 'Group',
|
||
minWidth: 200,
|
||
cellRendererParams: { suppressCount: false }
|
||
},
|
||
headerHeight: 32,
|
||
rowHeight: 28,
|
||
onRowClicked: onPivotRowClicked
|
||
});
|
||
}
|
||
|
||
/* ============================================================
|
||
FORECAST VIEW — slice selection
|
||
============================================================ */
|
||
function onPivotRowClicked(event) {
|
||
const node = event.node;
|
||
state.slice = extractSliceFromNode(node);
|
||
renderSliceDisplay();
|
||
|
||
// populate recode and clone fields whenever slice changes
|
||
renderDimFields('recode');
|
||
renderDimFields('clone');
|
||
}
|
||
|
||
function extractSliceFromNode(node) {
|
||
const slice = {};
|
||
let current = node;
|
||
while (current) {
|
||
if (current.field && current.key != null && current.key !== '') {
|
||
slice[current.field] = current.key;
|
||
}
|
||
current = current.parent;
|
||
}
|
||
return slice;
|
||
}
|
||
|
||
function renderSliceDisplay() {
|
||
const display = document.getElementById('slice-display');
|
||
const hasSlice = Object.keys(state.slice).length > 0;
|
||
|
||
if (!hasSlice) {
|
||
display.innerHTML = '<span class="op-hint">Click a row to select a slice</span>';
|
||
document.getElementById('btn-clear-slice').classList.add('hidden');
|
||
document.getElementById('op-forms-area').classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
display.innerHTML = Object.entries(state.slice)
|
||
.map(([k, v]) => `<span class="slice-tag">${k} = ${v}</span>`)
|
||
.join('');
|
||
|
||
document.getElementById('btn-clear-slice').classList.remove('hidden');
|
||
document.getElementById('op-forms-area').classList.remove('hidden');
|
||
}
|
||
|
||
function clearSlice() {
|
||
state.slice = {};
|
||
renderSliceDisplay();
|
||
}
|
||
|
||
/* ============================================================
|
||
FORECAST VIEW — operation tabs
|
||
============================================================ */
|
||
function switchOpTab(opName) {
|
||
document.querySelectorAll('.op-tab').forEach(t =>
|
||
t.classList.toggle('active', t.dataset.op === opName)
|
||
);
|
||
document.querySelectorAll('.op-form').forEach(f => f.classList.add('hidden'));
|
||
document.getElementById(`op-${opName}`).classList.remove('hidden');
|
||
}
|
||
|
||
// render input fields for each dimension column (recode / clone)
|
||
// is_key columns get a populated <select>, others get a free-text input
|
||
async function renderDimFields(op) {
|
||
const container = document.getElementById(`${op}-fields`);
|
||
const dims = state.colMeta.filter(c => c.role === 'dimension');
|
||
|
||
// fetch values for all key columns in parallel
|
||
const keyDims = dims.filter(c => c.is_key);
|
||
const valueMap = {};
|
||
await Promise.all(keyDims.map(async c => {
|
||
try {
|
||
const vals = await api('GET', `/sources/${state.source.id}/values/${encodeURIComponent(c.cname)}`);
|
||
valueMap[c.cname] = vals;
|
||
} catch (_) {
|
||
valueMap[c.cname] = [];
|
||
}
|
||
}));
|
||
|
||
container.innerHTML = dims.map(c => {
|
||
const current = state.slice[c.cname] ? `current: ${state.slice[c.cname]}` : '';
|
||
const hint = current ? `<span class="field-hint">${current}</span>` : '';
|
||
|
||
let input;
|
||
if (c.is_key && valueMap[c.cname]?.length) {
|
||
const options = valueMap[c.cname]
|
||
.map(v => `<option value="${v}">${v}</option>`)
|
||
.join('');
|
||
input = `<select data-col="${c.cname}"><option value="">— keep current —</option>${options}</select>`;
|
||
} else {
|
||
input = `<input type="text" data-col="${c.cname}" placeholder="new value (leave blank to keep)" />`;
|
||
}
|
||
|
||
return `
|
||
<div class="${op}-field">
|
||
<label>${c.label || c.cname}${hint}${input}</label>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
/* ============================================================
|
||
FORECAST VIEW — submit operations
|
||
============================================================ */
|
||
async function submitScale() {
|
||
const value_incr = parseFloat(document.getElementById('scale-value-incr').value) || null;
|
||
const units_incr = parseFloat(document.getElementById('scale-units-incr').value) || null;
|
||
const pct = document.getElementById('scale-pct').checked;
|
||
const note = document.getElementById('scale-note').value.trim();
|
||
|
||
if (!value_incr && !units_incr) {
|
||
showStatus('Enter a value or units increment', 'error'); return;
|
||
}
|
||
if (Object.keys(state.slice).length === 0) {
|
||
showStatus('Select a slice first', 'error'); return;
|
||
}
|
||
try {
|
||
const result = await api('POST', `/versions/${state.version.id}/scale`, {
|
||
pf_user: getPfUser(), note: note || undefined,
|
||
slice: state.slice, value_incr, units_incr, pct
|
||
});
|
||
showStatus(`Scale applied — ${result.rows_affected} rows inserted`, 'success');
|
||
document.getElementById('scale-value-incr').value = '';
|
||
document.getElementById('scale-units-incr').value = '';
|
||
document.getElementById('scale-note').value = '';
|
||
state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) });
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function submitRecode() {
|
||
const set = {};
|
||
document.querySelectorAll('#recode-fields input[data-col], #recode-fields select[data-col]').forEach(inp => {
|
||
if (inp.value.trim()) set[inp.dataset.col] = inp.value.trim();
|
||
});
|
||
if (Object.keys(set).length === 0) {
|
||
showStatus('Enter at least one new dimension value', 'error'); return;
|
||
}
|
||
if (Object.keys(state.slice).length === 0) {
|
||
showStatus('Select a slice first', 'error'); return;
|
||
}
|
||
try {
|
||
const result = await api('POST', `/versions/${state.version.id}/recode`, {
|
||
pf_user: getPfUser(),
|
||
note: document.getElementById('recode-note').value.trim() || undefined,
|
||
slice: state.slice,
|
||
set
|
||
});
|
||
showStatus(`Recode applied — ${result.rows_affected} rows inserted`, 'success');
|
||
document.querySelectorAll('#recode-fields input[data-col], #recode-fields select[data-col]').forEach(i => { i.value = ''; });
|
||
state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) });
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function submitClone() {
|
||
const set = {};
|
||
document.querySelectorAll('#clone-fields input[data-col], #clone-fields select[data-col]').forEach(inp => {
|
||
if (inp.value.trim()) set[inp.dataset.col] = inp.value.trim();
|
||
});
|
||
if (Object.keys(set).length === 0) {
|
||
showStatus('Enter at least one new dimension value', 'error'); return;
|
||
}
|
||
if (Object.keys(state.slice).length === 0) {
|
||
showStatus('Select a slice first', 'error'); return;
|
||
}
|
||
try {
|
||
const scale = parseFloat(document.getElementById('clone-scale').value) || 1.0;
|
||
const result = await api('POST', `/versions/${state.version.id}/clone`, {
|
||
pf_user: getPfUser(),
|
||
note: document.getElementById('clone-note').value.trim() || undefined,
|
||
slice: state.slice,
|
||
set,
|
||
scale
|
||
});
|
||
showStatus(`Clone applied — ${result.rows_affected} rows inserted`, 'success');
|
||
document.querySelectorAll('#clone-fields input[data-col], #clone-fields select[data-col]').forEach(i => { i.value = ''; });
|
||
document.getElementById('clone-scale').value = '1';
|
||
state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) });
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
LOG VIEW
|
||
============================================================ */
|
||
async function loadLogData() {
|
||
if (!state.version) return;
|
||
try {
|
||
const logs = await api('GET', `/versions/${state.version.id}/log`);
|
||
renderLogGrid(logs);
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function renderLogGrid(logs) {
|
||
const el = document.getElementById('log-grid');
|
||
|
||
const colDefs = [
|
||
{ field: 'id', headerName: 'ID', width: 65 },
|
||
{ field: 'pf_user', headerName: 'User', width: 90 },
|
||
{ field: 'stamp', headerName: 'Time', width: 140,
|
||
valueFormatter: p => p.value ? new Date(p.value).toLocaleString() : '' },
|
||
{ field: 'operation', headerName: 'Operation', width: 90 },
|
||
{ field: 'slice', headerName: 'Slice', flex: 1,
|
||
valueFormatter: p => p.value ? JSON.stringify(p.value) : '' },
|
||
{ field: 'params', headerName: 'Params', flex: 1,
|
||
valueFormatter: p => p.value ? JSON.stringify(p.value) : '' },
|
||
{ field: 'note', headerName: 'Note', flex: 1 },
|
||
{
|
||
headerName: '',
|
||
width: 70,
|
||
cellRenderer: p => {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn btn-sm btn-danger';
|
||
btn.textContent = 'Undo';
|
||
btn.dataset.logid = p.data.id;
|
||
return btn;
|
||
},
|
||
sortable: false
|
||
}
|
||
];
|
||
|
||
if (state.grids.log) {
|
||
state.grids.log.setGridOption('rowData', logs);
|
||
state.grids.log.setGridOption('columnDefs', colDefs);
|
||
return;
|
||
}
|
||
|
||
state.grids.log = agGrid.createGrid(el, {
|
||
columnDefs: colDefs,
|
||
rowData: logs,
|
||
defaultColDef: { resizable: true, sortable: true },
|
||
headerHeight: 32,
|
||
rowHeight: 28
|
||
});
|
||
}
|
||
|
||
async function undoOperation(logid) {
|
||
if (!confirm(`Undo operation ${logid}? This will delete the associated forecast rows.`)) return;
|
||
try {
|
||
const result = await api('DELETE', `/log/${logid}`);
|
||
showStatus(`Undone — ${result.rows_deleted} rows removed`, 'success');
|
||
await loadLogData();
|
||
// refresh forecast grid if open
|
||
if (state.view === 'forecast') await loadForecastData();
|
||
} catch (err) {
|
||
showStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
INIT
|
||
============================================================ */
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
|
||
// restore pf_user from localStorage
|
||
const savedUser = localStorage.getItem('pf_user');
|
||
if (savedUser) document.getElementById('input-pf-user').value = savedUser;
|
||
document.getElementById('input-pf-user').addEventListener('change', e => {
|
||
localStorage.setItem('pf_user', e.target.value.trim());
|
||
});
|
||
|
||
// navigation
|
||
document.querySelectorAll('.nav-links li').forEach(li => {
|
||
li.addEventListener('click', () => switchView(li.dataset.view));
|
||
});
|
||
|
||
// sources view buttons
|
||
document.getElementById('btn-register').addEventListener('click', () => {
|
||
if (state.previewSchema && state.previewTname) {
|
||
registerTable(state.previewSchema, state.previewTname);
|
||
}
|
||
});
|
||
document.getElementById('btn-back-sources').addEventListener('click', backToSources);
|
||
document.getElementById('btn-save-cols').addEventListener('click', saveColMeta);
|
||
document.getElementById('btn-generate-sql').addEventListener('click', generateSQL);
|
||
|
||
// modal
|
||
document.getElementById('modal-close').addEventListener('click', () =>
|
||
document.getElementById('modal-overlay').classList.add('hidden')
|
||
);
|
||
document.getElementById('btn-modal-close').addEventListener('click', () =>
|
||
document.getElementById('modal-overlay').classList.add('hidden')
|
||
);
|
||
document.getElementById('btn-modal-register').addEventListener('click', () => {
|
||
if (state.previewSchema && state.previewTname) {
|
||
registerTable(state.previewSchema, state.previewTname);
|
||
}
|
||
});
|
||
|
||
// versions view buttons
|
||
document.getElementById('btn-new-version').addEventListener('click', showNewVersionForm);
|
||
document.getElementById('btn-create-version').addEventListener('click', createVersion);
|
||
document.getElementById('btn-cancel-version').addEventListener('click', () => {
|
||
document.getElementById('new-version-form').classList.add('hidden');
|
||
});
|
||
document.getElementById('vbtn-forecast').addEventListener('click', openForecast);
|
||
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);
|
||
document.getElementById('btn-load-submit').addEventListener('click', submitLoadData);
|
||
document.getElementById('btn-load-cancel').addEventListener('click', hideLoadModal);
|
||
document.getElementById('btn-load-close').addEventListener('click', hideLoadModal);
|
||
document.getElementById('load-date-from').addEventListener('change', updateDatePreview);
|
||
document.getElementById('load-date-to').addEventListener('change', updateDatePreview);
|
||
document.getElementById('load-offset-years').addEventListener('input', updateDatePreview);
|
||
document.getElementById('load-offset-months').addEventListener('input', updateDatePreview);
|
||
|
||
// forecast view buttons
|
||
document.getElementById('btn-forecast-refresh').addEventListener('click', loadForecastData);
|
||
document.getElementById('btn-expand-all').addEventListener('click', () => state.grids.pivot?.expandAll());
|
||
document.getElementById('btn-collapse-all').addEventListener('click', () => state.grids.pivot?.collapseAll());
|
||
document.getElementById('btn-clear-slice').addEventListener('click', clearSlice);
|
||
|
||
document.querySelectorAll('.op-tab').forEach(tab => {
|
||
tab.addEventListener('click', () => switchOpTab(tab.dataset.op));
|
||
});
|
||
|
||
document.getElementById('btn-submit-scale').addEventListener('click', submitScale);
|
||
document.getElementById('btn-submit-recode').addEventListener('click', submitRecode);
|
||
document.getElementById('btn-submit-clone').addEventListener('click', submitClone);
|
||
|
||
// undo button delegation on log grid
|
||
document.getElementById('log-grid').addEventListener('click', e => {
|
||
const btn = e.target.closest('.btn-danger[data-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
|
||
initSourcesView().catch(err => showStatus(err.message, 'error'));
|
||
});
|