Compare commits
27 Commits
master
...
light-dark
| Author | SHA1 | Date | |
|---|---|---|---|
| a6e6efd36e | |||
| 11f5b02fc4 | |||
| 4a4cb80189 | |||
| bd5ea1c60e | |||
| 8f009e468e | |||
| 742d4b4cc4 | |||
| cda3943515 | |||
| 6449fff573 | |||
| 3bdd7d0028 | |||
| af52845523 | |||
| dc090fe394 | |||
| dd993e989c | |||
| 9084a87ea5 | |||
| 4c71049bf0 | |||
| 5171b9770c | |||
| 368127e098 | |||
| 1df37a5ff1 | |||
| d49aac70e4 | |||
| d4962f4376 | |||
| 5550a57f97 | |||
| 7e9ea456b6 | |||
| f0c51096ff | |||
| e9f37e09f2 | |||
| 6d8b052eb6 | |||
| ddd16bc7a0 | |||
| 10441a4761 | |||
| cfee3e96b9 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
|
public/app/
|
||||||
|
|||||||
@ -26,11 +26,10 @@ function generateSQL(source, colMeta) {
|
|||||||
if (dims.length === 0) throw new Error('No dimension columns defined in col_meta');
|
if (dims.length === 0) throw new Error('No dimension columns defined in col_meta');
|
||||||
|
|
||||||
const srcTable = `${source.schema}.${source.tname}`;
|
const srcTable = `${source.schema}.${source.tname}`;
|
||||||
// exclude 'id' — forecast table has its own bigserial id primary key
|
const dataCols = [...dims, dateCol, valueCol, unitsCol].filter(Boolean);
|
||||||
const dataCols = [...dims, dateCol, valueCol, unitsCol].filter(c => c !== 'id');
|
|
||||||
const effectiveValue = dataCols.includes(valueCol) ? valueCol : null;
|
const effectiveValue = dataCols.includes(valueCol) ? valueCol : null;
|
||||||
const effectiveUnits = dataCols.includes(unitsCol) ? unitsCol : null;
|
const effectiveUnits = dataCols.includes(unitsCol) ? unitsCol : null;
|
||||||
const insertCols = [...dataCols.map(q), 'iter', 'logid', 'pf_user', 'created_at'].join(', ');
|
const insertCols = [...dataCols.map(q), 'pf_iter', 'pf_logid', 'pf_user', 'pf_created_at'].join(', ');
|
||||||
const selectData = dataCols.map(q).join(', ');
|
const selectData = dataCols.map(q).join(', ');
|
||||||
const dimsJoined = dims.map(q).join(', ');
|
const dimsJoined = dims.map(q).join(', ');
|
||||||
|
|
||||||
@ -49,6 +48,9 @@ function generateSQL(source, colMeta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildBaseline() {
|
function buildBaseline() {
|
||||||
|
const baselineSelect = dataCols.map(c =>
|
||||||
|
c === dateCol ? `(${q(c)} + '{{date_offset}}'::interval)::date` : q(c)
|
||||||
|
).join(', ');
|
||||||
return `
|
return `
|
||||||
WITH
|
WITH
|
||||||
ilog AS (
|
ilog AS (
|
||||||
@ -56,14 +58,11 @@ ilog AS (
|
|||||||
VALUES ({{version_id}}, '{{pf_user}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}')
|
VALUES ({{version_id}}, '{{pf_user}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}')
|
||||||
RETURNING id
|
RETURNING id
|
||||||
)
|
)
|
||||||
,del AS (
|
|
||||||
DELETE FROM {{fc_table}} WHERE iter = 'baseline'
|
|
||||||
)
|
|
||||||
,ins AS (
|
,ins AS (
|
||||||
INSERT INTO {{fc_table}} (${insertCols})
|
INSERT INTO {{fc_table}} (${insertCols})
|
||||||
SELECT ${selectData}, 'baseline', (SELECT id FROM ilog), '{{pf_user}}', now()
|
SELECT ${baselineSelect}, 'baseline', (SELECT id FROM ilog), '{{pf_user}}', now()
|
||||||
FROM ${srcTable}
|
FROM ${srcTable}
|
||||||
WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
|
WHERE {{filter_clause}}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT count(*) AS rows_affected FROM ins`.trim();
|
SELECT count(*) AS rows_affected FROM ins`.trim();
|
||||||
@ -81,7 +80,7 @@ ilog AS (
|
|||||||
INSERT INTO {{fc_table}} (${insertCols})
|
INSERT INTO {{fc_table}} (${insertCols})
|
||||||
SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
|
SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
|
||||||
FROM ${srcTable}
|
FROM ${srcTable}
|
||||||
WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
|
WHERE {{filter_clause}}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT count(*) AS rows_affected FROM ins`.trim();
|
SELECT count(*) AS rows_affected FROM ins`.trim();
|
||||||
@ -124,7 +123,7 @@ ilog AS (
|
|||||||
FROM base
|
FROM base
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT count(*) AS rows_affected FROM ins`.trim();
|
SELECT * FROM ins`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRecode() {
|
function buildRecode() {
|
||||||
@ -146,16 +145,16 @@ ilog AS (
|
|||||||
SELECT ${dimsJoined}, ${q(dateCol)}, ${effectiveValue ? `-${q(effectiveValue)}` : '0'}, ${effectiveUnits ? `-${q(effectiveUnits)}` : '0'},
|
SELECT ${dimsJoined}, ${q(dateCol)}, ${effectiveValue ? `-${q(effectiveValue)}` : '0'}, ${effectiveUnits ? `-${q(effectiveUnits)}` : '0'},
|
||||||
'recode', (SELECT id FROM ilog), '{{pf_user}}', now()
|
'recode', (SELECT id FROM ilog), '{{pf_user}}', now()
|
||||||
FROM src
|
FROM src
|
||||||
RETURNING id
|
RETURNING *
|
||||||
)
|
)
|
||||||
,ins AS (
|
,ins AS (
|
||||||
INSERT INTO {{fc_table}} (${insertCols})
|
INSERT INTO {{fc_table}} (${insertCols})
|
||||||
SELECT {{set_clause}}, ${q(dateCol)}, ${effectiveValue ? q(effectiveValue) : '0'}, ${effectiveUnits ? q(effectiveUnits) : '0'},
|
SELECT {{set_clause}}, ${q(dateCol)}, ${effectiveValue ? q(effectiveValue) : '0'}, ${effectiveUnits ? q(effectiveUnits) : '0'},
|
||||||
'recode', (SELECT id FROM ilog), '{{pf_user}}', now()
|
'recode', (SELECT id FROM ilog), '{{pf_user}}', now()
|
||||||
FROM src
|
FROM src
|
||||||
RETURNING id
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT (SELECT count(*) FROM neg) + (SELECT count(*) FROM ins) AS rows_affected`.trim();
|
SELECT * FROM neg UNION ALL SELECT * FROM ins`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildClone() {
|
function buildClone() {
|
||||||
@ -179,7 +178,7 @@ ilog AS (
|
|||||||
{{exclude_clause}}
|
{{exclude_clause}}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT count(*) AS rows_affected FROM ins`.trim();
|
SELECT * FROM ins`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUndo() {
|
function buildUndo() {
|
||||||
@ -189,7 +188,7 @@ SELECT count(*) AS rows_affected FROM ins`.trim();
|
|||||||
// This entry is a placeholder — the undo route uses it as a template reference.
|
// This entry is a placeholder — the undo route uses it as a template reference.
|
||||||
return `
|
return `
|
||||||
-- step 1 (run first):
|
-- step 1 (run first):
|
||||||
DELETE FROM {{fc_table}} WHERE logid = {{logid}};
|
DELETE FROM {{fc_table}} WHERE pf_logid = {{logid}};
|
||||||
-- step 2 (run after step 1):
|
-- step 2 (run after step 1):
|
||||||
DELETE FROM pf.log WHERE id = {{logid}};`.trim();
|
DELETE FROM pf.log WHERE id = {{logid}};`.trim();
|
||||||
}
|
}
|
||||||
@ -205,7 +204,7 @@ function applyTokens(sql, tokens) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// build a SQL WHERE clause string from a slice object
|
// build a SQL WHERE clause string from a slice object
|
||||||
// validates all keys against the allowed dimension column list
|
// only dimension columns are included; unrecognised keys are silently skipped
|
||||||
function buildWhere(slice, dimCols) {
|
function buildWhere(slice, dimCols) {
|
||||||
if (!slice || Object.keys(slice).length === 0) return 'TRUE';
|
if (!slice || Object.keys(slice).length === 0) return 'TRUE';
|
||||||
|
|
||||||
@ -213,9 +212,7 @@ function buildWhere(slice, dimCols) {
|
|||||||
const parts = [];
|
const parts = [];
|
||||||
|
|
||||||
for (const [col, val] of Object.entries(slice)) {
|
for (const [col, val] of Object.entries(slice)) {
|
||||||
if (!allowed.has(col)) {
|
if (!allowed.has(col)) continue;
|
||||||
throw new Error(`"${col}" is not a dimension column`);
|
|
||||||
}
|
|
||||||
if (Array.isArray(val)) {
|
if (Array.isArray(val)) {
|
||||||
const escaped = val.map(v => esc(v));
|
const escaped = val.map(v => esc(v));
|
||||||
parts.push(`"${col}" IN ('${escaped.join("', '")}')`);
|
parts.push(`"${col}" IN ('${escaped.join("', '")}')`);
|
||||||
@ -224,14 +221,14 @@ function buildWhere(slice, dimCols) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join('\nAND ');
|
return parts.length ? parts.join('\nAND ') : 'TRUE';
|
||||||
}
|
}
|
||||||
|
|
||||||
// build AND iter NOT IN (...) from a version's exclude_iters array
|
// build AND iter NOT IN (...) from a version's exclude_iters array
|
||||||
function buildExcludeClause(excludeIters) {
|
function buildExcludeClause(excludeIters) {
|
||||||
if (!excludeIters || excludeIters.length === 0) return '';
|
if (!excludeIters || excludeIters.length === 0) return '';
|
||||||
const list = excludeIters.map(i => `'${esc(i)}'`).join(', ');
|
const list = excludeIters.map(i => `'${esc(i)}'`).join(', ');
|
||||||
return `AND iter NOT IN (${list})`;
|
return `AND pf_iter NOT IN (${list})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// build the dimension columns portion of a SELECT for recode/clone
|
// build the dimension columns portion of a SELECT for recode/clone
|
||||||
@ -245,10 +242,44 @@ function buildSetClause(dimCols, setObj) {
|
|||||||
}).join(', ');
|
}).join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// build a SQL WHERE clause from an array of filter objects { col, op, values }
|
||||||
|
// only allows columns with role 'date' or 'filter'
|
||||||
|
function buildFilterClause(filters, colMeta) {
|
||||||
|
if (!filters || filters.length === 0) {
|
||||||
|
const err = new Error('At least one filter is required');
|
||||||
|
err.status = 400; throw err;
|
||||||
|
}
|
||||||
|
const allowed = new Set(
|
||||||
|
colMeta.filter(c => c.role !== 'ignore').map(c => c.cname)
|
||||||
|
);
|
||||||
|
const parts = filters.map(({ col, op, values = [] }) => {
|
||||||
|
if (!allowed.has(col)) {
|
||||||
|
const err = new Error(`Column "${col}" is not available for baseline filtering`);
|
||||||
|
err.status = 400; throw err;
|
||||||
|
}
|
||||||
|
const c = `"${col}"`;
|
||||||
|
const v = values.map(x => `'${esc(String(x))}'`);
|
||||||
|
switch (op) {
|
||||||
|
case '=': return `${c} = ${v[0]}`;
|
||||||
|
case '!=': return `${c} != ${v[0]}`;
|
||||||
|
case 'IN': return `${c} IN (${v.join(', ')})`;
|
||||||
|
case 'NOT IN': return `${c} NOT IN (${v.join(', ')})`;
|
||||||
|
case 'BETWEEN': return `${c} BETWEEN ${v[0]} AND ${v[1]}`;
|
||||||
|
case 'IS NULL': return `${c} IS NULL`;
|
||||||
|
case 'IS NOT NULL': return `${c} IS NOT NULL`;
|
||||||
|
default: {
|
||||||
|
const err = new Error(`Unsupported operator "${op}"`);
|
||||||
|
err.status = 400; throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return parts.join('\nAND ');
|
||||||
|
}
|
||||||
|
|
||||||
// escape a value for safe SQL string substitution
|
// escape a value for safe SQL string substitution
|
||||||
function esc(val) {
|
function esc(val) {
|
||||||
if (val === null || val === undefined) return '';
|
if (val === null || val === undefined) return '';
|
||||||
return String(val).replace(/'/g, "''");
|
return String(val).replace(/'/g, "''");
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc };
|
module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, buildFilterClause, esc };
|
||||||
|
|||||||
263
package-lock.json
generated
263
package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "pf_app",
|
"name": "pf_app",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"apache-arrow": "^21.1.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
@ -17,6 +18,36 @@
|
|||||||
"nodemon": "^3.0.0"
|
"nodemon": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz",
|
||||||
|
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/command-line-args": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/command-line-usage": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "24.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||||
|
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@ -30,6 +61,21 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/anymatch": {
|
"node_modules/anymatch": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||||
@ -44,6 +90,35 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/apache-arrow": {
|
||||||
|
"version": "21.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz",
|
||||||
|
"integrity": "sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.11",
|
||||||
|
"@types/command-line-args": "^5.2.3",
|
||||||
|
"@types/command-line-usage": "^5.0.4",
|
||||||
|
"@types/node": "^24.0.3",
|
||||||
|
"command-line-args": "^6.0.1",
|
||||||
|
"command-line-usage": "^7.0.1",
|
||||||
|
"flatbuffers": "^25.1.24",
|
||||||
|
"json-bignum": "^0.0.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"arrow2csv": "bin/arrow2csv.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/array-back": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/array-flatten": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@ -161,6 +236,58 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk-template": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "^4.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk-template?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk/node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk/node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@ -186,6 +313,62 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/command-line-args": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"array-back": "^6.2.3",
|
||||||
|
"find-replace": "^5.0.2",
|
||||||
|
"lodash.camelcase": "^4.3.0",
|
||||||
|
"typical": "^7.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@75lb/nature": "latest"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@75lb/nature": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/command-line-usage": {
|
||||||
|
"version": "7.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.4.tgz",
|
||||||
|
"integrity": "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"array-back": "^6.2.2",
|
||||||
|
"chalk-template": "^0.4.0",
|
||||||
|
"table-layout": "^4.1.1",
|
||||||
|
"typical": "^7.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@ -430,6 +613,29 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-replace": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@75lb/nature": "latest"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@75lb/nature": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/flatbuffers": {
|
||||||
|
"version": "25.9.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
|
||||||
|
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@ -668,6 +874,20 @@
|
|||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/json-bignum": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lodash.camelcase": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@ -1300,6 +1520,19 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/table-layout": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"array-back": "^6.2.2",
|
||||||
|
"wordwrapjs": "^5.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@ -1332,6 +1565,12 @@
|
|||||||
"nodetouch": "bin/nodetouch.js"
|
"nodetouch": "bin/nodetouch.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
@ -1345,6 +1584,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typical": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undefsafe": {
|
"node_modules/undefsafe": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||||
@ -1352,6 +1600,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
@ -1379,6 +1633,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wordwrapjs": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@ -5,9 +5,11 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon server.js"
|
"dev": "nodemon server.js",
|
||||||
|
"build": "cd ui && npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"apache-arrow": "^21.1.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
|||||||
313
pf_spec.md
313
pf_spec.md
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
A web application for building named forecast scenarios against any PostgreSQL table. An admin configures a source table, generates a baseline, and opens it for users to make adjustments. Users interact with a pivot table to select slices of data and apply forecast operations. All changes are incremental (append-only), fully audited, and reversible.
|
A web application for building named forecast scenarios against any PostgreSQL table. The core workflow is: load known historical actuals as a baseline, shift those dates forward by a specified interval into the forecast period to establish a no-change starting point, then apply incremental adjustments (scale, recode, clone) to build the plan. An admin configures a source table, generates a baseline, and opens it for users to make adjustments. Users interact with a pivot table to select slices of data and apply forecast operations. All changes are incremental (append-only), fully audited, and reversible.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -10,7 +10,7 @@ A web application for building named forecast scenarios against any PostgreSQL t
|
|||||||
|
|
||||||
- **Backend:** Node.js / Express
|
- **Backend:** Node.js / Express
|
||||||
- **Database:** PostgreSQL — isolated `pf` schema, installs into any existing DB
|
- **Database:** PostgreSQL — isolated `pf` schema, installs into any existing DB
|
||||||
- **Frontend:** Vanilla JS + AG Grid (pivot mode)
|
- **Frontend:** React + Vite + Tailwind CSS; Perspective (forecast pivot)
|
||||||
- **Pattern:** Follows fc_webapp (shell) + pivot_forecast (operations)
|
- **Pattern:** Follows fc_webapp (shell) + pivot_forecast (operations)
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -52,11 +52,12 @@ CREATE TABLE pf.col_meta (
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Roles:**
|
**Roles:**
|
||||||
- `dimension` — categorical field (customer, part, channel, rep, geography, etc.) — appears as pivot rows/cols, used in WHERE filters
|
- `dimension` — categorical field (customer, part, channel, rep, geography, etc.) — appears as pivot rows/cols, used in WHERE filters for operations
|
||||||
- `value` — the money/revenue field to scale
|
- `value` — the money/revenue field to scale
|
||||||
- `units` — the quantity field to scale
|
- `units` — the quantity field to scale
|
||||||
- `date` — the date field used for baseline date range selection
|
- `date` — the primary date field; used for baseline/reference date range and stored in the forecast table
|
||||||
- `ignore` — exclude from forecast table
|
- `filter` — columns available as filter conditions in the Baseline Workbench (e.g. order status, ship date, open flag); used in baseline WHERE clauses but **not stored** in the forecast table
|
||||||
|
- `ignore` — exclude from forecast table entirely
|
||||||
|
|
||||||
### `pf.version`
|
### `pf.version`
|
||||||
Named forecast scenarios. One forecast table (`pf.fc_{tname}_{version_id}`) is created per version.
|
Named forecast scenarios. One forecast table (`pf.fc_{tname}_{version_id}`) is created per version.
|
||||||
@ -150,7 +151,8 @@ CREATE TABLE pf.sql (
|
|||||||
| `{{exclude_clause}}` | built from `version.exclude_iters` — e.g. `AND iter NOT IN ('reference')` |
|
| `{{exclude_clause}}` | built from `version.exclude_iters` — e.g. `AND iter NOT IN ('reference')` |
|
||||||
| `{{logid}}` | newly inserted `pf.log` id |
|
| `{{logid}}` | newly inserted `pf.log` id |
|
||||||
| `{{pf_user}}` | from request body |
|
| `{{pf_user}}` | from request body |
|
||||||
| `{{date_from}}` / `{{date_to}}` | baseline/reference date range |
|
| `{{date_from}}` / `{{date_to}}` | baseline/reference date range (source period) |
|
||||||
|
| `{{date_offset}}` | PostgreSQL interval string to shift dates into the forecast period — e.g. `1 year`, `6 months`, `2 years 3 months` (baseline only; empty string = no shift) |
|
||||||
| `{{value_incr}}` / `{{units_incr}}` | scale operation increments |
|
| `{{value_incr}}` / `{{units_incr}}` | scale operation increments |
|
||||||
| `{{pct}}` | scale mode: absolute or percentage |
|
| `{{pct}}` | scale mode: absolute or percentage |
|
||||||
| `{{set_clause}}` | recode/clone dimension overrides |
|
| `{{set_clause}}` | recode/clone dimension overrides |
|
||||||
@ -214,39 +216,85 @@ Source registration, col_meta configuration, SQL generation, version creation, a
|
|||||||
|
|
||||||
| Method | Route | Description |
|
| Method | Route | Description |
|
||||||
|--------|-------|-------------|
|
|--------|-------|-------------|
|
||||||
| POST | `/api/versions/:id/baseline` | Load baseline from source table for a date range |
|
| POST | `/api/versions/:id/baseline` | Load one baseline segment (additive — does not clear existing baseline rows) |
|
||||||
| POST | `/api/versions/:id/reference` | Load reference rows from source table for a date range |
|
| DELETE | `/api/versions/:id/baseline` | Clear all baseline rows and baseline log entries for this version |
|
||||||
|
| POST | `/api/versions/:id/reference` | Load reference rows from source table for a date range (additive) |
|
||||||
|
|
||||||
**Baseline request body:**
|
**Baseline load request body:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"date_from": "2024-01-01",
|
"date_offset": "1 year",
|
||||||
"date_to": "2024-12-31",
|
"filters": [
|
||||||
|
[
|
||||||
|
{ "col": "order_date", "op": "BETWEEN", "values": ["2024-01-01", "2024-12-31"] },
|
||||||
|
{ "col": "order_status", "op": "IN", "values": ["OPEN", "PENDING"] }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ "col": "order_status", "op": "IS NULL" }
|
||||||
|
]
|
||||||
|
],
|
||||||
"pf_user": "admin",
|
"pf_user": "admin",
|
||||||
"note": "restated actuals",
|
"note": "FY2024 actuals + open orders projected to FY2025",
|
||||||
"replay": false
|
"replay": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`replay` controls behavior when incremental rows exist:
|
The example above generates: `(order_date BETWEEN '2024-01-01' AND '2024-12-31' AND order_status IN ('OPEN','PENDING')) OR (order_status IS NULL)`
|
||||||
|
|
||||||
- `replay: false` (default) — delete existing `iter = 'baseline'` rows only, re-insert new baseline, leave all incremental rows (`scale`, `recode`, `clone`) untouched
|
- `date_offset` — PostgreSQL interval string applied to the primary `role = 'date'` column at insert time. Examples: `"1 year"`, `"6 months"`, `"2 years 3 months"`. Defaults to `"0 days"`. Applied to the stored date value only — filter columns are never shifted.
|
||||||
- `replay: true` — delete all rows, re-insert new baseline, then re-execute each log entry in chronological order against the new baseline, reconstructing all adjustments
|
- `filters` — an array of **groups**. Conditions within a group are AND-ed; groups are OR-ed together. Each group is an array of one or more condition objects:
|
||||||
|
- `col` — must be `role = 'date'` or `role = 'filter'` in col_meta
|
||||||
|
- `op` — one of `=`, `!=`, `IN`, `NOT IN`, `BETWEEN`, `IS NULL`, `IS NOT NULL`
|
||||||
|
- `values` — array of strings; two elements for `BETWEEN`; multiple for `IN`/`NOT IN`; omitted for `IS NULL`/`IS NOT NULL`
|
||||||
|
- Backward compatibility: a flat array of condition objects (non-nested) is treated as a single group (all AND).
|
||||||
|
- At least one group with at least one condition is required.
|
||||||
|
- `raw_where` — optional string. When present, bypasses `filters` entirely and injects the value verbatim as the WHERE clause body. **Admin-only** — rejected with `403` if the requesting `pf_user` is not in the admin list. Not validated against col_meta. Caller is responsible for correctness and SQL safety. Stored as-is in `pf.log.params` for audit. Cannot be combined with `filters` — if both are present the request is rejected with `400`.
|
||||||
|
- Baseline loads are **additive** — existing `iter = 'baseline'` rows are not touched. Each load is its own log entry and is independently undoable.
|
||||||
|
|
||||||
The UI presents this as a choice when the admin re-baselines and incremental rows exist:
|
`replay` controls behavior when incremental rows exist (applies to Clear + reload, not individual segments):
|
||||||
> "This version has N adjustments. Rebuild baseline only, or replay all adjustments against the new baseline?"
|
|
||||||
|
|
||||||
**v1 note:** `replay: true` returns `501 Not Implemented` until the replay engine is built. The flag is designed into the API now so the request shape doesn't change later.
|
- `replay: false` (default) — after clearing, re-load baseline segments, leave incremental rows untouched
|
||||||
|
- `replay: true` — after clearing, re-load baseline, then re-execute each incremental log entry in chronological order
|
||||||
|
|
||||||
**Reference request body:** same shape without `replay`. Reference loads are additive — multiple reference periods can be loaded independently under separate log entries. Each is undoable via its logid.
|
**v1 note:** `replay: true` returns `501 Not Implemented` until the replay engine is built.
|
||||||
|
|
||||||
|
**Clear baseline (`DELETE /api/versions/:id/baseline`)** — deletes all rows where `iter = 'baseline'` and all `operation = 'baseline'` log entries. Irreversible (no undo). Returns `{ rows_deleted, log_entries_deleted }`.
|
||||||
|
|
||||||
|
**Reference request body:** same shape as baseline load without `replay`. Reference dates land verbatim (no offset). Additive — multiple reference loads stack independently, each undoable by logid.
|
||||||
|
|
||||||
### Forecast Data
|
### Forecast Data
|
||||||
|
|
||||||
| Method | Route | Description |
|
| Method | Route | Description |
|
||||||
|--------|-------|-------------|
|
|--------|-------|-------------|
|
||||||
| GET | `/api/versions/:id/data` | Return all rows for this version (all iters including reference) |
|
| GET | `/api/versions/:id/data` | Stream all rows for this version as an Arrow IPC binary |
|
||||||
|
|
||||||
Returns flat array. AG Grid pivot runs client-side on this data.
|
**Transport format — Apache Arrow IPC stream**
|
||||||
|
|
||||||
|
The endpoint returns `Content-Type: application/vnd.apache.arrow.stream` (binary). JSON is not used for this route. The client fetches the response as `arrayBuffer()` and passes it directly to `worker.table(buffer)` — Perspective's native ingestion path with no JS deserialization overhead.
|
||||||
|
|
||||||
|
Arrow's columnar layout with dictionary encoding on string dimension columns keeps payload size manageable at scale (typically 50–150 MB for 1M rows depending on string cardinality), compared to several times that for equivalent JSON.
|
||||||
|
|
||||||
|
**Server-side streaming (cursor-based)**
|
||||||
|
|
||||||
|
For datasets that may reach 1M+ rows, the server must not buffer the full query result in memory before writing the response. Instead:
|
||||||
|
|
||||||
|
1. Open a PostgreSQL cursor over the `SELECT * FROM {{fc_table}}` query
|
||||||
|
2. Fetch rows in batches (target: 10 000 rows per batch)
|
||||||
|
3. For each batch, append a serialized Arrow record batch to the HTTP response using chunked transfer encoding
|
||||||
|
4. Close the cursor and end the response when all batches are written
|
||||||
|
|
||||||
|
This means the first bytes of the Arrow stream reach the client while the server is still reading from the database, and Node.js heap stays bounded regardless of dataset size.
|
||||||
|
|
||||||
|
**Client-side loading**
|
||||||
|
|
||||||
|
- **Moderate datasets (< ~500k rows):** accumulate the full `arrayBuffer()` then call `worker.table(buffer)` once. Perspective becomes interactive after the stream completes.
|
||||||
|
- **Large datasets (≥ ~500k rows):** process Arrow record batches incrementally — call `worker.table(firstBatch)` to create the table, then `pspTable.update(batch)` for each subsequent batch. Perspective is interactive and browseable while remaining batches are still arriving.
|
||||||
|
|
||||||
|
The client detects which path to use by checking the `X-Row-Count` response header (see below).
|
||||||
|
|
||||||
|
**Row-count pre-check**
|
||||||
|
|
||||||
|
Before opening the cursor, the server runs `SELECT COUNT(*) FROM {{fc_table}}`. The result is attached as the `X-Row-Count` response header so the client can choose its loading strategy. If the count exceeds 500 000, the UI displays a non-blocking notice ("Loading large dataset — pivot will become interactive as data arrives") rather than a blank screen.
|
||||||
|
|
||||||
### Forecast Operations
|
### Forecast Operations
|
||||||
|
|
||||||
@ -334,61 +382,140 @@ All operations share a common request envelope:
|
|||||||
|
|
||||||
### Navigation (sidebar)
|
### Navigation (sidebar)
|
||||||
|
|
||||||
1. **Sources** — browse DB tables, register sources, configure col_meta, generate SQL
|
Three-step collapsible sidebar (200 px expanded / 48 px collapsed, state persisted to `localStorage`):
|
||||||
2. **Versions** — list forecast versions per source, create/close/reopen/delete
|
|
||||||
3. **Forecast** — main working view (pivot + operation panel)
|
|
||||||
4. **Log** — change history with undo
|
|
||||||
|
|
||||||
### Sources View
|
1. **① Setup** — browse DB tables, register sources, configure col_meta, generate SQL. One-time admin task.
|
||||||
|
2. **② Baseline** — create/manage versions, load baseline segments, timeline preview. One-time per version.
|
||||||
|
3. **③ Forecast** — main working view: Perspective pivot + operation panel. Primary ongoing use.
|
||||||
|
|
||||||
- Left: DB table browser (like fc_webapp) — all tables with row counts, preview on click
|
### Setup View (① Setup)
|
||||||
- Right: Registered sources list — click to open col_meta editor
|
|
||||||
- Col_meta editor: AG Grid editable table — set role per column, toggle is_key, set label
|
|
||||||
- "Generate SQL" button — triggers generate-sql route, shows confirmation
|
|
||||||
- Must generate SQL before versions can be created against this source
|
|
||||||
|
|
||||||
### Versions View
|
- Left panel: DB table browser — all tables with row counts; click a table to open a preview modal (column list + sample rows)
|
||||||
|
- Right panel: Registered sources list; click a source to open col_meta editor below
|
||||||
|
- Col_meta editor: inline table — role dropdown per column, is_key checkbox, label text input, ordinal position
|
||||||
|
- "Save" button — upserts col_meta; "Generate SQL" button — triggers generate-sql route, shows confirmation
|
||||||
|
- "Register source" button available in the table preview modal
|
||||||
|
- New columns default to role `dimension` on registration
|
||||||
|
- Must generate SQL before a version can be created against this source
|
||||||
|
|
||||||
- List of versions for selected source — name, status (open/closed), created date, row count
|
### Baseline View (② Baseline)
|
||||||
- Create version form — name, description, exclude_iters (defaults to `["reference"]`)
|
|
||||||
- Per-version actions: open forecast, load baseline, load reference, close, reopen, delete
|
Source and version selectors at top. Version management inline: create new version (explains that a forecast table will be created), Close / Reopen / Delete buttons. Delete drops the forecast table and removes all version records.
|
||||||
|
|
||||||
|
### Baseline Workbench
|
||||||
|
|
||||||
|
A dedicated view for constructing the baseline for the selected version. The baseline is built from one or more **segments** — each segment is an independent query against the source table that appends rows to `iter = 'baseline'`. Segments are additive; clearing is explicit.
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Baseline — [Version name] [Clear Baseline] │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Segments loaded (from log): │
|
||||||
|
│ ┌──────┬────────────────┬──────────┬───────┬──────────┐ │
|
||||||
|
│ │ ID │ Description │ Rows │ By │ [Undo] │ │
|
||||||
|
│ └──────┴────────────────┴──────────┴───────┴──────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Add Segment │
|
||||||
|
│ │
|
||||||
|
│ Description [_______________________________________] │
|
||||||
|
│ │
|
||||||
|
│ Date range [date_from] to [date_to] on [date col ▾] │
|
||||||
|
│ Date offset [0] years [0] months │
|
||||||
|
│ │
|
||||||
|
│ Additional filters: │
|
||||||
|
│ [ + Add filter ] │
|
||||||
|
│ ┌──────────────────┬──────────┬──────────────┬───────┐ │
|
||||||
|
│ │ Column │ Op │ Value(s) │ [ x ]│ │
|
||||||
|
│ └──────────────────┴──────────┴──────────────┴───────┘ │
|
||||||
|
│ │
|
||||||
|
│ Preview: [projected month chips] │
|
||||||
|
│ │
|
||||||
|
│ Note [___________] [Load Segment] │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Segments list** — shows all `operation = 'baseline'` log entries for this version, newest first. Each has an Undo button. Undo removes only that segment's rows (by logid), leaving other segments intact.
|
||||||
|
|
||||||
|
**Clear Baseline** — deletes ALL `iter = 'baseline'` rows and all `operation = 'baseline'` log entries for this version. Prompts for confirmation. Used when starting over from scratch.
|
||||||
|
|
||||||
|
**Add Segment form:**
|
||||||
|
|
||||||
|
- **Description** — free text label stored as the log `note`, shown in the segments list
|
||||||
|
- **Date offset** — years + months spinners; shifts the primary `role = 'date'` column forward on insert
|
||||||
|
- **Filters** — one or more filter groups that define what rows to pull. Conditions within a group are AND-ed; groups are OR-ed. There is no separate "date range" section — period selection is just a filter like any other:
|
||||||
|
- Each group has a header row ("Group 1", "Group 2 — OR", …) and a `+ Add condition` link
|
||||||
|
- Within a group: Column (any `role = 'date'` or `role = 'filter'`), Operator (`=`, `!=`, `IN`, `NOT IN`, `BETWEEN`, `IS NULL`, `IS NOT NULL`), Value(s)
|
||||||
|
- Value inputs: `BETWEEN` → two date/text inputs; `IN`/`NOT IN` → comma-separated list; `=`/`!=` → single input; omitted for `IS NULL`/`IS NOT NULL`
|
||||||
|
- `+ Add OR group` button appends a new empty group below, joined by an "OR" separator label
|
||||||
|
- Groups with more than one condition render an "AND" badge between rows to make the logic explicit
|
||||||
|
- A group can be removed with `×` on its header (not available when only one group remains)
|
||||||
|
- At least one group with at least one condition is required to load a segment
|
||||||
|
- **Manual WHERE clause** (admin only) — a toggle link ("Switch to manual SQL") that replaces the filter builder with a plain textarea. The admin types a raw PostgreSQL WHERE clause body (no `WHERE` keyword). Switching back to the builder clears the textarea. When active, the filter builder is hidden and the structured `filters` field is not sent; `raw_where` is sent instead. A prominent warning banner reads: "Raw SQL is not validated. You are responsible for correctness and security."
|
||||||
|
- **Timeline preview** — rendered when any condition in any group is a `BETWEEN` or `=` on a `role = 'date'` column. Shows a horizontal bar (number-line style) for the source period and, if offset > 0, a second bar below for the projected period. Each bar shows start date on the left, end date on the right, duration in the centre. The two bars share the same visual width so the shift is immediately apparent. Not shown in manual WHERE mode or when no date condition is present.
|
||||||
|
- **Note** — optional free text
|
||||||
|
- **Load Segment** — submits; appends rows, does not clear existing baseline rows
|
||||||
|
|
||||||
|
**Example — three-segment baseline:**
|
||||||
|
|
||||||
|
| # | Description | Filter logic | Offset |
|
||||||
|
|---|-------------|--------------|--------|
|
||||||
|
| 1 | All orders taken 6/1/25–3/31/26 | `order_date BETWEEN 2025-06-01 AND 2026-03-31` | 0 |
|
||||||
|
| 2 | Open or unshipped orders (status missing or explicit) | `(status IN ('OPEN','PENDING')) OR (status IS NULL)` | 0 |
|
||||||
|
| 3 | Prior year book-and-ship 4/1/25–5/31/25 | `order_date BETWEEN 2025-04-01 AND 2025-05-31 AND ship_date BETWEEN 2025-04-01 AND 2025-05-31` | 0 |
|
||||||
|
|
||||||
|
Segment 2 uses two OR groups; segment 3 has two AND conditions in one group. Any combination is valid as long as at least one group with at least one condition is present.
|
||||||
|
|
||||||
### Forecast View
|
### Forecast View
|
||||||
|
|
||||||
**Layout:**
|
**Layout:**
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ [Source: sales] [Version: FY2024 v1 — open] [Refresh] │
|
│ [Version label] [Refresh] [Save layout] [Reset layout] │
|
||||||
├────────────────────────┬─────────────────────────────────┤
|
├──────────────────────────────────────┬──────────────────────────┤
|
||||||
│ │ │
|
│ │ │
|
||||||
│ Pivot Grid │ Operation Panel │
|
│ Perspective Viewer │ Operation Panel │
|
||||||
│ (AG Grid pivot mode) │ (active when slice selected) │
|
│ (interactive pivot web component) │ (active when slice set) │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ Slice: │
|
│ │ Slice: │
|
||||||
│ │ channel = WHS │
|
│ │ channel = WHS │
|
||||||
│ │ geography = WEST │
|
│ │ geography = WEST │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ [ Scale ] [ Recode ] [ Clone ] │
|
│ │ [ Scale ] [ Recode ] │
|
||||||
|
│ │ [ Clone ] │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ... operation form ... │
|
│ │ ... operation form ... │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ [ Submit ] │
|
│ │ [ Submit ] │
|
||||||
│ │ │
|
│ │ │
|
||||||
└────────────────────────┴─────────────────────────────────┘
|
└──────────────────────────────────────┴──────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Interaction flow:**
|
**Pivot control:** [Perspective](https://perspective.finos.org/) 4.4.0, loaded from CDN at runtime. Data is fetched from `GET /api/versions/:id/data` as an Arrow IPC binary stream and loaded into an in-browser Perspective worker — Perspective's native ingestion path. Supports grouping, splitting, filtering, sorting, and charting interactively. Layout (group_by, split_by, filters, plugin) is saved per version to `localStorage` via Save layout / Reset layout buttons.
|
||||||
1. Select cells in pivot — selected dimension values populate Operation Panel as slice
|
|
||||||
2. Pick operation tab, fill in parameters
|
|
||||||
3. Submit → POST to API → response shows rows affected
|
|
||||||
4. Grid refreshes (re-fetch `get_data`)
|
|
||||||
|
|
||||||
**Reference rows** shown in pivot (for context) but visually distinguished (e.g., muted color). Operations never affect them.
|
**Large-dataset loading sequence:**
|
||||||
|
1. Client issues `GET /api/versions/:id/data`
|
||||||
|
2. Server responds with `X-Row-Count` header and begins streaming Arrow record batches
|
||||||
|
3. If `X-Row-Count` ≥ 500 000, UI shows a non-blocking loading banner; otherwise no indicator
|
||||||
|
4. Client calls `worker.table(firstBatch)` on the first batch to make the pivot interactive immediately
|
||||||
|
5. Each subsequent batch is applied with `pspTable.update(batch)` as it arrives
|
||||||
|
6. Banner clears when the stream closes
|
||||||
|
|
||||||
|
**Interaction flow:**
|
||||||
|
1. Click a cell or row in the pivot — the `perspective-click` event fires
|
||||||
|
2. `detail.config.filter` from the event is parsed: only `==` filters on `role = dimension` columns are extracted as the slice
|
||||||
|
3. Slice populates the Operation Panel — pick operation tab, fill in parameters
|
||||||
|
4. Submit → POST to API → new rows returned via `RETURNING *` are streamed directly into the Perspective table (`pspTable.update(rows)`) — no full reload needed
|
||||||
|
5. For recode, both the negative offset rows and positive replacement rows are returned and streamed
|
||||||
|
|
||||||
|
**Pivot default layout:** built from col_meta — first two `dimension` columns as `group_by`, `date` column as `split_by`. User can rearrange in Perspective settings panel and save.
|
||||||
|
|
||||||
|
**Reference rows** (`pf_iter = 'reference'`) are visible in the pivot for comparison context. Operations never affect them (enforced by `exclude_iters` in the version).
|
||||||
|
|
||||||
### Log View
|
### Log View
|
||||||
|
|
||||||
AG Grid list of log entries — user, timestamp, operation, slice, note, rows affected.
|
AG Grid list of log entries — user, timestamp, operation, slice, note, rows affected.
|
||||||
"Undo" button per row → `DELETE /api/log/:logid` → grid and pivot refresh.
|
"Undo" button per row → `DELETE /api/log/:logid` → grid and pivot refresh (full reload of Perspective table).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -396,12 +523,50 @@ AG Grid list of log entries — user, timestamp, operation, slice, note, rows af
|
|||||||
|
|
||||||
Column names baked in at generation time. Tokens substituted at request time.
|
Column names baked in at generation time. Tokens substituted at request time.
|
||||||
|
|
||||||
### Baseline / Reference Load
|
### Baseline Load (one segment)
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
WITH ilog AS (
|
WITH ilog AS (
|
||||||
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
|
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note)
|
||||||
VALUES ({{version_id}}, '{{pf_user}}', '{{operation}}', NULL, '{{params}}'::jsonb, '{{note}}')
|
VALUES ({{version_id}}, '{{pf_user}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}')
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
INSERT INTO {{fc_table}} (
|
||||||
|
{dimension_cols}, {value_col}, {units_col}, {date_col},
|
||||||
|
iter, logid, pf_user, created_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
{dimension_cols}, {value_col}, {units_col},
|
||||||
|
({date_col} + '{{date_offset}}'::interval)::date,
|
||||||
|
'baseline', (SELECT id FROM ilog), '{{pf_user}}', now()
|
||||||
|
FROM
|
||||||
|
{schema}.{tname}
|
||||||
|
WHERE
|
||||||
|
{{filter_clause}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Baseline loads are **additive** — no DELETE before INSERT. Each segment appends independently.
|
||||||
|
|
||||||
|
Token details:
|
||||||
|
- `{{date_offset}}` — PostgreSQL interval string (e.g. `1 year`); defaults to `0 days`; applied only to the primary `role = 'date'` column on insert
|
||||||
|
- `{{filter_clause}}` — built from `filters` or `raw_where` at request time (not baked into stored SQL since conditions vary per segment).
|
||||||
|
- Structured path (`filters`): each group becomes a parenthesized AND block; groups are joined with `OR`. Every column is validated against col_meta (`role = 'date'` or `role = 'filter'`). Values are escaped (single quotes doubled). Supported operators: `=`, `!=`, `IN`, `NOT IN`, `BETWEEN`, `IS NULL`, `IS NOT NULL`.
|
||||||
|
- Raw path (`raw_where`): the string is injected verbatim. No col_meta validation. Admin-only.
|
||||||
|
|
||||||
|
### Clear Baseline
|
||||||
|
|
||||||
|
Two queries, run in a transaction:
|
||||||
|
```sql
|
||||||
|
DELETE FROM {{fc_table}} WHERE iter = 'baseline';
|
||||||
|
DELETE FROM pf.log WHERE version_id = {{version_id}} AND operation = 'baseline';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reference Load
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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
|
RETURNING id
|
||||||
)
|
)
|
||||||
INSERT INTO {{fc_table}} (
|
INSERT INTO {{fc_table}} (
|
||||||
@ -410,14 +575,14 @@ INSERT INTO {{fc_table}} (
|
|||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
{dimension_cols}, {value_col}, {units_col}, {date_col},
|
{dimension_cols}, {value_col}, {units_col}, {date_col},
|
||||||
'{{operation}}', (SELECT id FROM ilog), '{{pf_user}}', now()
|
'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
|
||||||
FROM
|
FROM
|
||||||
{schema}.{tname}
|
{schema}.{tname}
|
||||||
WHERE
|
WHERE
|
||||||
{date_col} BETWEEN '{{date_from}}' AND '{{date_to}}'
|
{date_col} BETWEEN '{{date_from}}' AND '{{date_to}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Baseline route also deletes existing `iter = 'baseline'` rows before inserting.
|
No date offset — reference rows land at their original dates for prior-period comparison.
|
||||||
|
|
||||||
### Scale
|
### Scale
|
||||||
|
|
||||||
@ -508,11 +673,14 @@ DELETE FROM pf.log WHERE id = {{logid}};
|
|||||||
## Admin Setup Flow (end-to-end)
|
## Admin Setup Flow (end-to-end)
|
||||||
|
|
||||||
1. Open **Sources** view → browse DB tables → register source table
|
1. Open **Sources** view → browse DB tables → register source table
|
||||||
2. Open col_meta editor → assign roles to columns, mark is_key dimensions, set labels
|
2. Open col_meta editor → assign roles to columns (`dimension`, `value`, `units`, `date`, `filter`, `ignore`), mark is_key dimensions, set labels
|
||||||
3. Click **Generate SQL** → app writes operation SQL to `pf.sql`
|
3. Click **Generate SQL** → app writes operation SQL to `pf.sql`
|
||||||
4. Open **Versions** view → create a named version (sets `exclude_iters`, creates forecast table)
|
4. Open **Versions** view → create a named version (sets `exclude_iters`, creates forecast table)
|
||||||
5. Load **Baseline** → pick date range → inserts `iter = 'baseline'` rows
|
5. Open **Baseline Workbench** → build the baseline from one or more segments:
|
||||||
6. Optionally load **Reference** → pick prior year date range → inserts `iter = 'reference'` rows
|
- Each segment specifies a date range (on any date/filter column), date offset, and optional additional filter conditions
|
||||||
|
- Add segments until the baseline is complete; each is independently undoable
|
||||||
|
- Use "Clear Baseline" to start over if needed
|
||||||
|
6. Optionally load **Reference** → pick prior period date range → inserts `iter = 'reference'` rows at their original dates (for comparison in the pivot)
|
||||||
7. Open **Forecast** view → share with users
|
7. Open **Forecast** view → share with users
|
||||||
|
|
||||||
## User Forecast Flow (end-to-end)
|
## User Forecast Flow (end-to-end)
|
||||||
@ -530,9 +698,38 @@ DELETE FROM pf.log WHERE id = {{logid}};
|
|||||||
## Open Questions / Future Scope
|
## Open Questions / Future Scope
|
||||||
|
|
||||||
- **Baseline replay** — re-execute change log against a restated baseline (`replay: true`); v1 returns 501
|
- **Baseline replay** — re-execute change log against a restated baseline (`replay: true`); v1 returns 501
|
||||||
- **Timing shifts** — redistribute value/units across date buckets (deferred)
|
- **Arrow IPC for initial data load** — at large row counts (1M+) the `/versions/:id/data` JSON response becomes a bottleneck. Option: serve Arrow IPC binary instead of JSON; Perspective's `worker.table()` accepts Arrow buffers natively. Incremental operation rows (scale/recode/clone) can stay as JSON fed to `table.update()` since they're always small. Could be implemented with `pg` + `apache-arrow` in Node, or by adding a server-side DuckDB instance (Postgres scanner → Arrow IPC) if a caching layer is also needed.
|
||||||
- **Approval workflow** — user submits, admin approves before changes are visible to others (deferred)
|
- **Approval workflow** — user submits, admin approves before changes are visible to others (deferred)
|
||||||
- **Territory filtering** — restrict what a user can see/edit by dimension value (deferred)
|
- **Territory filtering** — restrict what a user can see/edit by dimension value (deferred)
|
||||||
- **Export** — download forecast as CSV or push results to a reporting table
|
- **Export** — download forecast as CSV or push results to a reporting table
|
||||||
- **Version comparison** — side-by-side view of two versions (facilitated by isolated tables via UNION)
|
- **Version comparison** — side-by-side view of two versions (facilitated by isolated tables via UNION)
|
||||||
- **Multi-DB sources** — currently assumes same DB; cross-DB would need connection config per source
|
- **Col meta / version schema drift** — if col_meta roles are changed after a version's forecast table is already created, the generated SQL and the table DDL go out of sync (e.g. a column added to SQL that doesn't exist in the table). UI should detect this: compare col_meta against the forecast table's actual columns via `information_schema`, warn the user, and offer to rebuild the version (drop + recreate table, preserving the version record and log). For now the workaround is to delete and recreate the version manually.
|
||||||
|
- **Multi-connection support** — currently one DB via `.env`. Full vision: `pf.connection` table (host, port, dbname, user, password as env-var ref), `connection_id` on `pf.source`, per-connection pg pools at runtime. `pf` schema stays on a "home" connection; source data can live anywhere. Connections UI in Setup. Safe to defer while in dev — requires clean reinstall when added since it changes the source schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Status — 2026-04-25
|
||||||
|
|
||||||
|
### What's working
|
||||||
|
- Full backend: source registration, col_meta, SQL generation, versions, baseline segments, reference load, scale, recode, clone, undo
|
||||||
|
- React + Vite + Tailwind CSS frontend scaffolded in `ui/`, built output to `public/app/`, served by Express
|
||||||
|
- 3-step collapsible sidebar (Setup / Baseline / Forecast) — addresses prior UX concern about opaque 5-tab nav
|
||||||
|
- Setup view: DB table browser with preview modal, source registration, col_meta editor, SQL generation
|
||||||
|
- Baseline view: version management (create/close/reopen/delete), multi-segment baseline workbench, canvas timeline, filter builder
|
||||||
|
- Perspective pivot in Forecast view: loads all version rows, interactive group/split/filter/chart, layout saved per version
|
||||||
|
- Slice extraction from `perspective-click` event feeds operation panel directly
|
||||||
|
- Incremental row streaming: operation results (`RETURNING *`) stream into Perspective table without full reload
|
||||||
|
- Status bar: shows current source · version · baseline row count · status
|
||||||
|
|
||||||
|
### Known issues / next focus
|
||||||
|
|
||||||
|
- **Forecast view** — operation panel (Scale / Recode / Clone) is a stub; needs wiring to API
|
||||||
|
- **Status bar** — currently hardcoded; needs to reflect actual selected source/version from state
|
||||||
|
- **Col_meta / version schema drift** — if col_meta changes after a version's forecast table is created, the SQL and table DDL go out of sync. UI should detect this (compare col_meta against `information_schema`), warn, and offer rebuild. Workaround: delete and recreate the version.
|
||||||
|
- **No "current version" persistence** — source/version selection resets on page reload; session context not persisted
|
||||||
|
- **Perspective slice limitation** — computed date columns (Month, YearDate) extracted via split_by don't filter back to raw rows; only native dimension columns work for slice extraction
|
||||||
|
|
||||||
|
### Branch status
|
||||||
|
- `baseline-workbench` — merged to origin, stable
|
||||||
|
- `perspective-forecast` — active development branch; React UI scaffolded, Forecast operation panel pending
|
||||||
|
|
||||||
|
|||||||
112
pf_ux_mockup.md
Normal file
112
pf_ux_mockup.md
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
# Pivot Forecast — UX Mockup
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Pivot Forecast │
|
||||||
|
│ ① Setup ② Baseline ③ Forecast ◀ (default landing) │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
① SETUP
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
┌──── All Tables ──────────────┐ ┌──── Registered Sources ─────────┐
|
||||||
|
│ schema table rows │ │ │
|
||||||
|
│ ────── ────────── ────── │ │ sales_orders ✓ SQL ready │
|
||||||
|
│ public sales_orders 48,291 │◀─│ invoices ✓ SQL ready │
|
||||||
|
│ public invoices 12,004 │ │ + Register table │
|
||||||
|
│ public products 891 │ └──────────────────────────────────┘
|
||||||
|
│ rpt summary_mv 3,442 │
|
||||||
|
└──────────────────────────────┘ ┌──── Col Meta: sales_orders ─────┐
|
||||||
|
│ column role key label│
|
||||||
|
│ ────────── ──────── ─── ─── │
|
||||||
|
│ customer dimension ✓ │
|
||||||
|
│ channel dimension ✓ │
|
||||||
|
│ part dimension │
|
||||||
|
│ geography dimension │
|
||||||
|
│ order_date date │
|
||||||
|
│ ship_date filter │
|
||||||
|
│ status filter │
|
||||||
|
│ units units │
|
||||||
|
│ revenue value │
|
||||||
|
│ internal_id ignore │
|
||||||
|
│ │
|
||||||
|
│ [Generate SQL ▶] │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
② BASELINE
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
Source [sales_orders ▾] Version [FY2026 Plan ▾] [+ New version]
|
||||||
|
|
||||||
|
┌──── Segments ──────────────────────────────────────────────────────┐
|
||||||
|
│ # description rows by date │
|
||||||
|
│ ─ ──────────────────────────── ────── ────── ────────────── │
|
||||||
|
│ 1 FY25 actuals +1yr 41,204 paul Apr 24 │
|
||||||
|
│ 2 Open orders 3,109 paul Apr 24 [Undo] │
|
||||||
|
│ │
|
||||||
|
│ Total baseline rows: 44,313 [Clear all baseline] │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──── Add Segment ────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ Description [ ] │
|
||||||
|
│ │
|
||||||
|
│ Filters [+ Add filter] │
|
||||||
|
│ ┌─────────────────┬──────────┬─────────────────────┬───┐ │
|
||||||
|
│ │ order_date │ BETWEEN │ 2025-01-01 2025-12-31│ x │ │
|
||||||
|
│ └─────────────────┴──────────┴─────────────────────┴───┘ │
|
||||||
|
│ │
|
||||||
|
│ Date offset [1] yr [0] mo │
|
||||||
|
│ │
|
||||||
|
│ ·───────────────────────────· source │
|
||||||
|
│ Jan 2025 Dec 2025 │
|
||||||
|
│ ·───────────────────────────· projected (+1 yr) │
|
||||||
|
│ Jan 2026 Dec 2026 │
|
||||||
|
│ │
|
||||||
|
│ Note [ ] [Load Segment] │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──── Reference (optional) ──────────────────────────────────────────┐
|
||||||
|
│ Load prior-period rows for comparison in the pivot │
|
||||||
|
│ Date range [2024-01-01] to [2024-12-31] │
|
||||||
|
│ Note [ ] [Load Ref] │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
③ FORECAST source: sales_orders
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
Version [FY2026 Plan ▾] [Refresh] [Save layout] [Reset layout]
|
||||||
|
|
||||||
|
┌──── Pivot ───────────────────────────────┐ ┌──── Operations ───────┐
|
||||||
|
│ │ │ │
|
||||||
|
│ (Perspective viewer) │ │ Slice │
|
||||||
|
│ │ │ channel = WHS │
|
||||||
|
│ channel │ Jan 2026 │ Feb 2026 │ ... │ │ geo = WEST │
|
||||||
|
│ ──────────┼──────────┼──────────┼─── │ │ │
|
||||||
|
│ DIR │ 412,000 │ 388,000 │ │ │ [Scale][Recode] │
|
||||||
|
│ WHS ◀ │ 290,000 │ 310,000 │ │ │ [Clone] │
|
||||||
|
│ ──────── │ │ │ │ │ ─────────────────── │
|
||||||
|
│ Total │ 702,000 │ 698,000 │ │ │ Value incr [ ] │
|
||||||
|
│ │ │ Units incr [ ] │
|
||||||
|
│ │ │ Pct? [ ] │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ Note [ ] │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ [Submit] │
|
||||||
|
└──────────────────────────────────────────┘ └───────────────────────┘
|
||||||
|
|
||||||
|
▼ Change log (12 entries)
|
||||||
|
┌────┬───────────┬──────────┬─────────────────────────┬────────────┐
|
||||||
|
│ id │ operation │ by │ slice │ │
|
||||||
|
│ ── │ ───────── │ ──────── │ ───────────────────── ─ │ │
|
||||||
|
│ 12 │ scale │ paul │ channel=WHS geo=WEST │ [Undo] │
|
||||||
|
│ 11 │ recode │ paul │ part=OLD-SKU │ [Undo] │
|
||||||
|
│ 10 │ scale │ paul │ channel=DIR │ [Undo] │
|
||||||
|
└────┴───────────┴──────────┴─────────────────────────┴────────────┘
|
||||||
|
```
|
||||||
538
public/app.js
538
public/app.js
@ -16,9 +16,10 @@ const state = {
|
|||||||
sources: null,
|
sources: null,
|
||||||
colMeta: null,
|
colMeta: null,
|
||||||
versions: null,
|
versions: null,
|
||||||
pivot: null,
|
|
||||||
log: null
|
log: null
|
||||||
}
|
},
|
||||||
|
pspWorker: null, // Perspective worker
|
||||||
|
pspTable: null, // Perspective table
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@ -50,7 +51,7 @@ function showStatus(msg, type = 'info') {
|
|||||||
NAVIGATION
|
NAVIGATION
|
||||||
============================================================ */
|
============================================================ */
|
||||||
function switchView(name) {
|
function switchView(name) {
|
||||||
if ((name === 'forecast' || name === 'log') && !state.version) {
|
if ((name === 'forecast' || name === 'log' || name === 'baseline') && !state.version) {
|
||||||
showStatus('Select a version first', 'error');
|
showStatus('Select a version first', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -68,6 +69,7 @@ function switchView(name) {
|
|||||||
if (name === 'versions') renderVersions();
|
if (name === 'versions') renderVersions();
|
||||||
if (name === 'forecast') loadForecastData();
|
if (name === 'forecast') loadForecastData();
|
||||||
if (name === 'log') loadLogData();
|
if (name === 'log') loadLogData();
|
||||||
|
if (name === 'baseline') openBaselineWorkbench();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSource(source) {
|
function setSource(source) {
|
||||||
@ -115,15 +117,20 @@ function renderTablesGrid(tables) {
|
|||||||
|
|
||||||
state.grids.tables = agGrid.createGrid(el, {
|
state.grids.tables = agGrid.createGrid(el, {
|
||||||
columnDefs: [
|
columnDefs: [
|
||||||
{ field: 'schema', headerName: 'Schema', width: 90 },
|
{ field: 'schema', headerName: 'Schema', flex: 1 },
|
||||||
{ field: 'tname', headerName: 'Table', flex: 1 },
|
{ field: 'tname', headerName: 'Table', flex: 1 },
|
||||||
{ field: 'row_estimate', headerName: 'Rows', width: 80,
|
{ field: 'row_estimate', headerName: 'Rows', flex: 1,
|
||||||
valueFormatter: p => p.value != null ? Number(p.value).toLocaleString() : '—' }
|
valueFormatter: p => p.value != null ? Number(p.value).toLocaleString() : '—' }
|
||||||
],
|
],
|
||||||
rowData: tables,
|
rowData: tables,
|
||||||
rowSelection: 'single',
|
rowSelection: 'single',
|
||||||
onRowClicked: onTableRowClicked,
|
onRowClicked: onTableRowClicked,
|
||||||
onRowDoubleClicked: e => showTablePreview(e.data.schema, e.data.tname),
|
onRowDoubleClicked: e => showTablePreview(e.data.schema, e.data.tname),
|
||||||
|
allowContextMenuWithControlKey: true,
|
||||||
|
getContextMenuItems: e => [
|
||||||
|
{ name: 'Preview', action: () => showTablePreview(e.node.data.schema, e.node.data.tname) },
|
||||||
|
{ name: 'Register', action: () => registerTable(e.node.data.schema, e.node.data.tname) }
|
||||||
|
],
|
||||||
defaultColDef: { resizable: true, sortable: true },
|
defaultColDef: { resizable: true, sortable: true },
|
||||||
headerHeight: 32, rowHeight: 28
|
headerHeight: 32, rowHeight: 28
|
||||||
});
|
});
|
||||||
@ -133,6 +140,7 @@ function onTableRowClicked(e) {
|
|||||||
const { schema, tname } = e.data;
|
const { schema, tname } = e.data;
|
||||||
state.previewSchema = schema;
|
state.previewSchema = schema;
|
||||||
state.previewTname = tname;
|
state.previewTname = tname;
|
||||||
|
document.getElementById('btn-preview').classList.remove('hidden');
|
||||||
document.getElementById('btn-register').classList.remove('hidden');
|
document.getElementById('btn-register').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,13 +219,11 @@ function renderSourcesGrid(sources) {
|
|||||||
|
|
||||||
async function selectSource(source) {
|
async function selectSource(source) {
|
||||||
setSource(source);
|
setSource(source);
|
||||||
|
document.getElementById('btn-delete-source').classList.remove('hidden');
|
||||||
try {
|
try {
|
||||||
state.colMeta = await api('GET', `/sources/${source.id}/cols`);
|
state.colMeta = await api('GET', `/sources/${source.id}/cols`);
|
||||||
renderColMetaGrid(state.colMeta);
|
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('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-save-cols').classList.remove('hidden');
|
||||||
document.getElementById('btn-generate-sql').classList.remove('hidden');
|
document.getElementById('btn-generate-sql').classList.remove('hidden');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -225,13 +231,24 @@ async function selectSource(source) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function backToSources() {
|
async function deleteSource() {
|
||||||
document.getElementById('sources-list-grid').classList.remove('hidden');
|
if (!state.source) return;
|
||||||
document.getElementById('col-meta-grid').classList.add('hidden');
|
const { id, schema, tname } = state.source;
|
||||||
document.getElementById('right-panel-title').textContent = 'Registered Sources';
|
if (!confirm(`Delete source ${schema}.${tname}? This does not drop existing forecast tables.`)) return;
|
||||||
document.getElementById('btn-back-sources').classList.add('hidden');
|
try {
|
||||||
|
await api('DELETE', `/sources/${id}`);
|
||||||
|
showStatus(`Source ${tname} deleted`, 'success');
|
||||||
|
setSource(null);
|
||||||
|
document.getElementById('btn-delete-source').classList.add('hidden');
|
||||||
document.getElementById('btn-save-cols').classList.add('hidden');
|
document.getElementById('btn-save-cols').classList.add('hidden');
|
||||||
document.getElementById('btn-generate-sql').classList.add('hidden');
|
document.getElementById('btn-generate-sql').classList.add('hidden');
|
||||||
|
document.getElementById('right-panel-title').textContent = 'Select a source to map columns';
|
||||||
|
state.grids.colMeta?.setGridOption('rowData', []);
|
||||||
|
const sources = await api('GET', '/sources');
|
||||||
|
state.grids.sources?.setGridOption('rowData', sources);
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(err.message, 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderColMetaGrid(colMeta) {
|
function renderColMetaGrid(colMeta) {
|
||||||
@ -375,10 +392,81 @@ function showLoadForm(op) {
|
|||||||
state.loadDataOp = op;
|
state.loadDataOp = op;
|
||||||
document.getElementById('load-data-title').textContent =
|
document.getElementById('load-data-title').textContent =
|
||||||
op === 'baseline' ? 'Load Baseline' : 'Load Reference';
|
op === 'baseline' ? 'Load Baseline' : 'Load Reference';
|
||||||
document.getElementById('load-data-form').classList.remove('hidden');
|
document.getElementById('load-date-from').value = '';
|
||||||
|
document.getElementById('load-date-to').value = '';
|
||||||
|
document.getElementById('load-offset-years').value = '0';
|
||||||
|
document.getElementById('load-offset-months').value = '0';
|
||||||
|
document.getElementById('load-note').value = '';
|
||||||
|
document.getElementById('load-date-preview').classList.add('hidden');
|
||||||
|
|
||||||
|
const showOffset = op === 'baseline';
|
||||||
|
document.getElementById('load-offset-fields').classList.toggle('hidden', !showOffset);
|
||||||
|
|
||||||
|
document.getElementById('load-data-modal').classList.remove('hidden');
|
||||||
document.getElementById('load-date-from').focus();
|
document.getElementById('load-date-from').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideLoadModal() {
|
||||||
|
document.getElementById('load-data-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMonthList(fromVal, toVal) {
|
||||||
|
const from = new Date(fromVal + 'T00:00:00');
|
||||||
|
const to = new Date(toVal + 'T00:00:00');
|
||||||
|
if (isNaN(from) || isNaN(to) || from > to) return null;
|
||||||
|
const months = [];
|
||||||
|
const cur = new Date(from.getFullYear(), from.getMonth(), 1);
|
||||||
|
const end = new Date(to.getFullYear(), to.getMonth(), 1);
|
||||||
|
while (cur <= end) { months.push(new Date(cur)); cur.setMonth(cur.getMonth() + 1); }
|
||||||
|
return months;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChips(months, fmt) {
|
||||||
|
if (months.length <= 36) {
|
||||||
|
return months.map(m => `<span class="date-chip">${fmt.format(m)}</span>`).join('');
|
||||||
|
}
|
||||||
|
return `<span class="date-chip-summary">${months.length} months — ${fmt.format(months[0])} → ${fmt.format(months[months.length - 1])}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDatePreview() {
|
||||||
|
const fromVal = document.getElementById('load-date-from').value;
|
||||||
|
const toVal = document.getElementById('load-date-to').value;
|
||||||
|
const preview = document.getElementById('load-date-preview');
|
||||||
|
const simple = document.getElementById('load-preview-simple');
|
||||||
|
const offset = document.getElementById('load-preview-offset');
|
||||||
|
|
||||||
|
if (!fromVal || !toVal) { preview.classList.add('hidden'); return; }
|
||||||
|
|
||||||
|
const months = buildMonthList(fromVal, toVal);
|
||||||
|
if (!months) { preview.classList.add('hidden'); return; }
|
||||||
|
|
||||||
|
const fmt = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' });
|
||||||
|
|
||||||
|
if (state.loadDataOp === 'baseline') {
|
||||||
|
const years = parseInt(document.getElementById('load-offset-years').value) || 0;
|
||||||
|
const mths = parseInt(document.getElementById('load-offset-months').value) || 0;
|
||||||
|
const projected = months.map(d => {
|
||||||
|
const p = new Date(d);
|
||||||
|
p.setFullYear(p.getFullYear() + years);
|
||||||
|
p.setMonth(p.getMonth() + mths);
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('load-chips-source').innerHTML = renderChips(months, fmt);
|
||||||
|
document.getElementById('load-chips-projected').innerHTML = renderChips(projected, fmt);
|
||||||
|
simple.classList.add('hidden');
|
||||||
|
offset.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
simple.querySelector('.load-preview-label').textContent =
|
||||||
|
`${months.length} month${months.length !== 1 ? 's' : ''} covered`;
|
||||||
|
document.getElementById('load-date-chips').innerHTML = renderChips(months, fmt);
|
||||||
|
offset.classList.add('hidden');
|
||||||
|
simple.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
preview.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
async function submitLoadData() {
|
async function submitLoadData() {
|
||||||
const date_from = document.getElementById('load-date-from').value;
|
const date_from = document.getElementById('load-date-from').value;
|
||||||
const date_to = document.getElementById('load-date-to').value;
|
const date_to = document.getElementById('load-date-to').value;
|
||||||
@ -391,10 +479,19 @@ async function submitLoadData() {
|
|||||||
note: document.getElementById('load-note').value.trim() || undefined
|
note: document.getElementById('load-note').value.trim() || undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (state.loadDataOp === 'baseline') {
|
||||||
|
const years = parseInt(document.getElementById('load-offset-years').value) || 0;
|
||||||
|
const months = parseInt(document.getElementById('load-offset-months').value) || 0;
|
||||||
|
const parts = [];
|
||||||
|
if (years) parts.push(`${years} year${years !== 1 ? 's' : ''}`);
|
||||||
|
if (months) parts.push(`${months} month${months !== 1 ? 's' : ''}`);
|
||||||
|
body.date_offset = parts.length ? parts.join(' ') : '0 days';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showStatus(`Loading ${state.loadDataOp}...`, 'info');
|
showStatus(`Loading ${state.loadDataOp}...`, 'info');
|
||||||
const result = await api('POST', `/versions/${state.selectedVersionId}/${state.loadDataOp}`, body);
|
const result = await api('POST', `/versions/${state.selectedVersionId}/${state.loadDataOp}`, body);
|
||||||
document.getElementById('load-data-form').classList.add('hidden');
|
hideLoadModal();
|
||||||
showStatus(`${state.loadDataOp} loaded — ${result.rows_affected} rows`, 'success');
|
showStatus(`${state.loadDataOp} loaded — ${result.rows_affected} rows`, 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showStatus(err.message, 'error');
|
showStatus(err.message, 'error');
|
||||||
@ -448,9 +545,163 @@ function openForecast() {
|
|||||||
switchView('forecast');
|
switchView('forecast');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openVersionBaseline() {
|
||||||
|
if (!state.selectedVersionId) return;
|
||||||
|
let v = null;
|
||||||
|
state.grids.versions.forEachNode(n => {
|
||||||
|
if (n.data.id === state.selectedVersionId) v = n.data;
|
||||||
|
});
|
||||||
|
if (!v) return;
|
||||||
|
setVersion(v);
|
||||||
|
switchView('baseline');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
BASELINE WORKBENCH
|
||||||
|
============================================================ */
|
||||||
|
async function openBaselineWorkbench() {
|
||||||
|
if (!state.version) return;
|
||||||
|
document.getElementById('baseline-label').textContent =
|
||||||
|
`${state.source?.tname || ''} — ${state.version.name} [${state.version.status}]`;
|
||||||
|
|
||||||
|
// ensure colMeta loaded
|
||||||
|
if (!state.colMeta.length && state.source) {
|
||||||
|
state.colMeta = await api('GET', `/sources/${state.source.id}/cols`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// populate column chips
|
||||||
|
const chips = document.getElementById('seg-col-chips');
|
||||||
|
chips.innerHTML = state.colMeta
|
||||||
|
.filter(c => c.role !== 'ignore')
|
||||||
|
.map(c => `<span class="col-chip" data-col="${c.cname}">${c.cname}</span>`)
|
||||||
|
.join('');
|
||||||
|
chips.querySelectorAll('.col-chip').forEach(chip => {
|
||||||
|
chip.addEventListener('click', () => insertAtCursor(document.getElementById('seg-where'), chip.dataset.col));
|
||||||
|
});
|
||||||
|
|
||||||
|
// reset form
|
||||||
|
document.getElementById('seg-description').value = '';
|
||||||
|
document.getElementById('seg-offset-type').value = '';
|
||||||
|
document.getElementById('seg-offset-value').value = '0';
|
||||||
|
document.getElementById('seg-where').value = '';
|
||||||
|
document.getElementById('seg-timeline').classList.add('hidden');
|
||||||
|
|
||||||
|
await loadBaselineSegments();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertAtCursor(textarea, text) {
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const val = textarea.value;
|
||||||
|
textarea.value = val.slice(0, start) + text + val.slice(end);
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = start + text.length;
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBaselineSegments() {
|
||||||
|
if (!state.version) return;
|
||||||
|
try {
|
||||||
|
const logs = await api('GET', `/versions/${state.version.id}/log`);
|
||||||
|
const segments = logs.filter(l => l.operation === 'baseline');
|
||||||
|
renderBaselineSegments(segments);
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBaselineSegments(segments) {
|
||||||
|
const el = document.getElementById('baseline-segments-list');
|
||||||
|
if (segments.length === 0) {
|
||||||
|
el.innerHTML = '<div class="segments-empty">No segments loaded yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = segments.map(s => {
|
||||||
|
const params = s.params || {};
|
||||||
|
const whereClause = params.where_clause && params.where_clause !== 'TRUE' ? params.where_clause : '';
|
||||||
|
const offset = params.date_offset && params.date_offset !== '0 days' ? `offset: ${params.date_offset}` : '';
|
||||||
|
const paramsText = [whereClause, offset].filter(Boolean).join(' · ');
|
||||||
|
const stamp = s.stamp ? new Date(s.stamp).toLocaleString() : '';
|
||||||
|
return `
|
||||||
|
<div class="segment-card">
|
||||||
|
<div class="segment-card-header">
|
||||||
|
<span class="segment-card-note">${s.note || '(no description)'}</span>
|
||||||
|
<span class="segment-card-meta">${s.pf_user} — ${stamp}</span>
|
||||||
|
<button class="btn btn-sm btn-danger" data-logid="${s.id}">Undo</button>
|
||||||
|
</div>
|
||||||
|
${paramsText ? `<div class="segment-card-params">${paramsText}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function updateTimelinePreview() {
|
||||||
|
const el = document.getElementById('seg-timeline');
|
||||||
|
const offsetType = document.getElementById('seg-offset-type').value;
|
||||||
|
const offsetValue = parseInt(document.getElementById('seg-offset-value').value) || 0;
|
||||||
|
|
||||||
|
if (!offsetType || !offsetValue) { el.classList.add('hidden'); return; }
|
||||||
|
|
||||||
|
el.innerHTML = `<div class="timeline-offset-indicator">+ ${offsetValue} ${offsetType}${offsetValue !== 1 ? 's' : ''} applied to date column</div>`;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBaselineSegment() {
|
||||||
|
if (!state.version) return;
|
||||||
|
|
||||||
|
const offsetType = document.getElementById('seg-offset-type').value;
|
||||||
|
const offsetValue = parseInt(document.getElementById('seg-offset-value').value) || 0;
|
||||||
|
const date_offset = (offsetType && offsetValue)
|
||||||
|
? `${offsetValue} ${offsetType}${offsetValue !== 1 ? 's' : ''}`
|
||||||
|
: '0 days';
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
where_clause: document.getElementById('seg-where').value.trim() || undefined,
|
||||||
|
date_offset,
|
||||||
|
pf_user: getPfUser(),
|
||||||
|
note: document.getElementById('seg-description').value.trim() || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
showStatus('Loading segment...', 'info');
|
||||||
|
const result = await api('POST', `/versions/${state.version.id}/baseline`, body);
|
||||||
|
showStatus(`Segment loaded — ${result.rows_affected} rows`, 'success');
|
||||||
|
document.getElementById('seg-description').value = '';
|
||||||
|
document.getElementById('seg-where').value = '';
|
||||||
|
document.getElementById('seg-offset-type').value = '';
|
||||||
|
document.getElementById('seg-offset-value').value = '0';
|
||||||
|
document.getElementById('seg-timeline').classList.add('hidden');
|
||||||
|
await loadBaselineSegments();
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearBaseline() {
|
||||||
|
if (!state.version) return;
|
||||||
|
if (!confirm('Delete all baseline rows and segment history for this version? This cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
const result = await api('DELETE', `/versions/${state.version.id}/baseline`);
|
||||||
|
showStatus(`Baseline cleared — ${result.rows_deleted} rows removed`, 'success');
|
||||||
|
await loadBaselineSegments();
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
FORECAST VIEW — data loading
|
FORECAST VIEW — data loading
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
function parseNumericRows(rows) {
|
||||||
|
const numericCols = state.colMeta
|
||||||
|
.filter(c => c.role === 'value' || c.role === 'units')
|
||||||
|
.map(c => c.cname);
|
||||||
|
return rows.map(row => {
|
||||||
|
const r = { ...row };
|
||||||
|
numericCols.forEach(col => { if (r[col] != null) r[col] = parseFloat(r[col]); });
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadForecastData() {
|
async function loadForecastData() {
|
||||||
if (!state.version) return;
|
if (!state.version) return;
|
||||||
document.getElementById('forecast-label').textContent =
|
document.getElementById('forecast-label').textContent =
|
||||||
@ -462,15 +713,8 @@ async function loadForecastData() {
|
|||||||
}
|
}
|
||||||
showStatus('Loading forecast data...', 'info');
|
showStatus('Loading forecast data...', 'info');
|
||||||
const rawData = await api('GET', `/versions/${state.version.id}/data`);
|
const rawData = await api('GET', `/versions/${state.version.id}/data`);
|
||||||
const numericCols = state.colMeta
|
const data = parseNumericRows(rawData);
|
||||||
.filter(c => c.role === 'value' || c.role === 'units')
|
await initPerspectiveViewer(data);
|
||||||
.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');
|
showStatus(`Loaded ${data.length.toLocaleString()} rows`, 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showStatus(err.message, 'error');
|
showStatus(err.message, 'error');
|
||||||
@ -478,125 +722,100 @@ async function loadForecastData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
FORECAST VIEW — pivot grid
|
FORECAST VIEW — Perspective pivot
|
||||||
============================================================ */
|
============================================================ */
|
||||||
function buildPivotColDefs() {
|
let _perspectivePromise = null;
|
||||||
const defs = [];
|
function loadPerspective() {
|
||||||
|
if (_perspectivePromise) return _perspectivePromise;
|
||||||
|
_perspectivePromise = (async () => {
|
||||||
|
const [{ default: perspective }] = await Promise.all([
|
||||||
|
import('https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.4.0/dist/cdn/perspective.js'),
|
||||||
|
import('https://cdn.jsdelivr.net/npm/@perspective-dev/viewer@4.4.0/dist/cdn/perspective-viewer.js'),
|
||||||
|
import('https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-datagrid@4.4.0/dist/cdn/perspective-viewer-datagrid.js'),
|
||||||
|
import('https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-d3fc@4.4.0/dist/cdn/perspective-viewer-d3fc.js'),
|
||||||
|
]);
|
||||||
|
return perspective;
|
||||||
|
})();
|
||||||
|
return _perspectivePromise;
|
||||||
|
}
|
||||||
|
|
||||||
state.colMeta.forEach((c) => {
|
function pspLayoutKey() {
|
||||||
if (c.role === 'ignore') return;
|
return `psp_layout_pf_${state.version?.id}`;
|
||||||
const needsGetter = /\W/.test(c.cname);
|
}
|
||||||
const def = {
|
|
||||||
field: c.cname,
|
function buildDefaultLayout() {
|
||||||
headerName: c.label || c.cname,
|
const dims = state.colMeta.filter(c => c.role === 'dimension').map(c => c.cname);
|
||||||
resizable: true,
|
const values = state.colMeta.filter(c => c.role === 'value' || c.role === 'units').map(c => c.cname);
|
||||||
sortable: true,
|
const date = state.colMeta.find(c => c.role === 'date')?.cname;
|
||||||
...(needsGetter ? { valueGetter: p => p.data ? p.data[c.cname] : undefined } : {})
|
return {
|
||||||
|
group_by: dims.slice(0, 2), // first two dimensions as row groups
|
||||||
|
split_by: date ? [date] : [], // date as column pivot
|
||||||
|
columns: values,
|
||||||
|
settings: true,
|
||||||
|
plugin_config: { edit_mode: 'SELECT_ROW' }
|
||||||
};
|
};
|
||||||
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) {
|
async function initPerspectiveViewer(data) {
|
||||||
const el = document.getElementById('pivot-grid');
|
const tableName = `pf_${state.version.id}`;
|
||||||
|
|
||||||
if (state.grids.pivot) {
|
// terminate old worker if reloading
|
||||||
state.grids.pivot.setGridOption('rowData', data);
|
if (state.pspWorker) {
|
||||||
return;
|
try { state.pspWorker.terminate(); } catch (_) {}
|
||||||
|
state.pspWorker = null;
|
||||||
|
state.pspTable = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.grids.pivot = agGrid.createGrid(el, {
|
const perspective = await loadPerspective();
|
||||||
columnDefs: buildPivotColDefs(),
|
const worker = await perspective.worker();
|
||||||
rowData: data,
|
state.pspWorker = worker;
|
||||||
rowSelection: 'single',
|
|
||||||
groupDisplayType: 'singleColumn',
|
const table = await worker.table(data, { name: tableName });
|
||||||
rowGroupPanelShow: 'always',
|
state.pspTable = table;
|
||||||
groupDefaultExpanded: 1,
|
|
||||||
suppressAggFuncInHeader: true,
|
const v = document.getElementById('pivot-viewer');
|
||||||
animateRows: true,
|
|
||||||
sideBar: {
|
// re-attach click listener (use named handler so it can be replaced on refresh)
|
||||||
toolPanels: [{
|
v._pspClickHandler = async (e) => {
|
||||||
id: 'columns',
|
const detail = e.detail || {};
|
||||||
labelDefault: 'Columns',
|
const eventFilters = (detail.config || {}).filter || [];
|
||||||
labelKey: 'columns',
|
extractSliceFromPerspective(eventFilters);
|
||||||
iconKey: 'columns',
|
};
|
||||||
toolPanel: 'agColumnsToolPanel',
|
v.removeEventListener('perspective-click', v._pspClickHandler);
|
||||||
toolPanelParams: {}
|
v.addEventListener('perspective-click', v._pspClickHandler);
|
||||||
}],
|
|
||||||
defaultToolPanel: 'columns'
|
await v.load(worker);
|
||||||
},
|
|
||||||
defaultColDef: { resizable: true, sortable: true },
|
const saved = localStorage.getItem(pspLayoutKey());
|
||||||
autoGroupColumnDef: {
|
if (saved) {
|
||||||
headerName: 'Group',
|
const layout = JSON.parse(saved);
|
||||||
minWidth: 200,
|
await v.restore({ ...layout, table: tableName });
|
||||||
cellRendererParams: { suppressCount: false }
|
} else {
|
||||||
},
|
await v.restore({ ...buildDefaultLayout(), table: tableName });
|
||||||
headerHeight: 32,
|
}
|
||||||
rowHeight: 28,
|
|
||||||
onRowClicked: onPivotRowClicked
|
// update reset button visibility
|
||||||
});
|
document.getElementById('btn-reset-layout')
|
||||||
|
.classList.toggle('hidden', !localStorage.getItem(pspLayoutKey()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
FORECAST VIEW — slice selection
|
FORECAST VIEW — slice selection
|
||||||
============================================================ */
|
============================================================ */
|
||||||
function onPivotRowClicked(event) {
|
function extractSliceFromPerspective(eventFilters, config) {
|
||||||
const node = event.node;
|
const dimFields = new Set(state.colMeta.filter(c => c.role === 'dimension').map(c => c.cname));
|
||||||
state.slice = extractSliceFromNode(node);
|
const slice = {};
|
||||||
|
for (const [field, op, value] of eventFilters) {
|
||||||
|
if (op === '==' && dimFields.has(field) && value != null && value !== '') {
|
||||||
|
slice[field] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.slice = slice;
|
||||||
renderSliceDisplay();
|
renderSliceDisplay();
|
||||||
|
|
||||||
// populate recode and clone fields whenever slice changes
|
|
||||||
renderDimFields('recode');
|
renderDimFields('recode');
|
||||||
renderDimFields('clone');
|
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() {
|
function renderSliceDisplay() {
|
||||||
const display = document.getElementById('slice-display');
|
const display = document.getElementById('slice-display');
|
||||||
const hasSlice = Object.keys(state.slice).length > 0;
|
const hasSlice = Object.keys(state.slice).length > 0;
|
||||||
@ -695,7 +914,7 @@ async function submitScale() {
|
|||||||
document.getElementById('scale-value-incr').value = '';
|
document.getElementById('scale-value-incr').value = '';
|
||||||
document.getElementById('scale-units-incr').value = '';
|
document.getElementById('scale-units-incr').value = '';
|
||||||
document.getElementById('scale-note').value = '';
|
document.getElementById('scale-note').value = '';
|
||||||
await loadForecastData();
|
if (state.pspTable) await state.pspTable.update(parseNumericRows(result.rows));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showStatus(err.message, 'error');
|
showStatus(err.message, 'error');
|
||||||
}
|
}
|
||||||
@ -721,7 +940,7 @@ async function submitRecode() {
|
|||||||
});
|
});
|
||||||
showStatus(`Recode applied — ${result.rows_affected} rows inserted`, 'success');
|
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 = ''; });
|
document.querySelectorAll('#recode-fields input[data-col], #recode-fields select[data-col]').forEach(i => { i.value = ''; });
|
||||||
await loadForecastData();
|
if (state.pspTable) await state.pspTable.update(parseNumericRows(result.rows));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showStatus(err.message, 'error');
|
showStatus(err.message, 'error');
|
||||||
}
|
}
|
||||||
@ -750,7 +969,7 @@ async function submitClone() {
|
|||||||
showStatus(`Clone applied — ${result.rows_affected} rows inserted`, 'success');
|
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.querySelectorAll('#clone-fields input[data-col], #clone-fields select[data-col]').forEach(i => { i.value = ''; });
|
||||||
document.getElementById('clone-scale').value = '1';
|
document.getElementById('clone-scale').value = '1';
|
||||||
await loadForecastData();
|
if (state.pspTable) await state.pspTable.update(parseNumericRows(result.rows));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showStatus(err.message, 'error');
|
showStatus(err.message, 'error');
|
||||||
}
|
}
|
||||||
@ -780,6 +999,8 @@ function renderLogGrid(logs) {
|
|||||||
{ field: 'operation', headerName: 'Operation', width: 90 },
|
{ field: 'operation', headerName: 'Operation', width: 90 },
|
||||||
{ field: 'slice', headerName: 'Slice', flex: 1,
|
{ field: 'slice', headerName: 'Slice', flex: 1,
|
||||||
valueFormatter: p => p.value ? JSON.stringify(p.value) : '' },
|
valueFormatter: p => p.value ? JSON.stringify(p.value) : '' },
|
||||||
|
{ field: 'params', headerName: 'Params', flex: 1,
|
||||||
|
valueFormatter: p => p.value ? JSON.stringify(p.value) : '' },
|
||||||
{ field: 'note', headerName: 'Note', flex: 1 },
|
{ field: 'note', headerName: 'Note', flex: 1 },
|
||||||
{
|
{
|
||||||
headerName: '',
|
headerName: '',
|
||||||
@ -841,12 +1062,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// sources view buttons
|
// sources view buttons
|
||||||
|
document.getElementById('btn-preview').addEventListener('click', () => {
|
||||||
|
if (state.previewSchema && state.previewTname) {
|
||||||
|
showTablePreview(state.previewSchema, state.previewTname);
|
||||||
|
}
|
||||||
|
});
|
||||||
document.getElementById('btn-register').addEventListener('click', () => {
|
document.getElementById('btn-register').addEventListener('click', () => {
|
||||||
if (state.previewSchema && state.previewTname) {
|
if (state.previewSchema && state.previewTname) {
|
||||||
registerTable(state.previewSchema, state.previewTname);
|
registerTable(state.previewSchema, state.previewTname);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.getElementById('btn-back-sources').addEventListener('click', backToSources);
|
document.getElementById('btn-delete-source').addEventListener('click', deleteSource);
|
||||||
document.getElementById('btn-save-cols').addEventListener('click', saveColMeta);
|
document.getElementById('btn-save-cols').addEventListener('click', saveColMeta);
|
||||||
document.getElementById('btn-generate-sql').addEventListener('click', generateSQL);
|
document.getElementById('btn-generate-sql').addEventListener('click', generateSQL);
|
||||||
|
|
||||||
@ -870,19 +1096,35 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.getElementById('new-version-form').classList.add('hidden');
|
document.getElementById('new-version-form').classList.add('hidden');
|
||||||
});
|
});
|
||||||
document.getElementById('vbtn-forecast').addEventListener('click', openForecast);
|
document.getElementById('vbtn-forecast').addEventListener('click', openForecast);
|
||||||
document.getElementById('vbtn-baseline').addEventListener('click', () => showLoadForm('baseline'));
|
document.getElementById('vbtn-baseline').addEventListener('click', openVersionBaseline);
|
||||||
document.getElementById('vbtn-reference').addEventListener('click', () => showLoadForm('reference'));
|
document.getElementById('vbtn-reference').addEventListener('click', () => showLoadForm('reference'));
|
||||||
document.getElementById('vbtn-toggle').addEventListener('click', toggleVersionStatus);
|
document.getElementById('vbtn-toggle').addEventListener('click', toggleVersionStatus);
|
||||||
document.getElementById('vbtn-delete').addEventListener('click', deleteVersion);
|
document.getElementById('vbtn-delete').addEventListener('click', deleteVersion);
|
||||||
document.getElementById('btn-load-submit').addEventListener('click', submitLoadData);
|
document.getElementById('btn-load-submit').addEventListener('click', submitLoadData);
|
||||||
document.getElementById('btn-load-cancel').addEventListener('click', () => {
|
document.getElementById('btn-load-cancel').addEventListener('click', hideLoadModal);
|
||||||
document.getElementById('load-data-form').classList.add('hidden');
|
document.getElementById('btn-load-close').addEventListener('click', hideLoadModal);
|
||||||
});
|
document.getElementById('load-date-from').addEventListener('change', updateDatePreview);
|
||||||
|
document.getElementById('load-date-to').addEventListener('change', updateDatePreview);
|
||||||
|
document.getElementById('load-offset-years').addEventListener('input', updateDatePreview);
|
||||||
|
document.getElementById('load-offset-months').addEventListener('input', updateDatePreview);
|
||||||
|
|
||||||
// forecast view buttons
|
// forecast view buttons
|
||||||
document.getElementById('btn-forecast-refresh').addEventListener('click', loadForecastData);
|
document.getElementById('btn-forecast-refresh').addEventListener('click', loadForecastData);
|
||||||
document.getElementById('btn-expand-all').addEventListener('click', () => state.grids.pivot?.expandAll());
|
document.getElementById('btn-save-layout').addEventListener('click', async () => {
|
||||||
document.getElementById('btn-collapse-all').addEventListener('click', () => state.grids.pivot?.collapseAll());
|
const v = document.getElementById('pivot-viewer');
|
||||||
|
if (!v) return;
|
||||||
|
const layout = await v.save();
|
||||||
|
localStorage.setItem(pspLayoutKey(), JSON.stringify(layout));
|
||||||
|
document.getElementById('btn-reset-layout').classList.remove('hidden');
|
||||||
|
showStatus('Layout saved', 'success');
|
||||||
|
});
|
||||||
|
document.getElementById('btn-reset-layout').addEventListener('click', async () => {
|
||||||
|
const v = document.getElementById('pivot-viewer');
|
||||||
|
if (!v) return;
|
||||||
|
localStorage.removeItem(pspLayoutKey());
|
||||||
|
document.getElementById('btn-reset-layout').classList.add('hidden');
|
||||||
|
await v.restore(buildDefaultLayout());
|
||||||
|
});
|
||||||
document.getElementById('btn-clear-slice').addEventListener('click', clearSlice);
|
document.getElementById('btn-clear-slice').addEventListener('click', clearSlice);
|
||||||
|
|
||||||
document.querySelectorAll('.op-tab').forEach(tab => {
|
document.querySelectorAll('.op-tab').forEach(tab => {
|
||||||
@ -899,6 +1141,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (btn) undoOperation(parseInt(btn.dataset.logid));
|
if (btn) undoOperation(parseInt(btn.dataset.logid));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// baseline workbench
|
||||||
|
document.getElementById('btn-load-segment').addEventListener('click', submitBaselineSegment);
|
||||||
|
document.getElementById('btn-clear-baseline').addEventListener('click', clearBaseline);
|
||||||
|
document.getElementById('seg-offset-type').addEventListener('change', updateTimelinePreview);
|
||||||
|
document.getElementById('seg-offset-value').addEventListener('input', updateTimelinePreview);
|
||||||
|
|
||||||
|
// undo in baseline segments list
|
||||||
|
document.getElementById('baseline-segments-list').addEventListener('click', async e => {
|
||||||
|
const btn = e.target.closest('[data-logid]');
|
||||||
|
if (!btn) return;
|
||||||
|
const logid = parseInt(btn.dataset.logid);
|
||||||
|
const result = await api('DELETE', `/log/${logid}`);
|
||||||
|
showStatus(`Segment undone — ${result.rows_deleted} rows removed`, 'success');
|
||||||
|
await loadBaselineSegments();
|
||||||
|
});
|
||||||
|
|
||||||
|
// tables search
|
||||||
|
document.getElementById('tables-search').addEventListener('input', e => {
|
||||||
|
state.grids.tables?.setGridOption('quickFilterText', e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// columns search
|
||||||
|
document.getElementById('cols-search').addEventListener('input', e => {
|
||||||
|
state.grids.colMeta?.setGridOption('quickFilterText', e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
// init sources view
|
// init sources view
|
||||||
initSourcesView().catch(err => showStatus(err.message, 'error'));
|
initSourcesView().catch(err => showStatus(err.message, 'error'));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,8 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Pivot Forecast</title>
|
<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/@perspective-dev/viewer/dist/css/themes.css" crossorigin="anonymous">
|
||||||
<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">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -17,6 +16,7 @@
|
|||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li data-view="sources" class="active">Sources</li>
|
<li data-view="sources" class="active">Sources</li>
|
||||||
<li data-view="versions">Versions</li>
|
<li data-view="versions">Versions</li>
|
||||||
|
<li data-view="baseline">Baseline</li>
|
||||||
<li data-view="forecast">Forecast</li>
|
<li data-view="forecast">Forecast</li>
|
||||||
<li data-view="log">Log</li>
|
<li data-view="log">Log</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -43,27 +43,43 @@
|
|||||||
|
|
||||||
<!-- ===== SOURCES VIEW ===== -->
|
<!-- ===== SOURCES VIEW ===== -->
|
||||||
<div id="view-sources" class="view active">
|
<div id="view-sources" class="view active">
|
||||||
<div class="two-col-layout">
|
<div class="sources-layout">
|
||||||
<div class="panel">
|
<!-- Left column: two stacked panels -->
|
||||||
|
<div class="sources-left-col">
|
||||||
|
<div class="panel sources-tables-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span>Database Tables</span>
|
<span>Database Tables</span>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="btn-register" class="btn btn-primary hidden">Register Table</button>
|
<button id="btn-preview" class="btn btn-sm hidden">Preview</button>
|
||||||
|
<button id="btn-register" class="btn btn-primary btn-sm hidden">Register</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tables-grid" class="ag-theme-alpine grid-fill"></div>
|
<div class="tables-search-wrap">
|
||||||
|
<input type="text" id="tables-search" placeholder="Search…" />
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div id="tables-grid" class="ag-theme-alpine tables-grid"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel sources-list-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span id="right-panel-title">Registered Sources</span>
|
<span>Registered Sources</span>
|
||||||
|
<button id="btn-delete-source" class="btn btn-danger btn-sm hidden">Delete</button>
|
||||||
|
</div>
|
||||||
|
<div id="sources-list-grid" class="ag-theme-alpine grid-fill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Right column: column mapping workbench -->
|
||||||
|
<div class="panel sources-mapping-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span id="right-panel-title">Select a source to map columns</span>
|
||||||
<div class="header-actions">
|
<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-save-cols" class="btn hidden">Save Columns</button>
|
||||||
<button id="btn-generate-sql" class="btn btn-primary hidden">Generate SQL</button>
|
<button id="btn-generate-sql" class="btn btn-primary hidden">Generate SQL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="sources-list-grid" class="ag-theme-alpine grid-fill"></div>
|
<div class="tables-search-wrap">
|
||||||
<div id="col-meta-grid" class="ag-theme-alpine grid-fill hidden"></div>
|
<input type="text" id="cols-search" placeholder="Search columns…" />
|
||||||
|
</div>
|
||||||
|
<div id="col-meta-grid" class="ag-theme-alpine grid-fill"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -94,16 +110,49 @@
|
|||||||
<button class="btn" id="vbtn-toggle">Close Version</button>
|
<button class="btn" id="vbtn-toggle">Close Version</button>
|
||||||
<button class="btn btn-danger" id="vbtn-delete">Delete</button>
|
<button class="btn btn-danger" id="vbtn-delete">Delete</button>
|
||||||
</div>
|
</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>
|
||||||
<div class="form-actions">
|
|
||||||
<button id="btn-load-submit" class="btn btn-primary">Load</button>
|
<!-- ===== BASELINE WORKBENCH VIEW ===== -->
|
||||||
<button id="btn-load-cancel" class="btn">Cancel</button>
|
<div id="view-baseline" class="view hidden">
|
||||||
|
<div class="workbench-toolbar">
|
||||||
|
<span id="baseline-label">No version selected</span>
|
||||||
|
<button id="btn-clear-baseline" class="btn btn-danger">Clear Baseline</button>
|
||||||
|
</div>
|
||||||
|
<div class="baseline-layout">
|
||||||
|
<div class="baseline-form-panel">
|
||||||
|
<div class="panel-section-title">Add Segment</div>
|
||||||
|
<div class="baseline-form">
|
||||||
|
<label class="baseline-field-label">Description
|
||||||
|
<input type="text" id="seg-description" placeholder="e.g. All orders FY2024" />
|
||||||
|
</label>
|
||||||
|
<div class="offset-row">
|
||||||
|
<label class="baseline-field-label">Offset Type
|
||||||
|
<select id="seg-offset-type">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
<option value="year">Year</option>
|
||||||
|
<option value="month">Month</option>
|
||||||
|
<option value="week">Week</option>
|
||||||
|
<option value="day">Day</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="baseline-field-label">Offset Value
|
||||||
|
<input type="number" id="seg-offset-value" min="0" value="0" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="where-section">
|
||||||
|
<div class="filter-section-label">WHERE clause</div>
|
||||||
|
<div id="seg-col-chips" class="col-chips"></div>
|
||||||
|
<textarea id="seg-where" class="where-textarea" rows="4" placeholder="e.g. order_date BETWEEN '2024-01-01' AND '2024-12-31' AND region = 'East'"></textarea>
|
||||||
|
</div>
|
||||||
|
<div id="seg-timeline" class="timeline-preview hidden"></div>
|
||||||
|
<button id="btn-load-segment" class="btn btn-primary">Load Segment</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="baseline-segments-panel">
|
||||||
|
<div class="panel-section-title">Loaded Segments</div>
|
||||||
|
<div id="baseline-segments-list">
|
||||||
|
<div class="segments-empty">No segments loaded yet.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -113,12 +162,12 @@
|
|||||||
<div class="forecast-toolbar">
|
<div class="forecast-toolbar">
|
||||||
<span id="forecast-label">No version selected</span>
|
<span id="forecast-label">No version selected</span>
|
||||||
<button id="btn-forecast-refresh" class="btn">Refresh</button>
|
<button id="btn-forecast-refresh" class="btn">Refresh</button>
|
||||||
<button id="btn-expand-all" class="btn">Expand All</button>
|
<button id="btn-save-layout" class="btn">Save layout</button>
|
||||||
<button id="btn-collapse-all" class="btn">Collapse All</button>
|
<button id="btn-reset-layout" class="btn hidden">Reset layout</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="forecast-layout">
|
<div class="forecast-layout">
|
||||||
<div id="pivot-panel">
|
<div id="pivot-panel">
|
||||||
<div id="pivot-grid" class="ag-theme-alpine"></div>
|
<perspective-viewer id="pivot-viewer" theme="Pro Light"></perspective-viewer>
|
||||||
</div>
|
</div>
|
||||||
<div id="operation-panel">
|
<div id="operation-panel">
|
||||||
<div class="op-section">
|
<div class="op-section">
|
||||||
@ -170,6 +219,54 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Load baseline / reference modal -->
|
||||||
|
<div id="load-data-modal" class="modal-overlay hidden">
|
||||||
|
<div class="modal load-data-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span id="load-data-title">Load Baseline</span>
|
||||||
|
<button id="btn-load-close" class="btn-icon">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="load-data-body">
|
||||||
|
<div class="load-form-fields">
|
||||||
|
<label>Date From<input type="date" id="load-date-from" /></label>
|
||||||
|
<label>Date To<input type="date" id="load-date-to" /></label>
|
||||||
|
<div id="load-offset-fields">
|
||||||
|
<div class="load-offset-row">
|
||||||
|
<label>Offset Years<input type="number" id="load-offset-years" min="0" value="0" /></label>
|
||||||
|
<label>Offset Months<input type="number" id="load-offset-months" min="0" value="0" /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Note<input type="text" id="load-note" placeholder="optional" /></label>
|
||||||
|
</div>
|
||||||
|
<div id="load-date-preview" class="load-date-preview hidden">
|
||||||
|
<!-- reference: single chip list -->
|
||||||
|
<div id="load-preview-simple">
|
||||||
|
<div class="load-preview-label"></div>
|
||||||
|
<div id="load-date-chips" class="date-chips"></div>
|
||||||
|
</div>
|
||||||
|
<!-- baseline: before → after -->
|
||||||
|
<div id="load-preview-offset" class="hidden">
|
||||||
|
<div class="load-preview-columns">
|
||||||
|
<div class="load-preview-col">
|
||||||
|
<div class="load-preview-label">Source</div>
|
||||||
|
<div id="load-chips-source" class="date-chips"></div>
|
||||||
|
</div>
|
||||||
|
<div class="load-preview-arrow">→</div>
|
||||||
|
<div class="load-preview-col">
|
||||||
|
<div class="load-preview-label">Projected</div>
|
||||||
|
<div id="load-chips-projected" class="date-chips"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="btn-load-submit" class="btn btn-primary">Load</button>
|
||||||
|
<button id="btn-load-cancel" class="btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Table preview modal -->
|
<!-- Table preview modal -->
|
||||||
<div id="modal-overlay" class="modal-overlay hidden">
|
<div id="modal-overlay" class="modal-overlay hidden">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
|
|||||||
888
public/mockup.html
Normal file
888
public/mockup.html
Normal file
@ -0,0 +1,888 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Pivot Forecast — Mockup</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
.help-popup { display:none; position:absolute; z-index:50; }
|
||||||
|
.help-popup.open { display:block; }
|
||||||
|
#sidebar { transition: width 150ms ease; }
|
||||||
|
#sidebar.expanded { width: 200px; }
|
||||||
|
#sidebar.collapsed { width: 48px; }
|
||||||
|
#sidebar .nav-label { transition: opacity 100ms ease; }
|
||||||
|
#sidebar.collapsed .nav-label { opacity:0; pointer-events:none; width:0; overflow:hidden; }
|
||||||
|
#sidebar.expanded .nav-label { opacity:1; }
|
||||||
|
#sidebar .app-title { transition: opacity 100ms ease; }
|
||||||
|
#sidebar.collapsed .app-title { opacity:0; pointer-events:none; width:0; overflow:hidden; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 text-sm text-gray-800 font-sans">
|
||||||
|
|
||||||
|
<!-- Help popups (global, positioned by JS) -->
|
||||||
|
<div id="help-overlay" class="fixed inset-0 z-40 hidden" onclick="closeHelp()"></div>
|
||||||
|
<div id="help-box" class="help-popup w-72 bg-gray-900 text-gray-100 text-xs rounded-lg shadow-xl p-4 leading-relaxed"></div>
|
||||||
|
|
||||||
|
<div class="flex h-screen">
|
||||||
|
|
||||||
|
<!-- Side Nav -->
|
||||||
|
<div id="sidebar" class="expanded bg-white border-r border-gray-200 flex flex-col shrink-0 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Logo / toggle -->
|
||||||
|
<div class="h-12 flex items-center px-3 border-b border-gray-100 gap-2 shrink-0">
|
||||||
|
<button onclick="toggleSidebar()" class="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-gray-400 shrink-0" title="Toggle sidebar">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="3" width="14" height="1.5" rx="0.75" fill="currentColor"/><rect x="1" y="7.25" width="14" height="1.5" rx="0.75" fill="currentColor"/><rect x="1" y="11.5" width="14" height="1.5" rx="0.75" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="app-title text-xs font-semibold text-gray-600 tracking-wide uppercase whitespace-nowrap">Pivot Forecast</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nav items -->
|
||||||
|
<nav class="flex flex-col gap-0.5 p-2 flex-1">
|
||||||
|
<button onclick="show('setup')" id="tab-setup" class="nav-item flex items-center gap-3 px-2 py-2 rounded text-gray-500 hover:bg-gray-100 hover:text-gray-800 text-left w-full" title="Setup">
|
||||||
|
<!-- sliders / config icon -->
|
||||||
|
<svg class="shrink-0" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
||||||
|
<line x1="3" y1="5" x2="17" y2="5"/><circle cx="7" cy="5" r="1.8" fill="white" stroke="currentColor" stroke-width="1.6"/>
|
||||||
|
<line x1="3" y1="10" x2="17" y2="10"/><circle cx="13" cy="10" r="1.8" fill="white" stroke="currentColor" stroke-width="1.6"/>
|
||||||
|
<line x1="3" y1="15" x2="17" y2="15"/><circle cx="8" cy="15" r="1.8" fill="white" stroke="currentColor" stroke-width="1.6"/>
|
||||||
|
</svg>
|
||||||
|
<span class="nav-label text-sm whitespace-nowrap">Setup</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="show('baseline')" id="tab-baseline" class="nav-item flex items-center gap-3 px-2 py-2 rounded text-gray-500 hover:bg-gray-100 hover:text-gray-800 text-left w-full" title="Baseline">
|
||||||
|
<!-- layers / stack icon -->
|
||||||
|
<svg class="shrink-0" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="10,2 18,7 10,12 2,7"/>
|
||||||
|
<polyline points="2,12 10,17 18,12"/>
|
||||||
|
</svg>
|
||||||
|
<span class="nav-label text-sm whitespace-nowrap">Baseline</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="show('forecast')" id="tab-forecast" class="nav-item flex items-center gap-3 px-2 py-2 rounded text-gray-500 hover:bg-gray-100 hover:text-gray-800 text-left w-full" title="Forecast">
|
||||||
|
<!-- trending up / chart icon -->
|
||||||
|
<svg class="shrink-0" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="2,15 7,9 11,12 18,4"/>
|
||||||
|
<polyline points="14,4 18,4 18,8"/>
|
||||||
|
</svg>
|
||||||
|
<span class="nav-label text-sm whitespace-nowrap">Forecast</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-hidden flex flex-col">
|
||||||
|
|
||||||
|
<!-- Status bar -->
|
||||||
|
<div class="bg-white border-b border-gray-100 px-4 h-8 flex items-center gap-4 shrink-0 text-xs">
|
||||||
|
<span class="text-gray-400">Source</span>
|
||||||
|
<span class="font-medium text-gray-700">sales_orders</span>
|
||||||
|
<span class="text-gray-200">|</span>
|
||||||
|
<span class="text-gray-400">Version</span>
|
||||||
|
<span class="font-medium text-gray-700">FY2026 Plan</span>
|
||||||
|
<span class="text-gray-200">|</span>
|
||||||
|
<span class="text-gray-400">Baseline</span>
|
||||||
|
<span class="font-medium text-gray-700">44,313 rows</span>
|
||||||
|
<span class="text-gray-200">|</span>
|
||||||
|
<span class="text-gray-400">Status</span>
|
||||||
|
<span class="text-green-600 font-medium">open</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ① SETUP -->
|
||||||
|
<div id="view-setup" class="hidden h-full flex gap-0 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- All Tables -->
|
||||||
|
<div class="w-64 bg-white border-r border-gray-200 flex flex-col shrink-0">
|
||||||
|
<div class="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide">All Tables</div>
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead class="sticky top-0 bg-gray-50">
|
||||||
|
<tr class="text-left text-gray-400 border-b border-gray-100">
|
||||||
|
<th class="px-3 py-1.5 font-medium">schema</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium">table</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium text-right">rows</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr onclick="peekTable('sales_orders')" class="border-t border-gray-50 hover:bg-blue-50 cursor-pointer bg-blue-50">
|
||||||
|
<td class="px-3 py-1.5 text-gray-400">public</td>
|
||||||
|
<td class="px-3 py-1.5 font-medium text-blue-600">sales_orders</td>
|
||||||
|
<td class="px-3 py-1.5 text-right text-gray-500">48,291</td>
|
||||||
|
</tr>
|
||||||
|
<tr onclick="peekTable('invoices')" class="border-t border-gray-50 hover:bg-blue-50 cursor-pointer">
|
||||||
|
<td class="px-3 py-1.5 text-gray-400">public</td>
|
||||||
|
<td class="px-3 py-1.5">invoices</td>
|
||||||
|
<td class="px-3 py-1.5 text-right text-gray-500">12,004</td>
|
||||||
|
</tr>
|
||||||
|
<tr onclick="peekTable('products')" class="border-t border-gray-50 hover:bg-blue-50 cursor-pointer">
|
||||||
|
<td class="px-3 py-1.5 text-gray-400">public</td>
|
||||||
|
<td class="px-3 py-1.5">products</td>
|
||||||
|
<td class="px-3 py-1.5 text-right text-gray-500">891</td>
|
||||||
|
</tr>
|
||||||
|
<tr onclick="peekTable('summary_mv')" class="border-t border-gray-50 hover:bg-blue-50 cursor-pointer">
|
||||||
|
<td class="px-3 py-1.5 text-gray-400">rpt</td>
|
||||||
|
<td class="px-3 py-1.5">summary_mv</td>
|
||||||
|
<td class="px-3 py-1.5 text-right text-gray-500">3,442</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col gap-4 overflow-hidden min-w-0 p-4">
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded shrink-0">
|
||||||
|
<div class="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
|
||||||
|
<span>Registered Sources</span>
|
||||||
|
<button class="text-blue-600 hover:text-blue-700 text-xs font-medium">+ Register table</button>
|
||||||
|
</div>
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<tbody>
|
||||||
|
<tr class="border-t border-gray-50 hover:bg-gray-50 cursor-pointer bg-blue-50">
|
||||||
|
<td class="px-3 py-2 font-medium text-blue-600">sales_orders</td>
|
||||||
|
<td class="px-3 py-2 text-green-600 font-medium">✓ SQL ready</td>
|
||||||
|
<td class="px-3 py-2 text-gray-400 text-right">public</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-t border-gray-50 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<td class="px-3 py-2 font-medium">invoices</td>
|
||||||
|
<td class="px-3 py-2 text-green-600 font-medium">✓ SQL ready</td>
|
||||||
|
<td class="px-3 py-2 text-gray-400 text-right">public</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded flex flex-col flex-1 overflow-hidden">
|
||||||
|
<div class="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between shrink-0">
|
||||||
|
<span>Col Meta — <span class="text-gray-700">sales_orders</span></span>
|
||||||
|
<button class="bg-blue-600 text-white text-xs px-3 py-1 rounded hover:bg-blue-700">Generate SQL</button>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead class="sticky top-0 bg-gray-50">
|
||||||
|
<tr class="text-left text-gray-400 border-b border-gray-100">
|
||||||
|
<th class="px-3 py-1.5 font-medium">column</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium">role</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium text-center">key</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium">label</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">customer</td><td class="px-3 py-1.5"><span class="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">dimension</span></td><td class="px-3 py-1.5 text-center text-green-500">✓</td><td class="px-3 py-1.5 text-gray-400">—</td></tr>
|
||||||
|
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">channel</td><td class="px-3 py-1.5"><span class="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">dimension</span></td><td class="px-3 py-1.5 text-center text-green-500">✓</td><td class="px-3 py-1.5 text-gray-400">—</td></tr>
|
||||||
|
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">part</td><td class="px-3 py-1.5"><span class="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">dimension</span></td><td class="px-3 py-1.5 text-center text-gray-300">✓</td><td class="px-3 py-1.5 text-gray-400">—</td></tr>
|
||||||
|
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">geography</td><td class="px-3 py-1.5"><span class="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">dimension</span></td><td class="px-3 py-1.5 text-center text-gray-300">✓</td><td class="px-3 py-1.5 text-gray-400">—</td></tr>
|
||||||
|
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">order_date</td><td class="px-3 py-1.5"><span class="bg-purple-50 text-purple-700 px-1.5 py-0.5 rounded">date</span></td><td class="px-3 py-1.5 text-center text-gray-300">✓</td><td class="px-3 py-1.5 text-gray-400">—</td></tr>
|
||||||
|
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">ship_date</td><td class="px-3 py-1.5"><span class="bg-yellow-50 text-yellow-700 px-1.5 py-0.5 rounded">filter</span></td><td class="px-3 py-1.5 text-center text-gray-300">✓</td><td class="px-3 py-1.5 text-gray-400">—</td></tr>
|
||||||
|
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">status</td><td class="px-3 py-1.5"><span class="bg-yellow-50 text-yellow-700 px-1.5 py-0.5 rounded">filter</span></td><td class="px-3 py-1.5 text-center text-gray-300">✓</td><td class="px-3 py-1.5 text-gray-400">—</td></tr>
|
||||||
|
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">units</td><td class="px-3 py-1.5"><span class="bg-green-50 text-green-700 px-1.5 py-0.5 rounded">units</span></td><td class="px-3 py-1.5 text-center text-gray-300">✓</td><td class="px-3 py-1.5 text-gray-400">—</td></tr>
|
||||||
|
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">revenue</td><td class="px-3 py-1.5"><span class="bg-green-50 text-green-700 px-1.5 py-0.5 rounded">value</span></td><td class="px-3 py-1.5 text-center text-gray-300">✓</td><td class="px-3 py-1.5 text-gray-400">—</td></tr>
|
||||||
|
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">internal_id</td><td class="px-3 py-1.5"><span class="bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded">ignore</span></td><td class="px-3 py-1.5 text-center text-gray-300">✓</td><td class="px-3 py-1.5 text-gray-400">—</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ② BASELINE -->
|
||||||
|
<div id="view-baseline" class="hidden h-full overflow-y-auto">
|
||||||
|
<div class="p-4 flex flex-col gap-4">
|
||||||
|
|
||||||
|
<!-- Version bar -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500">Source</span>
|
||||||
|
<select class="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
|
||||||
|
<option>sales_orders</option><option>invoices</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500">Version</span>
|
||||||
|
<select class="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
|
||||||
|
<option>FY2026 Plan</option><option>FY2026 Conservative</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="text-blue-600 hover:text-blue-700 text-xs font-medium border border-blue-200 px-2 py-1 rounded">+ New version</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Segments loaded -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded">
|
||||||
|
<div class="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
|
||||||
|
<span>Segments loaded</span>
|
||||||
|
<button class="text-red-500 hover:text-red-600 text-xs normal-case font-normal">Clear all baseline</button>
|
||||||
|
</div>
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr class="text-left text-gray-400 border-b border-gray-100">
|
||||||
|
<th class="px-3 py-1.5 font-medium">#</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium">description</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium text-right">rows</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium">by</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium">date</th>
|
||||||
|
<th class="px-3 py-1.5 font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="border-t border-gray-50">
|
||||||
|
<td class="px-3 py-2 text-gray-400">1</td>
|
||||||
|
<td class="px-3 py-2">FY25 actuals +1yr</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono">41,204</td>
|
||||||
|
<td class="px-3 py-2 text-gray-500">paul</td>
|
||||||
|
<td class="px-3 py-2 text-gray-400">Apr 24</td>
|
||||||
|
<td class="px-3 py-2 text-right"><button class="text-gray-400 hover:text-red-500 text-xs">Undo</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-t border-gray-50">
|
||||||
|
<td class="px-3 py-2 text-gray-400">2</td>
|
||||||
|
<td class="px-3 py-2">Open orders</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono">3,109</td>
|
||||||
|
<td class="px-3 py-2 text-gray-500">paul</td>
|
||||||
|
<td class="px-3 py-2 text-gray-400">Apr 24</td>
|
||||||
|
<td class="px-3 py-2 text-right"><button class="text-gray-400 hover:text-red-500 text-xs">Undo</button></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="border-t border-gray-100 bg-gray-50">
|
||||||
|
<td colspan="2" class="px-3 py-1.5 text-gray-500 text-xs">Total baseline rows</td>
|
||||||
|
<td class="px-3 py-1.5 text-right font-mono font-medium">44,313</td>
|
||||||
|
<td colspan="3"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Segment -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded">
|
||||||
|
<div class="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
|
||||||
|
<span>Add Segment</span>
|
||||||
|
<button onclick="showHelp(this,'segment')" class="text-gray-300 hover:text-gray-500 text-xs normal-case font-normal">? Help</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 flex flex-col gap-4">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label class="text-xs text-gray-500 w-28">Description</label>
|
||||||
|
<input type="text" placeholder="e.g. FY25 actuals shifted +1yr" class="border border-gray-200 rounded px-2 py-1.5 text-sm flex-1 max-w-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500">Description</label>
|
||||||
|
<input type="text" placeholder="e.g. FY25 actuals shifted +1yr" class="border border-gray-200 rounded px-2 py-1.5 text-sm bg-white w-full max-w-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-xs text-gray-500">Filters</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button onclick="showHelp(this,'filters')" class="text-gray-300 hover:text-gray-500 text-xs">?</button>
|
||||||
|
<button class="text-blue-600 hover:text-blue-700 text-xs font-medium">+ Add filter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select class="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
|
||||||
|
<option>order_date</option><option>ship_date</option><option>status</option>
|
||||||
|
</select>
|
||||||
|
<select class="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
|
||||||
|
<option>BETWEEN</option><option>=</option><option>IN</option><option>NOT IN</option>
|
||||||
|
</select>
|
||||||
|
<input id="date-from" type="text" value="2025-01-01" oninput="drawTimeline()" class="border border-gray-200 rounded px-2 py-1 text-xs w-24 font-mono bg-white" />
|
||||||
|
<span class="text-gray-400 text-xs">and</span>
|
||||||
|
<input id="date-to" type="text" value="2025-12-31" oninput="drawTimeline()" class="border border-gray-200 rounded px-2 py-1 text-xs w-24 font-mono bg-white" />
|
||||||
|
<button class="text-gray-300 hover:text-red-400 text-xs">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date offset -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-xs text-gray-500">Date offset</label>
|
||||||
|
<button onclick="showHelp(this,'offset')" class="text-gray-300 hover:text-gray-500 text-xs">?</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input id="offset-yr" type="number" value="1" min="0" oninput="drawTimeline()" class="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
|
||||||
|
<span class="text-xs text-gray-500">yr</span>
|
||||||
|
<input id="offset-mo" type="number" value="0" min="0" max="11" oninput="drawTimeline()" class="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
|
||||||
|
<span class="text-xs text-gray-500">mo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-xs text-gray-500">Timeline preview</label>
|
||||||
|
<button onclick="showHelp(this,'timeline')" class="text-gray-300 hover:text-gray-500 text-xs">?</button>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white border border-gray-200 rounded p-3">
|
||||||
|
<canvas id="timeline-canvas" height="90" style="width:100%;display:block;"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note + submit -->
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<div class="flex flex-col gap-1 flex-1 max-w-xs">
|
||||||
|
<label class="text-xs text-gray-500">Note</label>
|
||||||
|
<input type="text" placeholder="optional" class="border border-gray-200 rounded px-2 py-1.5 text-sm bg-white" />
|
||||||
|
</div>
|
||||||
|
<button class="bg-blue-600 text-white text-xs px-5 py-2 rounded hover:bg-blue-700 shrink-0">Load Segment</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reference -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded">
|
||||||
|
<div class="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
|
||||||
|
<span>Reference <span class="text-gray-300 font-normal normal-case">optional</span></span>
|
||||||
|
<button onclick="showHelp(this,'reference')" class="text-gray-300 hover:text-gray-500 text-xs normal-case font-normal">?</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 flex items-center gap-3">
|
||||||
|
<label class="text-xs text-gray-500 w-28">Date range</label>
|
||||||
|
<input type="text" value="2024-01-01" class="border border-gray-200 rounded px-2 py-1 text-xs w-28 font-mono" />
|
||||||
|
<span class="text-xs text-gray-400">to</span>
|
||||||
|
<input type="text" value="2024-12-31" class="border border-gray-200 rounded px-2 py-1 text-xs w-28 font-mono" />
|
||||||
|
<input type="text" placeholder="note" class="border border-gray-200 rounded px-2 py-1 text-sm flex-1 max-w-xs" />
|
||||||
|
<button class="border border-gray-200 text-gray-600 text-xs px-4 py-1.5 rounded hover:bg-gray-50">Load Reference</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ③ FORECAST -->
|
||||||
|
<div id="view-forecast" class="hidden h-full flex flex-col overflow-hidden">
|
||||||
|
|
||||||
|
<div class="bg-white border-b border-gray-200 px-4 py-2 flex items-center gap-3 shrink-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500">Version</span>
|
||||||
|
<select class="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
|
||||||
|
<option>FY2026 Plan</option>
|
||||||
|
<option>FY2026 Conservative</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button class="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-3 py-1 rounded">Refresh</button>
|
||||||
|
<button class="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-3 py-1 rounded">Save layout</button>
|
||||||
|
<button class="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-3 py-1 rounded">Reset layout</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 overflow-hidden gap-0">
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-hidden flex flex-col bg-white border-r border-gray-200">
|
||||||
|
<div class="flex-1 overflow-hidden relative">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead class="sticky top-0 bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr class="text-right text-gray-500">
|
||||||
|
<th class="px-3 py-2 text-left text-gray-600 font-medium">channel</th>
|
||||||
|
<th class="px-3 py-2 font-medium">Jan 2026</th>
|
||||||
|
<th class="px-3 py-2 font-medium">Feb 2026</th>
|
||||||
|
<th class="px-3 py-2 font-medium">Mar 2026</th>
|
||||||
|
<th class="px-3 py-2 font-medium">Apr 2026</th>
|
||||||
|
<th class="px-3 py-2 font-medium">May 2026</th>
|
||||||
|
<th class="px-3 py-2 font-medium">Jun 2026</th>
|
||||||
|
<th class="px-3 py-2 font-medium text-gray-800">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="border-t border-gray-100 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<td class="px-3 py-2 font-medium">DIR</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono">412,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono">388,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono">425,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono">401,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono">390,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono">410,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono font-medium">2,426,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-t border-gray-100 hover:bg-blue-50 cursor-pointer bg-blue-50">
|
||||||
|
<td class="px-3 py-2 font-medium text-blue-700">WHS ◂</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono text-blue-700">290,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono text-blue-700">310,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono text-blue-700">298,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono text-blue-700">315,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono text-blue-700">305,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono text-blue-700">320,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono font-medium text-blue-700">1,838,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-t border-gray-100 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<td class="px-3 py-2 font-medium">ECOM</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono">155,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono">162,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono">170,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono">158,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono">165,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono">175,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono font-medium">985,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-t-2 border-gray-200 bg-gray-50">
|
||||||
|
<td class="px-3 py-2 font-medium text-gray-700">Total</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono font-medium">857,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono font-medium">860,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono font-medium">893,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono font-medium">874,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono font-medium">860,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono font-medium">905,000</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono font-medium">5,249,000</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="absolute bottom-2 right-3 text-xs text-gray-300 italic">Perspective viewer</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-56 bg-white flex flex-col shrink-0">
|
||||||
|
<div class="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
|
||||||
|
<span>Operations</span>
|
||||||
|
<button onclick="showHelp(this,'operations')" class="text-gray-300 hover:text-gray-500 text-xs">?</button>
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-2 border-b border-gray-100">
|
||||||
|
<div class="text-xs text-gray-400 mb-1.5">Slice</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span class="text-gray-500">channel</span>
|
||||||
|
<span class="font-mono text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded">WHS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex border-b border-gray-100 text-xs">
|
||||||
|
<button class="flex-1 py-1.5 text-center bg-blue-50 text-blue-700 font-medium border-b-2 border-blue-500">Scale</button>
|
||||||
|
<button class="flex-1 py-1.5 text-center text-gray-400 hover:text-gray-600">Recode</button>
|
||||||
|
<button class="flex-1 py-1.5 text-center text-gray-400 hover:text-gray-600">Clone</button>
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-3 flex flex-col gap-3 text-xs flex-1">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-gray-400">Value increment</label>
|
||||||
|
<input type="text" placeholder="e.g. 50000" class="border border-gray-200 rounded px-2 py-1 text-sm font-mono" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-gray-400">Units increment</label>
|
||||||
|
<input type="text" placeholder="e.g. 500" class="border border-gray-200 rounded px-2 py-1 text-sm font-mono" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="pct" class="rounded" />
|
||||||
|
<label for="pct" class="text-gray-500">% of slice total</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-gray-400">Note</label>
|
||||||
|
<input type="text" placeholder="optional" class="border border-gray-200 rounded px-2 py-1 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-3 border-t border-gray-100">
|
||||||
|
<button class="w-full bg-blue-600 text-white text-xs py-1.5 rounded hover:bg-blue-700">Submit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border-t border-gray-200 shrink-0">
|
||||||
|
<button onclick="toggleLog()" class="w-full px-4 py-2 text-left text-xs text-gray-500 hover:bg-gray-50 flex items-center gap-2">
|
||||||
|
<span id="log-arrow">▶</span>
|
||||||
|
<span>Change log (12 entries)</span>
|
||||||
|
</button>
|
||||||
|
<div id="log-panel" class="hidden overflow-auto max-h-40">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead class="bg-gray-50 sticky top-0">
|
||||||
|
<tr class="text-left text-gray-400 border-b border-gray-100">
|
||||||
|
<th class="px-4 py-1.5 font-medium">id</th>
|
||||||
|
<th class="px-4 py-1.5 font-medium">operation</th>
|
||||||
|
<th class="px-4 py-1.5 font-medium">by</th>
|
||||||
|
<th class="px-4 py-1.5 font-medium">slice</th>
|
||||||
|
<th class="px-4 py-1.5 font-medium">note</th>
|
||||||
|
<th class="px-4 py-1.5 font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="border-t border-gray-50"><td class="px-4 py-1.5 text-gray-400 font-mono">12</td><td class="px-4 py-1.5"><span class="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">scale</span></td><td class="px-4 py-1.5 text-gray-500">paul</td><td class="px-4 py-1.5 font-mono text-gray-600">channel=WHS geo=WEST</td><td class="px-4 py-1.5 text-gray-400">10% lift Q3</td><td class="px-4 py-1.5 text-right"><button class="text-gray-300 hover:text-red-500">Undo</button></td></tr>
|
||||||
|
<tr class="border-t border-gray-50"><td class="px-4 py-1.5 text-gray-400 font-mono">11</td><td class="px-4 py-1.5"><span class="bg-orange-50 text-orange-700 px-1.5 py-0.5 rounded">recode</span></td><td class="px-4 py-1.5 text-gray-500">paul</td><td class="px-4 py-1.5 font-mono text-gray-600">part=OLD-SKU-001</td><td class="px-4 py-1.5 text-gray-400">discontinued SKU</td><td class="px-4 py-1.5 text-right"><button class="text-gray-300 hover:text-red-500">Undo</button></td></tr>
|
||||||
|
<tr class="border-t border-gray-50"><td class="px-4 py-1.5 text-gray-400 font-mono">10</td><td class="px-4 py-1.5"><span class="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">scale</span></td><td class="px-4 py-1.5 text-gray-500">paul</td><td class="px-4 py-1.5 font-mono text-gray-600">channel=DIR</td><td class="px-4 py-1.5 text-gray-400"></td><td class="px-4 py-1.5 text-right"><button class="text-gray-300 hover:text-red-500">Undo</button></td></tr>
|
||||||
|
<tr class="border-t border-gray-50"><td class="px-4 py-1.5 text-gray-400 font-mono">9</td><td class="px-4 py-1.5"><span class="bg-green-50 text-green-700 px-1.5 py-0.5 rounded">clone</span></td><td class="px-4 py-1.5 text-gray-500">paul</td><td class="px-4 py-1.5 font-mono text-gray-600">customer=ACME</td><td class="px-4 py-1.5 text-gray-400">new account win</td><td class="px-4 py-1.5 text-right"><button class="text-gray-300 hover:text-red-500">Undo</button></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- flex-1 content area -->
|
||||||
|
</div><!-- app shell -->
|
||||||
|
|
||||||
|
<!-- Table peek modal -->
|
||||||
|
<div id="peek-modal" class="fixed inset-0 z-50 hidden flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 bg-black/30" onclick="closePeek()"></div>
|
||||||
|
<div class="relative bg-white rounded-lg shadow-2xl flex flex-col z-10" style="width:720px;max-width:90vw;max-height:80vh;">
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 shrink-0">
|
||||||
|
<div>
|
||||||
|
<span id="peek-title" class="text-sm font-semibold text-gray-800"></span>
|
||||||
|
<span id="peek-rowcount" class="ml-2 text-xs text-gray-400"></span>
|
||||||
|
</div>
|
||||||
|
<button onclick="closePeek()" class="text-gray-300 hover:text-gray-600 text-lg leading-none ml-4">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="peek-body" class="overflow-y-auto flex-1 text-xs"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
// ── Table peek ─────────────────────────────────────────────────
|
||||||
|
const peekData = {
|
||||||
|
sales_orders: {
|
||||||
|
schema: 'public', rows: 48291,
|
||||||
|
cols: [
|
||||||
|
{name:'customer', type:'text'},
|
||||||
|
{name:'channel', type:'text'},
|
||||||
|
{name:'part', type:'text'},
|
||||||
|
{name:'geography', type:'text'},
|
||||||
|
{name:'order_date', type:'date'},
|
||||||
|
{name:'ship_date', type:'date'},
|
||||||
|
{name:'status', type:'text'},
|
||||||
|
{name:'units', type:'numeric'},
|
||||||
|
{name:'revenue', type:'numeric'},
|
||||||
|
{name:'internal_id',type:'integer'},
|
||||||
|
],
|
||||||
|
sample: [
|
||||||
|
{customer:'ACME CORP', channel:'WHS', part:'SKU-001', geography:'WEST', order_date:'2025-03-14', ship_date:'2025-03-18', status:'SHIPPED', units:120, revenue:4800.00, internal_id:10041},
|
||||||
|
{customer:'GLOBEX INC', channel:'DIR', part:'SKU-004', geography:'EAST', order_date:'2025-03-15', ship_date:null, status:'OPEN', units:50, revenue:2250.00, internal_id:10042},
|
||||||
|
{customer:'INITECH', channel:'WHS', part:'SKU-002', geography:'CENT', order_date:'2025-03-15', ship_date:'2025-03-20', status:'SHIPPED', units:200, revenue:7600.00, internal_id:10043},
|
||||||
|
{customer:'UMBRELLA CO', channel:'ECOM',part:'SKU-007', geography:'WEST', order_date:'2025-03-16', ship_date:null, status:'PENDING', units:30, revenue:1350.00, internal_id:10044},
|
||||||
|
{customer:'ACME CORP', channel:'DIR', part:'SKU-001', geography:'EAST', order_date:'2025-03-16', ship_date:'2025-03-19', status:'SHIPPED', units:80, revenue:3200.00, internal_id:10045},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
invoices: {
|
||||||
|
schema: 'public', rows: 12004,
|
||||||
|
cols: [
|
||||||
|
{name:'invoice_id', type:'integer'},
|
||||||
|
{name:'customer', type:'text'},
|
||||||
|
{name:'invoice_date', type:'date'},
|
||||||
|
{name:'due_date', type:'date'},
|
||||||
|
{name:'amount', type:'numeric'},
|
||||||
|
{name:'status', type:'text'},
|
||||||
|
],
|
||||||
|
sample: [
|
||||||
|
{invoice_id:5001, customer:'ACME CORP', invoice_date:'2025-02-01', due_date:'2025-03-01', amount:12400.00, status:'PAID'},
|
||||||
|
{invoice_id:5002, customer:'GLOBEX INC', invoice_date:'2025-02-03', due_date:'2025-03-03', amount:8750.00, status:'OPEN'},
|
||||||
|
{invoice_id:5003, customer:'INITECH', invoice_date:'2025-02-05', due_date:'2025-03-05', amount:31200.00, status:'PAID'},
|
||||||
|
{invoice_id:5004, customer:'UMBRELLA CO', invoice_date:'2025-02-08', due_date:'2025-03-08', amount:4100.00, status:'OVERDUE'},
|
||||||
|
{invoice_id:5005, customer:'ACME CORP', invoice_date:'2025-02-10', due_date:'2025-03-10', amount:9600.00, status:'OPEN'},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
products: {
|
||||||
|
schema: 'public', rows: 891,
|
||||||
|
cols: [
|
||||||
|
{name:'sku', type:'text'},
|
||||||
|
{name:'name', type:'text'},
|
||||||
|
{name:'category', type:'text'},
|
||||||
|
{name:'price', type:'numeric'},
|
||||||
|
{name:'active', type:'boolean'},
|
||||||
|
],
|
||||||
|
sample: [
|
||||||
|
{sku:'SKU-001', name:'Widget A', category:'Widgets', price:40.00, active:true},
|
||||||
|
{sku:'SKU-002', name:'Widget B', category:'Widgets', price:38.00, active:true},
|
||||||
|
{sku:'SKU-004', name:'Gadget Pro', category:'Gadgets', price:45.00, active:true},
|
||||||
|
{sku:'SKU-007', name:'Doohickey X', category:'Other', price:45.00, active:true},
|
||||||
|
{sku:'SKU-009', name:'Old Widget', category:'Widgets', price:22.00, active:false},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
summary_mv: {
|
||||||
|
schema: 'rpt', rows: 3442,
|
||||||
|
cols: [
|
||||||
|
{name:'period', type:'text'},
|
||||||
|
{name:'channel', type:'text'},
|
||||||
|
{name:'revenue', type:'numeric'},
|
||||||
|
{name:'units', type:'numeric'},
|
||||||
|
],
|
||||||
|
sample: [
|
||||||
|
{period:'2025-01', channel:'WHS', revenue:1240000, units:32100},
|
||||||
|
{period:'2025-01', channel:'DIR', revenue:980000, units:24400},
|
||||||
|
{period:'2025-01', channel:'ECOM', revenue:410000, units:10200},
|
||||||
|
{period:'2025-02', channel:'WHS', revenue:1190000, units:30800},
|
||||||
|
{period:'2025-02', channel:'DIR', revenue:1020000, units:25500},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function peekTable(name) {
|
||||||
|
const d = peekData[name]
|
||||||
|
if (!d) return
|
||||||
|
const cols = d.cols
|
||||||
|
const sample = d.sample
|
||||||
|
|
||||||
|
let html = '<div class="px-3 pt-3 pb-1 text-gray-400 uppercase tracking-wide font-medium text-xs">Columns</div>'
|
||||||
|
html += '<table class="w-full mb-3"><thead><tr class="text-left text-gray-400 border-b border-gray-100 bg-gray-50"><th class="px-3 py-1">name</th><th class="px-3 py-1">type</th></tr></thead><tbody>'
|
||||||
|
cols.forEach(c => {
|
||||||
|
html += `<tr class="border-t border-gray-50"><td class="px-3 py-1 font-mono text-gray-700">${c.name}</td><td class="px-3 py-1 text-gray-400">${c.type}</td></tr>`
|
||||||
|
})
|
||||||
|
html += '</tbody></table>'
|
||||||
|
|
||||||
|
html += '<div class="px-3 py-1 text-gray-400 uppercase tracking-wide font-medium text-xs border-t border-gray-100">Sample rows</div>'
|
||||||
|
html += '<div class="overflow-x-auto"><table class="text-xs" style="min-width:100%"><thead><tr class="text-left text-gray-400 bg-gray-50 border-b border-gray-100">'
|
||||||
|
cols.forEach(c => { html += `<th class="px-3 py-1 font-medium whitespace-nowrap">${c.name}</th>` })
|
||||||
|
html += '</tr></thead><tbody>'
|
||||||
|
sample.forEach(row => {
|
||||||
|
html += '<tr class="border-t border-gray-50">'
|
||||||
|
cols.forEach(c => {
|
||||||
|
const v = row[c.name]
|
||||||
|
html += `<td class="px-3 py-1 font-mono whitespace-nowrap ${v == null ? 'text-gray-300' : 'text-gray-600'}">${v == null ? 'null' : v}</td>`
|
||||||
|
})
|
||||||
|
html += '</tr>'
|
||||||
|
})
|
||||||
|
html += '</tbody></table></div>'
|
||||||
|
|
||||||
|
document.getElementById('peek-title').textContent = d.schema + '.' + name
|
||||||
|
document.getElementById('peek-rowcount').textContent = d.rows.toLocaleString() + ' rows'
|
||||||
|
document.getElementById('peek-body').innerHTML = html
|
||||||
|
document.getElementById('peek-modal').classList.remove('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePeek() {
|
||||||
|
document.getElementById('peek-modal').classList.add('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sidebar toggle ─────────────────────────────────────────────
|
||||||
|
function toggleSidebar() {
|
||||||
|
const sb = document.getElementById('sidebar')
|
||||||
|
const expanded = sb.classList.contains('expanded')
|
||||||
|
sb.classList.toggle('expanded', !expanded)
|
||||||
|
sb.classList.toggle('collapsed', expanded)
|
||||||
|
localStorage.setItem('sb', expanded ? 'collapsed' : 'expanded')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab switching ──────────────────────────────────────────────
|
||||||
|
function show(view) {
|
||||||
|
['setup','baseline','forecast'].forEach(v => {
|
||||||
|
document.getElementById('view-' + v).classList.add('hidden')
|
||||||
|
const btn = document.getElementById('tab-' + v)
|
||||||
|
btn.classList.remove('bg-blue-50','text-blue-700')
|
||||||
|
btn.classList.add('text-gray-500')
|
||||||
|
})
|
||||||
|
document.getElementById('view-' + view).classList.remove('hidden')
|
||||||
|
const active = document.getElementById('tab-' + view)
|
||||||
|
active.classList.add('bg-blue-50','text-blue-700')
|
||||||
|
active.classList.remove('text-gray-500')
|
||||||
|
if (view === 'baseline') setTimeout(drawTimeline, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Log drawer ─────────────────────────────────────────────────
|
||||||
|
function toggleLog() {
|
||||||
|
const panel = document.getElementById('log-panel')
|
||||||
|
const arrow = document.getElementById('log-arrow')
|
||||||
|
panel.classList.toggle('hidden')
|
||||||
|
arrow.textContent = panel.classList.contains('hidden') ? '▶' : '▼'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Timeline canvas ────────────────────────────────────────────
|
||||||
|
function parseDate(s) {
|
||||||
|
const [y,m,d] = s.split('-').map(Number)
|
||||||
|
return new Date(y, (m||1)-1, (d||1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(date, months) {
|
||||||
|
const d = new Date(date)
|
||||||
|
d.setMonth(d.getMonth() + months)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTimeline() {
|
||||||
|
const from = document.getElementById('date-from').value
|
||||||
|
const to = document.getElementById('date-to').value
|
||||||
|
const yr = parseInt(document.getElementById('offset-yr').value) || 0
|
||||||
|
const mo = parseInt(document.getElementById('offset-mo').value) || 0
|
||||||
|
drawTimelineOn('timeline-canvas', from, to, yr, mo)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTimelineOn(canvasId, fromStr, toStr, yr, mo) {
|
||||||
|
const canvas = document.getElementById(canvasId)
|
||||||
|
if (!canvas) return
|
||||||
|
const W = canvas.offsetWidth || 500
|
||||||
|
canvas.width = W * devicePixelRatio
|
||||||
|
canvas.height = 90 * devicePixelRatio
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.scale(devicePixelRatio, devicePixelRatio)
|
||||||
|
|
||||||
|
const H = 90
|
||||||
|
const PAD = { l: 8, r: 8, top: 20 }
|
||||||
|
const trackH = 22
|
||||||
|
const gap = 10
|
||||||
|
const srcY = PAD.top
|
||||||
|
const projY = srcY + trackH + gap
|
||||||
|
|
||||||
|
const srcStart = parseDate(fromStr)
|
||||||
|
const srcEnd = parseDate(toStr)
|
||||||
|
if (isNaN(srcStart) || isNaN(srcEnd)) return
|
||||||
|
|
||||||
|
const offsetMo = yr * 12 + mo
|
||||||
|
const projStart = addMonths(srcStart, offsetMo)
|
||||||
|
const projEnd = addMonths(srcEnd, offsetMo)
|
||||||
|
|
||||||
|
// window: from 1 month before srcStart to 1 month after projEnd
|
||||||
|
const winStart = addMonths(srcStart, -1)
|
||||||
|
const winEnd = addMonths(projEnd, 1)
|
||||||
|
const winMs = winEnd - winStart
|
||||||
|
const drawW = W - PAD.l - PAD.r
|
||||||
|
|
||||||
|
function xOf(date) {
|
||||||
|
return PAD.l + ((date - winStart) / winMs) * drawW
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, W, H)
|
||||||
|
|
||||||
|
// ── axis line ──
|
||||||
|
ctx.strokeStyle = '#e5e7eb'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(PAD.l, srcY - 8)
|
||||||
|
ctx.lineTo(PAD.l + drawW, srcY - 8)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// ── month ticks ──
|
||||||
|
ctx.fillStyle = '#9ca3af'
|
||||||
|
ctx.font = '9px system-ui'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
const tickStart = new Date(winStart.getFullYear(), winStart.getMonth(), 1)
|
||||||
|
for (let d = new Date(tickStart); d <= winEnd; d = addMonths(d, 1)) {
|
||||||
|
const x = xOf(d)
|
||||||
|
if (x < PAD.l || x > PAD.l + drawW) continue
|
||||||
|
ctx.strokeStyle = '#f3f4f6'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, srcY - 8)
|
||||||
|
ctx.lineTo(x, projY + trackH)
|
||||||
|
ctx.stroke()
|
||||||
|
// year label on Jan
|
||||||
|
if (d.getMonth() === 0) {
|
||||||
|
ctx.fillStyle = '#6b7280'
|
||||||
|
ctx.font = 'bold 9px system-ui'
|
||||||
|
ctx.fillText(d.getFullYear(), x, srcY - 10)
|
||||||
|
ctx.font = '9px system-ui'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── source band ──
|
||||||
|
const sx1 = xOf(srcStart), sx2 = xOf(srcEnd)
|
||||||
|
ctx.fillStyle = '#dbeafe'
|
||||||
|
ctx.strokeStyle = '#93c5fd'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
roundRect(ctx, sx1, srcY, sx2 - sx1, trackH, 4, true, true)
|
||||||
|
ctx.fillStyle = '#1d4ed8'
|
||||||
|
ctx.font = '10px system-ui'
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.fillText('Source ' + fromStr + ' → ' + toStr, sx1 + 6, srcY + 14)
|
||||||
|
|
||||||
|
if (offsetMo > 0) {
|
||||||
|
// ── projected band ──
|
||||||
|
const px1 = xOf(projStart), px2 = xOf(projEnd)
|
||||||
|
ctx.fillStyle = '#dcfce7'
|
||||||
|
ctx.strokeStyle = '#86efac'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
roundRect(ctx, px1, projY, px2 - px1, trackH, 4, true, true)
|
||||||
|
ctx.fillStyle = '#15803d'
|
||||||
|
ctx.font = '10px system-ui'
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.fillText('Projected ' + fmtDate(projStart) + ' → ' + fmtDate(projEnd), px1 + 6, projY + 14)
|
||||||
|
|
||||||
|
// ── offset arrow ──
|
||||||
|
const arrowY = srcY + trackH / 2
|
||||||
|
ctx.strokeStyle = '#94a3b8'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.setLineDash([3, 3])
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(sx1, arrowY)
|
||||||
|
ctx.lineTo(px1 - 2, arrowY)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
|
// arrowhead
|
||||||
|
ctx.fillStyle = '#94a3b8'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(px1 + 4, arrowY)
|
||||||
|
ctx.lineTo(px1 - 4, arrowY - 4)
|
||||||
|
ctx.lineTo(px1 - 4, arrowY + 4)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
// label
|
||||||
|
const midX = (sx1 + px1) / 2
|
||||||
|
ctx.fillStyle = '#64748b'
|
||||||
|
ctx.font = '9px system-ui'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText('+' + (yr ? yr + 'yr ' : '') + (mo ? mo + 'mo' : ''), midX, arrowY - 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundRect(ctx, x, y, w, h, r, fill, stroke) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x + r, y)
|
||||||
|
ctx.lineTo(x + w - r, y)
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
|
||||||
|
ctx.lineTo(x + w, y + h - r)
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
|
||||||
|
ctx.lineTo(x + r, y + h)
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
|
||||||
|
ctx.lineTo(x, y + r)
|
||||||
|
ctx.quadraticCurveTo(x, y, x + r, y)
|
||||||
|
ctx.closePath()
|
||||||
|
if (fill) ctx.fill()
|
||||||
|
if (stroke) ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d) {
|
||||||
|
return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Help popovers ──────────────────────────────────────────────
|
||||||
|
const helpText = {
|
||||||
|
segment: {
|
||||||
|
title: 'What is a segment?',
|
||||||
|
body: 'A segment is one query against the source table. Each segment appends rows independently — you can layer multiple segments to build up the baseline (e.g. core actuals, open orders, special items). Each is independently undoable.'
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
title: 'Filters',
|
||||||
|
body: 'Define what rows to pull from the source table. You can use any date or filter-role column. At least one filter is required. Multiple filters are ANDed together.\n\nFor date ranges use BETWEEN. For lists use IN. For exact matches use =.'
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
title: 'Date offset',
|
||||||
|
body: 'Shifts the primary date column forward by this amount when rows are inserted. For example, with offset = 1 yr, a row with order_date 2025-03-15 is stored as 2026-03-15.\n\nLeave at 0 to keep dates as-is (useful for open orders or non-date segments).'
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
title: 'Timeline preview',
|
||||||
|
body: 'The blue band shows the source period — the date range of rows being pulled from the source table.\n\nThe green band shows where those dates will land after the offset is applied. The arrow shows the shift.'
|
||||||
|
},
|
||||||
|
reference: {
|
||||||
|
title: 'Reference rows',
|
||||||
|
body: 'Reference rows are prior-period actuals loaded for comparison only. They appear in the pivot alongside your forecast rows but are never touched by scale, recode, or clone operations.'
|
||||||
|
},
|
||||||
|
operations: {
|
||||||
|
title: 'Operations',
|
||||||
|
body: 'Click a cell in the pivot to set the slice, then choose an operation:\n\n• Scale — add or subtract value/units across the slice\n• Recode — reassign dimension values (e.g. rename a customer)\n• Clone — copy a slice to a new set of dimension values\n\nAll operations are incremental and undoable from the log.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHelp(btn, key) {
|
||||||
|
const data = helpText[key]
|
||||||
|
if (!data) return
|
||||||
|
const box = document.getElementById('help-box')
|
||||||
|
const over = document.getElementById('help-overlay')
|
||||||
|
box.innerHTML = `<div class="font-semibold text-white mb-1.5">${data.title}</div><div class="text-gray-300 whitespace-pre-line">${data.body}</div>`
|
||||||
|
const rect = btn.getBoundingClientRect()
|
||||||
|
box.style.top = (rect.bottom + 6 + window.scrollY) + 'px'
|
||||||
|
box.style.left = Math.min(rect.left, window.innerWidth - 300) + 'px'
|
||||||
|
box.classList.add('open')
|
||||||
|
over.classList.remove('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeHelp() {
|
||||||
|
document.getElementById('help-box').classList.remove('open')
|
||||||
|
document.getElementById('help-overlay').classList.add('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', drawTimeline)
|
||||||
|
|
||||||
|
// restore sidebar state
|
||||||
|
const sbState = localStorage.getItem('sb') || 'expanded'
|
||||||
|
const sb = document.getElementById('sidebar')
|
||||||
|
sb.classList.toggle('expanded', sbState === 'expanded')
|
||||||
|
sb.classList.toggle('collapsed', sbState === 'collapsed')
|
||||||
|
|
||||||
|
show('forecast')
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -83,9 +83,18 @@ body {
|
|||||||
.view.hidden { display: none !important; }
|
.view.hidden { display: none !important; }
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
SOURCES VIEW — two-column
|
SOURCES VIEW
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.two-col-layout { display: flex; gap: 10px; flex: 1; overflow: hidden; }
|
.two-col-layout { display: flex; gap: 10px; flex: 1; overflow: hidden; }
|
||||||
|
.sources-layout { display: flex; gap: 10px; flex: 1; overflow: hidden; }
|
||||||
|
.sources-left-col { width: 420px; flex-shrink: 0; display: flex; flex-direction: column; gap: 10px; overflow: hidden; }
|
||||||
|
.sources-tables-panel { flex-shrink: 0; }
|
||||||
|
.sources-list-panel { flex: 1; min-height: 0; }
|
||||||
|
.sources-mapping-panel { flex: 1; min-width: 0; }
|
||||||
|
.tables-search-wrap { padding: 6px 8px; border-bottom: 1px solid #e8ecf0; flex-shrink: 0; }
|
||||||
|
.tables-search-wrap input { width: 100%; padding: 4px 7px; border: 1px solid #d0d7de; border-radius: 4px; font-size: 12px; outline: none; }
|
||||||
|
.tables-search-wrap input:focus { border-color: #3498db; }
|
||||||
|
.tables-grid { height: 172px; flex-shrink: 0; }
|
||||||
.panel {
|
.panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: white;
|
background: white;
|
||||||
@ -190,7 +199,7 @@ body {
|
|||||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
#pivot-grid { width: 100%; height: 100%; }
|
#pivot-viewer { width: 100%; height: 100%; }
|
||||||
|
|
||||||
#operation-panel {
|
#operation-panel {
|
||||||
width: 270px;
|
width: 270px;
|
||||||
@ -312,9 +321,310 @@ body {
|
|||||||
#modal-body { padding: 16px 18px; overflow-y: auto; flex: 1; font-size: 12px; }
|
#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; }
|
.modal-footer { padding: 10px 18px; border-top: 1px solid #eee; display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
LOAD BASELINE / REFERENCE MODAL
|
||||||
|
============================================================ */
|
||||||
|
.load-data-modal { width: 480px; max-height: 80vh; }
|
||||||
|
|
||||||
|
#load-data-body {
|
||||||
|
padding: 20px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-form-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-form-fields label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-form-fields input[type=date],
|
||||||
|
.load-form-fields input[type=text] {
|
||||||
|
border: 1px solid #dce1e7;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-form-fields input[type=date]:focus,
|
||||||
|
.load-form-fields input[type=text]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #2980b9;
|
||||||
|
box-shadow: 0 0 0 2px rgba(41,128,185,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-offset-row { display: flex; gap: 12px; }
|
||||||
|
.load-offset-row label { flex: 1; }
|
||||||
|
|
||||||
|
.load-date-preview { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.load-date-preview.hidden { display: none; }
|
||||||
|
|
||||||
|
.load-preview-columns {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.load-preview-col { flex: 1; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.load-preview-arrow {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #aaa;
|
||||||
|
padding-top: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-preview-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #7f8c8d;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-chip {
|
||||||
|
background: #eaf4fb;
|
||||||
|
color: #1a6fa8;
|
||||||
|
border: 1px solid #c5dff0;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-chip-summary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-section h4 { font-size: 12px; margin-bottom: 6px; color: #555; }
|
.preview-section h4 { font-size: 12px; margin-bottom: 6px; color: #555; }
|
||||||
.preview-section + .preview-section { margin-top: 16px; }
|
.preview-section + .preview-section { margin-top: 16px; }
|
||||||
.preview-table { border-collapse: collapse; width: 100%; font-size: 11px; }
|
.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, .preview-table td { border: 1px solid #e0e0e0; padding: 4px 8px; text-align: left; }
|
||||||
.preview-table th { background: #f5f7f9; font-weight: 600; }
|
.preview-table th { background: #f5f7f9; font-weight: 600; }
|
||||||
.preview-table tr:hover td { background: #fafbfc; }
|
.preview-table tr:hover td { background: #fafbfc; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
BASELINE WORKBENCH
|
||||||
|
============================================================ */
|
||||||
|
.workbench-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#baseline-label { font-weight: 600; font-size: 13px; flex: 1; }
|
||||||
|
|
||||||
|
.baseline-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseline-form-panel {
|
||||||
|
background: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseline-segments-panel {
|
||||||
|
flex: 1;
|
||||||
|
background: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-section-title {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #7f8c8d;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseline-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseline-field-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseline-field-label input[type=text],
|
||||||
|
.baseline-field-label input[type=number],
|
||||||
|
.baseline-field-label select {
|
||||||
|
border: 1px solid #dce1e7;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offset-row { display: flex; gap: 10px; }
|
||||||
|
.offset-row label { flex: 1; }
|
||||||
|
|
||||||
|
/* WHERE clause builder */
|
||||||
|
.where-section { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.filter-section-label { font-size: 11px; color: #555; font-weight: 600; }
|
||||||
|
.col-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.col-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: #eaf4fb;
|
||||||
|
border: 1px solid #aed6f1;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #2471a3;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.col-chip:hover { background: #d6eaf8; border-color: #2471a3; }
|
||||||
|
.where-textarea {
|
||||||
|
width: 100%;
|
||||||
|
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
border-radius: 4px;
|
||||||
|
resize: vertical;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.where-textarea:focus { outline: none; border-color: #3498db; }
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e8ecf0;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row select,
|
||||||
|
.filter-row input[type=text] {
|
||||||
|
border: 1px solid #dce1e7;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #333;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-col-select { min-width: 120px; }
|
||||||
|
.filter-op-select { min-width: 90px; }
|
||||||
|
.filter-val-single,
|
||||||
|
.filter-val-list { min-width: 120px; flex: 1; }
|
||||||
|
.filter-val-from,
|
||||||
|
.filter-val-to { min-width: 90px; flex: 1; }
|
||||||
|
.filter-between-sep { font-size: 11px; color: #aaa; padding: 5px 2px; }
|
||||||
|
.filter-value-container { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
/* Timeline preview */
|
||||||
|
.timeline-preview { display: flex; flex-direction: column; gap: 6px; padding: 10px 0 4px; }
|
||||||
|
.timeline-preview.hidden { display: none; }
|
||||||
|
|
||||||
|
.timeline-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.timeline-row-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #7f8c8d;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
width: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.timeline-bar-wrap { flex: 1; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.timeline-bar {
|
||||||
|
height: 14px;
|
||||||
|
background: #2980b9;
|
||||||
|
border-radius: 3px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.timeline-bar-projected { background: #27ae60; }
|
||||||
|
.timeline-bar-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.timeline-offset-indicator {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #aaa;
|
||||||
|
padding: 2px 0 2px 68px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segment cards */
|
||||||
|
.segment-card {
|
||||||
|
border: 1px solid #e8ecf0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fafbfc;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.segment-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.segment-card-note {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.segment-card-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.segment-card-params {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.segments-empty { font-size: 12px; color: #aaa; font-style: italic; }
|
||||||
|
|||||||
@ -43,7 +43,7 @@ module.exports = function(pool) {
|
|||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
// delete forecast rows first (logid has no FK constraint — managed by app)
|
// delete forecast rows first (logid has no FK constraint — managed by app)
|
||||||
const del = await client.query(
|
const del = await client.query(
|
||||||
`DELETE FROM ${table} WHERE logid = $1 RETURNING id`,
|
`DELETE FROM ${table} WHERE pf_logid = $1 RETURNING pf_id`,
|
||||||
[logid]
|
[logid]
|
||||||
);
|
);
|
||||||
await client.query(`DELETE FROM pf.log WHERE id = $1`, [logid]);
|
await client.query(`DELETE FROM pf.log WHERE id = $1`, [logid]);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { tableFromJSON, RecordBatchStreamWriter, RecordBatch } = require('apache-arrow');
|
||||||
const { applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc } = require('../lib/sql_generator');
|
const { applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc } = require('../lib/sql_generator');
|
||||||
const { fcTable } = require('../lib/utils');
|
const { fcTable } = require('../lib/utils');
|
||||||
|
|
||||||
@ -61,41 +62,75 @@ module.exports = function(pool) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch all rows for a version (all iters including reference)
|
// stream all rows for a version as Arrow IPC (all iters including reference)
|
||||||
router.get('/versions/:id/data', async (req, res) => {
|
router.get('/versions/:id/data', async (req, res) => {
|
||||||
|
const versionId = parseInt(req.params.id);
|
||||||
|
let client, committed = false;
|
||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'get_data');
|
const verResult = await pool.query(
|
||||||
const sql = applyTokens(ctx.sql, { fc_table: ctx.table });
|
`SELECT v.*, s.tname FROM pf.version v JOIN pf.source s ON s.id = v.source_id WHERE v.id = $1`,
|
||||||
const result = await runSQL(sql);
|
[versionId]
|
||||||
res.json(result.rows);
|
);
|
||||||
|
if (!verResult.rows.length) {
|
||||||
|
const err = new Error('Version not found'); err.status = 404; throw err;
|
||||||
|
}
|
||||||
|
const tbl = fcTable(verResult.rows[0].tname, versionId);
|
||||||
|
|
||||||
|
const { rows: [{ count }] } = await pool.query(`SELECT COUNT(*) FROM ${tbl}`);
|
||||||
|
const rowCount = parseInt(count);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.apache.arrow.stream');
|
||||||
|
res.setHeader('X-Row-Count', String(rowCount));
|
||||||
|
|
||||||
|
if (rowCount === 0) { res.end(); return; }
|
||||||
|
|
||||||
|
client = await pool.connect();
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await client.query(`DECLARE pf_cur CURSOR FOR SELECT * FROM ${tbl}`);
|
||||||
|
|
||||||
|
const writer = RecordBatchStreamWriter.throughNode();
|
||||||
|
writer.pipe(res);
|
||||||
|
|
||||||
|
let schema = null;
|
||||||
|
while (true) {
|
||||||
|
const { rows } = await client.query('FETCH 10000 FROM pf_cur');
|
||||||
|
if (!rows.length) break;
|
||||||
|
const t = tableFromJSON(rows);
|
||||||
|
if (!schema) { schema = t.schema; writer.write(schema); }
|
||||||
|
for (const rb of t.batches) writer.write(new RecordBatch(schema, rb.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.end();
|
||||||
|
await client.query('COMMIT');
|
||||||
|
committed = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(err.status || 500).json({ error: err.message });
|
if (!res.headersSent) res.status(err.status || 500).json({ error: err.message });
|
||||||
|
else res.destroy();
|
||||||
|
} finally {
|
||||||
|
if (client) {
|
||||||
|
if (!committed) try { await client.query('ROLLBACK'); } catch {}
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// load baseline rows from source table for a date range
|
// load baseline rows from source table — additive, no delete
|
||||||
// deletes existing iter='baseline' rows before inserting (handled inside stored SQL)
|
|
||||||
router.post('/versions/:id/baseline', async (req, res) => {
|
router.post('/versions/:id/baseline', async (req, res) => {
|
||||||
const { date_from, date_to, pf_user, note, replay } = req.body;
|
const { where_clause, date_offset, pf_user, note } = req.body;
|
||||||
if (!date_from || !date_to) {
|
const dateOffset = date_offset || '0 days';
|
||||||
return res.status(400).json({ error: 'date_from and date_to are required' });
|
const filterClause = (where_clause || '').trim() || 'TRUE';
|
||||||
}
|
|
||||||
if (replay) {
|
|
||||||
return res.status(501).json({ error: 'replay is not yet implemented' });
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'baseline');
|
const ctx = await getContext(parseInt(req.params.id), 'baseline');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
|
|
||||||
const sql = applyTokens(ctx.sql, {
|
const sql = applyTokens(ctx.sql, {
|
||||||
fc_table: ctx.table,
|
fc_table: ctx.table,
|
||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
params: esc(JSON.stringify({ date_from, date_to })),
|
params: esc(JSON.stringify({ where_clause: filterClause, date_offset: dateOffset })),
|
||||||
date_from: esc(date_from),
|
filter_clause: filterClause,
|
||||||
date_to: esc(date_to)
|
date_offset: esc(dateOffset)
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSQL(sql);
|
const result = await runSQL(sql);
|
||||||
@ -106,24 +141,50 @@ module.exports = function(pool) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// delete all baseline rows and log entries for a version
|
||||||
|
router.delete('/versions/:id/baseline', async (req, res) => {
|
||||||
|
const versionId = parseInt(req.params.id);
|
||||||
|
try {
|
||||||
|
const ctx = await getContext(versionId, 'baseline');
|
||||||
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const delRows = await client.query(
|
||||||
|
`DELETE FROM ${ctx.table} WHERE pf_iter = 'baseline' RETURNING pf_id`
|
||||||
|
);
|
||||||
|
const delLog = await client.query(
|
||||||
|
`DELETE FROM pf.log WHERE version_id = $1 AND operation = 'baseline'`,
|
||||||
|
[versionId]
|
||||||
|
);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ rows_deleted: delRows.rowCount, log_entries_deleted: delLog.rowCount });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} 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)
|
// load reference rows from source table (additive — does not clear prior reference rows)
|
||||||
router.post('/versions/:id/reference', async (req, res) => {
|
router.post('/versions/:id/reference', async (req, res) => {
|
||||||
const { date_from, date_to, pf_user, note } = req.body;
|
const { where_clause, pf_user, note } = req.body;
|
||||||
if (!date_from || !date_to) {
|
const filterClause = (where_clause || '').trim() || 'TRUE';
|
||||||
return res.status(400).json({ error: 'date_from and date_to are required' });
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'reference');
|
const ctx = await getContext(parseInt(req.params.id), 'reference');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
|
|
||||||
const sql = applyTokens(ctx.sql, {
|
const sql = applyTokens(ctx.sql, {
|
||||||
fc_table: ctx.table,
|
fc_table: ctx.table,
|
||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
params: esc(JSON.stringify({ date_from, date_to })),
|
params: esc(JSON.stringify({ where_clause: filterClause })),
|
||||||
date_from: esc(date_from),
|
filter_clause: filterClause
|
||||||
date_to: esc(date_to)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSQL(sql);
|
const result = await runSQL(sql);
|
||||||
@ -183,7 +244,7 @@ module.exports = function(pool) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSQL(sql);
|
const result = await runSQL(sql);
|
||||||
res.json(result.rows[0]);
|
res.json({ rows: result.rows, rows_affected: result.rows.length });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(err.status || 500).json({ error: err.message });
|
res.status(err.status || 500).json({ error: err.message });
|
||||||
@ -218,7 +279,7 @@ module.exports = function(pool) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSQL(sql);
|
const result = await runSQL(sql);
|
||||||
res.json(result.rows[0]);
|
res.json({ rows: result.rows, rows_affected: result.rows.length });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(err.status || 500).json({ error: err.message });
|
res.status(err.status || 500).json({ error: err.message });
|
||||||
@ -255,6 +316,83 @@ module.exports = function(pool) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSQL(sql);
|
const result = await runSQL(sql);
|
||||||
|
res.json({ rows: result.rows, rows_affected: result.rows.length });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(err.status || 500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// list log entries for a version, newest first, with row counts
|
||||||
|
router.get('/versions/:id/log', async (req, res) => {
|
||||||
|
const versionId = parseInt(req.params.id);
|
||||||
|
try {
|
||||||
|
const verResult = await pool.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) return res.status(404).json({ error: 'Version not found' });
|
||||||
|
const table = fcTable(verResult.rows[0].tname, versionId);
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT l.*, count(f.pf_id)::int AS row_count
|
||||||
|
FROM pf.log l
|
||||||
|
LEFT JOIN ${table} f ON f.pf_logid = l.id
|
||||||
|
WHERE l.version_id = $1
|
||||||
|
GROUP BY l.id
|
||||||
|
ORDER BY l.id DESC
|
||||||
|
`, [versionId]);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(err.status || 500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// undo a log entry — delete all fc rows with this logid, then delete the log entry
|
||||||
|
router.delete('/log/:logid', async (req, res) => {
|
||||||
|
const logId = parseInt(req.params.logid);
|
||||||
|
try {
|
||||||
|
const logResult = await pool.query(`
|
||||||
|
SELECT l.*, v.status, s.tname, v.id AS version_id
|
||||||
|
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) return res.status(404).json({ error: 'Log entry not found' });
|
||||||
|
const log = logResult.rows[0];
|
||||||
|
if (log.status === 'closed') return res.status(403).json({ error: 'Version is closed' });
|
||||||
|
const table = fcTable(log.tname, log.version_id);
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const deleted = await client.query(
|
||||||
|
`DELETE FROM ${table} WHERE pf_logid = $1 RETURNING pf_id`, [logId]
|
||||||
|
);
|
||||||
|
await client.query('DELETE FROM pf.log WHERE id = $1', [logId]);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ rows_deleted: deleted.rowCount });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(err.status || 500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// update the note on a log entry
|
||||||
|
router.patch('/log/:logid', async (req, res) => {
|
||||||
|
const logId = parseInt(req.params.logid);
|
||||||
|
const { note } = req.body;
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE pf.log SET note = $1 WHERE id = $2 RETURNING *`, [note, logId]
|
||||||
|
);
|
||||||
|
if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' });
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@ -42,7 +42,7 @@ module.exports = function(pool) {
|
|||||||
// seed col_meta from information_schema
|
// seed col_meta from information_schema
|
||||||
await client.query(`
|
await client.query(`
|
||||||
INSERT INTO pf.col_meta (source_id, cname, role, opos)
|
INSERT INTO pf.col_meta (source_id, cname, role, opos)
|
||||||
SELECT $1, column_name, 'ignore', ordinal_position
|
SELECT $1, column_name, 'dimension', ordinal_position
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_schema = $2 AND table_name = $3
|
WHERE table_schema = $2 AND table_name = $3
|
||||||
ORDER BY ordinal_position
|
ORDER BY ordinal_position
|
||||||
|
|||||||
@ -79,8 +79,9 @@ module.exports = function(pool) {
|
|||||||
|
|
||||||
// build CREATE TABLE DDL using col_meta + mapped data types
|
// build CREATE TABLE DDL using col_meta + mapped data types
|
||||||
const table = fcTable(source.tname, version.id);
|
const table = fcTable(source.tname, version.id);
|
||||||
|
const systemCols = new Set(['pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'pf_created_at']);
|
||||||
const colDefs = colResult.rows
|
const colDefs = colResult.rows
|
||||||
.filter(c => c.cname !== 'id')
|
.filter(c => !systemCols.has(c.cname))
|
||||||
.map(c => {
|
.map(c => {
|
||||||
const pgType = mapType(c.data_type, c.numeric_precision, c.numeric_scale);
|
const pgType = mapType(c.data_type, c.numeric_precision, c.numeric_scale);
|
||||||
const quoted = `"${c.cname}"`;
|
const quoted = `"${c.cname}"`;
|
||||||
@ -89,12 +90,12 @@ module.exports = function(pool) {
|
|||||||
|
|
||||||
const ddl = `
|
const ddl = `
|
||||||
CREATE TABLE ${table} (
|
CREATE TABLE ${table} (
|
||||||
id bigserial PRIMARY KEY,
|
pf_id bigserial PRIMARY KEY,
|
||||||
${colDefs},
|
${colDefs},
|
||||||
iter text NOT NULL,
|
pf_iter text NOT NULL,
|
||||||
logid bigint NOT NULL,
|
pf_logid bigint NOT NULL,
|
||||||
pf_user text,
|
pf_user text,
|
||||||
created_at timestamptz NOT NULL DEFAULT now()
|
pf_created_at timestamptz NOT NULL DEFAULT now()
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
await client.query(ddl);
|
await client.query(ddl);
|
||||||
|
|||||||
@ -7,6 +7,8 @@ const app = express();
|
|||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
|
app.use(express.static('public/app'));
|
||||||
|
app.get('/', (req, res) => res.sendFile(__dirname + '/public/app/index.html'));
|
||||||
|
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
@ -27,7 +29,6 @@ app.use('/api', require('./routes/versions')(pool));
|
|||||||
app.use('/api', require('./routes/operations')(pool));
|
app.use('/api', require('./routes/operations')(pool));
|
||||||
app.use('/api', require('./routes/log')(pool));
|
app.use('/api', require('./routes/log')(pool));
|
||||||
|
|
||||||
app.get('/', (req, res) => res.send('pf_app running'));
|
|
||||||
|
|
||||||
const port = process.env.PORT || 3010;
|
const port = process.env.PORT || 3010;
|
||||||
app.listen(port, '0.0.0.0', () => console.log(`pf_app started on port ${port}`));
|
app.listen(port, '0.0.0.0', () => console.log(`pf_app started on port ${port}`));
|
||||||
|
|||||||
24
ui/.gitignore
vendored
Normal file
24
ui/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
16
ui/README.md
Normal file
16
ui/README.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
21
ui/eslint.config.js
Normal file
21
ui/eslint.config.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
14
ui/index.html
Normal file
14
ui/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Pivot Forecast</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@perspective-dev/viewer@4.4.0/dist/css/themes.css" crossorigin="anonymous">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2750
ui/package-lock.json
generated
Normal file
2750
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
ui/package.json
Normal file
28
ui/package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.5.0",
|
||||||
|
"vite": "^8.0.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ui/public/favicon.svg
Normal file
1
ui/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
ui/public/icons.svg
Normal file
24
ui/public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
184
ui/src/App.css
Normal file
184
ui/src/App.css
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
ui/src/App.jsx
Normal file
28
ui/src/App.jsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Sidebar from './components/Sidebar.jsx'
|
||||||
|
import StatusBar from './components/StatusBar.jsx'
|
||||||
|
import Setup from './views/Setup.jsx'
|
||||||
|
import Baseline from './views/Baseline.jsx'
|
||||||
|
import Forecast from './views/Forecast.jsx'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [view, setView] = useState(() => localStorage.getItem('pf_view') || 'forecast')
|
||||||
|
const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('pf_sidebar') !== 'collapsed')
|
||||||
|
|
||||||
|
useEffect(() => { localStorage.setItem('pf_view', view) }, [view])
|
||||||
|
useEffect(() => { localStorage.setItem('pf_sidebar', sidebarExpanded ? 'expanded' : 'collapsed') }, [sidebarExpanded])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full text-sm overflow-hidden">
|
||||||
|
<Sidebar view={view} setView={setView} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
|
||||||
|
<div className="flex flex-col flex-1 overflow-hidden min-w-0">
|
||||||
|
<StatusBar />
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{view === 'setup' && <Setup />}
|
||||||
|
{view === 'baseline' && <Baseline />}
|
||||||
|
{view === 'forecast' && <Forecast />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
ui/src/assets/hero.png
Normal file
BIN
ui/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
ui/src/assets/react.svg
Normal file
1
ui/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
ui/src/assets/vite.svg
Normal file
1
ui/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
88
ui/src/components/Sidebar.jsx
Normal file
88
ui/src/components/Sidebar.jsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
const NAV = [
|
||||||
|
{
|
||||||
|
id: 'setup',
|
||||||
|
label: 'Setup',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||||
|
<line x1="3" y1="5" x2="17" y2="5"/><circle cx="7" cy="5" r="1.8" fill="white" stroke="currentColor" strokeWidth="1.6"/>
|
||||||
|
<line x1="3" y1="10" x2="17" y2="10"/><circle cx="13" cy="10" r="1.8" fill="white" stroke="currentColor" strokeWidth="1.6"/>
|
||||||
|
<line x1="3" y1="15" x2="17" y2="15"/><circle cx="8" cy="15" r="1.8" fill="white" stroke="currentColor" strokeWidth="1.6"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'baseline',
|
||||||
|
label: 'Baseline',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="10,2 18,7 10,12 2,7"/>
|
||||||
|
<polyline points="2,12 10,17 18,12"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'forecast',
|
||||||
|
label: 'Forecast',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="2,15 7,9 11,12 18,4"/>
|
||||||
|
<polyline points="14,4 18,4 18,8"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Sidebar({ view, setView, expanded, setExpanded }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-white border-r border-gray-200 flex flex-col shrink-0 overflow-hidden transition-all duration-150"
|
||||||
|
style={{ width: expanded ? 200 : 48 }}
|
||||||
|
>
|
||||||
|
<div className="h-12 flex items-center px-3 border-b border-gray-100 gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(e => !e)}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-gray-400 shrink-0"
|
||||||
|
title="Toggle sidebar"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<rect x="1" y="3" width="14" height="1.5" rx="0.75" fill="currentColor"/>
|
||||||
|
<rect x="1" y="7.25" width="14" height="1.5" rx="0.75" fill="currentColor"/>
|
||||||
|
<rect x="1" y="11.5" width="14" height="1.5" rx="0.75" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className="text-xs font-semibold text-gray-600 tracking-wide uppercase whitespace-nowrap transition-opacity duration-100"
|
||||||
|
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none' }}
|
||||||
|
>
|
||||||
|
Pivot Forecast
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-0.5 p-2 flex-1">
|
||||||
|
{NAV.map(item => {
|
||||||
|
const active = view === item.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => setView(item.id)}
|
||||||
|
title={!expanded ? item.label : undefined}
|
||||||
|
className={`flex items-center gap-3 px-2 py-2 rounded text-left w-full transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="shrink-0">{item.icon}</span>
|
||||||
|
<span
|
||||||
|
className="text-sm whitespace-nowrap transition-opacity duration-100"
|
||||||
|
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
ui/src/components/StatusBar.jsx
Normal file
38
ui/src/components/StatusBar.jsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import useTheme from '../theme.jsx'
|
||||||
|
|
||||||
|
export default function StatusBar() {
|
||||||
|
const { dark, setDark } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border-b border-gray-200 px-4 h-8 flex items-center gap-4 shrink-0 text-xs">
|
||||||
|
<span className="text-gray-400">Source</span>
|
||||||
|
<span className="font-medium text-gray-700">sales_orders</span>
|
||||||
|
<span className="text-gray-200">|</span>
|
||||||
|
<span className="text-gray-400">Version</span>
|
||||||
|
<span className="font-medium text-gray-700">FY2026 Plan</span>
|
||||||
|
<span className="text-gray-200">|</span>
|
||||||
|
<span className="text-gray-400">Baseline</span>
|
||||||
|
<span className="font-medium text-gray-700">44,313 rows</span>
|
||||||
|
<span className="text-gray-200">|</span>
|
||||||
|
<span className="text-gray-400">Status</span>
|
||||||
|
<span className="text-green-600 font-medium">open</span>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setDark(d => !d)}
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100"
|
||||||
|
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
{dark ? (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8z"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M6 .278a.768.768 0 0 1 1.065.02A.75.75 0 0 1 5.792 15.5a.75.75 0 0 1-1.498-.075.768.768 0 0 1-.02-1.05A8 8 0 1 0 6.278 14.72a.768.768 0 0 1-1.055-.02A.75.75 0 0 1 2.5 13.75a.75.75 0 0 1 1.498.075A8 8 0 1 0 6 .278z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
162
ui/src/components/Timeline.jsx
Normal file
162
ui/src/components/Timeline.jsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
function parseDate(s) {
|
||||||
|
if (!s) return null
|
||||||
|
const [y, m, d] = s.split('-').map(Number)
|
||||||
|
return new Date(y, (m || 1) - 1, (d || 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(date, months) {
|
||||||
|
const d = new Date(date)
|
||||||
|
d.setMonth(d.getMonth() + months)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d) {
|
||||||
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundRect(ctx, x, y, w, h, r, fill, stroke) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x + r, y)
|
||||||
|
ctx.lineTo(x + w - r, y)
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
|
||||||
|
ctx.lineTo(x + w, y + h - r)
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
|
||||||
|
ctx.lineTo(x + r, y + h)
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
|
||||||
|
ctx.lineTo(x, y + r)
|
||||||
|
ctx.quadraticCurveTo(x, y, x + r, y)
|
||||||
|
ctx.closePath()
|
||||||
|
if (fill) ctx.fill()
|
||||||
|
if (stroke) ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Timeline({ dateFrom, dateTo, offsetYr, offsetMo, type = 'baseline' }) {
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
|
||||||
|
const offsetMoTotal = (offsetYr || 0) * 12 + (offsetMo || 0)
|
||||||
|
const twoBands = type === 'baseline' && offsetMoTotal > 0
|
||||||
|
const canvasH = twoBands ? 90 : 52
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
let raf
|
||||||
|
const draw = () => {
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
const W = canvas.offsetWidth || 500
|
||||||
|
canvas.width = W * dpr
|
||||||
|
canvas.height = canvasH * dpr
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
|
||||||
|
const PAD = { l: 8, r: 8 }
|
||||||
|
const trackH = 22
|
||||||
|
const drawW = W - PAD.l - PAD.r
|
||||||
|
const bandY = twoBands ? 20 : (canvasH - trackH) / 2
|
||||||
|
const projY = bandY + trackH + 10
|
||||||
|
|
||||||
|
const srcStart = parseDate(dateFrom)
|
||||||
|
const srcEnd = parseDate(dateTo)
|
||||||
|
if (!srcStart || !srcEnd || isNaN(srcStart) || isNaN(srcEnd)) return
|
||||||
|
|
||||||
|
const projStart = addMonths(srcStart, offsetMoTotal)
|
||||||
|
const projEnd = addMonths(srcEnd, offsetMoTotal)
|
||||||
|
|
||||||
|
const winStart = addMonths(srcStart, -1)
|
||||||
|
const winEnd = addMonths(twoBands ? projEnd : srcEnd, 1)
|
||||||
|
const winMs = winEnd - winStart
|
||||||
|
|
||||||
|
function xOf(date) {
|
||||||
|
return PAD.l + ((date - winStart) / winMs) * drawW
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, W, canvasH)
|
||||||
|
|
||||||
|
// axis
|
||||||
|
ctx.strokeStyle = '#e5e7eb'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(PAD.l, bandY - 8)
|
||||||
|
ctx.lineTo(PAD.l + drawW, bandY - 8)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// month ticks + year labels
|
||||||
|
const tickStart = new Date(winStart.getFullYear(), winStart.getMonth(), 1)
|
||||||
|
const tickBottom = twoBands ? projY + trackH : bandY + trackH
|
||||||
|
for (let d = new Date(tickStart); d <= winEnd; d = addMonths(d, 1)) {
|
||||||
|
const x = xOf(d)
|
||||||
|
if (x < PAD.l || x > PAD.l + drawW) continue
|
||||||
|
ctx.strokeStyle = '#f3f4f6'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, bandY - 8)
|
||||||
|
ctx.lineTo(x, tickBottom)
|
||||||
|
ctx.stroke()
|
||||||
|
if (d.getMonth() === 0) {
|
||||||
|
ctx.fillStyle = '#6b7280'
|
||||||
|
ctx.font = 'bold 9px system-ui'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(d.getFullYear(), x, bandY - 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// first band
|
||||||
|
const sx1 = xOf(srcStart), sx2 = xOf(srcEnd)
|
||||||
|
if (type === 'reference') {
|
||||||
|
ctx.fillStyle = '#f3e8ff'
|
||||||
|
ctx.strokeStyle = '#d8b4fe'
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = '#dbeafe'
|
||||||
|
ctx.strokeStyle = '#93c5fd'
|
||||||
|
}
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
roundRect(ctx, sx1, bandY, Math.max(sx2 - sx1, 4), trackH, 4, true, true)
|
||||||
|
|
||||||
|
ctx.fillStyle = type === 'reference' ? '#7c3aed' : '#1d4ed8'
|
||||||
|
ctx.font = '10px system-ui'
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
const bandLabel = type === 'reference' ? 'Reference' : 'Source'
|
||||||
|
ctx.fillText(bandLabel + ' ' + dateFrom + ' → ' + dateTo, sx1 + 6, bandY + 14)
|
||||||
|
|
||||||
|
// projected band + arrow (baseline only, when offset > 0)
|
||||||
|
if (twoBands) {
|
||||||
|
const px1 = xOf(projStart), px2 = xOf(projEnd)
|
||||||
|
ctx.fillStyle = '#dcfce7'
|
||||||
|
ctx.strokeStyle = '#86efac'
|
||||||
|
roundRect(ctx, px1, projY, Math.max(px2 - px1, 4), trackH, 4, true, true)
|
||||||
|
ctx.fillStyle = '#15803d'
|
||||||
|
ctx.font = '10px system-ui'
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.fillText('Projected ' + fmtDate(projStart) + ' → ' + fmtDate(projEnd), px1 + 6, projY + 14)
|
||||||
|
|
||||||
|
const arrowY = bandY + trackH / 2
|
||||||
|
ctx.strokeStyle = '#94a3b8'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.setLineDash([3, 3])
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(sx1, arrowY)
|
||||||
|
ctx.lineTo(px1 - 2, arrowY)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
|
ctx.fillStyle = '#94a3b8'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(px1 + 4, arrowY)
|
||||||
|
ctx.lineTo(px1 - 4, arrowY - 4)
|
||||||
|
ctx.lineTo(px1 - 4, arrowY + 4)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
const offsetLabel = '+' + (offsetYr ? offsetYr + 'yr ' : '') + (offsetMo ? offsetMo + 'mo' : '')
|
||||||
|
ctx.fillStyle = '#64748b'
|
||||||
|
ctx.font = '9px system-ui'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(offsetLabel.trim(), (sx1 + px1) / 2, arrowY - 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(draw)
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [dateFrom, dateTo, offsetYr, offsetMo, type, twoBands, canvasH])
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} height={canvasH} style={{ width: '100%', display: 'block' }} />
|
||||||
|
}
|
||||||
80
ui/src/index.css
Normal file
80
ui/src/index.css
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root, .light {
|
||||||
|
--bg-primary: #f3f4f6;
|
||||||
|
--bg-secondary: #ffffff;
|
||||||
|
--bg-tertiary: #f9fafb;
|
||||||
|
--text-primary: #1f2937;
|
||||||
|
--text-secondary: #374151;
|
||||||
|
--text-muted: #9ca3af;
|
||||||
|
--border-color: #e5e7eb;
|
||||||
|
--border-light: #f3f4f6;
|
||||||
|
--accent-bg: #eff6ff;
|
||||||
|
--accent-text: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--bg-primary: #111827;
|
||||||
|
--bg-secondary: #1f2937;
|
||||||
|
--bg-tertiary: #374151;
|
||||||
|
--text-primary: #f9fafb;
|
||||||
|
--text-secondary: #e5e7eb;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
--border-color: #374151;
|
||||||
|
--border-light: #1f2937;
|
||||||
|
--accent-bg: #1e3a5f;
|
||||||
|
--accent-text: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { margin: 0; background-color: var(--bg-primary); color: var(--text-primary); }
|
||||||
|
#root { height: 100vh; display: flex; }
|
||||||
|
|
||||||
|
.dark .bg-white { background-color: var(--bg-secondary); }
|
||||||
|
.dark .bg-gray-50 { background-color: var(--bg-tertiary); }
|
||||||
|
.dark .bg-gray-100 { background-color: var(--bg-tertiary); }
|
||||||
|
.dark .bg-gray-200 { background-color: var(--bg-tertiary); }
|
||||||
|
.dark .bg-gray-300 { background-color: var(--bg-tertiary); }
|
||||||
|
.dark .text-gray-300 { color: var(--text-muted); }
|
||||||
|
.dark .text-gray-400 { color: var(--text-muted); }
|
||||||
|
.dark .text-gray-500 { color: var(--text-muted); }
|
||||||
|
.dark .text-gray-600 { color: var(--text-secondary); }
|
||||||
|
.dark .text-gray-700 { color: var(--text-secondary); }
|
||||||
|
.dark .text-gray-800 { color: var(--text-primary); }
|
||||||
|
.dark .text-gray-900 { color: var(--text-primary); }
|
||||||
|
.dark .bg-blue-50 { background-color: var(--accent-bg); }
|
||||||
|
.dark .bg-blue-100 { background-color: var(--accent-bg); }
|
||||||
|
.dark .text-blue-600 { color: var(--accent-text); }
|
||||||
|
.dark .text-blue-700 { color: var(--accent-text); }
|
||||||
|
.dark .border-blue-300 { border-color: var(--accent-text); }
|
||||||
|
.dark .hover\:bg-blue-50:hover { background-color: var(--accent-bg); }
|
||||||
|
.dark .bg-green-50 { background-color: #064e3b; }
|
||||||
|
.dark .text-green-600 { color: #34d399; }
|
||||||
|
.dark .text-green-700 { color: #34d399; }
|
||||||
|
.dark .text-green-400 { color: #34d399; }
|
||||||
|
.dark .bg-yellow-50 { background-color: #451a03; }
|
||||||
|
.dark .text-yellow-700 { color: #fbbf24; }
|
||||||
|
.dark .bg-purple-50 { background-color: #1e1b4b; }
|
||||||
|
.dark .text-purple-700 { color: #a78bfa; }
|
||||||
|
.dark .bg-red-50 { background-color: #450a0a; }
|
||||||
|
.dark .text-red-700 { color: #f87171; }
|
||||||
|
.dark .border-gray-100 { border-color: var(--border-light); }
|
||||||
|
.dark .border-gray-200 { border-color: var(--border-color); }
|
||||||
|
.dark .border-gray-300 { border-color: var(--border-color); }
|
||||||
|
.dark .border-b { border-color: var(--border-color); }
|
||||||
|
.dark .border-t { border-color: var(--border-color); }
|
||||||
|
.dark .border-r { border-color: var(--border-color); }
|
||||||
|
.dark .border-l { border-color: var(--border-color); }
|
||||||
|
.dark .hover\:bg-gray-50:hover { background-color: var(--bg-tertiary); }
|
||||||
|
.dark .hover\:bg-gray-100:hover { background-color: var(--bg-tertiary); }
|
||||||
|
.dark .hover\:bg-gray-200:hover { background-color: var(--bg-tertiary); }
|
||||||
|
.dark .hover\:text-gray-500:hover { color: var(--text-secondary); }
|
||||||
|
.dark .hover\:text-gray-600:hover { color: var(--text-secondary); }
|
||||||
|
.dark .hover\:text-gray-800:hover { color: var(--text-primary); }
|
||||||
|
.dark .hover\:border-gray-300:hover { border-color: var(--border-color); }
|
||||||
|
.dark .hover\:border-gray-400:hover { border-color: var(--border-color); }
|
||||||
|
.dark .focus\:border-gray-300:focus { border-color: var(--border-color); }
|
||||||
|
.dark ::selection { background-color: var(--accent-bg); color: var(--accent-text); }
|
||||||
|
.dark input { background-color: var(--bg-secondary); color: var(--text-primary); }
|
||||||
|
.dark select { background-color: var(--bg-secondary); color: var(--text-primary); }
|
||||||
|
.dark textarea { background-color: var(--bg-secondary); color: var(--text-primary); }
|
||||||
|
.dark .bg-transparent { background-color: transparent; }
|
||||||
13
ui/src/main.jsx
Normal file
13
ui/src/main.jsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { ThemeProvider } from './theme.jsx'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
25
ui/src/theme.jsx
Normal file
25
ui/src/theme.jsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
const ThemeContext = createContext()
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }) {
|
||||||
|
const [dark, setDark] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('pf_dark')
|
||||||
|
if (saved !== null) return saved === 'true'
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('pf_dark', dark)
|
||||||
|
document.documentElement.classList.toggle('dark', dark)
|
||||||
|
}, [dark])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ dark, setDark }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useTheme = () => useContext(ThemeContext)
|
||||||
|
export default useTheme
|
||||||
520
ui/src/views/Baseline.jsx
Normal file
520
ui/src/views/Baseline.jsx
Normal file
@ -0,0 +1,520 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Timeline from '../components/Timeline.jsx'
|
||||||
|
|
||||||
|
const OPERATORS = ['BETWEEN', '=', '!=', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL']
|
||||||
|
|
||||||
|
function buildFilterClause(filters) {
|
||||||
|
if (!filters.length) return null
|
||||||
|
const parts = filters.map(f => {
|
||||||
|
const col = `"${f.col}"`
|
||||||
|
const op = f.op
|
||||||
|
if (op === 'IS NULL') return `${col} IS NULL`
|
||||||
|
if (op === 'IS NOT NULL') return `${col} IS NOT NULL`
|
||||||
|
if (op === 'BETWEEN') {
|
||||||
|
const [a, b] = f.values
|
||||||
|
return `${col} BETWEEN '${a}' AND '${b}'`
|
||||||
|
}
|
||||||
|
if (op === 'IN' || op === 'NOT IN') {
|
||||||
|
const vals = f.values.join("','")
|
||||||
|
return `${col} ${op} ('${vals}')`
|
||||||
|
}
|
||||||
|
return `${col} ${op} '${f.values[0]}'`
|
||||||
|
})
|
||||||
|
return parts.join(' AND ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateRange(filters) {
|
||||||
|
for (const f of filters) {
|
||||||
|
if (f.op === 'BETWEEN' && f.values[0] && f.values[1]) {
|
||||||
|
return { from: f.values[0], to: f.values[1] }
|
||||||
|
}
|
||||||
|
if (f.op === '=' && f.values[0]) {
|
||||||
|
return { from: f.values[0], to: f.values[0] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateRangeFromClause(clause) {
|
||||||
|
if (!clause) return null
|
||||||
|
const m = clause.match(/BETWEEN '(\d{4}-\d{2}-\d{2})' AND '(\d{4}-\d{2}-\d{2})'/)
|
||||||
|
if (m) return { from: m[1], to: m[2] }
|
||||||
|
const m2 = clause.match(/>= ?'(\d{4}-\d{2}-\d{2})'.+<= ?'(\d{4}-\d{2}-\d{2})'/)
|
||||||
|
if (m2) return { from: m2[1], to: m2[2] }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOffset(offsetStr) {
|
||||||
|
if (!offsetStr || offsetStr === '0 days') return { yr: 0, mo: 0 }
|
||||||
|
const yr = parseInt(offsetStr.match(/(\d+)\s+year/)?.[1] || 0)
|
||||||
|
const mo = parseInt(offsetStr.match(/(\d+)\s+month/)?.[1] || 0)
|
||||||
|
return { yr, mo }
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyFilter(cols) {
|
||||||
|
return { col: cols[0]?.cname || '', op: 'BETWEEN', values: ['', ''] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Baseline() {
|
||||||
|
const [sources, setSources] = useState([])
|
||||||
|
const [sourceId, setSourceId] = useState('')
|
||||||
|
const [versions, setVersions] = useState([])
|
||||||
|
const [versionId, setVersionId] = useState('')
|
||||||
|
const [filterCols, setFilterCols] = useState([])
|
||||||
|
const [log, setLog] = useState([])
|
||||||
|
|
||||||
|
// new version form
|
||||||
|
const [showNewVersion, setShowNewVersion] = useState(false)
|
||||||
|
const [newVerName, setNewVerName] = useState('')
|
||||||
|
const [newVerDesc, setNewVerDesc] = useState('')
|
||||||
|
const [creatingVer, setCreatingVer] = useState(false)
|
||||||
|
|
||||||
|
// add segment form
|
||||||
|
const [segType, setSegType] = useState('baseline')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [filters, setFilters] = useState([])
|
||||||
|
const [offsetYr, setOffsetYr] = useState(0)
|
||||||
|
const [offsetMo, setOffsetMo] = useState(0)
|
||||||
|
const [segNote, setSegNote] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const [expandedId, setExpandedId] = useState(null)
|
||||||
|
const [msg, setMsg] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/sources').then(r => r.json()).then(data => {
|
||||||
|
setSources(data)
|
||||||
|
if (data.length > 0) setSourceId(String(data[0].id))
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sourceId) return
|
||||||
|
fetch(`/api/sources/${sourceId}/versions`).then(r => r.json()).then(data => {
|
||||||
|
setVersions(data)
|
||||||
|
if (data.length > 0) setVersionId(String(data[0].id))
|
||||||
|
else setVersionId('')
|
||||||
|
})
|
||||||
|
fetch(`/api/sources/${sourceId}/cols`).then(r => r.json()).then(cols => {
|
||||||
|
const fc = cols.filter(c => c.role === 'date' || c.role === 'filter')
|
||||||
|
setFilterCols(fc)
|
||||||
|
setFilters(fc.length > 0 ? [emptyFilter(fc)] : [])
|
||||||
|
})
|
||||||
|
}, [sourceId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!versionId) { setLog([]); return }
|
||||||
|
loadLog()
|
||||||
|
}, [versionId])
|
||||||
|
|
||||||
|
function loadLog() {
|
||||||
|
fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => {
|
||||||
|
setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createVersion() {
|
||||||
|
if (!newVerName.trim()) return
|
||||||
|
setCreatingVer(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sources/${sourceId}/versions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: newVerName.trim(), description: newVerDesc, created_by: 'admin' })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
||||||
|
setVersions(updated)
|
||||||
|
setVersionId(String(data.id))
|
||||||
|
setShowNewVersion(false)
|
||||||
|
setNewVerName('')
|
||||||
|
setNewVerDesc('')
|
||||||
|
flash(`Version "${data.name}" created`)
|
||||||
|
} catch (err) {
|
||||||
|
flash(err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
setCreatingVer(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFilter() {
|
||||||
|
setFilters(f => [...f, emptyFilter(filterCols)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFilter(i) {
|
||||||
|
setFilters(f => f.filter((_, idx) => idx !== i))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilter(i, field, value) {
|
||||||
|
setFilters(f => f.map((row, idx) => {
|
||||||
|
if (idx !== i) return row
|
||||||
|
if (field === 'op') {
|
||||||
|
const needsTwo = value === 'BETWEEN'
|
||||||
|
const needsOne = ['=', '!='].includes(value)
|
||||||
|
const needsMany = ['IN', 'NOT IN'].includes(value)
|
||||||
|
const needsNone = ['IS NULL', 'IS NOT NULL'].includes(value)
|
||||||
|
return { ...row, op: value, values: needsNone ? [] : needsTwo ? ['', ''] : needsMany ? [''] : [''] }
|
||||||
|
}
|
||||||
|
return { ...row, [field]: value }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilterValue(i, vi, value) {
|
||||||
|
setFilters(f => f.map((row, idx) => {
|
||||||
|
if (idx !== i) return row
|
||||||
|
const vals = [...row.values]
|
||||||
|
vals[vi] = value
|
||||||
|
return { ...row, values: vals }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSegment() {
|
||||||
|
const clause = buildFilterClause(filters)
|
||||||
|
if (!clause) { flash('Add at least one filter', 'error'); return }
|
||||||
|
const isRef = segType === 'reference'
|
||||||
|
const offsetStr = isRef ? '0 days' : ([offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days')
|
||||||
|
const endpoint = isRef ? 'reference' : 'baseline'
|
||||||
|
const body = isRef
|
||||||
|
? { where_clause: clause, pf_user: 'admin', note: description || segNote }
|
||||||
|
: { where_clause: clause, date_offset: offsetStr, pf_user: 'admin', note: description || segNote }
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/versions/${versionId}/${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
flash(`Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`)
|
||||||
|
loadLog()
|
||||||
|
setDescription(''); setSegNote(''); setOffsetYr(0); setOffsetMo(0)
|
||||||
|
setFilters(filterCols.length > 0 ? [emptyFilter(filterCols)] : [])
|
||||||
|
} catch (err) {
|
||||||
|
flash(err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function undoSegment(logid) {
|
||||||
|
await fetch(`/api/log/${logid}`, { method: 'DELETE' })
|
||||||
|
loadLog()
|
||||||
|
flash('Segment undone')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearBaseline() {
|
||||||
|
if (!confirm('Delete all baseline rows for this version?')) return
|
||||||
|
const res = await fetch(`/api/versions/${versionId}/baseline`, { method: 'DELETE' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
loadLog()
|
||||||
|
flash(`Cleared ${data.rows_deleted} rows`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeVersion() {
|
||||||
|
const res = await fetch(`/api/versions/${versionId}/close`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pf_user: 'admin' })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
||||||
|
setVersions(updated)
|
||||||
|
flash('Version closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reopenVersion() {
|
||||||
|
const res = await fetch(`/api/versions/${versionId}/reopen`, { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
||||||
|
setVersions(updated)
|
||||||
|
flash('Version reopened')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteVersion() {
|
||||||
|
if (!confirm(`Delete version "${selectedVersion?.name}"? This drops the forecast table and cannot be undone.`)) return
|
||||||
|
const res = await fetch(`/api/versions/${versionId}`, { method: 'DELETE' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
||||||
|
setVersions(updated)
|
||||||
|
setVersionId(updated.length > 0 ? String(updated[0].id) : '')
|
||||||
|
flash('Version deleted')
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(text, type = 'ok') {
|
||||||
|
setMsg({ text, type })
|
||||||
|
setTimeout(() => setMsg(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateRange = getDateRange(filters)
|
||||||
|
const selectedVersion = versions.find(v => String(v.id) === versionId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto bg-gray-50">
|
||||||
|
<div className="p-4 flex flex-col gap-4 max-w-4xl">
|
||||||
|
|
||||||
|
{/* Flash */}
|
||||||
|
{msg && (
|
||||||
|
<div className={`px-3 py-2 text-xs rounded font-medium ${msg.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Source + Version bar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">Source</span>
|
||||||
|
<select value={sourceId} onChange={e => setSourceId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
|
||||||
|
{sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">Version</span>
|
||||||
|
<select value={versionId} onChange={e => setVersionId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white" disabled={versions.length === 0}>
|
||||||
|
{versions.length === 0
|
||||||
|
? <option value="">— no versions —</option>
|
||||||
|
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
{versionId && (
|
||||||
|
<span className={`text-xs font-medium ${selectedVersion?.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}>
|
||||||
|
{selectedVersion?.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowNewVersion(v => !v)} className="text-xs text-blue-600 hover:text-blue-700 border border-blue-200 px-2 py-1 rounded">
|
||||||
|
+ New version
|
||||||
|
</button>
|
||||||
|
{versionId && (
|
||||||
|
<div className="flex items-center gap-2 ml-2">
|
||||||
|
{selectedVersion?.status === 'open'
|
||||||
|
? <button onClick={closeVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Close</button>
|
||||||
|
: <button onClick={reopenVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Reopen</button>
|
||||||
|
}
|
||||||
|
<button onClick={deleteVersion} className="text-xs text-red-400 hover:text-red-600 border border-red-200 px-2 py-1 rounded">Delete</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New version inline form */}
|
||||||
|
{showNewVersion && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-3">
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-500">Name</label>
|
||||||
|
<input value={newVerName} onChange={e => setNewVerName(e.target.value)} placeholder="e.g. FY2026 Plan" className="border border-gray-200 rounded px-2 py-1 text-sm w-48" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 flex-1">
|
||||||
|
<label className="text-xs text-gray-500">Description</label>
|
||||||
|
<input value={newVerDesc} onChange={e => setNewVerDesc(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1 text-sm" />
|
||||||
|
</div>
|
||||||
|
<button onClick={createVersion} disabled={creatingVer || !newVerName.trim()} className="bg-blue-600 text-white text-xs px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50 shrink-0">
|
||||||
|
{creatingVer ? 'Creating table…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowNewVersion(false)} className="text-gray-400 hover:text-gray-600 text-xs shrink-0">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 border-t border-gray-100 pt-2">
|
||||||
|
Creates a forecast table <span className="font-mono text-gray-500">pf.fc_{sources.find(s=>String(s.id)===sourceId)?.tname}_<id></span> in the database from the current col meta. If col meta changes after creation the table and SQL will be out of sync — delete and recreate the version to realign.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{versionId && <>
|
||||||
|
|
||||||
|
{/* Segments loaded */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded">
|
||||||
|
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
|
||||||
|
<span>Segments loaded</span>
|
||||||
|
<button onClick={clearBaseline} className="text-red-400 hover:text-red-600 text-xs normal-case font-normal">Clear all baseline</button>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr className="text-left text-gray-400 border-b border-gray-100">
|
||||||
|
<th className="px-3 py-1.5 font-medium w-6"></th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">#</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">note</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">by</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">when</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{log.length === 0 && (
|
||||||
|
<tr><td colSpan={6} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
|
||||||
|
)}
|
||||||
|
{log.map((entry, i) => {
|
||||||
|
const isOpen = expandedId === entry.id
|
||||||
|
const params = entry.params || {}
|
||||||
|
const dr = parseDateRangeFromClause(params.where_clause)
|
||||||
|
const off = parseOffset(params.date_offset)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={entry.id}
|
||||||
|
onClick={() => setExpandedId(isOpen ? null : entry.id)}
|
||||||
|
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${isOpen ? 'bg-blue-50' : ''}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 text-gray-400 w-6">
|
||||||
|
<span className="text-gray-300 text-xs">{isOpen ? '▾' : '▸'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{log.length - i}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`inline-block mr-2 px-1.5 py-0.5 rounded text-xs font-medium ${entry.operation === 'reference' ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600'}`}>
|
||||||
|
{entry.operation}
|
||||||
|
</span>
|
||||||
|
{entry.note || <span className="text-gray-300">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-500">{entry.pf_user}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{new Date(entry.stamp).toLocaleDateString()}</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<button onClick={e => { e.stopPropagation(); undoSegment(entry.id) }} className="text-gray-400 hover:text-red-500 text-xs">Undo</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isOpen && (
|
||||||
|
<tr key={`${entry.id}-detail`} className="bg-blue-50 border-t border-blue-100">
|
||||||
|
<td colSpan={6} className="px-4 py-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-xs text-gray-400 w-24 shrink-0 pt-0.5">WHERE</span>
|
||||||
|
<code className="text-xs font-mono text-gray-700 bg-white border border-gray-200 rounded px-2 py-1 break-all">{params.where_clause || '—'}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-400 w-24 shrink-0">offset</span>
|
||||||
|
<span className="text-xs font-mono text-gray-600">{params.date_offset || '0 days'}</span>
|
||||||
|
</div>
|
||||||
|
{dr && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<Timeline dateFrom={dr.from} dateTo={dr.to} offsetYr={off.yr} offsetMo={off.mo} type={entry.operation === 'reference' ? 'reference' : 'baseline'} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Segment */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded">
|
||||||
|
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
Add Segment
|
||||||
|
</div>
|
||||||
|
<div className="p-4 flex flex-col gap-4">
|
||||||
|
|
||||||
|
{/* Type toggle */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs text-gray-500 w-28 shrink-0">Type</label>
|
||||||
|
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
|
||||||
|
{['baseline', 'reference'].map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => { setSegType(t); if (t === 'reference') { setOffsetYr(0); setOffsetMo(0) } }}
|
||||||
|
className={`px-3 py-1 capitalize ${segType === t ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}
|
||||||
|
>{t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{segType === 'reference' && (
|
||||||
|
<span className="text-xs text-gray-400">dates land verbatim — no offset applied</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs text-gray-500 w-28 shrink-0">Description</label>
|
||||||
|
<input value={description} onChange={e => setDescription(e.target.value)} placeholder="e.g. FY25 actuals +1yr" className="border border-gray-200 rounded px-2 py-1.5 text-sm flex-1 max-w-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-xs text-gray-500 w-28 shrink-0">Filters</label>
|
||||||
|
<button onClick={addFilter} className="text-blue-600 hover:text-blue-700 text-xs font-medium">+ Add filter</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 ml-28">
|
||||||
|
{filters.map((f, i) => {
|
||||||
|
const isDateCol = filterCols.find(c => c.cname === f.col)?.role === 'date'
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-2 flex-wrap">
|
||||||
|
<select value={f.col} onChange={e => updateFilter(i, 'col', e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
|
||||||
|
{filterCols.map(c => <option key={c.cname} value={c.cname}>{c.cname}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={f.op} onChange={e => updateFilter(i, 'op', e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
|
||||||
|
{OPERATORS.map(op => <option key={op} value={op}>{op}</option>)}
|
||||||
|
</select>
|
||||||
|
{f.op === 'BETWEEN' && <>
|
||||||
|
<input type={isDateCol ? 'date' : 'text'} value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="from" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
|
||||||
|
<span className="text-gray-400 text-xs">and</span>
|
||||||
|
<input type={isDateCol ? 'date' : 'text'} value={f.values[1]} onChange={e => updateFilterValue(i, 1, e.target.value)} placeholder="to" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
|
||||||
|
</>}
|
||||||
|
{(f.op === '=' || f.op === '!=') && (
|
||||||
|
<input type={isDateCol ? 'date' : 'text'} value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="value" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
|
||||||
|
)}
|
||||||
|
{(f.op === 'IN' || f.op === 'NOT IN') && (
|
||||||
|
<input value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="val1, val2, …" className="border border-gray-200 rounded px-2 py-1 text-xs w-48 font-mono bg-white" />
|
||||||
|
)}
|
||||||
|
<button onClick={() => removeFilter(i)} className="text-gray-300 hover:text-red-400 text-xs">✕</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{filters.length === 0 && (
|
||||||
|
<span className="text-xs text-gray-300 italic">No filters — at least one is required</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date offset — baseline only */}
|
||||||
|
{segType === 'baseline' && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs text-gray-500 w-28 shrink-0">Date offset</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="number" value={offsetYr} min={0} onChange={e => setOffsetYr(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
|
||||||
|
<span className="text-xs text-gray-500">yr</span>
|
||||||
|
<input type="number" value={offsetMo} min={0} max={11} onChange={e => setOffsetMo(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
|
||||||
|
<span className="text-xs text-gray-500">mo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
{dateRange && (
|
||||||
|
<div className="ml-28">
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded p-3">
|
||||||
|
<Timeline
|
||||||
|
dateFrom={dateRange.from}
|
||||||
|
dateTo={dateRange.to}
|
||||||
|
offsetYr={segType === 'baseline' ? offsetYr : 0}
|
||||||
|
offsetMo={segType === 'baseline' ? offsetMo : 0}
|
||||||
|
type={segType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Note + submit */}
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="flex flex-col gap-1 flex-1 max-w-xs">
|
||||||
|
<label className="text-xs text-gray-500">Note</label>
|
||||||
|
<input value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1.5 text-sm" />
|
||||||
|
</div>
|
||||||
|
<button onClick={loadSegment} disabled={submitting || filters.length === 0} className="bg-blue-600 text-white text-xs px-5 py-2 rounded hover:bg-blue-700 disabled:opacity-50 shrink-0">
|
||||||
|
{submitting ? 'Loading…' : `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</>}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
742
ui/src/views/Forecast.jsx
Normal file
742
ui/src/views/Forecast.jsx
Normal file
@ -0,0 +1,742 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
const LAYOUT_KEY = (vid) => `pf_layout_v${vid}` // last-used layout (auto restore)
|
||||||
|
const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // named layout list
|
||||||
|
|
||||||
|
let perspectivePromise = null
|
||||||
|
function loadPerspective() {
|
||||||
|
if (perspectivePromise) return perspectivePromise
|
||||||
|
perspectivePromise = Promise.all([
|
||||||
|
import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.4.0/dist/cdn/perspective.js'),
|
||||||
|
import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer@4.4.0/dist/cdn/perspective-viewer.js'),
|
||||||
|
import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-datagrid@4.4.0/dist/cdn/perspective-viewer-datagrid.js'),
|
||||||
|
import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-d3fc@4.4.0/dist/cdn/perspective-viewer-d3fc.js'),
|
||||||
|
]).then(([{ default: perspective }]) => perspective)
|
||||||
|
return perspectivePromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanLayout(cfg, validCols) {
|
||||||
|
if (!cfg) return cfg
|
||||||
|
const c = { ...cfg }
|
||||||
|
const exprNames = new Set(Object.keys(cfg.expressions || {}))
|
||||||
|
const ok = (col) => validCols.has(col) || exprNames.has(col)
|
||||||
|
if (c.columns) c.columns = c.columns.filter(col => col == null || ok(col))
|
||||||
|
if (c.group_by) c.group_by = c.group_by.filter(ok)
|
||||||
|
if (c.split_by) c.split_by = c.split_by.filter(ok)
|
||||||
|
if (c.sort) c.sort = c.sort.filter(([col]) => ok(col))
|
||||||
|
if (c.filter) c.filter = c.filter.filter(([col]) => ok(col))
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Forecast() {
|
||||||
|
const [sources, setSources] = useState([])
|
||||||
|
const [sourceId, setSourceId] = useState('')
|
||||||
|
const [versions, setVersions] = useState([])
|
||||||
|
const [versionId, setVersionId] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [largeDataset, setLargeDataset] = useState(false)
|
||||||
|
const [msg, setMsg] = useState(null)
|
||||||
|
|
||||||
|
// layouts
|
||||||
|
const [layouts, setLayouts] = useState([])
|
||||||
|
const [activeLayoutId, setActiveLayoutId] = useState(null)
|
||||||
|
const [showSaveAs, setShowSaveAs] = useState(false)
|
||||||
|
const [saveAsName, setSaveAsName] = useState('')
|
||||||
|
|
||||||
|
// operation panel
|
||||||
|
const [slice, setSlice] = useState({})
|
||||||
|
const [activeOp, setActiveOp] = useState('scale')
|
||||||
|
const [currentTotals, setCurrentTotals] = useState(null) // { value, units }
|
||||||
|
const [scaleMode, setScaleMode] = useState('target') // 'target' | 'delta'
|
||||||
|
const [scaleValue, setScaleValue] = useState('')
|
||||||
|
const [scaleUnits, setScaleUnits] = useState('')
|
||||||
|
const [scalePct, setScalePct] = useState(false)
|
||||||
|
const [scaleNote, setScaleNote] = useState('')
|
||||||
|
const [recodeSet, setRecodeSet] = useState({})
|
||||||
|
const [recodeNote, setRecodeNote] = useState('')
|
||||||
|
const [cloneSet, setCloneSet] = useState({})
|
||||||
|
const [cloneScale, setCloneScale] = useState('1')
|
||||||
|
const [cloneNote, setCloneNote] = useState('')
|
||||||
|
|
||||||
|
const [panelWidth, setPanelWidth] = useState(224)
|
||||||
|
|
||||||
|
// history modal
|
||||||
|
const [showLog, setShowLog] = useState(false)
|
||||||
|
const [logEntries, setLogEntries] = useState([])
|
||||||
|
const [logLoading, setLogLoading] = useState(false)
|
||||||
|
const [editingNote, setEditingNote] = useState(null) // { id, text }
|
||||||
|
const [undoingId, setUndoingId] = useState(null)
|
||||||
|
|
||||||
|
const viewerRef = useRef(null)
|
||||||
|
const workerRef = useRef(null)
|
||||||
|
const tableRef = useRef(null)
|
||||||
|
const colMetaRef = useRef([])
|
||||||
|
const expandDepthRef = useRef(null)
|
||||||
|
|
||||||
|
function onDragStart(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const startX = e.clientX
|
||||||
|
const startW = panelWidth
|
||||||
|
const onMove = (ev) => setPanelWidth(Math.max(160, Math.min(480, startW - (ev.clientX - startX))))
|
||||||
|
const onUp = () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) }
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
window.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/sources').then(r => r.json()).then(data => {
|
||||||
|
setSources(data)
|
||||||
|
if (data.length > 0) setSourceId(String(data[0].id))
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sourceId) return
|
||||||
|
fetch(`/api/sources/${sourceId}/versions`).then(r => r.json()).then(data => {
|
||||||
|
setVersions(data)
|
||||||
|
setVersionId(data.length > 0 ? String(data[0].id) : '')
|
||||||
|
})
|
||||||
|
}, [sourceId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!versionId || !sourceId) return
|
||||||
|
loadLayouts(versionId)
|
||||||
|
initViewer(versionId, sourceId)
|
||||||
|
}, [versionId, sourceId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const blank = Object.fromEntries(Object.keys(slice).map(k => [k, '']))
|
||||||
|
setRecodeSet(blank)
|
||||||
|
setCloneSet(blank)
|
||||||
|
setScaleValue('')
|
||||||
|
setScaleUnits('')
|
||||||
|
if (Object.keys(slice).length > 0) fetchCurrentTotals(slice)
|
||||||
|
else setCurrentTotals(null)
|
||||||
|
}, [slice])
|
||||||
|
|
||||||
|
async function fetchCurrentTotals(sliceObj) {
|
||||||
|
if (!tableRef.current) return
|
||||||
|
const valueCol = colMetaRef.current.find(c => c.role === 'value')?.cname
|
||||||
|
const unitsCol = colMetaRef.current.find(c => c.role === 'units')?.cname
|
||||||
|
if (!valueCol && !unitsCol) return
|
||||||
|
try {
|
||||||
|
const dimNames = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
|
||||||
|
const filters = [
|
||||||
|
...Object.entries(sliceObj)
|
||||||
|
.filter(([col]) => dimNames.has(col))
|
||||||
|
.map(([col, val]) => [col, '==', val]),
|
||||||
|
['pf_iter', '!=', 'reference'],
|
||||||
|
]
|
||||||
|
const view = await tableRef.current.view({ filter: filters })
|
||||||
|
const rows = await view.to_json()
|
||||||
|
await view.delete()
|
||||||
|
const sum = (col) => col ? rows.reduce((s, r) => s + (parseFloat(r[col]) || 0), 0) : null
|
||||||
|
setCurrentTotals({ value: sum(valueCol), units: sum(unitsCol), valueCol, unitsCol })
|
||||||
|
} catch {
|
||||||
|
setCurrentTotals(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLayouts(vid) {
|
||||||
|
const stored = localStorage.getItem(LAYOUTS_KEY(vid))
|
||||||
|
setLayouts(stored ? JSON.parse(stored) : [])
|
||||||
|
setActiveLayoutId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initViewer(vid, sid) {
|
||||||
|
const viewer = viewerRef.current
|
||||||
|
if (!viewer) return
|
||||||
|
setLoading(true)
|
||||||
|
setLargeDataset(false)
|
||||||
|
setSlice({})
|
||||||
|
expandDepthRef.current = null
|
||||||
|
try {
|
||||||
|
const [perspective, dataResult, meta] = await Promise.all([
|
||||||
|
loadPerspective(),
|
||||||
|
fetch(`/api/versions/${vid}/data`).then(async r => {
|
||||||
|
if (!r.ok) { const { error } = await r.json(); throw new Error(error || 'Failed to load data') }
|
||||||
|
const rowCount = parseInt(r.headers.get('X-Row-Count') || '0')
|
||||||
|
const buffer = await r.arrayBuffer()
|
||||||
|
return { buffer, rowCount }
|
||||||
|
}),
|
||||||
|
fetch(`/api/sources/${sid}/cols`).then(r => r.json()),
|
||||||
|
])
|
||||||
|
|
||||||
|
const { buffer, rowCount } = dataResult
|
||||||
|
colMetaRef.current = meta
|
||||||
|
const validCols = new Set([
|
||||||
|
...meta.filter(c => ['dimension','value','units','date'].includes(c.role)).map(c => c.cname),
|
||||||
|
'pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'created_at',
|
||||||
|
])
|
||||||
|
const tableName = `fc_${vid}`
|
||||||
|
|
||||||
|
if (rowCount >= 500000) setLargeDataset(true)
|
||||||
|
|
||||||
|
if (workerRef.current) { try { workerRef.current.terminate() } catch {} }
|
||||||
|
const worker = await perspective.worker()
|
||||||
|
workerRef.current = worker
|
||||||
|
tableRef.current = rowCount > 0
|
||||||
|
? await worker.table(buffer, { name: tableName })
|
||||||
|
: await worker.table([], { name: tableName })
|
||||||
|
|
||||||
|
await viewer.load(worker)
|
||||||
|
|
||||||
|
// restore last-used layout or build default
|
||||||
|
const saved = localStorage.getItem(LAYOUT_KEY(vid))
|
||||||
|
if (saved) {
|
||||||
|
const cfg = cleanLayout(JSON.parse(saved), validCols)
|
||||||
|
await viewer.restore(cfg)
|
||||||
|
const plugin = await viewer.getPlugin()
|
||||||
|
await plugin.restore({ edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) })
|
||||||
|
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
||||||
|
} else {
|
||||||
|
const dims = meta.filter(c => c.role === 'dimension').map(c => c.cname)
|
||||||
|
const dateCol = meta.find(c => c.role === 'date')?.cname
|
||||||
|
const cfg = { table: tableName, settings: false, plugin_config: { edit_mode: 'SELECT_REGION' } }
|
||||||
|
if (dims.length) cfg.group_by = dims.slice(0, 2)
|
||||||
|
if (dateCol) cfg.split_by = [dateCol]
|
||||||
|
await viewer.restore(cfg)
|
||||||
|
const plugin = await viewer.getPlugin()
|
||||||
|
await plugin.restore({ edit_mode: 'SELECT_REGION' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// click → slice via event filters (Perspective encodes row position as [col,'==',val] triples)
|
||||||
|
if (viewer._pspClick) viewer.removeEventListener('perspective-click', viewer._pspClick)
|
||||||
|
viewer._pspClick = async (e) => {
|
||||||
|
const detail = e.detail || {}
|
||||||
|
if (!detail.row) return
|
||||||
|
const config = await viewer.save()
|
||||||
|
if (!(config.group_by || []).length) return
|
||||||
|
const eventFilters = (detail.config || {}).filter || []
|
||||||
|
const s = {}
|
||||||
|
eventFilters.forEach(([col, op, val]) => {
|
||||||
|
if (op === '==' && val != null) s[col] = String(val)
|
||||||
|
})
|
||||||
|
if (Object.keys(s).length > 0) setSlice(s)
|
||||||
|
}
|
||||||
|
viewer.addEventListener('perspective-click', viewer._pspClick)
|
||||||
|
setLargeDataset(false)
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
flash(err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyDepth(d) {
|
||||||
|
const viewer = viewerRef.current
|
||||||
|
if (!viewer) return
|
||||||
|
const view = await viewer.getView()
|
||||||
|
await view.set_depth(d)
|
||||||
|
const plugin = await viewer.getPlugin()
|
||||||
|
await plugin.draw(view)
|
||||||
|
expandDepthRef.current = d
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureConfig() {
|
||||||
|
const viewer = viewerRef.current
|
||||||
|
if (!viewer) return null
|
||||||
|
const plugin = await viewer.getPlugin()
|
||||||
|
const [cfg, pluginCfg] = await Promise.all([viewer.save(), plugin.save()])
|
||||||
|
return { ...cfg, plugin_config: pluginCfg, expand_depth: expandDepthRef.current }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistLayout(vid, cfg) {
|
||||||
|
localStorage.setItem(LAYOUT_KEY(vid), JSON.stringify(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAs() {
|
||||||
|
const name = saveAsName.trim()
|
||||||
|
if (!name) return
|
||||||
|
const cfg = await captureConfig()
|
||||||
|
if (!cfg) return
|
||||||
|
const id = Date.now()
|
||||||
|
const updated = [...layouts, { id, name, config: cfg }]
|
||||||
|
localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
|
||||||
|
await persistLayout(versionId, cfg)
|
||||||
|
setLayouts(updated)
|
||||||
|
setActiveLayoutId(id)
|
||||||
|
setShowSaveAs(false)
|
||||||
|
setSaveAsName('')
|
||||||
|
flash('Saved')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveOver() {
|
||||||
|
const layout = layouts.find(l => l.id === activeLayoutId)
|
||||||
|
if (!layout) return
|
||||||
|
const cfg = await captureConfig()
|
||||||
|
if (!cfg) return
|
||||||
|
const updated = layouts.map(l => l.id === activeLayoutId ? { ...l, config: cfg } : l)
|
||||||
|
localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
|
||||||
|
await persistLayout(versionId, cfg)
|
||||||
|
setLayouts(updated)
|
||||||
|
flash('Saved')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyLayout(layout) {
|
||||||
|
const viewer = viewerRef.current
|
||||||
|
if (!viewer) return
|
||||||
|
const validCols = new Set(tableRef.current ? Object.keys(await (await tableRef.current.view()).schema()) : [])
|
||||||
|
const cfg = cleanLayout(layout.config, validCols)
|
||||||
|
await viewer.restore(cfg)
|
||||||
|
if (cfg.plugin_config) {
|
||||||
|
const plugin = await viewer.getPlugin()
|
||||||
|
await plugin.restore(cfg.plugin_config)
|
||||||
|
}
|
||||||
|
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
||||||
|
setActiveLayoutId(layout.id)
|
||||||
|
await persistLayout(versionId, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteLayout(id, e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
const updated = layouts.filter(l => l.id !== id)
|
||||||
|
localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated))
|
||||||
|
setLayouts(updated)
|
||||||
|
if (activeLayoutId === id) setActiveLayoutId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLayout() {
|
||||||
|
localStorage.removeItem(LAYOUT_KEY(versionId))
|
||||||
|
setActiveLayoutId(null)
|
||||||
|
const viewer = viewerRef.current
|
||||||
|
if (viewer) viewer.restore({ settings: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitOp(op) {
|
||||||
|
if (!Object.keys(slice).length) { flash('Select a slice first', 'error'); return }
|
||||||
|
let body = { pf_user: 'admin', slice }
|
||||||
|
|
||||||
|
if (op === 'scale') {
|
||||||
|
let vi = null, ui = null
|
||||||
|
if (scaleMode === 'target') {
|
||||||
|
if (scaleValue !== '' && currentTotals?.value != null)
|
||||||
|
vi = parseFloat(scaleValue) - currentTotals.value
|
||||||
|
if (scaleUnits !== '' && currentTotals?.units != null)
|
||||||
|
ui = parseFloat(scaleUnits) - currentTotals.units
|
||||||
|
} else {
|
||||||
|
if (scaleValue !== '') vi = scalePct ? parseFloat(scaleValue) : parseFloat(scaleValue)
|
||||||
|
if (scaleUnits !== '') ui = scalePct ? parseFloat(scaleUnits) : parseFloat(scaleUnits)
|
||||||
|
}
|
||||||
|
if (vi == null && ui == null) { flash('Enter a target or increment', 'error'); return }
|
||||||
|
body = { ...body, note: scaleNote, value_incr: vi, units_incr: ui, pct: scaleMode === 'delta' && scalePct }
|
||||||
|
} else if (op === 'recode') {
|
||||||
|
const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
|
||||||
|
if (!Object.keys(set).length) { flash('Enter at least one new dimension value', 'error'); return }
|
||||||
|
body = { ...body, note: recodeNote, set }
|
||||||
|
} else if (op === 'clone') {
|
||||||
|
const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim()))
|
||||||
|
if (!Object.keys(set).length) { flash('Enter at least one override value', 'error'); return }
|
||||||
|
body = { ...body, note: cloneNote, set, scale: parseFloat(cloneScale) || 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/versions/${versionId}/${op}`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
if (data.rows?.length && tableRef.current) await tableRef.current.update(data.rows)
|
||||||
|
flash(`${op}: ${data.rows_affected ?? data.rows?.length ?? ''} rows`)
|
||||||
|
if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScaleNote(''); fetchCurrentTotals(slice) }
|
||||||
|
if (op === 'recode') { setRecodeNote('') }
|
||||||
|
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
|
||||||
|
} catch (err) { flash(err.message, 'error') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(text, type = 'ok') {
|
||||||
|
setMsg({ text, type })
|
||||||
|
setTimeout(() => setMsg(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLog() {
|
||||||
|
setShowLog(true)
|
||||||
|
setLogLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetch(`/api/versions/${versionId}/log`).then(r => r.json())
|
||||||
|
setLogEntries(data)
|
||||||
|
} catch (err) {
|
||||||
|
flash(err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
setLogLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function undoEntry(logId) {
|
||||||
|
setUndoingId(logId)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/log/${logId}`, { method: 'DELETE' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
setLogEntries(prev => prev.filter(e => e.id !== logId))
|
||||||
|
flash(`Undone — ${data.rows_deleted} rows removed`)
|
||||||
|
initViewer(versionId, sourceId)
|
||||||
|
} catch (err) {
|
||||||
|
flash(err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
setUndoingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNote(logId, text) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/log/${logId}`, {
|
||||||
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ note: text })
|
||||||
|
})
|
||||||
|
if (!res.ok) { flash('Failed to save note', 'error'); return }
|
||||||
|
setLogEntries(prev => prev.map(e => e.id === logId ? { ...e, note: text } : e))
|
||||||
|
setEditingNote(null)
|
||||||
|
} catch (err) {
|
||||||
|
flash(err.message, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedVersion = versions.find(v => String(v.id) === versionId)
|
||||||
|
const dimCols = colMetaRef.current.filter(c => c.role === 'dimension')
|
||||||
|
const hasSlice = Object.keys(slice).length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
|
||||||
|
{/* Source / version bar */}
|
||||||
|
<div className="px-3 py-2 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">Source</span>
|
||||||
|
<select value={sourceId} onChange={e => setSourceId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
|
||||||
|
{sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">Version</span>
|
||||||
|
<select value={versionId} onChange={e => setVersionId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white" disabled={!versions.length}>
|
||||||
|
{versions.length === 0
|
||||||
|
? <option value="">— no versions —</option>
|
||||||
|
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||||
|
</select>
|
||||||
|
{selectedVersion && (
|
||||||
|
<span className={`text-xs font-medium ${selectedVersion.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}>
|
||||||
|
{selectedVersion.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap text-xs">
|
||||||
|
|
||||||
|
{/* Layout group */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Layout</span>
|
||||||
|
{layouts.map(l => (
|
||||||
|
<div key={l.id} onClick={() => applyLayout(l)}
|
||||||
|
className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors
|
||||||
|
${activeLayoutId === l.id ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
||||||
|
{l.name}
|
||||||
|
<button onClick={e => deleteLayout(l.id, e)} className="text-gray-300 hover:text-red-400 text-sm leading-none ml-0.5">×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{showSaveAs ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input autoFocus value={saveAsName} onChange={e => setSaveAsName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
|
||||||
|
placeholder="Layout name…" className="border border-gray-300 rounded px-2 py-0.5 w-32 focus:outline-none focus:border-blue-400" />
|
||||||
|
<button onClick={handleSaveAs} className="text-blue-600 hover:text-blue-800 px-1">Save</button>
|
||||||
|
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-gray-400 px-1">Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{activeLayoutId !== null && (
|
||||||
|
<button onClick={handleSaveOver} className="border border-blue-200 text-blue-500 hover:text-blue-700 rounded px-2 py-0.5">Save</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setShowSaveAs(true)} className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5">
|
||||||
|
Save as…
|
||||||
|
</button>
|
||||||
|
{activeLayoutId !== null && (
|
||||||
|
<button onClick={resetLayout} className="text-gray-300 hover:text-red-400">Reset</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||||||
|
|
||||||
|
{/* Expand group */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Expand</span>
|
||||||
|
{[0, 1, 2, 3].map(d => (
|
||||||
|
<button key={d} onClick={() => applyDepth(d)}
|
||||||
|
className={`border rounded px-1.5 py-0.5 transition-colors
|
||||||
|
${expandDepthRef.current === d ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||||||
|
|
||||||
|
{/* Data group */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button onClick={() => initViewer(versionId, sourceId)} disabled={loading || !versionId}
|
||||||
|
className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
|
||||||
|
{loading ? 'Loading…' : 'Refresh data'}
|
||||||
|
</button>
|
||||||
|
<button onClick={openLog} disabled={!versionId}
|
||||||
|
className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
|
||||||
|
Change log
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{msg && (
|
||||||
|
<span className={`ml-2 text-xs font-medium px-2 py-0.5 rounded ${msg.type === 'error' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>
|
||||||
|
{msg.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History modal */}
|
||||||
|
{showLog && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setShowLog(false)}>
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl mx-4 flex flex-col max-h-[80vh]" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
|
||||||
|
<span className="font-medium text-gray-700 text-sm">Change History</span>
|
||||||
|
<button onClick={() => setShowLog(false)} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
{logLoading ? (
|
||||||
|
<div className="p-8 text-center text-sm text-gray-400">Loading…</div>
|
||||||
|
) : logEntries.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-gray-400">No log entries yet.</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead className="sticky top-0 bg-gray-50 text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2 font-medium w-32">Time</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium w-24">Op</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Slice</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Note</th>
|
||||||
|
<th className="text-right px-4 py-2 font-medium w-16">Rows</th>
|
||||||
|
<th className="px-4 py-2 w-16"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logEntries.map(entry => (
|
||||||
|
<tr key={entry.id} className="border-t border-gray-100 hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-2 text-gray-400 whitespace-nowrap">{fmtStamp(entry.stamp)}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${opBadge(entry.operation)}`}>
|
||||||
|
{entry.operation}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600 font-mono">{fmtSlice(entry.slice)}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600 max-w-xs">
|
||||||
|
{editingNote?.id === entry.id ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input autoFocus value={editingNote.text}
|
||||||
|
onChange={e => setEditingNote(n => ({ ...n, text: e.target.value }))}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') saveNote(entry.id, editingNote.text)
|
||||||
|
if (e.key === 'Escape') setEditingNote(null)
|
||||||
|
}}
|
||||||
|
className="border border-blue-300 rounded px-1.5 py-0.5 text-xs flex-1 focus:outline-none" />
|
||||||
|
<button onClick={() => saveNote(entry.id, editingNote.text)} className="text-blue-600 hover:text-blue-800">✓</button>
|
||||||
|
<button onClick={() => setEditingNote(null)} className="text-gray-400 hover:text-gray-600">✕</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span onClick={() => setEditingNote({ id: entry.id, text: entry.note || '' })}
|
||||||
|
className="cursor-text hover:bg-blue-50 rounded px-1 -mx-1 block truncate"
|
||||||
|
title={entry.note || 'Click to add note'}>
|
||||||
|
{entry.note || <span className="text-gray-300 italic">add note</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-500 tabular-nums">{entry.row_count ?? '—'}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<button
|
||||||
|
onClick={() => undoEntry(entry.id)}
|
||||||
|
disabled={undoingId === entry.id}
|
||||||
|
className="text-xs border border-red-200 text-red-400 hover:text-red-600 hover:border-red-400 rounded px-2 py-0.5 disabled:opacity-40 whitespace-nowrap">
|
||||||
|
{undoingId === entry.id ? '…' : 'Undo'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main area */}
|
||||||
|
<div className="flex-1 flex min-h-0">
|
||||||
|
{/* Perspective viewer */}
|
||||||
|
<div className="relative flex-1 min-w-0">
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-50 z-10">
|
||||||
|
<span className="text-sm text-gray-400">Loading…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && largeDataset && (
|
||||||
|
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-10 bg-amber-50 border border-amber-200 text-amber-800 text-xs px-3 py-1.5 rounded shadow-sm">
|
||||||
|
Large dataset — pivot may take a moment to render
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<perspective-viewer ref={viewerRef} style={{ position: 'absolute', inset: 0 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drag handle */}
|
||||||
|
<div onMouseDown={onDragStart} className="w-1 shrink-0 cursor-col-resize hover:bg-blue-400 bg-transparent transition-colors" />
|
||||||
|
|
||||||
|
{/* Operation panel */}
|
||||||
|
<div className="shrink-0 border-l border-gray-200 bg-white flex flex-col overflow-y-auto text-xs" style={{ width: panelWidth }}>
|
||||||
|
<div className="p-3 border-b border-gray-100">
|
||||||
|
<div className="font-medium text-gray-400 uppercase tracking-wide mb-2" style={{fontSize:'10px'}}>Slice</div>
|
||||||
|
{!hasSlice ? (
|
||||||
|
<div className="text-gray-300 italic">Click a pivot row to select a slice</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{Object.entries(slice).map(([k, v]) => (
|
||||||
|
<div key={k} className="text-gray-700">
|
||||||
|
<span className="text-gray-400">{k}</span> = <span className="font-medium">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={() => setSlice({})} className="text-gray-300 hover:text-red-500 mt-1 text-left">Clear</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasSlice && (
|
||||||
|
<>
|
||||||
|
<div className="flex border-b border-gray-100">
|
||||||
|
{['scale', 'recode', 'clone'].map(op => (
|
||||||
|
<button key={op} onClick={() => setActiveOp(op)}
|
||||||
|
className={`flex-1 py-2 capitalize ${activeOp === op ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-400 hover:text-gray-600'}`}>
|
||||||
|
{op}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 flex flex-col gap-2.5">
|
||||||
|
{activeOp === 'scale' && <>
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div className="flex rounded border border-gray-200 overflow-hidden">
|
||||||
|
{[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => (
|
||||||
|
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits('') }}
|
||||||
|
className={`flex-1 py-1 text-xs ${scaleMode === m ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value row */}
|
||||||
|
{currentTotals?.valueCol && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between text-gray-400">
|
||||||
|
<span>{currentTotals.valueCol}</span>
|
||||||
|
<span className="font-mono">{currentTotals.value?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
|
||||||
|
</div>
|
||||||
|
<input type="number" step="any" value={scaleValue}
|
||||||
|
onChange={e => setScaleValue(e.target.value)}
|
||||||
|
placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
|
||||||
|
className={inp} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Units row */}
|
||||||
|
{currentTotals?.unitsCol && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between text-gray-400">
|
||||||
|
<span>{currentTotals.unitsCol}</span>
|
||||||
|
<span className="font-mono">{currentTotals.units?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
|
||||||
|
</div>
|
||||||
|
<input type="number" step="any" value={scaleUnits}
|
||||||
|
onChange={e => setScaleUnits(e.target.value)}
|
||||||
|
placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
|
||||||
|
className={inp} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scaleMode === 'delta' && (
|
||||||
|
<label className="flex items-center gap-2 text-gray-500">
|
||||||
|
<input type="checkbox" checked={scalePct} onChange={e => setScalePct(e.target.checked)} /> % of slice
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Row label="Note"><input value={scaleNote} onChange={e => setScaleNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
||||||
|
<Submit onClick={() => submitOp('scale')}>Apply Scale</Submit>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{activeOp === 'recode' && <>
|
||||||
|
<p className="text-gray-400">New values for dimensions to replace. Leave blank to keep.</p>
|
||||||
|
{dimCols.map(c => (
|
||||||
|
<Row key={c.cname} label={c.cname}>
|
||||||
|
<input value={recodeSet[c.cname] || ''} onChange={e => setRecodeSet(s => ({ ...s, [c.cname]: e.target.value }))}
|
||||||
|
placeholder={slice[c.cname] || '—'} className={`${inp} font-mono`} />
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
<Row label="Note"><input value={recodeNote} onChange={e => setRecodeNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
||||||
|
<Submit onClick={() => submitOp('recode')}>Apply Recode</Submit>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{activeOp === 'clone' && <>
|
||||||
|
<p className="text-gray-400">Override dimensions on cloned rows. Leave blank to keep.</p>
|
||||||
|
{dimCols.map(c => (
|
||||||
|
<Row key={c.cname} label={c.cname}>
|
||||||
|
<input value={cloneSet[c.cname] || ''} onChange={e => setCloneSet(s => ({ ...s, [c.cname]: e.target.value }))}
|
||||||
|
placeholder={slice[c.cname] || '—'} className={`${inp} font-mono`} />
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
<Row label="Scale"><input type="number" step="any" value={cloneScale} onChange={e => setCloneScale(e.target.value)} className={inp} /></Row>
|
||||||
|
<Row label="Note"><input value={cloneNote} onChange={e => setCloneNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
||||||
|
<Submit onClick={() => submitOp('clone')}>Apply Clone</Submit>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inp = 'border border-gray-200 rounded px-2 py-1 text-xs flex-1 bg-white min-w-0'
|
||||||
|
|
||||||
|
function fmtStamp(stamp) {
|
||||||
|
return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSlice(slice) {
|
||||||
|
if (!slice || !Object.keys(slice).length) return '—'
|
||||||
|
return Object.entries(slice).map(([k, v]) => `${k} = ${v}`).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const OP_BADGE = {
|
||||||
|
baseline: 'bg-gray-100 text-gray-600',
|
||||||
|
reference: 'bg-blue-50 text-blue-600',
|
||||||
|
scale: 'bg-green-50 text-green-700',
|
||||||
|
recode: 'bg-amber-50 text-amber-700',
|
||||||
|
clone: 'bg-purple-50 text-purple-700',
|
||||||
|
}
|
||||||
|
function opBadge(op) { return OP_BADGE[op] || 'bg-gray-100 text-gray-500' }
|
||||||
|
|
||||||
|
function Row({ label, children }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-gray-400 w-14 shrink-0 truncate" title={label}>{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Submit({ onClick, children }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className="mt-1 bg-blue-600 text-white text-xs px-3 py-1.5 rounded hover:bg-blue-700 w-full">
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
410
ui/src/views/Setup.jsx
Normal file
410
ui/src/views/Setup.jsx
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
const ROLES = ['ignore', 'dimension', 'value', 'units', 'date', 'filter']
|
||||||
|
|
||||||
|
const ROLE_STYLE = {
|
||||||
|
dimension: 'bg-blue-50 text-blue-700',
|
||||||
|
value: 'bg-green-50 text-green-700',
|
||||||
|
units: 'bg-green-50 text-green-700',
|
||||||
|
date: 'bg-purple-50 text-purple-700',
|
||||||
|
filter: 'bg-yellow-50 text-yellow-700',
|
||||||
|
ignore: 'bg-gray-100 text-gray-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Setup() {
|
||||||
|
const [tables, setTables] = useState([])
|
||||||
|
const [sources, setSources] = useState([])
|
||||||
|
const [selectedSource, setSelectedSource] = useState(null)
|
||||||
|
const [cols, setCols] = useState([])
|
||||||
|
const [editedCols, setEditedCols] = useState([])
|
||||||
|
const [colsDirty, setColsDirty] = useState(false)
|
||||||
|
const [preview, setPreview] = useState(null) // { schema, tname, columns, rows }
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
|
const [sqlStatus, setSqlStatus] = useState({}) // sourceId -> bool
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [msg, setMsg] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/tables').then(r => r.json()).then(setTables).catch(console.error)
|
||||||
|
loadSources()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function loadSources() {
|
||||||
|
fetch('/api/sources').then(r => r.json()).then(data => {
|
||||||
|
setSources(data)
|
||||||
|
// check sql status for each source
|
||||||
|
data.forEach(s => {
|
||||||
|
fetch(`/api/sources/${s.id}/sql`).then(r => r.json()).then(sqls => {
|
||||||
|
setSqlStatus(prev => ({ ...prev, [s.id]: sqls.length > 0 }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSource(source) {
|
||||||
|
setSelectedSource(source)
|
||||||
|
setColsDirty(false)
|
||||||
|
fetch(`/api/sources/${source.id}/cols`).then(r => r.json()).then(data => {
|
||||||
|
setCols(data)
|
||||||
|
setEditedCols(data.map(c => ({ ...c })))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPreview(schema, tname, e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
setPreviewLoading(true)
|
||||||
|
setPreview({ schema, tname, loading: true })
|
||||||
|
try {
|
||||||
|
const data = await fetch(`/api/tables/${schema}/${tname}/preview`).then(r => r.json())
|
||||||
|
setPreview({ schema, tname, ...data })
|
||||||
|
} catch {
|
||||||
|
setPreview(null)
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerSource(schema, tname) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sources', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ schema, tname, created_by: 'admin' })
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json()
|
||||||
|
flash(err.error, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const source = await res.json()
|
||||||
|
loadSources()
|
||||||
|
flash(`Registered ${schema}.${tname}`)
|
||||||
|
// auto-select new source and load its cols
|
||||||
|
fetch(`/api/sources/${source.id}/cols`).then(r => r.json()).then(data => {
|
||||||
|
setSelectedSource(source)
|
||||||
|
setCols(data)
|
||||||
|
setEditedCols(data.map(c => ({ ...c })))
|
||||||
|
setColsDirty(false)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
flash(err.message, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCol(idx, field, value) {
|
||||||
|
setEditedCols(prev => {
|
||||||
|
const next = prev.map((c, i) => i === idx ? { ...c, [field]: value } : c)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setColsDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCols() {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sources/${selectedSource.id}/cols`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(editedCols)
|
||||||
|
})
|
||||||
|
if (!res.ok) { const e = await res.json(); flash(e.error, 'error'); return }
|
||||||
|
const saved = await res.json()
|
||||||
|
setCols(saved)
|
||||||
|
setEditedCols(saved.map(c => ({ ...c })))
|
||||||
|
setColsDirty(false)
|
||||||
|
flash('Saved')
|
||||||
|
} catch (err) {
|
||||||
|
flash(err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSQL() {
|
||||||
|
setGenerating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sources/${selectedSource.id}/generate-sql`, { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
setSqlStatus(prev => ({ ...prev, [selectedSource.id]: true }))
|
||||||
|
flash(`SQL generated: ${data.operations.join(', ')}`)
|
||||||
|
} catch (err) {
|
||||||
|
flash(err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
setGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSource(id, e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!confirm('Deregister this source? Existing forecast tables are not affected.')) return
|
||||||
|
await fetch(`/api/sources/${id}`, { method: 'DELETE' })
|
||||||
|
if (selectedSource?.id === id) { setSelectedSource(null); setCols([]); setEditedCols([]) }
|
||||||
|
loadSources()
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(text, type = 'ok') {
|
||||||
|
setMsg({ text, type })
|
||||||
|
setTimeout(() => setMsg(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const registeredKeys = new Set(sources.map(s => `${s.schema}.${s.tname}`))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex overflow-hidden text-sm">
|
||||||
|
|
||||||
|
{/* All Tables */}
|
||||||
|
<div className="w-64 bg-white border-r border-gray-200 flex flex-col shrink-0">
|
||||||
|
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
All Tables
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-gray-50">
|
||||||
|
<tr className="text-left text-gray-400 border-b border-gray-100">
|
||||||
|
<th className="px-3 py-1.5 font-medium">schema</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">table</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium text-right">rows</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tables.map(t => {
|
||||||
|
const key = `${t.schema}.${t.tname}`
|
||||||
|
const registered = registeredKeys.has(key)
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={key}
|
||||||
|
onClick={e => openPreview(t.schema, t.tname, e)}
|
||||||
|
className="border-t border-gray-50 hover:bg-blue-50 cursor-pointer group"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-1.5 text-gray-400">{t.schema}</td>
|
||||||
|
<td className="px-3 py-1.5 font-medium">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className={registered ? 'text-green-600' : ''}>{t.tname}</span>
|
||||||
|
{registered && <span className="text-green-400 text-xs">✓</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right text-gray-500">
|
||||||
|
{Number(t.row_estimate).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||||
|
|
||||||
|
{/* Flash message */}
|
||||||
|
{msg && (
|
||||||
|
<div className={`px-4 py-2 text-xs font-medium ${msg.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col gap-0 overflow-hidden">
|
||||||
|
|
||||||
|
{/* Registered Sources */}
|
||||||
|
<div className="bg-white border-b border-gray-200 shrink-0">
|
||||||
|
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
Registered Sources
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr className="text-left text-gray-400 border-b border-gray-100">
|
||||||
|
<th className="px-3 py-1.5 font-medium">source</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">schema</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">sql</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">created</th>
|
||||||
|
<th className="px-3 py-1.5"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sources.length === 0 && (
|
||||||
|
<tr><td colSpan={5} className="px-3 py-3 text-gray-300 italic">No sources registered — click a table to preview, then register it</td></tr>
|
||||||
|
)}
|
||||||
|
{sources.map(s => (
|
||||||
|
<tr
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => selectSource(s)}
|
||||||
|
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${selectedSource?.id === s.id ? 'bg-blue-50' : ''}`}
|
||||||
|
>
|
||||||
|
<td className={`px-3 py-2 font-medium ${selectedSource?.id === s.id ? 'text-blue-700' : ''}`}>{s.tname}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{s.schema}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{sqlStatus[s.id]
|
||||||
|
? <span className="text-green-600 font-medium">✓ ready</span>
|
||||||
|
: <span className="text-gray-300">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{s.created_by || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<button onClick={e => deleteSource(s.id, e)} className="text-gray-300 hover:text-red-500 text-xs">✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col Meta Editor */}
|
||||||
|
{selectedSource ? (
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden bg-white">
|
||||||
|
<div className="px-3 py-2 border-b border-gray-100 flex items-center justify-between shrink-0">
|
||||||
|
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
Col Meta — <span className="text-gray-700 normal-case">{selectedSource.schema}.{selectedSource.tname}</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{colsDirty && (
|
||||||
|
<button onClick={saveCols} disabled={saving} className="text-xs border border-gray-200 px-3 py-1 rounded hover:bg-gray-50 disabled:opacity-50">
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={generateSQL}
|
||||||
|
disabled={generating || colsDirty}
|
||||||
|
className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
title={colsDirty ? 'Save col meta first' : ''}
|
||||||
|
>
|
||||||
|
{generating ? 'Generating…' : 'Generate SQL'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-gray-50">
|
||||||
|
<tr className="text-left text-gray-400 border-b border-gray-100">
|
||||||
|
<th className="px-3 py-1.5 font-medium">column</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">role</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium text-center">key</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">label</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{editedCols.map((col, i) => (
|
||||||
|
<tr key={col.cname} className="border-t border-gray-50 hover:bg-gray-50">
|
||||||
|
<td className="px-3 py-1.5 font-mono text-gray-700">{col.cname}</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<select
|
||||||
|
value={col.role}
|
||||||
|
onChange={e => updateCol(i, 'role', e.target.value)}
|
||||||
|
className={`text-xs px-1.5 py-0.5 rounded border-0 font-medium cursor-pointer ${ROLE_STYLE[col.role] || ''}`}
|
||||||
|
>
|
||||||
|
{ROLES.map(r => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!col.is_key}
|
||||||
|
onChange={e => updateCol(i, 'is_key', e.target.checked)}
|
||||||
|
disabled={col.role !== 'dimension'}
|
||||||
|
className="cursor-pointer disabled:opacity-20"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={col.label || ''}
|
||||||
|
onChange={e => updateCol(i, 'label', e.target.value)}
|
||||||
|
placeholder={col.cname}
|
||||||
|
className="border border-transparent hover:border-gray-200 focus:border-gray-300 rounded px-1.5 py-0.5 w-full outline-none bg-transparent"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-gray-300 text-xs italic">
|
||||||
|
Select a source to edit col meta
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table preview modal */}
|
||||||
|
{preview && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/30" onClick={() => setPreview(null)} />
|
||||||
|
<div className="relative bg-white rounded-lg shadow-2xl flex flex-col z-10 text-xs" style={{ width: 720, maxWidth: '90vw', maxHeight: '80vh' }}>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-semibold text-gray-800">{preview.schema}.{preview.tname}</span>
|
||||||
|
{preview.columns && (
|
||||||
|
<span className="text-gray-400">{preview.columns.length} columns</span>
|
||||||
|
)}
|
||||||
|
{!registeredKeys.has(`${preview.schema}.${preview.tname}`) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { registerSource(preview.schema, preview.tname); setPreview(null) }}
|
||||||
|
className="bg-blue-600 text-white text-xs px-3 py-1 rounded hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
+ Register source
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setPreview(null)} className="text-gray-300 hover:text-gray-600 text-lg leading-none ml-4">✕</button>
|
||||||
|
</div>
|
||||||
|
{preview.loading ? (
|
||||||
|
<div className="p-8 text-center text-gray-400">Loading…</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
{/* Columns */}
|
||||||
|
<div className="px-4 pt-3 pb-1 text-gray-400 uppercase tracking-wide font-medium text-xs">Columns</div>
|
||||||
|
<table className="w-full mb-2">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50">
|
||||||
|
<th className="px-4 py-1 font-medium">name</th>
|
||||||
|
<th className="px-4 py-1 font-medium">type</th>
|
||||||
|
<th className="px-4 py-1 font-medium">nullable</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(preview.columns || []).map(c => (
|
||||||
|
<tr key={c.column_name} className="border-t border-gray-50">
|
||||||
|
<td className="px-4 py-1 font-mono text-gray-700">{c.column_name}</td>
|
||||||
|
<td className="px-4 py-1 text-gray-400">{c.data_type}</td>
|
||||||
|
<td className="px-4 py-1 text-gray-400">{c.is_nullable}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/* Sample rows */}
|
||||||
|
<div className="px-4 py-1 text-gray-400 uppercase tracking-wide font-medium text-xs border-t border-gray-100">Sample rows</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="text-xs" style={{ minWidth: '100%' }}>
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-400 bg-gray-50 border-b border-gray-100">
|
||||||
|
{(preview.columns || []).map(c => (
|
||||||
|
<th key={c.column_name} className="px-4 py-1 font-medium whitespace-nowrap">{c.column_name}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(preview.rows || []).map((row, i) => (
|
||||||
|
<tr key={i} className="border-t border-gray-50">
|
||||||
|
{(preview.columns || []).map(c => (
|
||||||
|
<td key={c.column_name} className={`px-4 py-1 font-mono whitespace-nowrap ${row[c.column_name] == null ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||||
|
{row[c.column_name] == null ? 'null' : String(row[c.column_name])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
ui/vite.config.js
Normal file
17
ui/vite.config.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3030'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../public/app',
|
||||||
|
emptyOutDir: true
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user