Compare commits

..

No commits in common. "4a4cb8018990bc0237508fd70571a7e2fb77fa44" and "9084a87ea5fbffd0aa1ee1199357f712717f986d" have entirely different histories.

32 changed files with 61 additions and 6295 deletions

1
.gitignore vendored
View File

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

View File

@ -80,10 +80,10 @@ ilog AS (
INSERT INTO {{fc_table}} (${insertCols}) INSERT INTO {{fc_table}} (${insertCols})
SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now() SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM ${srcTable} FROM ${srcTable}
WHERE {{filter_clause}} WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
RETURNING * RETURNING *
) )
SELECT count(*) AS rows_affected FROM ins`.trim(); SELECT * FROM ins`.trim();
} }
function buildScale() { function buildScale() {
@ -204,7 +204,7 @@ function applyTokens(sql, tokens) {
} }
// build a SQL WHERE clause string from a slice object // build a SQL WHERE clause string from a slice object
// only dimension columns are included; unrecognised keys are silently skipped // validates all keys against the allowed dimension column list
function buildWhere(slice, dimCols) { function buildWhere(slice, dimCols) {
if (!slice || Object.keys(slice).length === 0) return 'TRUE'; if (!slice || Object.keys(slice).length === 0) return 'TRUE';
@ -212,7 +212,9 @@ function buildWhere(slice, dimCols) {
const parts = []; const parts = [];
for (const [col, val] of Object.entries(slice)) { for (const [col, val] of Object.entries(slice)) {
if (!allowed.has(col)) continue; if (!allowed.has(col)) {
throw new Error(`"${col}" is not a dimension column`);
}
if (Array.isArray(val)) { if (Array.isArray(val)) {
const escaped = val.map(v => esc(v)); const escaped = val.map(v => esc(v));
parts.push(`"${col}" IN ('${escaped.join("', '")}')`); parts.push(`"${col}" IN ('${escaped.join("', '")}')`);
@ -221,7 +223,7 @@ function buildWhere(slice, dimCols) {
} }
} }
return parts.length ? parts.join('\nAND ') : 'TRUE'; return parts.join('\nAND ');
} }
// build AND iter NOT IN (...) from a version's exclude_iters array // build AND iter NOT IN (...) from a version's exclude_iters array

View File

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

View File

@ -10,7 +10,7 @@ A web application for building named forecast scenarios against any PostgreSQL t
- **Backend:** Node.js / Express - **Backend:** Node.js / Express
- **Database:** PostgreSQL — isolated `pf` schema, installs into any existing DB - **Database:** PostgreSQL — isolated `pf` schema, installs into any existing DB
- **Frontend:** React + Vite + Tailwind CSS; Perspective (forecast pivot) - **Frontend:** Vanilla JS + AG Grid (sources/versions/log grids) + Perspective (forecast pivot)
- **Pattern:** Follows fc_webapp (shell) + pivot_forecast (operations) - **Pattern:** Follows fc_webapp (shell) + pivot_forecast (operations)
--- ---
@ -347,25 +347,38 @@ All operations share a common request envelope:
### Navigation (sidebar) ### Navigation (sidebar)
Three-step collapsible sidebar (200 px expanded / 48 px collapsed, state persisted to `localStorage`): 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
1. **① Setup** — browse DB tables, register sources, configure col_meta, generate SQL. One-time admin task. ### Sources View
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.
### Setup View (① Setup) - 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
- Left panel: DB table browser — all tables with row counts; click a table to open a preview modal (column list + sample rows) ### Versions View
- 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
### Baseline View (② Baseline) - 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
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. **Load Baseline modal:**
- Source date range (date_from / date_to) — the actuals period to pull from
- Date offset (years + months spinners) — how far forward to project the dates
- Before/after preview: left side shows source months, right side shows where they land after the offset
- Note field
- On submit: shows row count; grid reloads
**Load Reference modal:**
- Source date range only — no offset
- Month chip preview of the period being loaded
- Note field
### Baseline Workbench ### Baseline Workbench
@ -649,38 +662,30 @@ DELETE FROM pf.log WHERE id = {{logid}};
## Open Questions / Future Scope ## Open Questions / Future Scope
- **Baseline replay** — re-execute change log against a restated baseline (`replay: true`); v1 returns 501 - **Baseline replay** — re-execute change log against a restated baseline (`replay: true`); v1 returns 501
- **Arrow IPC for initial data load** — at large row counts (1M+) the `/versions/:id/data` JSON response becomes a bottleneck. Option: serve Arrow IPC binary instead of JSON; Perspective's `worker.table()` accepts Arrow buffers natively. Incremental operation rows (scale/recode/clone) can stay as JSON fed to `table.update()` since they're always small. Could be implemented with `pg` + `apache-arrow` in Node, or by adding a server-side DuckDB instance (Postgres scanner → Arrow IPC) if a caching layer is also needed.
- **Approval workflow** — user submits, admin approves before changes are visible to others (deferred) - **Approval workflow** — user submits, admin approves before changes are visible to others (deferred)
- **Territory filtering** — restrict what a user can see/edit by dimension value (deferred) - **Territory filtering** — restrict what a user can see/edit by dimension value (deferred)
- **Export** — download forecast as CSV or push results to a reporting table - **Export** — download forecast as CSV or push results to a reporting table
- **Version comparison** — side-by-side view of two versions (facilitated by isolated tables via UNION) - **Version comparison** — side-by-side view of two versions (facilitated by isolated tables via UNION)
- **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-DB sources** — currently assumes same DB; cross-DB would need connection config per source
- **Multi-connection support** — currently one DB via `.env`. Full vision: `pf.connection` table (host, port, dbname, user, password as env-var ref), `connection_id` on `pf.source`, per-connection pg pools at runtime. `pf` schema stays on a "home" connection; source data can live anywhere. Connections UI in Setup. Safe to defer while in dev — requires clean reinstall when added since it changes the source schema.
--- ---
## Project Status — 2026-04-25 ## Project Status — 2026-04-15
### What's working ### What's working
- Full backend: source registration, col_meta, SQL generation, versions, baseline segments, reference load, scale, recode, clone, undo - Full backend: source registration, col_meta, SQL generation, versions, baseline segments, reference load, scale, recode, clone, undo
- React + Vite + Tailwind CSS frontend scaffolded in `ui/`, built output to `public/app/`, served by Express
- 3-step collapsible sidebar (Setup / Baseline / Forecast) — addresses prior UX concern about opaque 5-tab nav
- Setup view: DB table browser with preview modal, source registration, col_meta editor, SQL generation
- Baseline view: version management (create/close/reopen/delete), multi-segment baseline workbench, canvas timeline, filter builder
- Perspective pivot in Forecast view: loads all version rows, interactive group/split/filter/chart, layout saved per version - Perspective pivot in Forecast view: loads all version rows, interactive group/split/filter/chart, layout saved per version
- Slice extraction from `perspective-click` event feeds operation panel directly - Slice extraction from `perspective-click` event feeds operation panel directly
- Incremental row streaming: operation results (`RETURNING *`) stream into Perspective table without full reload - Incremental row streaming: operation results (`RETURNING *`) stream into Perspective table without full reload
- Status bar: shows current source · version · baseline row count · status - Baseline workbench: multi-segment additive baseline with WHERE clause editor and offset
### Known issues / next focus ### 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
- **Forecast view** — operation panel (Scale / Recode / Clone) is a stub; needs wiring to API - No clear "current version" concept — user has to re-select source → version each session
- **Status bar** — currently hardcoded; needs to reflect actual selected source/version from state - Operation panel feedback is minimal (row count only, no before/after summary)
- **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. - 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
- **No "current version" persistence** — source/version selection resets on page reload; session context not persisted
- **Perspective slice limitation** — computed date columns (Month, YearDate) extracted via split_by don't filter back to raw rows; only native dimension columns work for slice extraction
### Branch status ### Branch status
- `baseline-workbench` — merged to origin, stable - `baseline-workbench` — merged to origin, stable
- `perspective-forecast` — active development branch; React UI scaffolded, Forecast operation panel pending - `perspective-forecast` — active development branch; Perspective pivot working, UI flow improvements pending

View File

@ -1,112 +0,0 @@
# 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] │
└────┴───────────┴──────────┴─────────────────────────┴────────────┘
```

View File

@ -1,888 +0,0 @@
<!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,6 +82,7 @@ module.exports = function(pool) {
try { try {
const ctx = await getContext(parseInt(req.params.id), 'baseline'); const ctx = await getContext(parseInt(req.params.id), 'baseline');
if (!guardOpen(ctx.version, res)) return; if (!guardOpen(ctx.version, res)) return;
const sql = applyTokens(ctx.sql, { const sql = applyTokens(ctx.sql, {
fc_table: ctx.table, fc_table: ctx.table,
version_id: ctx.version.id, version_id: ctx.version.id,
@ -132,22 +133,26 @@ module.exports = function(pool) {
// load reference rows from source table (additive — does not clear prior reference rows) // load reference rows from source table (additive — does not clear prior reference rows)
router.post('/versions/:id/reference', async (req, res) => { router.post('/versions/:id/reference', async (req, res) => {
const { where_clause, pf_user, note } = req.body; const { date_from, date_to, pf_user, note } = req.body;
const filterClause = (where_clause || '').trim() || 'TRUE'; if (!date_from || !date_to) {
return res.status(400).json({ error: 'date_from and date_to are required' });
}
try { try {
const ctx = await getContext(parseInt(req.params.id), 'reference'); const ctx = await getContext(parseInt(req.params.id), 'reference');
if (!guardOpen(ctx.version, res)) return; if (!guardOpen(ctx.version, res)) return;
const sql = applyTokens(ctx.sql, { const sql = applyTokens(ctx.sql, {
fc_table: ctx.table, fc_table: ctx.table,
version_id: ctx.version.id, version_id: ctx.version.id,
pf_user: esc(pf_user || ''), pf_user: esc(pf_user || ''),
note: esc(note || ''), note: esc(note || ''),
params: esc(JSON.stringify({ where_clause: filterClause })), params: esc(JSON.stringify({ date_from, date_to })),
filter_clause: filterClause date_from: esc(date_from),
date_to: esc(date_to)
}); });
const result = await runSQL(sql); const result = await runSQL(sql);
res.json(result.rows[0]); res.json({ rows: result.rows, rows_affected: result.rows.length });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(err.status || 500).json({ error: err.message }); res.status(err.status || 500).json({ error: err.message });
@ -282,82 +287,5 @@ module.exports = function(pool) {
} }
}); });
// list log entries for a version, newest first, with row counts
router.get('/versions/:id/log', async (req, res) => {
const versionId = parseInt(req.params.id);
try {
const verResult = await pool.query(
`SELECT v.*, s.tname FROM pf.version v JOIN pf.source s ON s.id = v.source_id WHERE v.id = $1`,
[versionId]
);
if (!verResult.rows.length) return res.status(404).json({ error: 'Version not found' });
const table = fcTable(verResult.rows[0].tname, versionId);
const result = await pool.query(`
SELECT l.*, count(f.pf_id)::int AS row_count
FROM pf.log l
LEFT JOIN ${table} f ON f.pf_logid = l.id
WHERE l.version_id = $1
GROUP BY l.id
ORDER BY l.id DESC
`, [versionId]);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
// undo a log entry — delete all fc rows with this logid, then delete the log entry
router.delete('/log/:logid', async (req, res) => {
const logId = parseInt(req.params.logid);
try {
const logResult = await pool.query(`
SELECT l.*, v.status, s.tname, v.id AS version_id
FROM pf.log l
JOIN pf.version v ON v.id = l.version_id
JOIN pf.source s ON s.id = v.source_id
WHERE l.id = $1
`, [logId]);
if (!logResult.rows.length) return res.status(404).json({ error: 'Log entry not found' });
const log = logResult.rows[0];
if (log.status === 'closed') return res.status(403).json({ error: 'Version is closed' });
const table = fcTable(log.tname, log.version_id);
const client = await pool.connect();
try {
await client.query('BEGIN');
const deleted = await client.query(
`DELETE FROM ${table} WHERE pf_logid = $1 RETURNING pf_id`, [logId]
);
await client.query('DELETE FROM pf.log WHERE id = $1', [logId]);
await client.query('COMMIT');
res.json({ rows_deleted: deleted.rowCount });
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
// update the note on a log entry
router.patch('/log/:logid', async (req, res) => {
const logId = parseInt(req.params.logid);
const { note } = req.body;
try {
const result = await pool.query(
`UPDATE pf.log SET note = $1 WHERE id = $2 RETURNING *`, [note, logId]
);
if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' });
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
return router; return router;
}; };

View File

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

View File

@ -7,8 +7,6 @@ const app = express();
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use(express.static('public')); app.use(express.static('public'));
app.use(express.static('public/app'));
app.get('/', (req, res) => res.sendFile(__dirname + '/public/app/index.html'));
const pool = new Pool({ const pool = new Pool({
host: process.env.DB_HOST, host: process.env.DB_HOST,
@ -29,6 +27,7 @@ app.use('/api', require('./routes/versions')(pool));
app.use('/api', require('./routes/operations')(pool)); app.use('/api', require('./routes/operations')(pool));
app.use('/api', require('./routes/log')(pool)); app.use('/api', require('./routes/log')(pool));
app.get('/', (req, res) => res.send('pf_app running'));
const port = process.env.PORT || 3010; const port = process.env.PORT || 3010;
app.listen(port, '0.0.0.0', () => console.log(`pf_app started on port ${port}`)); app.listen(port, '0.0.0.0', () => console.log(`pf_app started on port ${port}`));

24
ui/.gitignore vendored
View File

@ -1,24 +0,0 @@
# 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?

View File

@ -1,16 +0,0 @@
# 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.

View File

@ -1,21 +0,0 @@
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 } },
},
},
])

View File

@ -1,14 +0,0 @@
<!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

File diff suppressed because it is too large Load Diff

View File

@ -1,28 +0,0 @@
{
"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"
}
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@ -1,24 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -1,184 +0,0 @@
.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);
}
}

View File

@ -1,28 +0,0 @@
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>
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -1,88 +0,0 @@
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

@ -1,38 +0,0 @@
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

@ -1,162 +0,0 @@
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' }} />
}

View File

@ -1,80 +0,0 @@
@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; }

View File

@ -1,13 +0,0 @@
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>,
)

View File

@ -1,25 +0,0 @@
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

View File

@ -1,520 +0,0 @@
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>
)
}

View File

@ -1,721 +0,0 @@
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>
)
}

View File

@ -1,410 +0,0 @@
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>
)
}

View File

@ -1,17 +0,0 @@
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
}
})