- Sources page: left column with stacked DB tables + registered sources panels, right column as full-height column mapping workbench - Add compact table search, column search, table preview button, delete source button - Rename fc_table system columns to pf_ prefix (pf_id, pf_iter, pf_logid, pf_created_at) to avoid collisions with source table columns like 'id' - Remove 'filter' col_meta role — any non-ignore column usable in baseline filters - Replace structured filter row builder with free-form SQL WHERE clause textarea and clickable column chips for insertion; fully flexible AND/OR logic - Baseline segment cards now display raw WHERE clause text + offset Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1184 lines
47 KiB
JavaScript
1184 lines
47 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'
|
|
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', flex: 1 },
|
|
{ field: 'tname', headerName: 'Table', flex: 1 },
|
|
{ field: 'row_estimate', headerName: 'Rows', flex: 1,
|
|
valueFormatter: p => p.value != null ? Number(p.value).toLocaleString() : '—' }
|
|
],
|
|
rowData: tables,
|
|
rowSelection: 'single',
|
|
onRowClicked: onTableRowClicked,
|
|
onRowDoubleClicked: e => showTablePreview(e.data.schema, e.data.tname),
|
|
allowContextMenuWithControlKey: true,
|
|
getContextMenuItems: e => [
|
|
{ name: 'Preview', action: () => showTablePreview(e.node.data.schema, e.node.data.tname) },
|
|
{ name: 'Register', action: () => registerTable(e.node.data.schema, e.node.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-preview').classList.remove('hidden');
|
|
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);
|
|
document.getElementById('btn-delete-source').classList.remove('hidden');
|
|
try {
|
|
state.colMeta = await api('GET', `/sources/${source.id}/cols`);
|
|
renderColMetaGrid(state.colMeta);
|
|
document.getElementById('right-panel-title').textContent = `${source.schema}.${source.tname} — Columns`;
|
|
document.getElementById('btn-save-cols').classList.remove('hidden');
|
|
document.getElementById('btn-generate-sql').classList.remove('hidden');
|
|
} catch (err) {
|
|
showStatus(err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteSource() {
|
|
if (!state.source) return;
|
|
const { id, schema, tname } = state.source;
|
|
if (!confirm(`Delete source ${schema}.${tname}? This does not drop existing forecast tables.`)) return;
|
|
try {
|
|
await api('DELETE', `/sources/${id}`);
|
|
showStatus(`Source ${tname} deleted`, 'success');
|
|
setSource(null);
|
|
document.getElementById('btn-delete-source').classList.add('hidden');
|
|
document.getElementById('btn-save-cols').classList.add('hidden');
|
|
document.getElementById('btn-generate-sql').classList.add('hidden');
|
|
document.getElementById('right-panel-title').textContent = 'Select a source to map columns';
|
|
state.grids.colMeta?.setGridOption('rowData', []);
|
|
const sources = await api('GET', '/sources');
|
|
state.grids.sources?.setGridOption('rowData', sources);
|
|
} catch (err) {
|
|
showStatus(err.message, 'error');
|
|
}
|
|
}
|
|
|
|
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', 'ignore'] },
|
|
cellStyle: p => {
|
|
const colors = { dimension: '#eaf4fb', value: '#eafaf1', units: '#fef9e7', date: '#fdf2f8', 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`);
|
|
}
|
|
|
|
// populate column chips
|
|
const chips = document.getElementById('seg-col-chips');
|
|
chips.innerHTML = state.colMeta
|
|
.filter(c => c.role !== 'ignore')
|
|
.map(c => `<span class="col-chip" data-col="${c.cname}">${c.cname}</span>`)
|
|
.join('');
|
|
chips.querySelectorAll('.col-chip').forEach(chip => {
|
|
chip.addEventListener('click', () => insertAtCursor(document.getElementById('seg-where'), chip.dataset.col));
|
|
});
|
|
|
|
// reset form
|
|
document.getElementById('seg-description').value = '';
|
|
document.getElementById('seg-offset-type').value = '';
|
|
document.getElementById('seg-offset-value').value = '0';
|
|
document.getElementById('seg-where').value = '';
|
|
document.getElementById('seg-timeline').classList.add('hidden');
|
|
|
|
await loadBaselineSegments();
|
|
}
|
|
|
|
function insertAtCursor(textarea, text) {
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const val = textarea.value;
|
|
textarea.value = val.slice(0, start) + text + val.slice(end);
|
|
textarea.selectionStart = textarea.selectionEnd = start + text.length;
|
|
textarea.focus();
|
|
}
|
|
|
|
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 whereClause = params.where_clause && params.where_clause !== 'TRUE' ? params.where_clause : '';
|
|
const offset = params.date_offset && params.date_offset !== '0 days' ? `offset: ${params.date_offset}` : '';
|
|
const paramsText = [whereClause, offset].filter(Boolean).join(' · ');
|
|
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>
|
|
${paramsText ? `<div class="segment-card-params">${paramsText}</div>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
|
|
function updateTimelinePreview() {
|
|
const el = document.getElementById('seg-timeline');
|
|
const offsetType = document.getElementById('seg-offset-type').value;
|
|
const offsetValue = parseInt(document.getElementById('seg-offset-value').value) || 0;
|
|
|
|
if (!offsetType || !offsetValue) { el.classList.add('hidden'); return; }
|
|
|
|
el.innerHTML = `<div class="timeline-offset-indicator">+ ${offsetValue} ${offsetType}${offsetValue !== 1 ? 's' : ''} applied to date column</div>`;
|
|
el.classList.remove('hidden');
|
|
}
|
|
|
|
async function submitBaselineSegment() {
|
|
if (!state.version) return;
|
|
|
|
const offsetType = document.getElementById('seg-offset-type').value;
|
|
const offsetValue = parseInt(document.getElementById('seg-offset-value').value) || 0;
|
|
const date_offset = (offsetType && offsetValue)
|
|
? `${offsetValue} ${offsetType}${offsetValue !== 1 ? 's' : ''}`
|
|
: '0 days';
|
|
|
|
const body = {
|
|
where_clause: document.getElementById('seg-where').value.trim() || undefined,
|
|
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-where').value = '';
|
|
document.getElementById('seg-offset-type').value = '';
|
|
document.getElementById('seg-offset-value').value = '0';
|
|
document.getElementById('seg-timeline').classList.add('hidden');
|
|
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 pf_iter for grouping context
|
|
defs.push({ field: 'pf_iter', headerName: 'Iter', enableRowGroup: true, enablePivot: true, width: 90 });
|
|
defs.push({ field: 'pf_user', headerName: 'User', width: 90, hide: true });
|
|
defs.push({ field: 'pf_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-preview').addEventListener('click', () => {
|
|
if (state.previewSchema && state.previewTname) {
|
|
showTablePreview(state.previewSchema, state.previewTname);
|
|
}
|
|
});
|
|
document.getElementById('btn-register').addEventListener('click', () => {
|
|
if (state.previewSchema && state.previewTname) {
|
|
registerTable(state.previewSchema, state.previewTname);
|
|
}
|
|
});
|
|
document.getElementById('btn-delete-source').addEventListener('click', deleteSource);
|
|
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-load-segment').addEventListener('click', submitBaselineSegment);
|
|
document.getElementById('btn-clear-baseline').addEventListener('click', clearBaseline);
|
|
document.getElementById('seg-offset-type').addEventListener('change', updateTimelinePreview);
|
|
document.getElementById('seg-offset-value').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();
|
|
});
|
|
|
|
// tables search
|
|
document.getElementById('tables-search').addEventListener('input', e => {
|
|
state.grids.tables?.setGridOption('quickFilterText', e.target.value);
|
|
});
|
|
|
|
// columns search
|
|
document.getElementById('cols-search').addEventListener('input', e => {
|
|
state.grids.colMeta?.setGridOption('quickFilterText', e.target.value);
|
|
});
|
|
|
|
// init sources view
|
|
initSourcesView().catch(err => showStatus(err.message, 'error'));
|
|
});
|