pf_app/public/app.js
Paul Trowbridge 1df37a5ff1 Refactor sources UI, rename pf_ system cols, replace filter builder with raw SQL
- 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>
2026-04-03 00:47:57 -04:00

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'));
});