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:
commit
08dc415bfd
6
.env.example
Normal file
6
.env.example
Normal 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
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.env
|
||||
65
install.sh
Executable file
65
install.sh
Executable 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
254
lib/sql_generator.js
Normal 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
39
lib/utils.js
Normal 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
1392
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal 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
904
public/app.js
Normal 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
191
public/index.html
Normal 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
320
public/styles.css
Normal 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
63
routes/log.js
Normal 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
266
routes/operations.js
Normal 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
243
routes/sources.js
Normal 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
53
routes/tables.js
Normal 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
216
routes/versions.js
Normal 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
33
server.js
Normal 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
61
setup_sql/01_schema.sql
Normal 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)
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user