Scaffold React/Vite/Tailwind UI with 3-step Setup → Baseline → Forecast flow
- ui/: React + Vite + Tailwind app (Setup, Baseline, Forecast views, collapsible sidebar, status bar, canvas timeline) - server.js: serve built UI from public/app/ - package.json: add build script (cd ui && npm run build) - routes/sources.js: default new col_meta role to 'dimension' instead of 'ignore' - .gitignore: exclude public/app/ build output - pf_spec.md: update tech stack, nav, frontend section, and project status to reflect current implementation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dd993e989c
commit
dc090fe394
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
|
public/app/
|
||||||
|
|||||||
@ -5,7 +5,8 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon server.js"
|
"dev": "nodemon server.js",
|
||||||
|
"build": "cd ui && npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
80
pf_spec.md
80
pf_spec.md
@ -10,7 +10,7 @@ A web application for building named forecast scenarios against any PostgreSQL t
|
|||||||
|
|
||||||
- **Backend:** Node.js / Express
|
- **Backend:** Node.js / Express
|
||||||
- **Database:** PostgreSQL — isolated `pf` schema, installs into any existing DB
|
- **Database:** PostgreSQL — isolated `pf` schema, installs into any existing DB
|
||||||
- **Frontend:** Vanilla JS + AG Grid (sources/versions/log grids) + Perspective (forecast pivot)
|
- **Frontend:** React + Vite + Tailwind CSS; Perspective (forecast pivot)
|
||||||
- **Pattern:** Follows fc_webapp (shell) + pivot_forecast (operations)
|
- **Pattern:** Follows fc_webapp (shell) + pivot_forecast (operations)
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -347,38 +347,25 @@ All operations share a common request envelope:
|
|||||||
|
|
||||||
### Navigation (sidebar)
|
### Navigation (sidebar)
|
||||||
|
|
||||||
1. **Sources** — browse DB tables, register sources, configure col_meta, generate SQL
|
Three-step collapsible sidebar (200 px expanded / 48 px collapsed, state persisted to `localStorage`):
|
||||||
2. **Versions** — list forecast versions per source, create/close/reopen/delete
|
|
||||||
3. **Baseline** — baseline workbench for the selected version
|
|
||||||
4. **Forecast** — main working view (pivot + operation panel)
|
|
||||||
5. **Log** — change history with undo
|
|
||||||
|
|
||||||
### Sources View
|
1. **① Setup** — browse DB tables, register sources, configure col_meta, generate SQL. One-time admin task.
|
||||||
|
2. **② Baseline** — create/manage versions, load baseline segments, timeline preview. One-time per version.
|
||||||
|
3. **③ Forecast** — main working view: Perspective pivot + operation panel. Primary ongoing use.
|
||||||
|
|
||||||
- Left: DB table browser (like fc_webapp) — all tables with row counts, preview on click
|
### Setup View (① Setup)
|
||||||
- Right: Registered sources list — click to open col_meta editor
|
|
||||||
- Col_meta editor: AG Grid editable table — set role per column, toggle is_key, set label
|
|
||||||
- AG Grid is used for all admin grids (tables browser, sources list, col_meta editor, versions list, log)
|
|
||||||
- "Generate SQL" button — triggers generate-sql route, shows confirmation
|
|
||||||
- Must generate SQL before versions can be created against this source
|
|
||||||
|
|
||||||
### Versions View
|
- Left panel: DB table browser — all tables with row counts; click a table to open a preview modal (column list + sample rows)
|
||||||
|
- Right panel: Registered sources list; click a source to open col_meta editor below
|
||||||
|
- Col_meta editor: inline table — role dropdown per column, is_key checkbox, label text input, ordinal position
|
||||||
|
- "Save" button — upserts col_meta; "Generate SQL" button — triggers generate-sql route, shows confirmation
|
||||||
|
- "Register source" button available in the table preview modal
|
||||||
|
- New columns default to role `dimension` on registration
|
||||||
|
- Must generate SQL before a version can be created against this source
|
||||||
|
|
||||||
- List of versions for selected source — name, status (open/closed), created date, row count
|
### Baseline View (② Baseline)
|
||||||
- Create version form — name, description, exclude_iters (defaults to `["reference"]`)
|
|
||||||
- Per-version actions: open forecast, load baseline, load reference, close, reopen, delete
|
|
||||||
|
|
||||||
**Load Baseline modal:**
|
Source and version selectors at top. Version management inline: create new version (explains that a forecast table will be created), Close / Reopen / Delete buttons. Delete drops the forecast table and removes all version records.
|
||||||
- Source date range (date_from / date_to) — the actuals period to pull from
|
|
||||||
- Date offset (years + months spinners) — how far forward to project the dates
|
|
||||||
- Before/after preview: left side shows source months, right side shows where they land after the offset
|
|
||||||
- Note field
|
|
||||||
- On submit: shows row count; grid reloads
|
|
||||||
|
|
||||||
**Load Reference modal:**
|
|
||||||
- Source date range only — no offset
|
|
||||||
- Month chip preview of the period being loaded
|
|
||||||
- Note field
|
|
||||||
|
|
||||||
### Baseline Workbench
|
### Baseline Workbench
|
||||||
|
|
||||||
@ -667,40 +654,33 @@ DELETE FROM pf.log WHERE id = {{logid}};
|
|||||||
- **Territory filtering** — restrict what a user can see/edit by dimension value (deferred)
|
- **Territory filtering** — restrict what a user can see/edit by dimension value (deferred)
|
||||||
- **Export** — download forecast as CSV or push results to a reporting table
|
- **Export** — download forecast as CSV or push results to a reporting table
|
||||||
- **Version comparison** — side-by-side view of two versions (facilitated by isolated tables via UNION)
|
- **Version comparison** — side-by-side view of two versions (facilitated by isolated tables via UNION)
|
||||||
- **Multi-DB sources** — currently assumes same DB; cross-DB would need connection config per source
|
- **Col meta / version schema drift** — if col_meta roles are changed after a version's forecast table is already created, the generated SQL and the table DDL go out of sync (e.g. a column added to SQL that doesn't exist in the table). UI should detect this: compare col_meta against the forecast table's actual columns via `information_schema`, warn the user, and offer to rebuild the version (drop + recreate table, preserving the version record and log). For now the workaround is to delete and recreate the version manually.
|
||||||
|
- **Multi-connection support** — currently one DB via `.env`. Full vision: `pf.connection` table (host, port, dbname, user, password as env-var ref), `connection_id` on `pf.source`, per-connection pg pools at runtime. `pf` schema stays on a "home" connection; source data can live anywhere. Connections UI in Setup. Safe to defer while in dev — requires clean reinstall when added since it changes the source schema.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Status — 2026-04-15
|
## Project Status — 2026-04-25
|
||||||
|
|
||||||
### What's working
|
### What's working
|
||||||
- Full backend: source registration, col_meta, SQL generation, versions, baseline segments, reference load, scale, recode, clone, undo
|
- Full backend: source registration, col_meta, SQL generation, versions, baseline segments, reference load, scale, recode, clone, undo
|
||||||
|
- React + Vite + Tailwind CSS frontend scaffolded in `ui/`, built output to `public/app/`, served by Express
|
||||||
|
- 3-step collapsible sidebar (Setup / Baseline / Forecast) — addresses prior UX concern about opaque 5-tab nav
|
||||||
|
- Setup view: DB table browser with preview modal, source registration, col_meta editor, SQL generation
|
||||||
|
- Baseline view: version management (create/close/reopen/delete), multi-segment baseline workbench, canvas timeline, filter builder
|
||||||
- Perspective pivot in Forecast view: loads all version rows, interactive group/split/filter/chart, layout saved per version
|
- Perspective pivot in Forecast view: loads all version rows, interactive group/split/filter/chart, layout saved per version
|
||||||
- Slice extraction from `perspective-click` event feeds operation panel directly
|
- Slice extraction from `perspective-click` event feeds operation panel directly
|
||||||
- Incremental row streaming: operation results (`RETURNING *`) stream into Perspective table without full reload
|
- Incremental row streaming: operation results (`RETURNING *`) stream into Perspective table without full reload
|
||||||
- Baseline workbench: multi-segment additive baseline with WHERE clause editor and offset
|
- Status bar: shows current source · version · baseline row count · status
|
||||||
|
|
||||||
### Known UX issues — next focus area
|
### Known issues / next focus
|
||||||
|
|
||||||
**Primary: overall interaction is not intuitive — needs a clearer 3-step flow**
|
- **Forecast view** — operation panel (Scale / Recode / Clone) is a stub; needs wiring to API
|
||||||
|
- **Status bar** — currently hardcoded; needs to reflect actual selected source/version from state
|
||||||
The app should present itself as three distinct phases in order:
|
- **Col_meta / version schema drift** — if col_meta changes after a version's forecast table is created, the SQL and table DDL go out of sync. UI should detect this (compare col_meta against `information_schema`), warn, and offer rebuild. Workaround: delete and recreate the version.
|
||||||
|
- **No "current version" persistence** — source/version selection resets on page reload; session context not persisted
|
||||||
1. **Setup source** — register a table, configure col_meta, generate SQL. One-time admin task. Once done, user should never need to return here.
|
- **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
|
||||||
2. **Build baseline** — create a version, load baseline segments, optionally load reference. Also largely a one-time setup per version.
|
|
||||||
3. **Do edits** — the Forecast view; the primary ongoing working mode. Pivot + operation panel.
|
|
||||||
|
|
||||||
Current 5-tab nav (Sources / Versions / Baseline / Forecast / Log) obscures this progression. Users have to discover the order themselves, and context (which source, which version) is lost between tabs.
|
|
||||||
|
|
||||||
**Direction:** Redesign navigation around the 3-step mental model. Steps 1 and 2 should feel like "setup" that gets out of the way; step 3 is the main working surface. Consider a wizard/stepper for first-time setup, with the Forecast view as the default landing once a version exists.
|
|
||||||
|
|
||||||
**Other issues:**
|
|
||||||
- No clear "current version" concept — user has to re-select source → version each session
|
|
||||||
- Operation panel feedback is minimal (row count only, no before/after summary)
|
|
||||||
- Perspective computed date columns (Month, YearDate) extracted via split_by don't filter back to raw rows when used as slice — only native dimension columns work for slice extraction
|
|
||||||
- AG Grid watermark (community edition) and non-intuitive interaction on admin grids — replace with plain `<table>` + Tailwind, same pattern used in dataflow (`/opt/dataflow/ui/src/pages/`)
|
|
||||||
|
|
||||||
### Branch status
|
### Branch status
|
||||||
- `baseline-workbench` — merged to origin, stable
|
- `baseline-workbench` — merged to origin, stable
|
||||||
- `perspective-forecast` — active development branch; Perspective pivot working, UI flow improvements pending
|
- `perspective-forecast` — active development branch; React UI scaffolded, Forecast operation panel pending
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ module.exports = function(pool) {
|
|||||||
// seed col_meta from information_schema
|
// seed col_meta from information_schema
|
||||||
await client.query(`
|
await client.query(`
|
||||||
INSERT INTO pf.col_meta (source_id, cname, role, opos)
|
INSERT INTO pf.col_meta (source_id, cname, role, opos)
|
||||||
SELECT $1, column_name, 'ignore', ordinal_position
|
SELECT $1, column_name, 'dimension', ordinal_position
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_schema = $2 AND table_name = $3
|
WHERE table_schema = $2 AND table_name = $3
|
||||||
ORDER BY ordinal_position
|
ORDER BY ordinal_position
|
||||||
|
|||||||
@ -7,6 +7,8 @@ const app = express();
|
|||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
|
app.use(express.static('public/app'));
|
||||||
|
app.get('/', (req, res) => res.sendFile(__dirname + '/public/app/index.html'));
|
||||||
|
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
@ -27,7 +29,6 @@ app.use('/api', require('./routes/versions')(pool));
|
|||||||
app.use('/api', require('./routes/operations')(pool));
|
app.use('/api', require('./routes/operations')(pool));
|
||||||
app.use('/api', require('./routes/log')(pool));
|
app.use('/api', require('./routes/log')(pool));
|
||||||
|
|
||||||
app.get('/', (req, res) => res.send('pf_app running'));
|
|
||||||
|
|
||||||
const port = process.env.PORT || 3010;
|
const port = process.env.PORT || 3010;
|
||||||
app.listen(port, '0.0.0.0', () => console.log(`pf_app started on port ${port}`));
|
app.listen(port, '0.0.0.0', () => console.log(`pf_app started on port ${port}`));
|
||||||
|
|||||||
24
ui/.gitignore
vendored
Normal file
24
ui/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
16
ui/README.md
Normal file
16
ui/README.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
21
ui/eslint.config.js
Normal file
21
ui/eslint.config.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
ui/index.html
Normal file
13
ui/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!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>ui</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2750
ui/package-lock.json
generated
Normal file
2750
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
ui/package.json
Normal file
28
ui/package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.5.0",
|
||||||
|
"vite": "^8.0.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ui/public/favicon.svg
Normal file
1
ui/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
ui/public/icons.svg
Normal file
24
ui/public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
184
ui/src/App.css
Normal file
184
ui/src/App.css
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
ui/src/App.jsx
Normal file
28
ui/src/App.jsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Sidebar from './components/Sidebar.jsx'
|
||||||
|
import StatusBar from './components/StatusBar.jsx'
|
||||||
|
import Setup from './views/Setup.jsx'
|
||||||
|
import Baseline from './views/Baseline.jsx'
|
||||||
|
import Forecast from './views/Forecast.jsx'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [view, setView] = useState(() => localStorage.getItem('pf_view') || 'forecast')
|
||||||
|
const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('pf_sidebar') !== 'collapsed')
|
||||||
|
|
||||||
|
useEffect(() => { localStorage.setItem('pf_view', view) }, [view])
|
||||||
|
useEffect(() => { localStorage.setItem('pf_sidebar', sidebarExpanded ? 'expanded' : 'collapsed') }, [sidebarExpanded])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full bg-gray-100 text-sm text-gray-800 overflow-hidden">
|
||||||
|
<Sidebar view={view} setView={setView} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
|
||||||
|
<div className="flex flex-col flex-1 overflow-hidden min-w-0">
|
||||||
|
<StatusBar />
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{view === 'setup' && <Setup />}
|
||||||
|
{view === 'baseline' && <Baseline />}
|
||||||
|
{view === 'forecast' && <Forecast />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
ui/src/assets/hero.png
Normal file
BIN
ui/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
ui/src/assets/react.svg
Normal file
1
ui/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
ui/src/assets/vite.svg
Normal file
1
ui/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
90
ui/src/components/Sidebar.jsx
Normal file
90
ui/src/components/Sidebar.jsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
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 }}
|
||||||
|
>
|
||||||
|
{/* Logo / toggle */}
|
||||||
|
<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 */}
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
ui/src/components/StatusBar.jsx
Normal file
17
ui/src/components/StatusBar.jsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export default function StatusBar() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border-b border-gray-100 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
147
ui/src/components/Timeline.jsx
Normal file
147
ui/src/components/Timeline.jsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
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 }) {
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
const W = canvas.offsetWidth || 500
|
||||||
|
canvas.width = W * dpr
|
||||||
|
canvas.height = 90 * dpr
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
|
||||||
|
const H = 90
|
||||||
|
const PAD = { l: 8, r: 8 }
|
||||||
|
const trackH = 22
|
||||||
|
const srcY = 20
|
||||||
|
const projY = srcY + trackH + 10
|
||||||
|
const drawW = W - PAD.l - PAD.r
|
||||||
|
|
||||||
|
const srcStart = parseDate(dateFrom)
|
||||||
|
const srcEnd = parseDate(dateTo)
|
||||||
|
if (!srcStart || !srcEnd || isNaN(srcStart) || isNaN(srcEnd)) return
|
||||||
|
|
||||||
|
const offsetMoTotal = (offsetYr || 0) * 12 + (offsetMo || 0)
|
||||||
|
const projStart = addMonths(srcStart, offsetMoTotal)
|
||||||
|
const projEnd = addMonths(srcEnd, offsetMoTotal)
|
||||||
|
|
||||||
|
const winStart = addMonths(srcStart, -1)
|
||||||
|
const winEnd = addMonths(projEnd, 1)
|
||||||
|
const winMs = winEnd - winStart
|
||||||
|
|
||||||
|
function xOf(date) {
|
||||||
|
return PAD.l + ((date - winStart) / winMs) * drawW
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, W, H)
|
||||||
|
|
||||||
|
// axis
|
||||||
|
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 + year labels
|
||||||
|
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()
|
||||||
|
if (d.getMonth() === 0) {
|
||||||
|
ctx.fillStyle = '#6b7280'
|
||||||
|
ctx.font = 'bold 9px system-ui'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(d.getFullYear(), x, srcY - 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// source band
|
||||||
|
const sx1 = xOf(srcStart), sx2 = xOf(srcEnd)
|
||||||
|
ctx.fillStyle = '#dbeafe'
|
||||||
|
ctx.strokeStyle = '#93c5fd'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
roundRect(ctx, sx1, srcY, Math.max(sx2 - sx1, 4), trackH, 4, true, true)
|
||||||
|
ctx.fillStyle = '#1d4ed8'
|
||||||
|
ctx.font = '10px system-ui'
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.fillText('Source ' + dateFrom + ' → ' + dateTo, sx1 + 6, srcY + 14)
|
||||||
|
|
||||||
|
if (offsetMoTotal > 0) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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([])
|
||||||
|
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 label = '+' + (offsetYr ? offsetYr + 'yr ' : '') + (offsetMo ? offsetMo + 'mo' : '')
|
||||||
|
ctx.fillStyle = '#64748b'
|
||||||
|
ctx.font = '9px system-ui'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(label.trim(), (sx1 + px1) / 2, arrowY - 5)
|
||||||
|
}
|
||||||
|
}, [dateFrom, dateTo, offsetYr, offsetMo])
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} height={90} style={{ width: '100%', display: 'block' }} />
|
||||||
|
}
|
||||||
4
ui/src/index.css
Normal file
4
ui/src/index.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
body { margin: 0; }
|
||||||
|
#root { height: 100vh; display: flex; }
|
||||||
10
ui/src/main.jsx
Normal file
10
ui/src/main.jsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
477
ui/src/views/Baseline.jsx
Normal file
477
ui/src/views/Baseline.jsx
Normal file
@ -0,0 +1,477 @@
|
|||||||
|
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 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 [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)
|
||||||
|
|
||||||
|
// reference form
|
||||||
|
const [refFrom, setRefFrom] = useState('')
|
||||||
|
const [refTo, setRefTo] = useState('')
|
||||||
|
const [refNote, setRefNote] = useState('')
|
||||||
|
const [loadingRef, setLoadingRef] = useState(false)
|
||||||
|
|
||||||
|
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'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 offsetStr = [offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days'
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/versions/${versionId}/baseline`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ where_clause: clause, date_offset: offsetStr, pf_user: 'admin', note: description || segNote })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
flash(`Loaded ${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 loadReference() {
|
||||||
|
if (!refFrom || !refTo) { flash('Enter a date range', 'error'); return }
|
||||||
|
setLoadingRef(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/versions/${versionId}/reference`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ date_from: refFrom, date_to: refTo, pf_user: 'admin', note: refNote })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
flash(`Loaded ${data.row_count ?? ''} reference rows`)
|
||||||
|
setRefFrom(''); setRefTo(''); setRefNote('')
|
||||||
|
} catch (err) {
|
||||||
|
flash(err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
setLoadingRef(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeVersion() {
|
||||||
|
const res = await fetch(`/api/versions/${versionId}/close`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pf_user: 'admin' })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
||||||
|
setVersions(updated)
|
||||||
|
flash('Version closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reopenVersion() {
|
||||||
|
const res = await fetch(`/api/versions/${versionId}/reopen`, { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
||||||
|
setVersions(updated)
|
||||||
|
flash('Version reopened')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteVersion() {
|
||||||
|
if (!confirm(`Delete version "${selectedVersion?.name}"? This drops the forecast table and cannot be undone.`)) return
|
||||||
|
const res = await fetch(`/api/versions/${versionId}`, { method: 'DELETE' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
||||||
|
setVersions(updated)
|
||||||
|
setVersionId(updated.length > 0 ? String(updated[0].id) : '')
|
||||||
|
flash('Version deleted')
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(text, type = 'ok') {
|
||||||
|
setMsg({ text, type })
|
||||||
|
setTimeout(() => setMsg(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateRange = getDateRange(filters)
|
||||||
|
const selectedVersion = versions.find(v => String(v.id) === versionId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto bg-gray-50">
|
||||||
|
<div className="p-4 flex flex-col gap-4 max-w-4xl">
|
||||||
|
|
||||||
|
{/* Flash */}
|
||||||
|
{msg && (
|
||||||
|
<div className={`px-3 py-2 text-xs rounded font-medium ${msg.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Source + Version bar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">Source</span>
|
||||||
|
<select value={sourceId} onChange={e => setSourceId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
|
||||||
|
{sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">Version</span>
|
||||||
|
<select value={versionId} onChange={e => setVersionId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white" disabled={versions.length === 0}>
|
||||||
|
{versions.length === 0
|
||||||
|
? <option value="">— no versions —</option>
|
||||||
|
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
{versionId && (
|
||||||
|
<span className={`text-xs font-medium ${selectedVersion?.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}>
|
||||||
|
{selectedVersion?.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowNewVersion(v => !v)} className="text-xs text-blue-600 hover:text-blue-700 border border-blue-200 px-2 py-1 rounded">
|
||||||
|
+ New version
|
||||||
|
</button>
|
||||||
|
{versionId && (
|
||||||
|
<div className="flex items-center gap-2 ml-2">
|
||||||
|
{selectedVersion?.status === 'open'
|
||||||
|
? <button onClick={closeVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Close</button>
|
||||||
|
: <button onClick={reopenVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Reopen</button>
|
||||||
|
}
|
||||||
|
<button onClick={deleteVersion} className="text-xs text-red-400 hover:text-red-600 border border-red-200 px-2 py-1 rounded">Delete</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New version inline form */}
|
||||||
|
{showNewVersion && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-3">
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-500">Name</label>
|
||||||
|
<input value={newVerName} onChange={e => setNewVerName(e.target.value)} placeholder="e.g. FY2026 Plan" className="border border-gray-200 rounded px-2 py-1 text-sm w-48" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 flex-1">
|
||||||
|
<label className="text-xs text-gray-500">Description</label>
|
||||||
|
<input value={newVerDesc} onChange={e => setNewVerDesc(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1 text-sm" />
|
||||||
|
</div>
|
||||||
|
<button onClick={createVersion} disabled={creatingVer || !newVerName.trim()} className="bg-blue-600 text-white text-xs px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50 shrink-0">
|
||||||
|
{creatingVer ? 'Creating table…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowNewVersion(false)} className="text-gray-400 hover:text-gray-600 text-xs shrink-0">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 border-t border-gray-100 pt-2">
|
||||||
|
Creates a forecast table <span className="font-mono text-gray-500">pf.fc_{sources.find(s=>String(s.id)===sourceId)?.tname}_<id></span> in the database from the current col meta. If col meta changes after creation the table and SQL will be out of sync — delete and recreate the version to realign.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{versionId && <>
|
||||||
|
|
||||||
|
{/* Segments loaded */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded">
|
||||||
|
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
|
||||||
|
<span>Segments loaded</span>
|
||||||
|
<button onClick={clearBaseline} className="text-red-400 hover:text-red-600 text-xs normal-case font-normal">Clear all baseline</button>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr className="text-left text-gray-400 border-b border-gray-100">
|
||||||
|
<th className="px-3 py-1.5 font-medium">#</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={5} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
|
||||||
|
)}
|
||||||
|
{log.map((entry, i) => (
|
||||||
|
<tr key={entry.id} className="border-t border-gray-50">
|
||||||
|
<td className="px-3 py-2 text-gray-400">{log.length - i}</td>
|
||||||
|
<td className="px-3 py-2">{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={() => undoSegment(entry.id)} className="text-gray-400 hover:text-red-500 text-xs">Undo</button>
|
||||||
|
</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">
|
||||||
|
|
||||||
|
{/* 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) => (
|
||||||
|
<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 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-28 font-mono bg-white" />
|
||||||
|
<span className="text-gray-400 text-xs">and</span>
|
||||||
|
<input 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-28 font-mono bg-white" />
|
||||||
|
</>}
|
||||||
|
{(f.op === '=' || f.op === '!=') && (
|
||||||
|
<input 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 */}
|
||||||
|
<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-white border border-gray-200 rounded p-3">
|
||||||
|
<Timeline dateFrom={dateRange.from} dateTo={dateRange.to} offsetYr={offsetYr} offsetMo={offsetMo} />
|
||||||
|
</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 Segment'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reference */}
|
||||||
|
<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">
|
||||||
|
Reference <span className="text-gray-300 font-normal normal-case">optional — prior-period rows for comparison</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 flex items-end gap-3 flex-wrap">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-500">Date range</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input value={refFrom} onChange={e => setRefFrom(e.target.value)} placeholder="2024-01-01" className="border border-gray-200 rounded px-2 py-1 text-xs w-28 font-mono" />
|
||||||
|
<span className="text-xs text-gray-400">to</span>
|
||||||
|
<input value={refTo} onChange={e => setRefTo(e.target.value)} placeholder="2024-12-31" className="border border-gray-200 rounded px-2 py-1 text-xs w-28 font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 flex-1 max-w-xs">
|
||||||
|
<label className="text-xs text-gray-500">Note</label>
|
||||||
|
<input value={refNote} onChange={e => setRefNote(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1 text-sm" />
|
||||||
|
</div>
|
||||||
|
<button onClick={loadReference} disabled={loadingRef} className="border border-gray-200 text-gray-600 text-xs px-4 py-1.5 rounded hover:bg-gray-50 disabled:opacity-50 shrink-0">
|
||||||
|
{loadingRef ? 'Loading…' : 'Load Reference'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</>}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
ui/src/views/Forecast.jsx
Normal file
5
ui/src/views/Forecast.jsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export default function Forecast() {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-gray-400 text-sm">Forecast — coming soon</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
410
ui/src/views/Setup.jsx
Normal file
410
ui/src/views/Setup.jsx
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
const ROLES = ['ignore', 'dimension', 'value', 'units', 'date', 'filter']
|
||||||
|
|
||||||
|
const ROLE_STYLE = {
|
||||||
|
dimension: 'bg-blue-50 text-blue-700',
|
||||||
|
value: 'bg-green-50 text-green-700',
|
||||||
|
units: 'bg-green-50 text-green-700',
|
||||||
|
date: 'bg-purple-50 text-purple-700',
|
||||||
|
filter: 'bg-yellow-50 text-yellow-700',
|
||||||
|
ignore: 'bg-gray-100 text-gray-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Setup() {
|
||||||
|
const [tables, setTables] = useState([])
|
||||||
|
const [sources, setSources] = useState([])
|
||||||
|
const [selectedSource, setSelectedSource] = useState(null)
|
||||||
|
const [cols, setCols] = useState([])
|
||||||
|
const [editedCols, setEditedCols] = useState([])
|
||||||
|
const [colsDirty, setColsDirty] = useState(false)
|
||||||
|
const [preview, setPreview] = useState(null) // { schema, tname, columns, rows }
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
|
const [sqlStatus, setSqlStatus] = useState({}) // sourceId -> bool
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [msg, setMsg] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/tables').then(r => r.json()).then(setTables).catch(console.error)
|
||||||
|
loadSources()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function loadSources() {
|
||||||
|
fetch('/api/sources').then(r => r.json()).then(data => {
|
||||||
|
setSources(data)
|
||||||
|
// check sql status for each source
|
||||||
|
data.forEach(s => {
|
||||||
|
fetch(`/api/sources/${s.id}/sql`).then(r => r.json()).then(sqls => {
|
||||||
|
setSqlStatus(prev => ({ ...prev, [s.id]: sqls.length > 0 }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSource(source) {
|
||||||
|
setSelectedSource(source)
|
||||||
|
setColsDirty(false)
|
||||||
|
fetch(`/api/sources/${source.id}/cols`).then(r => r.json()).then(data => {
|
||||||
|
setCols(data)
|
||||||
|
setEditedCols(data.map(c => ({ ...c })))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPreview(schema, tname, e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
setPreviewLoading(true)
|
||||||
|
setPreview({ schema, tname, loading: true })
|
||||||
|
try {
|
||||||
|
const data = await fetch(`/api/tables/${schema}/${tname}/preview`).then(r => r.json())
|
||||||
|
setPreview({ schema, tname, ...data })
|
||||||
|
} catch {
|
||||||
|
setPreview(null)
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerSource(schema, tname) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sources', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ schema, tname, created_by: 'admin' })
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json()
|
||||||
|
flash(err.error, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const source = await res.json()
|
||||||
|
loadSources()
|
||||||
|
flash(`Registered ${schema}.${tname}`)
|
||||||
|
// auto-select new source and load its cols
|
||||||
|
fetch(`/api/sources/${source.id}/cols`).then(r => r.json()).then(data => {
|
||||||
|
setSelectedSource(source)
|
||||||
|
setCols(data)
|
||||||
|
setEditedCols(data.map(c => ({ ...c })))
|
||||||
|
setColsDirty(false)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
flash(err.message, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCol(idx, field, value) {
|
||||||
|
setEditedCols(prev => {
|
||||||
|
const next = prev.map((c, i) => i === idx ? { ...c, [field]: value } : c)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setColsDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCols() {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sources/${selectedSource.id}/cols`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(editedCols)
|
||||||
|
})
|
||||||
|
if (!res.ok) { const e = await res.json(); flash(e.error, 'error'); return }
|
||||||
|
const saved = await res.json()
|
||||||
|
setCols(saved)
|
||||||
|
setEditedCols(saved.map(c => ({ ...c })))
|
||||||
|
setColsDirty(false)
|
||||||
|
flash('Saved')
|
||||||
|
} catch (err) {
|
||||||
|
flash(err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSQL() {
|
||||||
|
setGenerating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sources/${selectedSource.id}/generate-sql`, { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
setSqlStatus(prev => ({ ...prev, [selectedSource.id]: true }))
|
||||||
|
flash(`SQL generated: ${data.operations.join(', ')}`)
|
||||||
|
} catch (err) {
|
||||||
|
flash(err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
setGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSource(id, e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!confirm('Deregister this source? Existing forecast tables are not affected.')) return
|
||||||
|
await fetch(`/api/sources/${id}`, { method: 'DELETE' })
|
||||||
|
if (selectedSource?.id === id) { setSelectedSource(null); setCols([]); setEditedCols([]) }
|
||||||
|
loadSources()
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(text, type = 'ok') {
|
||||||
|
setMsg({ text, type })
|
||||||
|
setTimeout(() => setMsg(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const registeredKeys = new Set(sources.map(s => `${s.schema}.${s.tname}`))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex overflow-hidden text-sm">
|
||||||
|
|
||||||
|
{/* All Tables */}
|
||||||
|
<div className="w-64 bg-white border-r border-gray-200 flex flex-col shrink-0">
|
||||||
|
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
All Tables
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-gray-50">
|
||||||
|
<tr className="text-left text-gray-400 border-b border-gray-100">
|
||||||
|
<th className="px-3 py-1.5 font-medium">schema</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">table</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium text-right">rows</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tables.map(t => {
|
||||||
|
const key = `${t.schema}.${t.tname}`
|
||||||
|
const registered = registeredKeys.has(key)
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={key}
|
||||||
|
onClick={e => openPreview(t.schema, t.tname, e)}
|
||||||
|
className="border-t border-gray-50 hover:bg-blue-50 cursor-pointer group"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-1.5 text-gray-400">{t.schema}</td>
|
||||||
|
<td className="px-3 py-1.5 font-medium">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className={registered ? 'text-green-600' : ''}>{t.tname}</span>
|
||||||
|
{registered && <span className="text-green-400 text-xs">✓</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right text-gray-500">
|
||||||
|
{Number(t.row_estimate).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||||
|
|
||||||
|
{/* Flash message */}
|
||||||
|
{msg && (
|
||||||
|
<div className={`px-4 py-2 text-xs font-medium ${msg.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col gap-0 overflow-hidden">
|
||||||
|
|
||||||
|
{/* Registered Sources */}
|
||||||
|
<div className="bg-white border-b border-gray-200 shrink-0">
|
||||||
|
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
Registered Sources
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr className="text-left text-gray-400 border-b border-gray-100">
|
||||||
|
<th className="px-3 py-1.5 font-medium">source</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">schema</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">sql</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">created</th>
|
||||||
|
<th className="px-3 py-1.5"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sources.length === 0 && (
|
||||||
|
<tr><td colSpan={5} className="px-3 py-3 text-gray-300 italic">No sources registered — click a table to preview, then register it</td></tr>
|
||||||
|
)}
|
||||||
|
{sources.map(s => (
|
||||||
|
<tr
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => selectSource(s)}
|
||||||
|
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${selectedSource?.id === s.id ? 'bg-blue-50' : ''}`}
|
||||||
|
>
|
||||||
|
<td className={`px-3 py-2 font-medium ${selectedSource?.id === s.id ? 'text-blue-700' : ''}`}>{s.tname}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{s.schema}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{sqlStatus[s.id]
|
||||||
|
? <span className="text-green-600 font-medium">✓ ready</span>
|
||||||
|
: <span className="text-gray-300">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{s.created_by || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<button onClick={e => deleteSource(s.id, e)} className="text-gray-300 hover:text-red-500 text-xs">✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col Meta Editor */}
|
||||||
|
{selectedSource ? (
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden bg-white">
|
||||||
|
<div className="px-3 py-2 border-b border-gray-100 flex items-center justify-between shrink-0">
|
||||||
|
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
Col Meta — <span className="text-gray-700 normal-case">{selectedSource.schema}.{selectedSource.tname}</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{colsDirty && (
|
||||||
|
<button onClick={saveCols} disabled={saving} className="text-xs border border-gray-200 px-3 py-1 rounded hover:bg-gray-50 disabled:opacity-50">
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={generateSQL}
|
||||||
|
disabled={generating || colsDirty}
|
||||||
|
className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
title={colsDirty ? 'Save col meta first' : ''}
|
||||||
|
>
|
||||||
|
{generating ? 'Generating…' : 'Generate SQL'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-gray-50">
|
||||||
|
<tr className="text-left text-gray-400 border-b border-gray-100">
|
||||||
|
<th className="px-3 py-1.5 font-medium">column</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">role</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium text-center">key</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium">label</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{editedCols.map((col, i) => (
|
||||||
|
<tr key={col.cname} className="border-t border-gray-50 hover:bg-gray-50">
|
||||||
|
<td className="px-3 py-1.5 font-mono text-gray-700">{col.cname}</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<select
|
||||||
|
value={col.role}
|
||||||
|
onChange={e => updateCol(i, 'role', e.target.value)}
|
||||||
|
className={`text-xs px-1.5 py-0.5 rounded border-0 font-medium cursor-pointer ${ROLE_STYLE[col.role] || ''}`}
|
||||||
|
>
|
||||||
|
{ROLES.map(r => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!col.is_key}
|
||||||
|
onChange={e => updateCol(i, 'is_key', e.target.checked)}
|
||||||
|
disabled={col.role !== 'dimension'}
|
||||||
|
className="cursor-pointer disabled:opacity-20"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={col.label || ''}
|
||||||
|
onChange={e => updateCol(i, 'label', e.target.value)}
|
||||||
|
placeholder={col.cname}
|
||||||
|
className="border border-transparent hover:border-gray-200 focus:border-gray-300 rounded px-1.5 py-0.5 w-full outline-none bg-transparent"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-gray-300 text-xs italic">
|
||||||
|
Select a source to edit col meta
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table preview modal */}
|
||||||
|
{preview && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/30" onClick={() => setPreview(null)} />
|
||||||
|
<div className="relative bg-white rounded-lg shadow-2xl flex flex-col z-10 text-xs" style={{ width: 720, maxWidth: '90vw', maxHeight: '80vh' }}>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-semibold text-gray-800">{preview.schema}.{preview.tname}</span>
|
||||||
|
{preview.columns && (
|
||||||
|
<span className="text-gray-400">{preview.columns.length} columns</span>
|
||||||
|
)}
|
||||||
|
{!registeredKeys.has(`${preview.schema}.${preview.tname}`) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { registerSource(preview.schema, preview.tname); setPreview(null) }}
|
||||||
|
className="bg-blue-600 text-white text-xs px-3 py-1 rounded hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
+ Register source
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setPreview(null)} className="text-gray-300 hover:text-gray-600 text-lg leading-none ml-4">✕</button>
|
||||||
|
</div>
|
||||||
|
{preview.loading ? (
|
||||||
|
<div className="p-8 text-center text-gray-400">Loading…</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
{/* Columns */}
|
||||||
|
<div className="px-4 pt-3 pb-1 text-gray-400 uppercase tracking-wide font-medium text-xs">Columns</div>
|
||||||
|
<table className="w-full mb-2">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50">
|
||||||
|
<th className="px-4 py-1 font-medium">name</th>
|
||||||
|
<th className="px-4 py-1 font-medium">type</th>
|
||||||
|
<th className="px-4 py-1 font-medium">nullable</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(preview.columns || []).map(c => (
|
||||||
|
<tr key={c.column_name} className="border-t border-gray-50">
|
||||||
|
<td className="px-4 py-1 font-mono text-gray-700">{c.column_name}</td>
|
||||||
|
<td className="px-4 py-1 text-gray-400">{c.data_type}</td>
|
||||||
|
<td className="px-4 py-1 text-gray-400">{c.is_nullable}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/* Sample rows */}
|
||||||
|
<div className="px-4 py-1 text-gray-400 uppercase tracking-wide font-medium text-xs border-t border-gray-100">Sample rows</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="text-xs" style={{ minWidth: '100%' }}>
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-400 bg-gray-50 border-b border-gray-100">
|
||||||
|
{(preview.columns || []).map(c => (
|
||||||
|
<th key={c.column_name} className="px-4 py-1 font-medium whitespace-nowrap">{c.column_name}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(preview.rows || []).map((row, i) => (
|
||||||
|
<tr key={i} className="border-t border-gray-50">
|
||||||
|
{(preview.columns || []).map(c => (
|
||||||
|
<td key={c.column_name} className={`px-4 py-1 font-mono whitespace-nowrap ${row[c.column_name] == null ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||||
|
{row[c.column_name] == null ? 'null' : String(row[c.column_name])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
ui/vite.config.js
Normal file
17
ui/vite.config.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3030'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../public/app',
|
||||||
|
emptyOutDir: true
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user