Initial commit — pivot forecast application

Node.js/Express + PostgreSQL forecasting app with AG Grid Enterprise pivot UI.
Supports baseline, scale, recode, clone operations on configurable source tables.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-01 07:59:05 -04:00
commit 08dc415bfd
17 changed files with 4127 additions and 0 deletions

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
DB_HOST=localhost
DB_PORT=5432
DB_NAME=your_database
DB_USER=your_user
DB_PASSWORD=your_password
PORT=3010

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
.env

65
install.sh Executable file
View File

@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -e
echo ""
echo "========================================"
echo " Pivot Forecast — Install"
echo "========================================"
echo ""
# ── DB connection ─────────────────────────────────────────────
read -p "DB host [192.168.1.110]: " DB_HOST
DB_HOST=${DB_HOST:-192.168.1.110}
read -p "DB port [5432]: " DB_PORT
DB_PORT=${DB_PORT:-5432}
read -p "DB name [ubm]: " DB_NAME
DB_NAME=${DB_NAME:-ubm}
read -p "DB user [ptrowbridge]: " DB_USER
DB_USER=${DB_USER:-ptrowbridge}
read -s -p "DB password: " DB_PASSWORD
echo ""
read -p "App port [3030]: " PORT
PORT=${PORT:-3030}
# ── Write .env ────────────────────────────────────────────────
cat > .env <<EOF
DB_HOST=${DB_HOST}
DB_PORT=${DB_PORT}
DB_NAME=${DB_NAME}
DB_USER=${DB_USER}
DB_PASSWORD=${DB_PASSWORD}
PORT=${PORT}
EOF
echo "✓ .env written"
# ── npm install ───────────────────────────────────────────────
echo ""
echo "Installing node dependencies..."
npm install --silent
echo "✓ dependencies installed"
# ── schema install ────────────────────────────────────────────
echo ""
echo "Installing pf schema into ${DB_NAME}..."
PGPASSWORD=${DB_PASSWORD} psql \
-h "${DB_HOST}" \
-p "${DB_PORT}" \
-U "${DB_USER}" \
-d "${DB_NAME}" \
-f setup_sql/01_schema.sql
echo "✓ schema installed"
# ── done ─────────────────────────────────────────────────────
echo ""
echo "========================================"
echo " Install complete"
echo " Start with: npm run dev"
echo " Open: http://$(hostname -I | awk '{print $1}'):${PORT}"
echo "========================================"
echo ""

254
lib/sql_generator.js Normal file
View File

@ -0,0 +1,254 @@
// Generates operation SQL for a source table, baking in column names from col_meta.
// Runtime values are left as {{token}} substitution points.
//
// Tokens baked in at generation time: column names, source schema.table
// Tokens substituted at request time: {{fc_table}}, {{where_clause}}, {{exclude_clause}},
// {{version_id}}, {{logid}}, {{pf_user}}, {{note}},
// {{params}}, {{slice}}, {{date_from}}, {{date_to}},
// {{value_incr}}, {{units_incr}}, {{set_clause}}, {{scale_factor}}
// wrap a column name in double quotes for safe use in SQL
function q(name) { return `"${name}"`; }
function generateSQL(source, colMeta) {
const dims = colMeta
.filter(c => c.role === 'dimension')
.sort((a, b) => (a.opos || 0) - (b.opos || 0))
.map(c => c.cname);
const valueCol = colMeta.find(c => c.role === 'value')?.cname;
const unitsCol = colMeta.find(c => c.role === 'units')?.cname;
const dateCol = colMeta.find(c => c.role === 'date')?.cname;
if (!valueCol) throw new Error('No value column defined in col_meta');
if (!unitsCol) throw new Error('No units column defined in col_meta');
if (!dateCol) throw new Error('No date column defined in col_meta');
if (dims.length === 0) throw new Error('No dimension columns defined in col_meta');
const srcTable = `${source.schema}.${source.tname}`;
// exclude 'id' — forecast table has its own bigserial id primary key
const dataCols = [...dims, dateCol, valueCol, unitsCol].filter(c => c !== 'id');
const effectiveValue = dataCols.includes(valueCol) ? valueCol : null;
const effectiveUnits = dataCols.includes(unitsCol) ? unitsCol : null;
const insertCols = [...dataCols.map(q), 'iter', 'logid', 'pf_user', 'created_at'].join(', ');
const selectData = dataCols.map(q).join(', ');
const dimsJoined = dims.map(q).join(', ');
return {
get_data: buildGetData(),
baseline: buildBaseline(),
reference: buildReference(),
scale: buildScale(),
recode: buildRecode(),
clone: buildClone(),
undo: buildUndo()
};
function buildGetData() {
return `SELECT * FROM {{fc_table}}`;
}
function buildBaseline() {
return `
WITH
ilog AS (
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
VALUES ({{version_id}}, '{{pf_user}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}')
RETURNING id
)
,del AS (
DELETE FROM {{fc_table}} WHERE iter = 'baseline'
)
,ins AS (
INSERT INTO {{fc_table}} (${insertCols})
SELECT ${selectData}, 'baseline', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM ${srcTable}
WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
RETURNING *
)
SELECT count(*) AS rows_affected FROM ins`.trim();
}
function buildReference() {
return `
WITH
ilog AS (
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
VALUES ({{version_id}}, '{{pf_user}}', 'reference', NULL, '{{params}}'::jsonb, '{{note}}')
RETURNING id
)
,ins AS (
INSERT INTO {{fc_table}} (${insertCols})
SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM ${srcTable}
WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
RETURNING *
)
SELECT count(*) AS rows_affected FROM ins`.trim();
}
function buildScale() {
const vSel = effectiveValue
? `round((${q(effectiveValue)} / NULLIF(total_value, 0)) * {{value_incr}}, 2)`
: `0`;
const uSel = effectiveUnits
? `round((${q(effectiveUnits)} / NULLIF(total_units, 0)) * {{units_incr}}, 5)`
: `0`;
const baseSelectParts = [
...dimsJoined ? [dimsJoined] : [],
q(dateCol),
effectiveValue ? q(effectiveValue) : null,
effectiveUnits ? q(effectiveUnits) : null,
effectiveValue ? `sum(${q(effectiveValue)}) OVER () AS total_value` : null,
effectiveUnits ? `sum(${q(effectiveUnits)}) OVER () AS total_units` : null
].filter(Boolean).join(',\n ');
return `
WITH
ilog AS (
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
VALUES ({{version_id}}, '{{pf_user}}', 'scale', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}')
RETURNING id
)
,base AS (
SELECT
${baseSelectParts}
FROM {{fc_table}}
WHERE {{where_clause}}
{{exclude_clause}}
)
,ins AS (
INSERT INTO {{fc_table}} (${insertCols})
SELECT
${[dimsJoined, q(dateCol), ...(effectiveValue ? [vSel] : []), ...(effectiveUnits ? [uSel] : [])].join(',\n ')},
'scale', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM base
RETURNING *
)
SELECT count(*) AS rows_affected FROM ins`.trim();
}
function buildRecode() {
return `
WITH
ilog AS (
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
VALUES ({{version_id}}, '{{pf_user}}', 'recode', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}')
RETURNING id
)
,src AS (
SELECT ${selectData}
FROM {{fc_table}}
WHERE {{where_clause}}
{{exclude_clause}}
)
,neg AS (
INSERT INTO {{fc_table}} (${insertCols})
SELECT ${dimsJoined}, ${q(dateCol)}, ${effectiveValue ? `-${q(effectiveValue)}` : '0'}, ${effectiveUnits ? `-${q(effectiveUnits)}` : '0'},
'recode', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM src
RETURNING id
)
,ins AS (
INSERT INTO {{fc_table}} (${insertCols})
SELECT {{set_clause}}, ${q(dateCol)}, ${effectiveValue ? q(effectiveValue) : '0'}, ${effectiveUnits ? q(effectiveUnits) : '0'},
'recode', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM src
RETURNING id
)
SELECT (SELECT count(*) FROM neg) + (SELECT count(*) FROM ins) AS rows_affected`.trim();
}
function buildClone() {
return `
WITH
ilog AS (
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
VALUES ({{version_id}}, '{{pf_user}}', 'clone', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}')
RETURNING id
)
,ins AS (
INSERT INTO {{fc_table}} (${insertCols})
SELECT
{{set_clause}},
${q(dateCol)},
${effectiveValue ? `round(${q(effectiveValue)} * {{scale_factor}}, 2)` : '0'},
${effectiveUnits ? `round(${q(effectiveUnits)} * {{scale_factor}}, 5)` : '0'},
'clone', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM {{fc_table}}
WHERE {{where_clause}}
{{exclude_clause}}
RETURNING *
)
SELECT count(*) AS rows_affected FROM ins`.trim();
}
function buildUndo() {
// undo is executed as two separate queries in the route handler
// (delete from fc_table first, then delete from pf.log) to avoid
// FK constraint ordering issues within a single CTE statement.
// This entry is a placeholder — the undo route uses it as a template reference.
return `
-- step 1 (run first):
DELETE FROM {{fc_table}} WHERE logid = {{logid}};
-- step 2 (run after step 1):
DELETE FROM pf.log WHERE id = {{logid}};`.trim();
}
}
// substitute {{token}} placeholders in a SQL string
function applyTokens(sql, tokens) {
let result = sql;
for (const [key, value] of Object.entries(tokens)) {
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value ?? '');
}
return result;
}
// build a SQL WHERE clause string from a slice object
// validates all keys against the allowed dimension column list
function buildWhere(slice, dimCols) {
if (!slice || Object.keys(slice).length === 0) return 'TRUE';
const allowed = new Set(dimCols);
const parts = [];
for (const [col, val] of Object.entries(slice)) {
if (!allowed.has(col)) {
throw new Error(`"${col}" is not a dimension column`);
}
if (Array.isArray(val)) {
const escaped = val.map(v => esc(v));
parts.push(`"${col}" IN ('${escaped.join("', '")}')`);
} else {
parts.push(`"${col}" = '${esc(val)}'`);
}
}
return parts.join('\nAND ');
}
// build AND iter NOT IN (...) from a version's exclude_iters array
function buildExcludeClause(excludeIters) {
if (!excludeIters || excludeIters.length === 0) return '';
const list = excludeIters.map(i => `'${esc(i)}'`).join(', ');
return `AND iter NOT IN (${list})`;
}
// build the dimension columns portion of a SELECT for recode/clone
// replaces named dimensions with literal values, passes others through unchanged
function buildSetClause(dimCols, setObj) {
return dimCols.map(col => {
if (setObj && setObj[col] !== undefined) {
return `'${esc(setObj[col])}' AS "${col}"`;
}
return `"${col}"`;
}).join(', ');
}
// escape a value for safe SQL string substitution
function esc(val) {
if (val === null || val === undefined) return '';
return String(val).replace(/'/g, "''");
}
module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc };

39
lib/utils.js Normal file
View File

@ -0,0 +1,39 @@
// derive forecast table name from source tname + version id
function fcTable(tname, versionId) {
return `pf.fc_${tname}_${versionId}`;
}
// map information_schema data_type to a clean postgres column type
function mapType(dataType, numericPrecision, numericScale) {
switch (dataType) {
case 'character varying':
case 'character':
case 'text':
return 'text';
case 'smallint':
case 'integer':
return 'integer';
case 'bigint':
return 'bigint';
case 'numeric':
case 'decimal':
return (numericPrecision)
? `numeric(${numericPrecision}, ${numericScale || 0})`
: 'numeric';
case 'real':
case 'double precision':
return 'numeric';
case 'date':
return 'date';
case 'timestamp without time zone':
return 'timestamp';
case 'timestamp with time zone':
return 'timestamptz';
case 'boolean':
return 'boolean';
default:
return 'text';
}
}
module.exports = { fcTable, mapType };

1392
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "pf_app",
"version": "1.0.0",
"description": "Pivot Forecast Application",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.0.0",
"express": "^4.18.2",
"pg": "^8.11.3"
},
"devDependencies": {
"nodemon": "^3.0.0"
}
}

904
public/app.js Normal file
View File

@ -0,0 +1,904 @@
/* ============================================================
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') && !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();
}
function setSource(source) {
state.source = source;
const el = document.getElementById('ctx-source');
if (source) {
el.classList.remove('hidden');
document.getElementById('ctx-source-name').textContent = `${source.schema}.${source.tname}`;
} else {
el.classList.add('hidden');
}
}
function setVersion(version) {
state.version = version;
const el = document.getElementById('ctx-version');
if (version) {
el.classList.remove('hidden');
document.getElementById('ctx-version-name').textContent = version.name;
const badge = document.getElementById('ctx-version-status');
badge.textContent = version.status;
badge.className = `status-badge ${version.status}`;
} else {
el.classList.add('hidden');
}
}
function getPfUser() {
return document.getElementById('input-pf-user').value.trim() || 'unknown';
}
/* ============================================================
SOURCES VIEW table browser
============================================================ */
async function initSourcesView() {
const tables = await api('GET', '/tables');
renderTablesGrid(tables);
const sources = await api('GET', '/sources');
renderSourcesGrid(sources);
}
function renderTablesGrid(tables) {
const el = document.getElementById('tables-grid');
if (state.grids.tables) { state.grids.tables.setGridOption('rowData', tables); return; }
state.grids.tables = agGrid.createGrid(el, {
columnDefs: [
{ field: 'schema', headerName: 'Schema', width: 90 },
{ field: 'tname', headerName: 'Table', flex: 1 },
{ field: 'row_estimate', headerName: 'Rows', width: 80,
valueFormatter: p => p.value != null ? Number(p.value).toLocaleString() : '—' }
],
rowData: tables,
rowSelection: 'single',
onRowClicked: onTableRowClicked,
onRowDoubleClicked: e => showTablePreview(e.data.schema, e.data.tname),
defaultColDef: { resizable: true, sortable: true },
headerHeight: 32, rowHeight: 28
});
}
function onTableRowClicked(e) {
const { schema, tname } = e.data;
state.previewSchema = schema;
state.previewTname = tname;
document.getElementById('btn-register').classList.remove('hidden');
}
async function showTablePreview(schema, tname) {
try {
const data = await api('GET', `/tables/${schema}/${tname}/preview`);
const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body');
title.textContent = `${schema}.${tname}`;
// columns table
let colHtml = `<div class="preview-section"><h4>Columns</h4><table class="preview-table">
<tr><th>Column</th><th>Type</th><th>Nullable</th></tr>`;
data.columns.forEach(c => {
colHtml += `<tr><td>${c.column_name}</td><td>${c.data_type}</td><td>${c.is_nullable}</td></tr>`;
});
colHtml += '</table></div>';
// sample rows
let rowHtml = '';
if (data.rows.length > 0) {
const cols = Object.keys(data.rows[0]);
rowHtml = `<div class="preview-section"><h4>Sample rows</h4><table class="preview-table"><tr>`;
cols.forEach(c => rowHtml += `<th>${c}</th>`);
rowHtml += '</tr>';
data.rows.forEach(row => {
rowHtml += '<tr>';
cols.forEach(c => rowHtml += `<td>${row[c] ?? ''}</td>`);
rowHtml += '</tr>';
});
rowHtml += '</table></div>';
}
body.innerHTML = colHtml + rowHtml;
state.previewSchema = schema;
state.previewTname = tname;
document.getElementById('modal-overlay').classList.remove('hidden');
} catch (err) {
showStatus(err.message, 'error');
}
}
async function registerTable(schema, tname) {
try {
await api('POST', '/sources', { schema, tname, created_by: getPfUser() });
showStatus(`Registered ${schema}.${tname}`, 'success');
const sources = await api('GET', '/sources');
renderSourcesGrid(sources);
document.getElementById('modal-overlay').classList.add('hidden');
} catch (err) {
showStatus(err.message, 'error');
}
}
/* ============================================================
SOURCES VIEW registered sources + col_meta
============================================================ */
function renderSourcesGrid(sources) {
const el = document.getElementById('sources-list-grid');
if (state.grids.sources) { state.grids.sources.setGridOption('rowData', sources); return; }
state.grids.sources = agGrid.createGrid(el, {
columnDefs: [
{ field: 'schema', headerName: 'Schema', width: 90 },
{ field: 'tname', headerName: 'Table', flex: 1 },
{ field: 'label', headerName: 'Label', flex: 1 },
{ field: 'status', headerName: 'Status', width: 70 }
],
rowData: sources,
rowSelection: 'single',
onRowClicked: e => selectSource(e.data),
defaultColDef: { resizable: true, sortable: true },
headerHeight: 32, rowHeight: 28
});
}
async function selectSource(source) {
setSource(source);
try {
state.colMeta = await api('GET', `/sources/${source.id}/cols`);
renderColMetaGrid(state.colMeta);
document.getElementById('sources-list-grid').classList.add('hidden');
document.getElementById('col-meta-grid').classList.remove('hidden');
document.getElementById('right-panel-title').textContent = `${source.schema}.${source.tname} — Columns`;
document.getElementById('btn-back-sources').classList.remove('hidden');
document.getElementById('btn-save-cols').classList.remove('hidden');
document.getElementById('btn-generate-sql').classList.remove('hidden');
} catch (err) {
showStatus(err.message, 'error');
}
}
function backToSources() {
document.getElementById('sources-list-grid').classList.remove('hidden');
document.getElementById('col-meta-grid').classList.add('hidden');
document.getElementById('right-panel-title').textContent = 'Registered Sources';
document.getElementById('btn-back-sources').classList.add('hidden');
document.getElementById('btn-save-cols').classList.add('hidden');
document.getElementById('btn-generate-sql').classList.add('hidden');
}
function renderColMetaGrid(colMeta) {
const el = document.getElementById('col-meta-grid');
if (state.grids.colMeta) { state.grids.colMeta.setGridOption('rowData', colMeta); return; }
state.grids.colMeta = agGrid.createGrid(el, {
columnDefs: [
{ field: 'opos', headerName: '#', width: 45, sortable: true },
{ field: 'cname', headerName: 'Column', flex: 1 },
{
field: 'role', headerName: 'Role', width: 110, editable: true,
cellEditor: 'agSelectCellEditor',
cellEditorParams: { values: ['dimension', 'value', 'units', 'date', '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-data-form').classList.remove('hidden');
document.getElementById('load-date-from').focus();
}
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
};
try {
showStatus(`Loading ${state.loadDataOp}...`, 'info');
const result = await api('POST', `/versions/${state.selectedVersionId}/${state.loadDataOp}`, body);
document.getElementById('load-data-form').classList.add('hidden');
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');
}
/* ============================================================
FORECAST VIEW data loading
============================================================ */
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 numericCols = state.colMeta
.filter(c => c.role === 'value' || c.role === 'units')
.map(c => c.cname);
const data = rawData.map(row => {
const r = { ...row };
numericCols.forEach(col => { if (r[col] != null) r[col] = parseFloat(r[col]); });
return r;
});
initPivotGrid(data);
showStatus(`Loaded ${data.length.toLocaleString()} rows`, 'success');
} catch (err) {
showStatus(err.message, 'error');
}
}
/* ============================================================
FORECAST VIEW pivot grid
============================================================ */
function buildPivotColDefs() {
const defs = [];
state.colMeta.forEach((c) => {
if (c.role === 'ignore') return;
const needsGetter = /\W/.test(c.cname);
const def = {
field: c.cname,
headerName: c.label || c.cname,
resizable: true,
sortable: true,
...(needsGetter ? { valueGetter: p => p.data ? p.data[c.cname] : undefined } : {})
};
if (c.role === 'dimension' || c.role === 'date') {
def.enableRowGroup = true;
def.enablePivot = true;
}
if (c.role === 'value' || c.role === 'units') {
def.enableValue = true;
def.aggFunc = 'sum';
def.type = 'numericColumn';
def.valueFormatter = p => p.value != null
? Number(p.value).toLocaleString(undefined, { maximumFractionDigits: 2 })
: '';
}
defs.push(def);
if (c.role === 'date') {
defs.push({
colId: c.cname + '__month',
headerName: (c.label || c.cname) + ' (Month)',
enableRowGroup: true,
enablePivot: true,
valueGetter: p => {
const v = p.data ? p.data[c.cname] : undefined;
if (!v) return undefined;
const d = new Date(v);
return isNaN(d) ? undefined : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
});
}
});
// always include iter for grouping context
defs.push({ field: 'iter', headerName: 'Iter', enableRowGroup: true, enablePivot: true, width: 90 });
defs.push({ field: 'pf_user', headerName: 'User', width: 90, hide: true });
defs.push({ field: 'created_at', headerName: 'Created', width: 130, hide: true,
valueFormatter: p => p.value ? new Date(p.value).toLocaleDateString() : '' });
return defs;
}
function initPivotGrid(data) {
const el = document.getElementById('pivot-grid');
if (state.grids.pivot) {
state.grids.pivot.setGridOption('rowData', data);
return;
}
state.grids.pivot = agGrid.createGrid(el, {
columnDefs: buildPivotColDefs(),
rowData: data,
rowSelection: 'single',
groupDisplayType: 'singleColumn',
rowGroupPanelShow: 'always',
groupDefaultExpanded: 1,
suppressAggFuncInHeader: true,
animateRows: true,
sideBar: {
toolPanels: [{
id: 'columns',
labelDefault: 'Columns',
labelKey: 'columns',
iconKey: 'columns',
toolPanel: 'agColumnsToolPanel',
toolPanelParams: {}
}],
defaultToolPanel: 'columns'
},
defaultColDef: { resizable: true, sortable: true },
autoGroupColumnDef: {
headerName: 'Group',
minWidth: 200,
cellRendererParams: { suppressCount: false }
},
headerHeight: 32,
rowHeight: 28,
onRowClicked: onPivotRowClicked
});
}
/* ============================================================
FORECAST VIEW slice selection
============================================================ */
function onPivotRowClicked(event) {
const node = event.node;
state.slice = extractSliceFromNode(node);
renderSliceDisplay();
// populate recode and clone fields whenever slice changes
renderDimFields('recode');
renderDimFields('clone');
}
function extractSliceFromNode(node) {
const slice = {};
let current = node;
while (current) {
if (current.field && current.key != null && current.key !== '') {
slice[current.field] = current.key;
}
current = current.parent;
}
return slice;
}
function renderSliceDisplay() {
const display = document.getElementById('slice-display');
const hasSlice = Object.keys(state.slice).length > 0;
if (!hasSlice) {
display.innerHTML = '<span class="op-hint">Click a row to select a slice</span>';
document.getElementById('btn-clear-slice').classList.add('hidden');
document.getElementById('op-forms-area').classList.add('hidden');
return;
}
display.innerHTML = Object.entries(state.slice)
.map(([k, v]) => `<span class="slice-tag">${k} = ${v}</span>`)
.join('');
document.getElementById('btn-clear-slice').classList.remove('hidden');
document.getElementById('op-forms-area').classList.remove('hidden');
}
function clearSlice() {
state.slice = {};
renderSliceDisplay();
}
/* ============================================================
FORECAST VIEW operation tabs
============================================================ */
function switchOpTab(opName) {
document.querySelectorAll('.op-tab').forEach(t =>
t.classList.toggle('active', t.dataset.op === opName)
);
document.querySelectorAll('.op-form').forEach(f => f.classList.add('hidden'));
document.getElementById(`op-${opName}`).classList.remove('hidden');
}
// render input fields for each dimension column (recode / clone)
// is_key columns get a populated <select>, others get a free-text input
async function renderDimFields(op) {
const container = document.getElementById(`${op}-fields`);
const dims = state.colMeta.filter(c => c.role === 'dimension');
// fetch values for all key columns in parallel
const keyDims = dims.filter(c => c.is_key);
const valueMap = {};
await Promise.all(keyDims.map(async c => {
try {
const vals = await api('GET', `/sources/${state.source.id}/values/${encodeURIComponent(c.cname)}`);
valueMap[c.cname] = vals;
} catch (_) {
valueMap[c.cname] = [];
}
}));
container.innerHTML = dims.map(c => {
const current = state.slice[c.cname] ? `current: ${state.slice[c.cname]}` : '';
const hint = current ? `<span class="field-hint">${current}</span>` : '';
let input;
if (c.is_key && valueMap[c.cname]?.length) {
const options = valueMap[c.cname]
.map(v => `<option value="${v}">${v}</option>`)
.join('');
input = `<select data-col="${c.cname}"><option value="">— keep current —</option>${options}</select>`;
} else {
input = `<input type="text" data-col="${c.cname}" placeholder="new value (leave blank to keep)" />`;
}
return `
<div class="${op}-field">
<label>${c.label || c.cname}${hint}${input}</label>
</div>`;
}).join('');
}
/* ============================================================
FORECAST VIEW submit operations
============================================================ */
async function submitScale() {
const value_incr = parseFloat(document.getElementById('scale-value-incr').value) || null;
const units_incr = parseFloat(document.getElementById('scale-units-incr').value) || null;
const pct = document.getElementById('scale-pct').checked;
const note = document.getElementById('scale-note').value.trim();
if (!value_incr && !units_incr) {
showStatus('Enter a value or units increment', 'error'); return;
}
if (Object.keys(state.slice).length === 0) {
showStatus('Select a slice first', 'error'); return;
}
try {
const result = await api('POST', `/versions/${state.version.id}/scale`, {
pf_user: getPfUser(), note: note || undefined,
slice: state.slice, value_incr, units_incr, pct
});
showStatus(`Scale applied — ${result.rows_affected} rows inserted`, 'success');
document.getElementById('scale-value-incr').value = '';
document.getElementById('scale-units-incr').value = '';
document.getElementById('scale-note').value = '';
await loadForecastData();
} 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 = ''; });
await loadForecastData();
} 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';
await loadForecastData();
} 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: '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', () => showLoadForm('baseline'));
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', () => {
document.getElementById('load-data-form').classList.add('hidden');
});
// 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));
});
// init sources view
initSourcesView().catch(err => showStatus(err.message, 'error'));
});

191
public/index.html Normal file
View File

@ -0,0 +1,191 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pivot Forecast</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.0/styles/ag-grid.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.0/styles/ag-theme-alpine.css">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="app">
<!-- Sidebar -->
<nav id="sidebar">
<div class="sidebar-brand">Pivot Forecast</div>
<ul class="nav-links">
<li data-view="sources" class="active">Sources</li>
<li data-view="versions">Versions</li>
<li data-view="forecast">Forecast</li>
<li data-view="log">Log</li>
</ul>
<div class="sidebar-context">
<div id="ctx-source" class="ctx-item hidden">
<span class="ctx-label">Source</span>
<span id="ctx-source-name"></span>
</div>
<div id="ctx-version" class="ctx-item hidden">
<span class="ctx-label">Version</span>
<span id="ctx-version-name"></span>
<span id="ctx-version-status" class="status-badge"></span>
</div>
</div>
<div class="sidebar-user">
<label>User</label>
<input type="text" id="input-pf-user" placeholder="your name" />
</div>
</nav>
<!-- Main content -->
<main id="content">
<div id="status-bar" class="hidden"></div>
<!-- ===== SOURCES VIEW ===== -->
<div id="view-sources" class="view active">
<div class="two-col-layout">
<div class="panel">
<div class="panel-header">
<span>Database Tables</span>
<div class="header-actions">
<button id="btn-register" class="btn btn-primary hidden">Register Table</button>
</div>
</div>
<div id="tables-grid" class="ag-theme-alpine grid-fill"></div>
</div>
<div class="panel">
<div class="panel-header">
<span id="right-panel-title">Registered Sources</span>
<div class="header-actions">
<button id="btn-back-sources" class="btn hidden">← Sources</button>
<button id="btn-save-cols" class="btn hidden">Save Columns</button>
<button id="btn-generate-sql" class="btn btn-primary hidden">Generate SQL</button>
</div>
</div>
<div id="sources-list-grid" class="ag-theme-alpine grid-fill"></div>
<div id="col-meta-grid" class="ag-theme-alpine grid-fill hidden"></div>
</div>
</div>
</div>
<!-- ===== VERSIONS VIEW ===== -->
<div id="view-versions" class="view hidden">
<div class="view-toolbar">
<span id="versions-source-label"></span>
<button id="btn-new-version" class="btn btn-primary">New Version</button>
</div>
<div id="new-version-form" class="inline-form hidden">
<h3>Create Version</h3>
<div class="form-row">
<label>Name<input type="text" id="ver-name" placeholder="e.g. FY2025 v1" /></label>
<label>Description<input type="text" id="ver-desc" placeholder="optional" /></label>
</div>
<div class="form-actions">
<button id="btn-create-version" class="btn btn-primary">Create</button>
<button id="btn-cancel-version" class="btn">Cancel</button>
</div>
</div>
<div id="versions-grid" class="ag-theme-alpine"></div>
<div id="version-actions" class="version-actions hidden">
<span id="version-actions-label"></span>
<button class="btn btn-primary" id="vbtn-forecast">Open Forecast</button>
<button class="btn" id="vbtn-baseline">Load Baseline</button>
<button class="btn" id="vbtn-reference">Load Reference</button>
<button class="btn" id="vbtn-toggle">Close Version</button>
<button class="btn btn-danger" id="vbtn-delete">Delete</button>
</div>
<div id="load-data-form" class="inline-form hidden">
<h3 id="load-data-title">Load Baseline</h3>
<div class="form-row">
<label>Date From<input type="date" id="load-date-from" /></label>
<label>Date To<input type="date" id="load-date-to" /></label>
<label>Note<input type="text" id="load-note" placeholder="optional" /></label>
</div>
<div class="form-actions">
<button id="btn-load-submit" class="btn btn-primary">Load</button>
<button id="btn-load-cancel" class="btn">Cancel</button>
</div>
</div>
</div>
<!-- ===== FORECAST VIEW ===== -->
<div id="view-forecast" class="view hidden">
<div class="forecast-toolbar">
<span id="forecast-label">No version selected</span>
<button id="btn-forecast-refresh" class="btn">Refresh</button>
<button id="btn-expand-all" class="btn">Expand All</button>
<button id="btn-collapse-all" class="btn">Collapse All</button>
</div>
<div class="forecast-layout">
<div id="pivot-panel">
<div id="pivot-grid" class="ag-theme-alpine"></div>
</div>
<div id="operation-panel">
<div class="op-section">
<div class="op-title">Slice</div>
<div id="slice-display">
<span class="op-hint">Click a row to select a slice</span>
</div>
<button id="btn-clear-slice" class="btn btn-sm hidden">Clear</button>
</div>
<div id="op-forms-area" class="hidden">
<div class="op-tabs">
<button class="op-tab active" data-op="scale">Scale</button>
<button class="op-tab" data-op="recode">Recode</button>
<button class="op-tab" data-op="clone">Clone</button>
</div>
<!-- Scale -->
<div id="op-scale" class="op-form">
<label>Value Δ<input type="number" id="scale-value-incr" step="any" placeholder="0" /></label>
<label>Units Δ<input type="number" id="scale-units-incr" step="any" placeholder="0" /></label>
<label class="label-inline"><input type="checkbox" id="scale-pct" /> Treat as %</label>
<label>Note<input type="text" id="scale-note" placeholder="optional" /></label>
<button id="btn-submit-scale" class="btn btn-primary">Apply Scale</button>
</div>
<!-- Recode -->
<div id="op-recode" class="op-form hidden">
<div class="op-hint">Enter new values for dimensions to replace:</div>
<div id="recode-fields"></div>
<label>Note<input type="text" id="recode-note" placeholder="optional" /></label>
<button id="btn-submit-recode" class="btn btn-primary">Apply Recode</button>
</div>
<!-- Clone -->
<div id="op-clone" class="op-form hidden">
<div class="op-hint">Override dimension values on cloned rows:</div>
<div id="clone-fields"></div>
<label>Scale Factor<input type="number" id="clone-scale" step="any" value="1" /></label>
<label>Note<input type="text" id="clone-note" placeholder="optional" /></label>
<button id="btn-submit-clone" class="btn btn-primary">Apply Clone</button>
</div>
</div>
</div>
</div>
</div>
<!-- ===== LOG VIEW ===== -->
<div id="view-log" class="view hidden">
<div id="log-grid" class="ag-theme-alpine grid-fill"></div>
</div>
</main>
</div>
<!-- Table preview modal -->
<div id="modal-overlay" class="modal-overlay hidden">
<div class="modal">
<div class="modal-header">
<span id="modal-title">Preview</span>
<button id="modal-close" class="btn-icon">×</button>
</div>
<div id="modal-body"></div>
<div class="modal-footer">
<button id="btn-modal-register" class="btn btn-primary">Register This Table</button>
<button id="btn-modal-close" class="btn">Close</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/ag-grid-enterprise@31.0.0/dist/ag-grid-enterprise.min.js"></script>
<script src="app.js"></script>
</body>
</html>

320
public/styles.css Normal file
View File

@ -0,0 +1,320 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
height: 100vh;
overflow: hidden;
background: #f0f2f5;
color: #333;
}
/* ============================================================
LAYOUT
============================================================ */
#app { display: flex; height: 100vh; }
#content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
/* ============================================================
SIDEBAR
============================================================ */
#sidebar {
width: 200px;
flex-shrink: 0;
background: #2c3e50;
color: #ecf0f1;
display: flex;
flex-direction: column;
}
.sidebar-brand {
padding: 16px;
font-size: 15px;
font-weight: 700;
border-bottom: 1px solid #3d5166;
letter-spacing: 0.02em;
}
.nav-links { list-style: none; padding: 6px 0; }
.nav-links li {
padding: 10px 16px;
cursor: pointer;
font-size: 13px;
color: #bdc3c7;
transition: background 0.1s;
}
.nav-links li:hover { background: #3d5166; color: #ecf0f1; }
.nav-links li.active { background: #1a252f; color: #ecf0f1; border-left: 3px solid #3498db; padding-left: 13px; }
.sidebar-context { flex: 1; padding: 12px 16px; border-top: 1px solid #3d5166; }
.ctx-item { margin-bottom: 10px; }
.ctx-item.hidden { display: none; }
.ctx-label { display: block; font-size: 10px; color: #7f8c8d; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; }
.ctx-item span:not(.ctx-label) { font-size: 12px; word-break: break-word; }
.status-badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 10px; margin-left: 4px; vertical-align: middle; }
.status-badge.open { background: #27ae60; }
.status-badge.closed { background: #c0392b; }
.sidebar-user { padding: 12px 16px; border-top: 1px solid #3d5166; }
.sidebar-user label { display: block; font-size: 10px; color: #7f8c8d; text-transform: uppercase; margin-bottom: 4px; }
.sidebar-user input {
width: 100%;
background: #3d5166;
border: 1px solid #4a6278;
color: #ecf0f1;
padding: 5px 8px;
border-radius: 3px;
font-size: 12px;
}
.sidebar-user input::placeholder { color: #7f8c8d; }
/* ============================================================
STATUS BAR
============================================================ */
#status-bar { padding: 7px 16px; font-size: 12px; flex-shrink: 0; }
#status-bar.hidden { display: none; }
.status-info { background: #d6eaf8; color: #1a5276; border-bottom: 1px solid #aed6f1; }
.status-success { background: #d5f5e3; color: #1e8449; border-bottom: 1px solid #a9dfbf; }
.status-error { background: #fadbd8; color: #922b21; border-bottom: 1px solid #f1948a; }
/* ============================================================
VIEWS
============================================================ */
.view { flex: 1; overflow: hidden; padding: 12px; display: none; flex-direction: column; gap: 8px; }
.view.active { display: flex; }
.view.hidden { display: none !important; }
/* ============================================================
SOURCES VIEW two-column
============================================================ */
.two-col-layout { display: flex; gap: 10px; flex: 1; overflow: hidden; }
.panel {
flex: 1;
background: white;
border-radius: 5px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
min-width: 0;
}
.panel-header {
padding: 9px 12px;
border-bottom: 1px solid #e8ecf0;
display: flex;
align-items: center;
justify-content: space-between;
background: #f8fafc;
font-weight: 600;
font-size: 12px;
flex-shrink: 0;
}
.header-actions { display: flex; gap: 6px; align-items: center; }
.grid-fill { flex: 1; min-height: 0; }
/* ============================================================
VERSIONS VIEW
============================================================ */
.view-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
#versions-source-label { font-weight: 600; font-size: 13px; flex: 1; }
#versions-grid { flex: 1; min-height: 200px; }
.version-actions {
background: white;
border-radius: 5px;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
flex-wrap: wrap;
}
.version-actions.hidden { display: none; }
#version-actions-label { font-size: 12px; font-weight: 600; flex: 1; min-width: 120px; }
.inline-form {
background: white;
border-radius: 5px;
padding: 14px 16px;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
flex-shrink: 0;
}
.inline-form h3 { font-size: 13px; margin-bottom: 10px; font-weight: 600; }
.inline-form.hidden { display: none; }
.form-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.form-row label, .inline-form label {
font-size: 11px;
color: #555;
display: flex;
flex-direction: column;
gap: 3px;
}
.form-row input[type=text],
.form-row input[type=date],
.form-row input[type=number],
.inline-form input[type=text],
.inline-form input[type=date],
.inline-form input[type=number] {
border: 1px solid #dce1e7;
padding: 5px 8px;
border-radius: 3px;
font-size: 12px;
min-width: 140px;
color: #333;
}
.form-actions { display: flex; gap: 8px; }
/* ============================================================
FORECAST VIEW
============================================================ */
.forecast-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
#forecast-label { font-weight: 600; font-size: 13px; flex: 1; }
.forecast-layout { display: flex; gap: 10px; flex: 1; overflow: hidden; min-height: 0; }
#pivot-panel {
flex: 1;
background: white;
border-radius: 5px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
min-width: 0;
}
#pivot-grid { width: 100%; height: 100%; }
#operation-panel {
width: 270px;
flex-shrink: 0;
background: white;
border-radius: 5px;
padding: 14px;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.op-section { display: flex; flex-direction: column; gap: 6px; }
.op-title { font-size: 10px; font-weight: 700; text-transform: uppercase; color: #7f8c8d; letter-spacing: 0.05em; }
.op-hint { font-size: 11px; color: #aaa; }
#slice-display { display: flex; flex-wrap: wrap; gap: 4px; min-height: 24px; }
.slice-tag {
background: #eaf4fb;
color: #1a6fa8;
padding: 2px 10px;
border-radius: 12px;
font-size: 11px;
cursor: default;
}
.op-tabs { display: flex; border-bottom: 2px solid #eee; margin-bottom: 10px; }
.op-tab {
background: none;
border: none;
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
color: #666;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.op-tab.active { color: #2980b9; border-bottom-color: #2980b9; font-weight: 600; }
.op-tab:hover:not(.active) { color: #333; }
.op-form { display: flex; flex-direction: column; gap: 8px; }
.op-form.hidden { display: none; }
.op-form label { font-size: 11px; color: #555; display: flex; flex-direction: column; gap: 3px; }
.op-form input[type=text],
.op-form input[type=number] {
border: 1px solid #dce1e7;
padding: 5px 8px;
border-radius: 3px;
font-size: 12px;
}
.label-inline { flex-direction: row !important; align-items: center; gap: 6px !important; font-size: 12px !important; }
#op-forms-area { display: flex; flex-direction: column; gap: 0; }
#op-forms-area.hidden { display: none; }
.recode-field, .clone-field { margin-bottom: 8px; }
.recode-field label, .clone-field label { font-size: 11px; color: #555; display: block; margin-bottom: 2px; }
.field-hint { font-size: 10px; color: #aaa; }
/* ============================================================
LOG VIEW
============================================================ */
#log-grid { flex: 1; min-height: 0; }
/* ============================================================
BUTTONS
============================================================ */
.btn {
padding: 5px 12px;
border: 1px solid #dce1e7;
background: white;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
color: #333;
}
.btn:hover { background: #f0f2f5; }
.btn:active { background: #e8ecf0; }
.btn-primary { background: #2980b9; color: white; border-color: #2574a9; }
.btn-primary:hover { background: #2574a9; }
.btn-danger { background: #e74c3c; color: white; border-color: #d44332; }
.btn-danger:hover { background: #d44332; }
.btn-sm { padding: 2px 8px; font-size: 11px; }
.btn.hidden { display: none; }
.btn-icon { background: none; border: none; font-size: 20px; cursor: pointer; color: #666; line-height: 1; padding: 0 4px; }
.btn-icon:hover { color: #333; }
/* ============================================================
MODAL
============================================================ */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-overlay.hidden { display: none; }
.modal {
background: white;
border-radius: 6px;
width: 720px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 40px rgba(0,0,0,.2);
}
.modal-header {
padding: 14px 18px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 13px;
}
#modal-body { padding: 16px 18px; overflow-y: auto; flex: 1; font-size: 12px; }
.modal-footer { padding: 10px 18px; border-top: 1px solid #eee; display: flex; justify-content: flex-end; gap: 8px; }
.preview-section h4 { font-size: 12px; margin-bottom: 6px; color: #555; }
.preview-section + .preview-section { margin-top: 16px; }
.preview-table { border-collapse: collapse; width: 100%; font-size: 11px; }
.preview-table th, .preview-table td { border: 1px solid #e0e0e0; padding: 4px 8px; text-align: left; }
.preview-table th { background: #f5f7f9; font-weight: 600; }
.preview-table tr:hover td { background: #fafbfc; }

63
routes/log.js Normal file
View File

@ -0,0 +1,63 @@
const express = require('express');
const { fcTable } = require('../lib/utils');
module.exports = function(pool) {
const router = express.Router();
// list all log entries for a version, newest first
router.get('/versions/:id/log', async (req, res) => {
try {
const result = await pool.query(
`SELECT * FROM pf.log WHERE version_id = $1 ORDER BY stamp DESC`,
[req.params.id]
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// undo an operation — deletes all forecast rows with this logid, then the log entry
// two separate queries in a transaction to avoid FK ordering issues
router.delete('/log/:logid', async (req, res) => {
const logid = parseInt(req.params.logid);
const client = await pool.connect();
try {
// look up the log entry to find the version and fc_table name
const logResult = await client.query(`
SELECT l.*, v.id AS version_id, s.tname
FROM pf.log l
JOIN pf.version v ON v.id = l.version_id
JOIN pf.source s ON s.id = v.source_id
WHERE l.id = $1
`, [logid]);
if (logResult.rows.length === 0) {
return res.status(404).json({ error: 'Log entry not found' });
}
const { tname, version_id } = logResult.rows[0];
const table = fcTable(tname, version_id);
await client.query('BEGIN');
// delete forecast rows first (logid has no FK constraint — managed by app)
const del = await client.query(
`DELETE FROM ${table} WHERE logid = $1 RETURNING id`,
[logid]
);
await client.query(`DELETE FROM pf.log WHERE id = $1`, [logid]);
await client.query('COMMIT');
res.json({ message: 'Operation undone', rows_deleted: del.rowCount });
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
return router;
};

266
routes/operations.js Normal file
View File

@ -0,0 +1,266 @@
const express = require('express');
const { applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc } = require('../lib/sql_generator');
const { fcTable } = require('../lib/utils');
module.exports = function(pool) {
const router = express.Router();
async function runSQL(sql) {
console.log('--- SQL ---\n', sql, '\n--- END SQL ---');
return pool.query(sql);
}
// fetch everything needed to execute an operation:
// version + source info, col_meta, fc_table name, stored SQL
async function getContext(versionId, operation) {
const verResult = await pool.query(`
SELECT v.*, s.schema, s.tname, s.id AS source_id
FROM pf.version v
JOIN pf.source s ON s.id = v.source_id
WHERE v.id = $1
`, [versionId]);
if (verResult.rows.length === 0) {
const err = new Error('Version not found'); err.status = 404; throw err;
}
const version = verResult.rows[0];
const colResult = await pool.query(
`SELECT * FROM pf.col_meta WHERE source_id = $1 ORDER BY opos`,
[version.source_id]
);
const colMeta = colResult.rows;
const dimCols = colMeta.filter(c => c.role === 'dimension').map(c => c.cname);
const valueCol = colMeta.find(c => c.role === 'value')?.cname;
const unitsCol = colMeta.find(c => c.role === 'units')?.cname;
const sqlResult = await pool.query(
`SELECT sql FROM pf.sql WHERE source_id = $1 AND operation = $2`,
[version.source_id, operation]
);
if (sqlResult.rows.length === 0) {
const err = new Error(`No generated SQL for operation "${operation}" — run generate-sql first`);
err.status = 400; throw err;
}
return {
version,
table: fcTable(version.tname, version.id),
colMeta,
dimCols,
valueCol,
unitsCol,
sql: sqlResult.rows[0].sql
};
}
function guardOpen(version, res) {
if (version.status === 'closed') {
res.status(403).json({ error: 'Version is closed' });
return false;
}
return true;
}
// fetch all rows for a version (all iters including reference)
router.get('/versions/:id/data', async (req, res) => {
try {
const ctx = await getContext(parseInt(req.params.id), 'get_data');
const sql = applyTokens(ctx.sql, { fc_table: ctx.table });
const result = await runSQL(sql);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
// load baseline rows from source table for a date range
// deletes existing iter='baseline' rows before inserting (handled inside stored SQL)
router.post('/versions/:id/baseline', async (req, res) => {
const { date_from, date_to, pf_user, note, replay } = req.body;
if (!date_from || !date_to) {
return res.status(400).json({ error: 'date_from and date_to are required' });
}
if (replay) {
return res.status(501).json({ error: 'replay is not yet implemented' });
}
try {
const ctx = await getContext(parseInt(req.params.id), 'baseline');
if (!guardOpen(ctx.version, res)) return;
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(JSON.stringify({ date_from, date_to })),
date_from: esc(date_from),
date_to: esc(date_to)
});
const result = await runSQL(sql);
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
// load reference rows from source table (additive — does not clear prior reference rows)
router.post('/versions/:id/reference', async (req, res) => {
const { date_from, date_to, pf_user, note } = req.body;
if (!date_from || !date_to) {
return res.status(400).json({ error: 'date_from and date_to are required' });
}
try {
const ctx = await getContext(parseInt(req.params.id), 'reference');
if (!guardOpen(ctx.version, res)) return;
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(JSON.stringify({ date_from, date_to })),
date_from: esc(date_from),
date_to: esc(date_to)
});
const result = await runSQL(sql);
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
// scale a slice — adjust value and/or units by absolute amount or percentage
router.post('/versions/:id/scale', async (req, res) => {
const { pf_user, note, slice, value_incr, units_incr, pct } = req.body;
if (!slice || Object.keys(slice).length === 0) {
return res.status(400).json({ error: 'slice is required' });
}
try {
const ctx = await getContext(parseInt(req.params.id), 'scale');
if (!guardOpen(ctx.version, res)) return;
const whereClause = buildWhere(slice, ctx.dimCols);
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
let absValueIncr = value_incr || 0;
let absUnitsIncr = units_incr || 0;
// pct mode: run a quick totals query, convert percentages to absolutes
if (pct && (value_incr || units_incr)) {
const totals = await pool.query(`
SELECT
sum("${ctx.valueCol}") AS total_value,
sum("${ctx.unitsCol}") AS total_units
FROM ${ctx.table}
WHERE ${whereClause}
${excludeClause}
`);
const { total_value, total_units } = totals.rows[0];
if (value_incr) absValueIncr = (parseFloat(total_value) || 0) * value_incr / 100;
if (units_incr) absUnitsIncr = (parseFloat(total_units) || 0) * units_incr / 100;
}
if (absValueIncr === 0 && absUnitsIncr === 0) {
return res.status(400).json({ error: 'value_incr and/or units_incr must be non-zero' });
}
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(JSON.stringify({ slice, value_incr, units_incr, pct })),
slice: esc(JSON.stringify(slice)),
where_clause: whereClause,
exclude_clause: excludeClause,
value_incr: absValueIncr,
units_incr: absUnitsIncr
});
const result = await runSQL(sql);
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
// recode dimension values on a slice
// inserts negative rows to zero out the original, positive rows with new dimension values
router.post('/versions/:id/recode', async (req, res) => {
const { pf_user, note, slice, set } = req.body;
if (!slice || Object.keys(slice).length === 0) return res.status(400).json({ error: 'slice is required' });
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
try {
const ctx = await getContext(parseInt(req.params.id), 'recode');
if (!guardOpen(ctx.version, res)) return;
const whereClause = buildWhere(slice, ctx.dimCols);
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
const setClause = buildSetClause(ctx.dimCols, set);
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(JSON.stringify({ slice, set })),
slice: esc(JSON.stringify(slice)),
where_clause: whereClause,
exclude_clause: excludeClause,
set_clause: setClause
});
const result = await runSQL(sql);
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
// clone a slice as new business under new dimension values
// does not offset the original slice
router.post('/versions/:id/clone', async (req, res) => {
const { pf_user, note, slice, set, scale } = req.body;
if (!slice || Object.keys(slice).length === 0) return res.status(400).json({ error: 'slice is required' });
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
try {
const ctx = await getContext(parseInt(req.params.id), 'clone');
if (!guardOpen(ctx.version, res)) return;
const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0;
const whereClause = buildWhere(slice, ctx.dimCols);
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
const setClause = buildSetClause(ctx.dimCols, set);
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(JSON.stringify({ slice, set, scale: scaleFactor })),
slice: esc(JSON.stringify(slice)),
where_clause: whereClause,
exclude_clause: excludeClause,
set_clause: setClause,
scale_factor: scaleFactor
});
const result = await runSQL(sql);
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
return router;
};

243
routes/sources.js Normal file
View File

@ -0,0 +1,243 @@
const express = require('express');
const { generateSQL } = require('../lib/sql_generator');
module.exports = function(pool) {
const router = express.Router();
// list all registered sources
router.get('/sources', async (req, res) => {
try {
const result = await pool.query(
`SELECT * FROM pf.source ORDER BY schema, tname`
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// register a source table
// auto-populates col_meta from information_schema with role='ignore'
router.post('/sources', async (req, res) => {
const { schema, tname, label, created_by } = req.body;
if (!schema || !tname) {
return res.status(400).json({ error: 'schema and tname are required' });
}
if (!/^\w+$/.test(schema) || !/^\w+$/.test(tname)) {
return res.status(400).json({ error: 'Invalid schema or table name' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const src = await client.query(
`INSERT INTO pf.source (schema, tname, label, created_by)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[schema, tname, label || null, created_by || null]
);
const source = src.rows[0];
// seed col_meta from information_schema
await client.query(`
INSERT INTO pf.col_meta (source_id, cname, role, opos)
SELECT $1, column_name, 'ignore', ordinal_position
FROM information_schema.columns
WHERE table_schema = $2 AND table_name = $3
ORDER BY ordinal_position
ON CONFLICT (source_id, cname) DO NOTHING
`, [source.id, schema, tname]);
await client.query('COMMIT');
res.status(201).json(source);
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
if (err.code === '23505') {
return res.status(409).json({ error: 'Source already registered' });
}
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
// get col_meta for a source
router.get('/sources/:id/cols', async (req, res) => {
try {
const result = await pool.query(
`SELECT * FROM pf.col_meta WHERE source_id = $1 ORDER BY opos`,
[req.params.id]
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// save col_meta — accepts full array, upserts each row
router.put('/sources/:id/cols', async (req, res) => {
const sourceId = parseInt(req.params.id);
const cols = req.body;
if (!Array.isArray(cols)) {
return res.status(400).json({ error: 'body must be an array' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const col of cols) {
await client.query(`
INSERT INTO pf.col_meta (source_id, cname, label, role, is_key, opos)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (source_id, cname) DO UPDATE SET
label = EXCLUDED.label,
role = EXCLUDED.role,
is_key = EXCLUDED.is_key,
opos = EXCLUDED.opos
`, [
sourceId,
col.cname,
col.label || null,
col.role || 'ignore',
col.is_key || false,
col.opos || null
]);
}
await client.query('COMMIT');
const result = await pool.query(
`SELECT * FROM pf.col_meta WHERE source_id = $1 ORDER BY opos`,
[sourceId]
);
res.json(result.rows);
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
// generate SQL for all operations from current col_meta and store in pf.sql
router.post('/sources/:id/generate-sql', async (req, res) => {
const sourceId = parseInt(req.params.id);
try {
const srcResult = await pool.query(
`SELECT * FROM pf.source WHERE id = $1`, [sourceId]
);
if (srcResult.rows.length === 0) {
return res.status(404).json({ error: 'Source not found' });
}
const colResult = await pool.query(
`SELECT * FROM pf.col_meta WHERE source_id = $1 ORDER BY opos`,
[sourceId]
);
// validate required roles
const colMeta = colResult.rows;
const roles = new Set(colMeta.map(c => c.role));
const missing = ['value', 'units', 'date'].filter(r => !roles.has(r));
if (missing.length > 0) {
return res.status(400).json({
error: `col_meta is missing required roles: ${missing.join(', ')}`
});
}
if (!colMeta.some(c => c.role === 'dimension')) {
return res.status(400).json({ error: 'col_meta has no dimension columns' });
}
const sqls = generateSQL(srcResult.rows[0], colMeta);
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const [operation, sql] of Object.entries(sqls)) {
await client.query(`
INSERT INTO pf.sql (source_id, operation, sql, generated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (source_id, operation) DO UPDATE SET
sql = EXCLUDED.sql,
generated_at = EXCLUDED.generated_at
`, [sourceId, operation, sql]);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
res.json({ message: 'SQL generated', operations: Object.keys(sqls) });
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// view generated SQL for a source (for inspection / debug)
router.get('/sources/:id/sql', async (req, res) => {
try {
const result = await pool.query(
`SELECT operation, sql, generated_at
FROM pf.sql WHERE source_id = $1 ORDER BY operation`,
[req.params.id]
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// get distinct values for a key column (used to populate operation panel dropdowns)
router.get('/sources/:id/values/:col', async (req, res) => {
const col = req.params.col;
try {
const srcResult = await pool.query(
`SELECT schema, tname FROM pf.source WHERE id = $1`, [req.params.id]
);
if (srcResult.rows.length === 0) return res.status(404).json({ error: 'Source not found' });
// validate col is a key dimension on this source
const metaResult = await pool.query(
`SELECT 1 FROM pf.col_meta WHERE source_id = $1 AND cname = $2 AND is_key = true`,
[req.params.id, col]
);
if (metaResult.rows.length === 0) {
return res.status(400).json({ error: `"${col}" is not a key column` });
}
const { schema, tname } = srcResult.rows[0];
const result = await pool.query(
`SELECT DISTINCT "${col}" AS val FROM ${schema}.${tname}
WHERE "${col}" IS NOT NULL ORDER BY "${col}"`
);
res.json(result.rows.map(r => r.val));
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// deregister a source — does not drop existing forecast tables
router.delete('/sources/:id', async (req, res) => {
try {
const result = await pool.query(
`DELETE FROM pf.source WHERE id = $1 RETURNING *`,
[req.params.id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Source not found' });
}
res.json({ message: 'Source deregistered', source: result.rows[0] });
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
return router;
};

53
routes/tables.js Normal file
View File

@ -0,0 +1,53 @@
const express = require('express');
module.exports = function(pool) {
const router = express.Router();
// list all non-system tables with row estimates
router.get('/tables', async (req, res) => {
try {
const result = await pool.query(`
SELECT
t.table_schema AS schema,
t.table_name AS tname,
c.reltuples::bigint AS row_estimate
FROM information_schema.tables t
LEFT JOIN pg_class c ON c.relname = t.table_name
LEFT JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.table_schema
WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema', 'pf')
ORDER BY t.table_schema, t.table_name
`);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// preview a table: column list + 5 sample rows
router.get('/tables/:schema/:tname/preview', async (req, res) => {
const { schema, tname } = req.params;
if (!/^\w+$/.test(schema) || !/^\w+$/.test(tname)) {
return res.status(400).json({ error: 'Invalid schema or table name' });
}
try {
const cols = await pool.query(`
SELECT column_name, data_type, is_nullable, ordinal_position
FROM information_schema.columns
WHERE table_schema = $1 AND table_name = $2
ORDER BY ordinal_position
`, [schema, tname]);
const rows = await pool.query(
`SELECT * FROM ${schema}.${tname} LIMIT 5`
);
res.json({ columns: cols.rows, rows: rows.rows });
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
return router;
};

216
routes/versions.js Normal file
View File

@ -0,0 +1,216 @@
const express = require('express');
const { fcTable, mapType } = require('../lib/utils');
module.exports = function(pool) {
const router = express.Router();
// list versions for a source
router.get('/sources/:id/versions', async (req, res) => {
try {
const result = await pool.query(
`SELECT * FROM pf.version WHERE source_id = $1 ORDER BY created_at DESC`,
[req.params.id]
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// create a new version
// inserts version row, then CREATE TABLE pf.fc_{tname}_{version_id} in one transaction
router.post('/sources/:id/versions', async (req, res) => {
const sourceId = parseInt(req.params.id);
const { name, description, created_by, exclude_iters } = req.body;
if (!name) return res.status(400).json({ error: 'name is required' });
const client = await pool.connect();
try {
// fetch source
const srcResult = await client.query(
`SELECT * FROM pf.source WHERE id = $1`, [sourceId]
);
if (srcResult.rows.length === 0) {
return res.status(404).json({ error: 'Source not found' });
}
const source = srcResult.rows[0];
// fetch col_meta joined to information_schema for data types
const colResult = await client.query(`
SELECT
m.cname,
m.role,
m.opos,
i.data_type,
i.numeric_precision,
i.numeric_scale
FROM pf.col_meta m
JOIN information_schema.columns i
ON i.table_schema = $2
AND i.table_name = $3
AND i.column_name = m.cname
WHERE m.source_id = $1
AND m.role NOT IN ('ignore')
ORDER BY m.opos
`, [sourceId, source.schema, source.tname]);
if (colResult.rows.length === 0) {
return res.status(400).json({
error: 'No usable columns in col_meta — configure roles before creating a version'
});
}
await client.query('BEGIN');
// insert version to get id
const verResult = await client.query(`
INSERT INTO pf.version (source_id, name, description, created_by, exclude_iters)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`, [
sourceId,
name,
description || null,
created_by || null,
exclude_iters ? JSON.stringify(exclude_iters) : '["reference"]'
]);
const version = verResult.rows[0];
// build CREATE TABLE DDL using col_meta + mapped data types
const table = fcTable(source.tname, version.id);
const colDefs = colResult.rows
.filter(c => c.cname !== 'id')
.map(c => {
const pgType = mapType(c.data_type, c.numeric_precision, c.numeric_scale);
const quoted = `"${c.cname}"`;
return ` ${quoted.padEnd(26)}${pgType}`;
}).join(',\n');
const ddl = `
CREATE TABLE ${table} (
id bigserial PRIMARY KEY,
${colDefs},
iter text NOT NULL,
logid bigint NOT NULL,
pf_user text,
created_at timestamptz NOT NULL DEFAULT now()
)
`;
await client.query(ddl);
await client.query('COMMIT');
res.status(201).json({ ...version, fc_table: table });
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
if (err.code === '23505') {
return res.status(409).json({ error: 'A version with that name already exists for this source' });
}
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
// update version name, description, or exclude_iters
router.put('/versions/:id', async (req, res) => {
const { name, description, exclude_iters } = req.body;
try {
const result = await pool.query(`
UPDATE pf.version SET
name = COALESCE($2, name),
description = COALESCE($3, description),
exclude_iters = COALESCE($4, exclude_iters)
WHERE id = $1
RETURNING *
`, [
req.params.id,
name || null,
description || null,
exclude_iters ? JSON.stringify(exclude_iters) : null
]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Version not found' });
}
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// close a version — blocks further edits
router.post('/versions/:id/close', async (req, res) => {
const { pf_user } = req.body;
try {
const result = await pool.query(`
UPDATE pf.version
SET status = 'closed', closed_at = now(), closed_by = $2
WHERE id = $1 AND status = 'open'
RETURNING *
`, [req.params.id, pf_user || null]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Version not found or already closed' });
}
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// reopen a closed version
router.post('/versions/:id/reopen', async (req, res) => {
try {
const result = await pool.query(`
UPDATE pf.version
SET status = 'open', closed_at = NULL, closed_by = NULL
WHERE id = $1 AND status = 'closed'
RETURNING *
`, [req.params.id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Version not found or already open' });
}
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// delete a version — drops forecast table then deletes version record
// log entries are removed by ON DELETE CASCADE on pf.log.version_id
router.delete('/versions/:id', async (req, res) => {
const versionId = parseInt(req.params.id);
const client = await pool.connect();
try {
const verResult = await client.query(`
SELECT v.*, s.tname
FROM pf.version v
JOIN pf.source s ON s.id = v.source_id
WHERE v.id = $1
`, [versionId]);
if (verResult.rows.length === 0) {
return res.status(404).json({ error: 'Version not found' });
}
const { tname } = verResult.rows[0];
const table = fcTable(tname, versionId);
await client.query('BEGIN');
await client.query(`DROP TABLE IF EXISTS ${table}`);
await client.query(`DELETE FROM pf.version WHERE id = $1`, [versionId]);
await client.query('COMMIT');
res.json({ message: 'Version deleted', fc_table: table });
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
return router;
};

33
server.js Normal file
View File

@ -0,0 +1,33 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const { Pool } = require('pg');
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
const pool = new Pool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT) || 5432,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: false
});
pool.on('error', (err) => {
console.error('pg pool error', err);
});
app.use('/api', require('./routes/tables')(pool));
app.use('/api', require('./routes/sources')(pool));
app.use('/api', require('./routes/versions')(pool));
app.use('/api', require('./routes/operations')(pool));
app.use('/api', require('./routes/log')(pool));
app.get('/', (req, res) => res.send('pf_app running'));
const port = process.env.PORT || 3010;
app.listen(port, '0.0.0.0', () => console.log(`pf_app started on port ${port}`));

61
setup_sql/01_schema.sql Normal file
View File

@ -0,0 +1,61 @@
-- Pivot Forecast schema install
-- Run once against target database: psql -d <db> -f setup_sql/01_schema.sql
CREATE SCHEMA IF NOT EXISTS pf;
CREATE TABLE IF NOT EXISTS pf.source (
id serial PRIMARY KEY,
schema text NOT NULL,
tname text NOT NULL,
label text,
status text NOT NULL DEFAULT 'active', -- active | archived
created_at timestamptz NOT NULL DEFAULT now(),
created_by text,
UNIQUE (schema, tname)
);
CREATE TABLE IF NOT EXISTS pf.col_meta (
id serial PRIMARY KEY,
source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE,
cname text NOT NULL,
label text,
role text NOT NULL DEFAULT 'ignore', -- dimension | value | units | date | ignore
is_key boolean NOT NULL DEFAULT false, -- true = usable in WHERE slice
opos integer,
UNIQUE (source_id, cname)
);
CREATE TABLE IF NOT EXISTS pf.version (
id serial PRIMARY KEY,
source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE RESTRICT,
name text NOT NULL,
description text,
status text NOT NULL DEFAULT 'open', -- open | closed
exclude_iters jsonb NOT NULL DEFAULT '["reference"]'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
created_by text,
closed_at timestamptz,
closed_by text,
UNIQUE (source_id, name)
);
CREATE TABLE IF NOT EXISTS pf.log (
id bigserial PRIMARY KEY,
version_id integer NOT NULL REFERENCES pf.version(id) ON DELETE CASCADE,
pf_user text NOT NULL,
stamp timestamptz NOT NULL DEFAULT now(),
operation text NOT NULL, -- baseline | reference | scale | recode | clone
slice jsonb,
params jsonb,
note text
);
-- generated operation SQL per source, stored after col_meta is configured
CREATE TABLE IF NOT EXISTS pf.sql (
id serial PRIMARY KEY,
source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE,
operation text NOT NULL, -- get_data | baseline | reference | scale | recode | clone | undo
sql text NOT NULL,
generated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (source_id, operation)
);