diff --git a/lib/sql_generator.js b/lib/sql_generator.js index 242b08c..6f45032 100644 --- a/lib/sql_generator.js +++ b/lib/sql_generator.js @@ -204,7 +204,7 @@ function applyTokens(sql, tokens) { } // build a SQL WHERE clause string from a slice object -// validates all keys against the allowed dimension column list +// only dimension columns are included; unrecognised keys are silently skipped function buildWhere(slice, dimCols) { if (!slice || Object.keys(slice).length === 0) return 'TRUE'; @@ -212,9 +212,7 @@ function buildWhere(slice, dimCols) { const parts = []; for (const [col, val] of Object.entries(slice)) { - if (!allowed.has(col)) { - throw new Error(`"${col}" is not a dimension column`); - } + if (!allowed.has(col)) continue; if (Array.isArray(val)) { const escaped = val.map(v => esc(v)); parts.push(`"${col}" IN ('${escaped.join("', '")}')`); @@ -223,7 +221,7 @@ function buildWhere(slice, dimCols) { } } - return parts.join('\nAND '); + return parts.length ? parts.join('\nAND ') : 'TRUE'; } // build AND iter NOT IN (...) from a version's exclude_iters array diff --git a/routes/operations.js b/routes/operations.js index 7bb11d0..be397fe 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -82,7 +82,6 @@ module.exports = function(pool) { try { const ctx = await getContext(parseInt(req.params.id), 'baseline'); if (!guardOpen(ctx.version, res)) return; - const sql = applyTokens(ctx.sql, { fc_table: ctx.table, version_id: ctx.version.id, @@ -138,7 +137,6 @@ module.exports = function(pool) { try { const ctx = await getContext(parseInt(req.params.id), 'reference'); if (!guardOpen(ctx.version, res)) return; - const sql = applyTokens(ctx.sql, { fc_table: ctx.table, version_id: ctx.version.id, diff --git a/ui/index.html b/ui/index.html index 830671e..79bf1b9 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,7 +4,8 @@ - ui + Pivot Forecast +
diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 0340180..30acef1 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -1,5 +1,557 @@ +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 ok = (col) => validCols.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) + + 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 filters = [ + ...Object.entries(sliceObj).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) + } + + 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 ( -
Forecast — coming soon
+
+ + {/* Source / version bar */} +
+
+ Source + +
+
+ Version + + {selectedVersion && ( + + {selectedVersion.status} + + )} +
+ {msg && ( + + {msg.text} + + )} +
+ + {/* Layout / depth bar */} +
+ Layouts + + {layouts.map(l => ( +
applyLayout(l)} + className={`flex items-center gap-1 text-xs 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} + +
+ ))} + + {activeLayoutId !== null && !showSaveAs && ( + + )} + + {showSaveAs ? ( +
+ setSaveAsName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }} + placeholder="Layout name…" className="text-xs border border-gray-300 rounded px-2 py-0.5 w-32 focus:outline-none focus:border-blue-400" /> + + +
+ ) : ( + + )} + + {activeLayoutId !== null && ( + + )} + + {/* Depth controls */} +
+ depth + {[0, 1, 2, 3].map(d => ( + + ))} + +
+
+ + {/* Main area */} +
+ {/* Perspective viewer */} +
+ {loading && ( +
+ Loading… +
+ )} + +
+ + {/* Drag handle */} +
+ + {/* Operation panel */} +
+
+
Slice
+ {!hasSlice ? ( +
Click a pivot row to select a slice
+ ) : ( +
+ {Object.entries(slice).map(([k, v]) => ( +
+ {k} = {v} +
+ ))} + +
+ )} +
+ + {hasSlice && ( + <> +
+ {['scale', 'recode', 'clone'].map(op => ( + + ))} +
+ +
+ {activeOp === 'scale' && <> + {/* Mode toggle */} +
+ {[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => ( + + ))} +
+ + {/* Value row */} + {currentTotals?.valueCol && ( +
+
+ {currentTotals.valueCol} + {currentTotals.value?.toLocaleString(undefined, {maximumFractionDigits:2})} +
+ setScaleValue(e.target.value)} + placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'} + className={inp} /> +
+ )} + + {/* Units row */} + {currentTotals?.unitsCol && ( +
+
+ {currentTotals.unitsCol} + {currentTotals.units?.toLocaleString(undefined, {maximumFractionDigits:2})} +
+ setScaleUnits(e.target.value)} + placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'} + className={inp} /> +
+ )} + + {scaleMode === 'delta' && ( + + )} + + setScaleNote(e.target.value)} placeholder="optional" className={inp} /> + submitOp('scale')}>Apply Scale + } + + {activeOp === 'recode' && <> +

New values for dimensions to replace. Leave blank to keep.

+ {dimCols.map(c => ( + + setRecodeSet(s => ({ ...s, [c.cname]: e.target.value }))} + placeholder={slice[c.cname] || '—'} className={`${inp} font-mono`} /> + + ))} + setRecodeNote(e.target.value)} placeholder="optional" className={inp} /> + submitOp('recode')}>Apply Recode + } + + {activeOp === 'clone' && <> +

Override dimensions on cloned rows. Leave blank to keep.

+ {dimCols.map(c => ( + + setCloneSet(s => ({ ...s, [c.cname]: e.target.value }))} + placeholder={slice[c.cname] || '—'} className={`${inp} font-mono`} /> + + ))} + setCloneScale(e.target.value)} className={inp} /> + setCloneNote(e.target.value)} placeholder="optional" className={inp} /> + submitOp('clone')}>Apply Clone + } +
+ + )} +
+
+
+ ) +} + +const inp = 'border border-gray-200 rounded px-2 py-1 text-xs flex-1 bg-white min-w-0' + +function Row({ label, children }) { + return ( +
+ {label} + {children} +
+ ) +} + +function Submit({ onClick, children }) { + return ( + ) }