Compare commits
10 Commits
9084a87ea5
...
4a4cb80189
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a4cb80189 | |||
| bd5ea1c60e | |||
| 8f009e468e | |||
| 742d4b4cc4 | |||
| cda3943515 | |||
| 6449fff573 | |||
| 3bdd7d0028 | |||
| af52845523 | |||
| dc090fe394 | |||
| dd993e989c |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
|
public/app/
|
||||||
|
|||||||
@ -80,10 +80,10 @@ 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 * FROM ins`.trim();
|
SELECT count(*) AS rows_affected FROM ins`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildScale() {
|
function buildScale() {
|
||||||
@ -204,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';
|
||||||
|
|
||||||
@ -212,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("', '")}')`);
|
||||||
@ -223,7 +221,7 @@ 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
|
||||||
|
|||||||
@ -5,7 +5,8 @@
|
|||||||
"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": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
69
pf_spec.md
69
pf_spec.md
@ -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 (sources/versions/log grids) + Perspective (forecast pivot)
|
- **Frontend:** React + Vite + Tailwind CSS; Perspective (forecast pivot)
|
||||||
- **Pattern:** Follows fc_webapp (shell) + pivot_forecast (operations)
|
- **Pattern:** Follows fc_webapp (shell) + pivot_forecast (operations)
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -347,38 +347,25 @@ 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. **Baseline** — baseline workbench for the selected version
|
|
||||||
4. **Forecast** — main working view (pivot + operation panel)
|
|
||||||
5. **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
|
|
||||||
- AG Grid is used for all admin grids (tables browser, sources list, col_meta editor, versions list, log)
|
|
||||||
- "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
|
|
||||||
|
|
||||||
**Load Baseline modal:**
|
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.
|
||||||
- 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
|
### Baseline Workbench
|
||||||
|
|
||||||
@ -662,30 +649,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
|
||||||
|
- **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-15
|
## Project Status — 2026-04-25
|
||||||
|
|
||||||
### What's working
|
### What's working
|
||||||
- Full backend: source registration, col_meta, SQL generation, versions, baseline segments, reference load, scale, recode, clone, undo
|
- 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
|
- 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
|
- Slice extraction from `perspective-click` event feeds operation panel directly
|
||||||
- Incremental row streaming: operation results (`RETURNING *`) stream into Perspective table without full reload
|
- Incremental row streaming: operation results (`RETURNING *`) stream into Perspective table without full reload
|
||||||
- Baseline workbench: multi-segment additive baseline with WHERE clause editor and offset
|
- Status bar: shows current source · version · baseline row count · status
|
||||||
|
|
||||||
### Known UX issues — next focus area
|
### Known issues / next focus
|
||||||
- Navigation flow is clunky: user is forced to start at Sources even when a version already exists; context (source/version) is lost between views; getting to Forecast requires too many steps
|
|
||||||
- No clear "current version" concept — user has to re-select source → version each session
|
- **Forecast view** — operation panel (Scale / Recode / Clone) is a stub; needs wiring to API
|
||||||
- Operation panel feedback is minimal (row count only, no before/after summary)
|
- **Status bar** — currently hardcoded; needs to reflect actual selected source/version from state
|
||||||
- Perspective computed date columns (Month, YearDate) extracted via split_by don't filter back to raw rows when used as slice — only native dimension columns work for slice extraction
|
- **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
|
### Branch status
|
||||||
- `baseline-workbench` — merged to origin, stable
|
- `baseline-workbench` — merged to origin, stable
|
||||||
- `perspective-forecast` — active development branch; Perspective pivot working, UI flow improvements pending
|
- `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] │
|
||||||
|
└────┴───────────┴──────────┴─────────────────────────┴────────────┘
|
||||||
|
```
|
||||||
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>
|
||||||
@ -82,7 +82,6 @@ module.exports = function(pool) {
|
|||||||
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,
|
||||||
@ -133,26 +132,22 @@ module.exports = function(pool) {
|
|||||||
|
|
||||||
// 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);
|
||||||
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 });
|
||||||
@ -287,5 +282,82 @@ module.exports = function(pool) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(err.status || 500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
721
ui/src/views/Forecast.jsx
Normal file
721
ui/src/views/Forecast.jsx
Normal file
@ -0,0 +1,721 @@
|
|||||||
|
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 [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)
|
||||||
|
setSlice({})
|
||||||
|
expandDepthRef.current = null
|
||||||
|
try {
|
||||||
|
const [perspective, rows, meta] = await Promise.all([
|
||||||
|
loadPerspective(),
|
||||||
|
fetch(`/api/versions/${vid}/data`).then(r => r.json()),
|
||||||
|
fetch(`/api/sources/${sid}/cols`).then(r => r.json()),
|
||||||
|
])
|
||||||
|
|
||||||
|
colMetaRef.current = meta
|
||||||
|
const validCols = new Set(rows.length ? Object.keys(rows[0]) : [])
|
||||||
|
const tableName = `fc_${vid}`
|
||||||
|
|
||||||
|
if (workerRef.current) { try { workerRef.current.terminate() } catch {} }
|
||||||
|
const worker = await perspective.worker()
|
||||||
|
workerRef.current = worker
|
||||||
|
tableRef.current = await worker.table(rows, { 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)
|
||||||
|
|
||||||
|
} 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>
|
||||||
|
)}
|
||||||
|
<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