Compare commits
1 Commits
baseline-w
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 681b6c21b6 |
@ -26,10 +26,11 @@ 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}`;
|
||||||
const dataCols = [...dims, dateCol, valueCol, unitsCol].filter(Boolean);
|
// exclude 'id' — forecast table has its own bigserial id primary key
|
||||||
|
const dataCols = [...dims, dateCol, valueCol, unitsCol].filter(c => c !== 'id');
|
||||||
const effectiveValue = dataCols.includes(valueCol) ? valueCol : null;
|
const 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), 'pf_iter', 'pf_logid', 'pf_user', 'pf_created_at'].join(', ');
|
const insertCols = [...dataCols.map(q), 'iter', 'logid', 'pf_user', '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(', ');
|
||||||
|
|
||||||
@ -48,9 +49,6 @@ 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 (
|
||||||
@ -58,11 +56,14 @@ 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 ${baselineSelect}, 'baseline', (SELECT id FROM ilog), '{{pf_user}}', now()
|
SELECT ${selectData}, 'baseline', (SELECT id FROM ilog), '{{pf_user}}', now()
|
||||||
FROM ${srcTable}
|
FROM ${srcTable}
|
||||||
WHERE {{filter_clause}}
|
WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT count(*) AS rows_affected FROM ins`.trim();
|
SELECT count(*) AS rows_affected FROM ins`.trim();
|
||||||
@ -83,7 +84,7 @@ ilog AS (
|
|||||||
WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
|
WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT * FROM ins`.trim();
|
SELECT count(*) AS rows_affected FROM ins`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildScale() {
|
function buildScale() {
|
||||||
@ -123,7 +124,7 @@ ilog AS (
|
|||||||
FROM base
|
FROM base
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT * FROM ins`.trim();
|
SELECT count(*) AS rows_affected FROM ins`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRecode() {
|
function buildRecode() {
|
||||||
@ -145,16 +146,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 *
|
RETURNING id
|
||||||
)
|
)
|
||||||
,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 *
|
RETURNING id
|
||||||
)
|
)
|
||||||
SELECT * FROM neg UNION ALL SELECT * FROM ins`.trim();
|
SELECT (SELECT count(*) FROM neg) + (SELECT count(*) FROM ins) AS rows_affected`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildClone() {
|
function buildClone() {
|
||||||
@ -178,7 +179,7 @@ ilog AS (
|
|||||||
{{exclude_clause}}
|
{{exclude_clause}}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT * FROM ins`.trim();
|
SELECT count(*) AS rows_affected FROM ins`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUndo() {
|
function buildUndo() {
|
||||||
@ -188,7 +189,7 @@ SELECT * 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 pf_logid = {{logid}};
|
DELETE FROM {{fc_table}} WHERE 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();
|
||||||
}
|
}
|
||||||
@ -230,7 +231,7 @@ function buildWhere(slice, dimCols) {
|
|||||||
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 pf_iter NOT IN (${list})`;
|
return `AND 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
|
||||||
@ -244,44 +245,10 @@ 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, buildFilterClause, esc };
|
module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc };
|
||||||
|
|||||||
185
pf_spec.md
185
pf_spec.md
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -52,12 +52,11 @@ 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 for operations
|
- `dimension` — categorical field (customer, part, channel, rep, geography, etc.) — appears as pivot rows/cols, used in WHERE filters
|
||||||
- `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 primary date field; used for baseline/reference date range and stored in the forecast table
|
- `date` — the date field used for baseline date range selection
|
||||||
- `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
|
||||||
- `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.
|
||||||
@ -151,8 +150,7 @@ 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 (source period) |
|
| `{{date_from}}` / `{{date_to}}` | baseline/reference date range |
|
||||||
| `{{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 |
|
||||||
@ -216,42 +214,31 @@ Source registration, col_meta configuration, SQL generation, version creation, a
|
|||||||
|
|
||||||
| Method | Route | Description |
|
| Method | Route | Description |
|
||||||
|--------|-------|-------------|
|
|--------|-------|-------------|
|
||||||
| POST | `/api/versions/:id/baseline` | Load one baseline segment (additive — does not clear existing baseline rows) |
|
| POST | `/api/versions/:id/baseline` | Load baseline 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 |
|
||||||
| POST | `/api/versions/:id/reference` | Load reference rows from source table for a date range (additive) |
|
|
||||||
|
|
||||||
**Baseline load request body:**
|
**Baseline request body:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"date_offset": "1 year",
|
"date_from": "2024-01-01",
|
||||||
"filters": [
|
"date_to": "2024-12-31",
|
||||||
{ "col": "order_date", "op": "BETWEEN", "values": ["2024-01-01", "2024-12-31"] },
|
"pf_user": "admin",
|
||||||
{ "col": "order_status", "op": "IN", "values": ["OPEN", "PENDING"] }
|
"note": "restated actuals",
|
||||||
],
|
"replay": false
|
||||||
"pf_user": "admin",
|
|
||||||
"note": "FY2024 actuals + open orders projected to FY2025",
|
|
||||||
"replay": false
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `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` controls behavior when incremental rows exist:
|
||||||
- `filters` — one or more filter conditions defining what rows to pull from the source table. Period selection (date range, season, fiscal year, etc.) is expressed here as a regular filter — there is no separate date range parameter. Each condition has:
|
|
||||||
- `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`
|
|
||||||
- At least one filter is required.
|
|
||||||
- Baseline loads are **additive** — existing `iter = 'baseline'` rows are not touched. Each load is its own log entry and is independently undoable.
|
|
||||||
|
|
||||||
`replay` controls behavior when incremental rows exist (applies to Clear + reload, not individual segments):
|
- `replay: false` (default) — delete existing `iter = 'baseline'` rows only, re-insert new baseline, leave all incremental rows (`scale`, `recode`, `clone`) untouched
|
||||||
|
- `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
|
||||||
|
|
||||||
- `replay: false` (default) — after clearing, re-load baseline segments, leave incremental rows untouched
|
The UI presents this as a choice when the admin re-baselines and incremental rows exist:
|
||||||
- `replay: true` — after clearing, re-load baseline, then re-execute each incremental log entry in chronological order
|
> "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.
|
**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.
|
||||||
|
|
||||||
**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 without `replay`. Reference loads are additive — multiple reference periods can be loaded independently under separate log entries. Each is undoable via its logid.
|
||||||
|
|
||||||
**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
|
||||||
|
|
||||||
@ -349,9 +336,8 @@ All operations share a common request envelope:
|
|||||||
|
|
||||||
1. **Sources** — browse DB tables, register sources, configure col_meta, generate SQL
|
1. **Sources** — browse DB tables, register sources, configure col_meta, generate SQL
|
||||||
2. **Versions** — list forecast versions per source, create/close/reopen/delete
|
2. **Versions** — list forecast versions per source, create/close/reopen/delete
|
||||||
3. **Baseline** — baseline workbench for the selected version
|
3. **Forecast** — main working view (pivot + operation panel)
|
||||||
4. **Forecast** — main working view (pivot + operation panel)
|
4. **Log** — change history with undo
|
||||||
5. **Log** — change history with undo
|
|
||||||
|
|
||||||
### Sources View
|
### Sources View
|
||||||
|
|
||||||
@ -367,78 +353,6 @@ All operations share a common request envelope:
|
|||||||
- Create version form — name, description, exclude_iters (defaults to `["reference"]`)
|
- Create version form — name, description, exclude_iters (defaults to `["reference"]`)
|
||||||
- Per-version actions: open forecast, load baseline, load reference, close, reopen, delete
|
- Per-version actions: open forecast, load baseline, load reference, close, reopen, delete
|
||||||
|
|
||||||
**Load Baseline modal:**
|
|
||||||
- Source date range (date_from / date_to) — the actuals period to pull from
|
|
||||||
- Date offset (years + months spinners) — how far forward to project the dates
|
|
||||||
- Before/after preview: left side shows source months, right side shows where they land after the offset
|
|
||||||
- Note field
|
|
||||||
- On submit: shows row count; grid reloads
|
|
||||||
|
|
||||||
**Load Reference modal:**
|
|
||||||
- Source date range only — no offset
|
|
||||||
- Month chip preview of the period being loaded
|
|
||||||
- Note field
|
|
||||||
|
|
||||||
### 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 conditions that define what rows to pull. There is no separate "date range" section — period selection is just a filter like any other:
|
|
||||||
- Column — any `role = 'date'` or `role = 'filter'` column
|
|
||||||
- Operator — `=`, `!=`, `IN`, `NOT IN`, `BETWEEN`, `IS NULL`, `IS NOT NULL`
|
|
||||||
- Value(s) — for `BETWEEN`: two date/text inputs; for `IN`/`NOT IN`: comma-separated list; for `=`/`!=`: single input; omitted for `IS NULL`/`IS NOT NULL`
|
|
||||||
- At least one filter is required to load a segment
|
|
||||||
- **Timeline preview** — rendered when any filter condition 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. For non-date filters (e.g. `season IN (...)`) no timeline is shown.
|
|
||||||
- **Note** — optional free text
|
|
||||||
- **Load Segment** — submits; appends rows, does not clear existing baseline rows
|
|
||||||
|
|
||||||
**Example — three-segment baseline:**
|
|
||||||
|
|
||||||
| # | Description | Filters | Offset |
|
|
||||||
|---|-------------|---------|--------|
|
|
||||||
| 1 | All orders taken 6/1/25–3/31/26 | `order_date BETWEEN 2025-06-01 AND 2026-03-31` | 0 |
|
|
||||||
| 2 | All open/unshipped orders | `status IN (OPEN, PENDING)` | 0 |
|
|
||||||
| 3 | Prior year book-and-ship 4/1/25–5/31/25 | `order_date BETWEEN 2025-04-01 AND 2025-05-31`, `ship_date BETWEEN 2025-04-01 AND 2025-05-31` | 0 |
|
|
||||||
|
|
||||||
Note: segment 2 has no date filter — any filter combination is valid as long as at least one filter is present.
|
|
||||||
|
|
||||||
### Forecast View
|
### Forecast View
|
||||||
|
|
||||||
**Layout:**
|
**Layout:**
|
||||||
@ -482,48 +396,12 @@ 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 Load (one segment)
|
### Baseline / Reference Load
|
||||||
|
|
||||||
```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}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}')
|
VALUES ({{version_id}}, '{{pf_user}}', '{{operation}}', 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}}` — one or more `AND` conditions built from the `filters` array at request time (not baked into stored SQL since conditions vary per segment). Each condition is validated against col_meta (column must be `role = 'date'` or `role = 'filter'`). Supported operators: `=`, `!=`, `IN`, `NOT IN`, `BETWEEN`, `IS NULL`, `IS NOT NULL`.
|
|
||||||
|
|
||||||
### 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}} (
|
||||||
@ -532,14 +410,14 @@ INSERT INTO {{fc_table}} (
|
|||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
{dimension_cols}, {value_col}, {units_col}, {date_col},
|
{dimension_cols}, {value_col}, {units_col}, {date_col},
|
||||||
'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
|
'{{operation}}', (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}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
No date offset — reference rows land at their original dates for prior-period comparison.
|
Baseline route also deletes existing `iter = 'baseline'` rows before inserting.
|
||||||
|
|
||||||
### Scale
|
### Scale
|
||||||
|
|
||||||
@ -630,14 +508,11 @@ 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 (`dimension`, `value`, `units`, `date`, `filter`, `ignore`), mark is_key dimensions, set labels
|
2. Open col_meta editor → assign roles to columns, 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. Open **Baseline Workbench** → build the baseline from one or more segments:
|
5. Load **Baseline** → pick date range → inserts `iter = 'baseline'` rows
|
||||||
- Each segment specifies a date range (on any date/filter column), date offset, and optional additional filter conditions
|
6. Optionally load **Reference** → pick prior year date range → inserts `iter = 'reference'` rows
|
||||||
- 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)
|
||||||
@ -655,9 +530,9 @@ 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)
|
||||||
- **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
|
- **Multi-DB sources** — currently assumes same DB; cross-DB would need connection config per source
|
||||||
|
|
||||||
|
|||||||
349
public/app.js
349
public/app.js
@ -50,7 +50,7 @@ function showStatus(msg, type = 'info') {
|
|||||||
NAVIGATION
|
NAVIGATION
|
||||||
============================================================ */
|
============================================================ */
|
||||||
function switchView(name) {
|
function switchView(name) {
|
||||||
if ((name === 'forecast' || name === 'log' || name === 'baseline') && !state.version) {
|
if ((name === 'forecast' || name === 'log') && !state.version) {
|
||||||
showStatus('Select a version first', 'error');
|
showStatus('Select a version first', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -68,7 +68,6 @@ 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) {
|
||||||
@ -116,20 +115,15 @@ function renderTablesGrid(tables) {
|
|||||||
|
|
||||||
state.grids.tables = agGrid.createGrid(el, {
|
state.grids.tables = agGrid.createGrid(el, {
|
||||||
columnDefs: [
|
columnDefs: [
|
||||||
{ field: 'schema', headerName: 'Schema', flex: 1 },
|
{ field: 'schema', headerName: 'Schema', width: 90 },
|
||||||
{ field: 'tname', headerName: 'Table', flex: 1 },
|
{ field: 'tname', headerName: 'Table', flex: 1 },
|
||||||
{ field: 'row_estimate', headerName: 'Rows', flex: 1,
|
{ field: 'row_estimate', headerName: 'Rows', width: 80,
|
||||||
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
|
||||||
});
|
});
|
||||||
@ -139,7 +133,6 @@ 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,11 +211,13 @@ 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) {
|
||||||
@ -230,24 +225,13 @@ async function selectSource(source) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSource() {
|
function backToSources() {
|
||||||
if (!state.source) return;
|
document.getElementById('sources-list-grid').classList.remove('hidden');
|
||||||
const { id, schema, tname } = state.source;
|
document.getElementById('col-meta-grid').classList.add('hidden');
|
||||||
if (!confirm(`Delete source ${schema}.${tname}? This does not drop existing forecast tables.`)) return;
|
document.getElementById('right-panel-title').textContent = 'Registered Sources';
|
||||||
try {
|
document.getElementById('btn-back-sources').classList.add('hidden');
|
||||||
await api('DELETE', `/sources/${id}`);
|
document.getElementById('btn-save-cols').classList.add('hidden');
|
||||||
showStatus(`Source ${tname} deleted`, 'success');
|
document.getElementById('btn-generate-sql').classList.add('hidden');
|
||||||
setSource(null);
|
|
||||||
document.getElementById('btn-delete-source').classList.add('hidden');
|
|
||||||
document.getElementById('btn-save-cols').classList.add('hidden');
|
|
||||||
document.getElementById('btn-generate-sql').classList.add('hidden');
|
|
||||||
document.getElementById('right-panel-title').textContent = 'Select a source to map columns';
|
|
||||||
state.grids.colMeta?.setGridOption('rowData', []);
|
|
||||||
const sources = await api('GET', '/sources');
|
|
||||||
state.grids.sources?.setGridOption('rowData', sources);
|
|
||||||
} catch (err) {
|
|
||||||
showStatus(err.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderColMetaGrid(colMeta) {
|
function renderColMetaGrid(colMeta) {
|
||||||
@ -391,81 +375,10 @@ 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-date-from').value = '';
|
document.getElementById('load-data-form').classList.remove('hidden');
|
||||||
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;
|
||||||
@ -478,19 +391,10 @@ 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);
|
||||||
hideLoadModal();
|
document.getElementById('load-data-form').classList.add('hidden');
|
||||||
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');
|
||||||
@ -544,163 +448,9 @@ 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 =
|
||||||
@ -712,7 +462,14 @@ 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 data = parseNumericRows(rawData);
|
const numericCols = state.colMeta
|
||||||
|
.filter(c => c.role === 'value' || c.role === 'units')
|
||||||
|
.map(c => c.cname);
|
||||||
|
const data = rawData.map(row => {
|
||||||
|
const r = { ...row };
|
||||||
|
numericCols.forEach(col => { if (r[col] != null) r[col] = parseFloat(r[col]); });
|
||||||
|
return r;
|
||||||
|
});
|
||||||
initPivotGrid(data);
|
initPivotGrid(data);
|
||||||
showStatus(`Loaded ${data.length.toLocaleString()} rows`, 'success');
|
showStatus(`Loaded ${data.length.toLocaleString()} rows`, 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -766,10 +523,10 @@ function buildPivotColDefs() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// always include pf_iter for grouping context
|
// always include iter for grouping context
|
||||||
defs.push({ field: 'pf_iter', headerName: 'Iter', enableRowGroup: true, enablePivot: true, width: 90 });
|
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: 'pf_user', headerName: 'User', width: 90, hide: true });
|
||||||
defs.push({ field: 'pf_created_at', headerName: 'Created', width: 130, hide: true,
|
defs.push({ field: 'created_at', headerName: 'Created', width: 130, hide: true,
|
||||||
valueFormatter: p => p.value ? new Date(p.value).toLocaleDateString() : '' });
|
valueFormatter: p => p.value ? new Date(p.value).toLocaleDateString() : '' });
|
||||||
|
|
||||||
return defs;
|
return defs;
|
||||||
@ -938,7 +695,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 = '';
|
||||||
state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) });
|
await loadForecastData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showStatus(err.message, 'error');
|
showStatus(err.message, 'error');
|
||||||
}
|
}
|
||||||
@ -964,7 +721,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 = ''; });
|
||||||
state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) });
|
await loadForecastData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showStatus(err.message, 'error');
|
showStatus(err.message, 'error');
|
||||||
}
|
}
|
||||||
@ -993,7 +750,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';
|
||||||
state.grids.pivot.applyTransaction({ add: parseNumericRows(result.rows) });
|
await loadForecastData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showStatus(err.message, 'error');
|
showStatus(err.message, 'error');
|
||||||
}
|
}
|
||||||
@ -1023,8 +780,6 @@ 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: '',
|
||||||
@ -1086,17 +841,12 @@ 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-delete-source').addEventListener('click', deleteSource);
|
document.getElementById('btn-back-sources').addEventListener('click', backToSources);
|
||||||
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);
|
||||||
|
|
||||||
@ -1120,17 +870,14 @@ 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', openVersionBaseline);
|
document.getElementById('vbtn-baseline').addEventListener('click', () => showLoadForm('baseline'));
|
||||||
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', hideLoadModal);
|
document.getElementById('btn-load-cancel').addEventListener('click', () => {
|
||||||
document.getElementById('btn-load-close').addEventListener('click', hideLoadModal);
|
document.getElementById('load-data-form').classList.add('hidden');
|
||||||
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);
|
||||||
@ -1152,32 +899,6 @@ 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'));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,7 +17,6 @@
|
|||||||
<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>
|
||||||
@ -44,43 +43,27 @@
|
|||||||
|
|
||||||
<!-- ===== SOURCES VIEW ===== -->
|
<!-- ===== SOURCES VIEW ===== -->
|
||||||
<div id="view-sources" class="view active">
|
<div id="view-sources" class="view active">
|
||||||
<div class="sources-layout">
|
<div class="two-col-layout">
|
||||||
<!-- Left column: two stacked panels -->
|
<div class="panel">
|
||||||
<div class="sources-left-col">
|
|
||||||
<div class="panel sources-tables-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span>Database Tables</span>
|
|
||||||
<div class="header-actions">
|
|
||||||
<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 class="tables-search-wrap">
|
|
||||||
<input type="text" id="tables-search" placeholder="Search…" />
|
|
||||||
</div>
|
|
||||||
<div id="tables-grid" class="ag-theme-alpine tables-grid"></div>
|
|
||||||
</div>
|
|
||||||
<div class="panel sources-list-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<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">
|
<div class="panel-header">
|
||||||
<span id="right-panel-title">Select a source to map columns</span>
|
<span>Database Tables</span>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="btn-save-cols" class="btn hidden">Save Columns</button>
|
<button id="btn-register" class="btn btn-primary hidden">Register Table</button>
|
||||||
<button id="btn-generate-sql" class="btn btn-primary hidden">Generate SQL</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tables-search-wrap">
|
<div id="tables-grid" class="ag-theme-alpine grid-fill"></div>
|
||||||
<input type="text" id="cols-search" placeholder="Search columns…" />
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span id="right-panel-title">Registered Sources</span>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button id="btn-back-sources" class="btn hidden">← Sources</button>
|
||||||
|
<button id="btn-save-cols" class="btn hidden">Save Columns</button>
|
||||||
|
<button id="btn-generate-sql" class="btn btn-primary hidden">Generate SQL</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="col-meta-grid" class="ag-theme-alpine grid-fill"></div>
|
<div id="sources-list-grid" class="ag-theme-alpine grid-fill"></div>
|
||||||
|
<div id="col-meta-grid" class="ag-theme-alpine grid-fill hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -111,49 +94,16 @@
|
|||||||
<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>
|
<div id="load-data-form" class="inline-form hidden">
|
||||||
|
<h3 id="load-data-title">Load Baseline</h3>
|
||||||
<!-- ===== BASELINE WORKBENCH VIEW ===== -->
|
<div class="form-row">
|
||||||
<div id="view-baseline" class="view hidden">
|
<label>Date From<input type="date" id="load-date-from" /></label>
|
||||||
<div class="workbench-toolbar">
|
<label>Date To<input type="date" id="load-date-to" /></label>
|
||||||
<span id="baseline-label">No version selected</span>
|
<label>Note<input type="text" id="load-note" placeholder="optional" /></label>
|
||||||
<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>
|
||||||
<div class="baseline-segments-panel">
|
<div class="form-actions">
|
||||||
<div class="panel-section-title">Loaded Segments</div>
|
<button id="btn-load-submit" class="btn btn-primary">Load</button>
|
||||||
<div id="baseline-segments-list">
|
<button id="btn-load-cancel" class="btn">Cancel</button>
|
||||||
<div class="segments-empty">No segments loaded yet.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -220,54 +170,6 @@
|
|||||||
</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">
|
||||||
|
|||||||
@ -83,18 +83,9 @@ body {
|
|||||||
.view.hidden { display: none !important; }
|
.view.hidden { display: none !important; }
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
SOURCES VIEW
|
SOURCES VIEW — two-column
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.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;
|
||||||
@ -321,310 +312,9 @@ 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 pf_logid = $1 RETURNING pf_id`,
|
`DELETE FROM ${table} WHERE logid = $1 RETURNING 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]);
|
||||||
|
|||||||
@ -74,23 +74,28 @@ module.exports = function(pool) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// load baseline rows from source table — additive, no delete
|
// load baseline rows from source table for a date range
|
||||||
|
// deletes existing iter='baseline' rows before inserting (handled inside stored SQL)
|
||||||
router.post('/versions/:id/baseline', async (req, res) => {
|
router.post('/versions/:id/baseline', async (req, res) => {
|
||||||
const { where_clause, date_offset, pf_user, note } = req.body;
|
const { date_from, date_to, pf_user, note, replay } = req.body;
|
||||||
const dateOffset = date_offset || '0 days';
|
if (!date_from || !date_to) {
|
||||||
const filterClause = (where_clause || '').trim() || 'TRUE';
|
return res.status(400).json({ error: 'date_from and date_to are required' });
|
||||||
|
}
|
||||||
|
if (replay) {
|
||||||
|
return res.status(501).json({ error: 'replay is not yet implemented' });
|
||||||
|
}
|
||||||
try {
|
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({ where_clause: filterClause, date_offset: dateOffset })),
|
params: esc(JSON.stringify({ date_from, date_to })),
|
||||||
filter_clause: filterClause,
|
date_from: esc(date_from),
|
||||||
date_offset: esc(dateOffset)
|
date_to: esc(date_to)
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSQL(sql);
|
const result = await runSQL(sql);
|
||||||
@ -101,36 +106,6 @@ 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 { date_from, date_to, pf_user, note } = req.body;
|
||||||
@ -152,7 +127,7 @@ module.exports = function(pool) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSQL(sql);
|
const result = await runSQL(sql);
|
||||||
res.json({ rows: result.rows, rows_affected: result.rows.length });
|
res.json(result.rows[0]);
|
||||||
} 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 });
|
||||||
@ -208,7 +183,7 @@ module.exports = function(pool) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSQL(sql);
|
const result = await runSQL(sql);
|
||||||
res.json({ rows: result.rows, rows_affected: result.rows.length });
|
res.json(result.rows[0]);
|
||||||
} 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 });
|
||||||
@ -243,7 +218,7 @@ module.exports = function(pool) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSQL(sql);
|
const result = await runSQL(sql);
|
||||||
res.json({ rows: result.rows, rows_affected: result.rows.length });
|
res.json(result.rows[0]);
|
||||||
} 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 });
|
||||||
@ -280,7 +255,7 @@ module.exports = function(pool) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSQL(sql);
|
const result = await runSQL(sql);
|
||||||
res.json({ rows: result.rows, rows_affected: result.rows.length });
|
res.json(result.rows[0]);
|
||||||
} 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 });
|
||||||
|
|||||||
@ -79,9 +79,8 @@ 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 => !systemCols.has(c.cname))
|
.filter(c => c.cname !== 'id')
|
||||||
.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}"`;
|
||||||
@ -90,12 +89,12 @@ module.exports = function(pool) {
|
|||||||
|
|
||||||
const ddl = `
|
const ddl = `
|
||||||
CREATE TABLE ${table} (
|
CREATE TABLE ${table} (
|
||||||
pf_id bigserial PRIMARY KEY,
|
id bigserial PRIMARY KEY,
|
||||||
${colDefs},
|
${colDefs},
|
||||||
pf_iter text NOT NULL,
|
iter text NOT NULL,
|
||||||
pf_logid bigint NOT NULL,
|
logid bigint NOT NULL,
|
||||||
pf_user text,
|
pf_user text,
|
||||||
pf_created_at timestamptz NOT NULL DEFAULT now()
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
await client.query(ddl);
|
await client.query(ddl);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user