/* ============================================================ 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 = `

Columns

`; data.columns.forEach(c => { colHtml += ``; }); colHtml += '
ColumnTypeNullable
${c.column_name}${c.data_type}${c.is_nullable}
'; // sample rows let rowHtml = ''; if (data.rows.length > 0) { const cols = Object.keys(data.rows[0]); rowHtml = `

Sample rows

`; cols.forEach(c => rowHtml += ``); rowHtml += ''; data.rows.forEach(row => { rowHtml += ''; cols.forEach(c => rowHtml += ``); rowHtml += ''; }); rowHtml += '
${c}
${row[c] ?? ''}
'; } 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 => `${fmt.format(m)}`).join(''); } return `${months.length} months — ${fmt.format(months[0])} → ${fmt.format(months[months.length - 1])}`; } 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 = '
No segments loaded yet.
'; 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 `
${s.note || '(no description)'} ${s.pf_user} — ${stamp}
${filters}${offset}
`; }).join(''); } function addFilterRow() { const container = document.getElementById('seg-filter-rows'); const idx = container.children.length; const colOptions = state.baselineFilterCols .map(c => ``) .join(''); const div = document.createElement('div'); div.className = 'filter-row'; div.dataset.index = idx; div.innerHTML = `
`; 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 = ` and `; container.querySelectorAll('input').forEach(i => i.addEventListener('input', updateTimelinePreview)); return; } if (op === 'IN' || op === 'NOT IN') { container.innerHTML = ``; return; } // = or != container.innerHTML = ``; } 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 = `
Source
${fmt.format(from)} ${months} month${months !== 1 ? 's' : ''} ${fmt.format(to)}
`; if (offsetYears || offsetMonths) { const parts = []; if (offsetYears) parts.push(`${offsetYears} yr`); if (offsetMonths) parts.push(`${offsetMonths} mo`); html += `
+ ${parts.join(' ')} →
Projected
${fmt.format(projFrom)} ${months} month${months !== 1 ? 's' : ''} ${fmt.format(projTo)}
`; } 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 = 'Click a row to select a slice'; 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]) => `${k} = ${v}`) .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 ${options}`; } else { input = ``; } return `
`; }).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')); });