Compare commits

...

10 Commits

Author SHA1 Message Date
4a4cb80189 Add light/dark mode with theme toggle 2026-04-25 23:29:25 -04:00
bd5ea1c60e Restore functional changes lost in dark theme revert
Re-applies fetchCurrentTotals dimension-only filter (prevents Perspective errors
from split_by/expression columns in the filter), toolbar three-group reorganization
(Layout | Expand | Data with dividers), always-visible Save as…, msg in toolbar,
resizable panel, change log modal with undo and inline note editing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:32:00 -04:00
8f009e468e Fix cleanLayout stripping expression columns (e.g. Year) on restore
Expression columns (bucket, computed) are defined in cfg.expressions and
are valid pivot axes, but weren't in validCols (raw table columns), so
they were filtered out of group_by/split_by on every layout restore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:24:01 -04:00
742d4b4cc4 Revert "Dark theme for Forecast view controls and operation panel"
This reverts commit cda3943515.
2026-04-25 21:23:43 -04:00
cda3943515 Dark theme for Forecast view controls and operation panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:20:40 -04:00
6449fff573 Add change history modal with undo and note editing
- GET /api/versions/:id/log — log entries with row counts via JOIN
- DELETE /api/log/:logid — undo in a transaction (delete fc rows + log entry)
- PATCH /api/log/:logid — update note text
- History button opens a modal: op badge, slice, editable note, row count, Undo per entry
- Undo triggers full Perspective table reload via initViewer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:36:18 -04:00
3bdd7d0028 Fix Forecast pivot row click, buildWhere, and add resizable panel
- Fix perspective-click handler to use event filter triples instead of
  __ROW_PATH__ — Perspective encodes row position as [col,'==',val] in
  detail.config.filter
- buildWhere now skips unrecognised slice keys (e.g. pf_iter) instead of
  throwing, so only dimension columns reach the WHERE clause
- Add draggable resize handle on the operation panel (160–480px)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:21:13 -04:00
af52845523 Unify baseline/reference into one form; fix timeline for both types
- Baseline.jsx: merge Reference section into Add Segment form with baseline/reference toggle; segment rows now clickable to expand stored WHERE clause + timeline; date filter inputs use type="date" for date-role columns
- Timeline.jsx: add type prop ('baseline'|'reference'); reference band uses purple; single-band height shrinks to 52px; canvas uses requestAnimationFrame to fix offsetWidth=0 on mount
- operations.js: reference route now accepts where_clause like baseline (drops date_from/date_to)
- sql_generator.js: reference SQL template uses {{filter_clause}} instead of hardcoded BETWEEN

Note: existing sources need Generate SQL re-run to pick up the new reference template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:45:03 -04:00
dc090fe394 Scaffold React/Vite/Tailwind UI with 3-step Setup → Baseline → Forecast flow
- ui/: React + Vite + Tailwind app (Setup, Baseline, Forecast views, collapsible sidebar, status bar, canvas timeline)
- server.js: serve built UI from public/app/
- package.json: add build script (cd ui && npm run build)
- routes/sources.js: default new col_meta role to 'dimension' instead of 'ignore'
- .gitignore: exclude public/app/ build output
- pf_spec.md: update tech stack, nav, frontend section, and project status to reflect current implementation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:28:45 -04:00
dd993e989c Add UX mockup and update spec with navigation direction
- HTML mockup with collapsible side nav, 3-step flow (Setup/Baseline/Forecast)
- Canvas-based timeline preview in baseline segment form
- Table peek modal, status bar, help popovers
- Spec updated: 3-step mental model, AG Grid replacement note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:56:29 -04:00
32 changed files with 6295 additions and 61 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
node_modules/
.env
public/app/

View File

@ -80,10 +80,10 @@ ilog AS (
INSERT INTO {{fc_table}} (${insertCols})
SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM ${srcTable}
WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
WHERE {{filter_clause}}
RETURNING *
)
SELECT * FROM ins`.trim();
SELECT count(*) AS rows_affected FROM ins`.trim();
}
function buildScale() {
@ -204,7 +204,7 @@ function applyTokens(sql, tokens) {
}
// 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) {
if (!slice || Object.keys(slice).length === 0) return 'TRUE';
@ -212,9 +212,7 @@ function buildWhere(slice, dimCols) {
const parts = [];
for (const [col, val] of Object.entries(slice)) {
if (!allowed.has(col)) {
throw new Error(`"${col}" is not a dimension column`);
}
if (!allowed.has(col)) continue;
if (Array.isArray(val)) {
const escaped = val.map(v => esc(v));
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

View File

@ -5,7 +5,8 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
"dev": "nodemon server.js",
"build": "cd ui && npm run build"
},
"dependencies": {
"cors": "^2.8.5",

View File

@ -10,7 +10,7 @@ A web application for building named forecast scenarios against any PostgreSQL t
- **Backend:** Node.js / Express
- **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)
---
@ -347,38 +347,25 @@ All operations share a common request envelope:
### Navigation (sidebar)
1. **Sources** — browse DB tables, register sources, configure col_meta, generate SQL
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
Three-step collapsible sidebar (200 px expanded / 48 px collapsed, state persisted to `localStorage`):
### 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
- 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
### Setup View (① Setup)
### 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
- Create version form — name, description, exclude_iters (defaults to `["reference"]`)
- Per-version actions: open forecast, load baseline, load reference, close, reopen, delete
### Baseline View (② Baseline)
**Load Baseline modal:**
- Source date range (date_from / date_to) — the actuals period to pull from
- Date offset (years + months spinners) — how far forward to project the dates
- Before/after preview: left side shows source months, right side shows where they land after the offset
- Note field
- On submit: shows row count; grid reloads
**Load Reference modal:**
- Source date range only — no offset
- Month chip preview of the period being loaded
- Note field
Source and version selectors at top. Version management inline: create new version (explains that a forecast table will be created), Close / Reopen / Delete buttons. Delete drops the forecast table and removes all version records.
### Baseline Workbench
@ -662,30 +649,38 @@ DELETE FROM pf.log WHERE id = {{logid}};
## Open Questions / Future Scope
- **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)
- **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
- **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
- Full backend: source registration, col_meta, SQL generation, versions, baseline segments, reference load, scale, recode, clone, undo
- React + Vite + Tailwind CSS frontend scaffolded in `ui/`, built output to `public/app/`, served by Express
- 3-step collapsible sidebar (Setup / Baseline / Forecast) — addresses prior UX concern about opaque 5-tab nav
- Setup view: DB table browser with preview modal, source registration, col_meta editor, SQL generation
- Baseline view: version management (create/close/reopen/delete), multi-segment baseline workbench, canvas timeline, filter builder
- Perspective pivot in Forecast view: loads all version rows, interactive group/split/filter/chart, layout saved per version
- Slice extraction from `perspective-click` event feeds operation panel directly
- Incremental row streaming: operation results (`RETURNING *`) stream into Perspective table without full reload
- 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
- 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
- Operation panel feedback is minimal (row count only, no before/after summary)
- 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
### Known issues / next focus
- **Forecast view** — operation panel (Scale / Recode / Clone) is a stub; needs wiring to API
- **Status bar** — currently hardcoded; needs to reflect actual selected source/version from state
- **Col_meta / version schema drift** — if col_meta changes after a version's forecast table is created, the SQL and table DDL go out of sync. UI should detect this (compare col_meta against `information_schema`), warn, and offer rebuild. Workaround: delete and recreate the version.
- **No "current version" persistence** — source/version selection resets on page reload; session context not persisted
- **Perspective slice limitation** — computed date columns (Month, YearDate) extracted via split_by don't filter back to raw rows; only native dimension columns work for slice extraction
### Branch status
- `baseline-workbench` — merged to origin, stable
- `perspective-forecast` — active development branch; 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
View 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
View 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>

View File

@ -82,7 +82,6 @@ module.exports = function(pool) {
try {
const ctx = await getContext(parseInt(req.params.id), 'baseline');
if (!guardOpen(ctx.version, res)) return;
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
@ -133,26 +132,22 @@ module.exports = function(pool) {
// load reference rows from source table (additive — does not clear prior reference rows)
router.post('/versions/:id/reference', async (req, res) => {
const { date_from, date_to, pf_user, note } = req.body;
if (!date_from || !date_to) {
return res.status(400).json({ error: 'date_from and date_to are required' });
}
const { where_clause, pf_user, note } = req.body;
const filterClause = (where_clause || '').trim() || 'TRUE';
try {
const ctx = await getContext(parseInt(req.params.id), 'reference');
if (!guardOpen(ctx.version, res)) return;
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(JSON.stringify({ date_from, date_to })),
date_from: esc(date_from),
date_to: esc(date_to)
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(JSON.stringify({ where_clause: filterClause })),
filter_clause: filterClause
});
const result = await runSQL(sql);
res.json({ rows: result.rows, rows_affected: result.rows.length });
res.json(result.rows[0]);
} catch (err) {
console.error(err);
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;
};

View File

@ -42,7 +42,7 @@ module.exports = function(pool) {
// seed col_meta from information_schema
await client.query(`
INSERT INTO pf.col_meta (source_id, cname, role, opos)
SELECT $1, column_name, 'ignore', ordinal_position
SELECT $1, column_name, 'dimension', ordinal_position
FROM information_schema.columns
WHERE table_schema = $2 AND table_name = $3
ORDER BY ordinal_position

View File

@ -7,6 +7,8 @@ const app = express();
app.use(cors());
app.use(express.json());
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({
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/log')(pool));
app.get('/', (req, res) => res.send('pf_app running'));
const port = process.env.PORT || 3010;
app.listen(port, '0.0.0.0', () => console.log(`pf_app started on port ${port}`));

24
ui/.gitignore vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

28
ui/package.json Normal file
View 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

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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
ui/src/assets/react.svg Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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
View 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
View 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}_&lt;id&gt;</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
View 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
View 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
View 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
}
})