Fix Forecast pivot row click, buildWhere, and add resizable panel
- Fix perspective-click handler to use event filter triples instead of __ROW_PATH__ — Perspective encodes row position as [col,'==',val] in detail.config.filter - buildWhere now skips unrecognised slice keys (e.g. pf_iter) instead of throwing, so only dimension columns reach the WHERE clause - Add draggable resize handle on the operation panel (160–480px) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
af52845523
commit
3bdd7d0028
@ -204,7 +204,7 @@ function applyTokens(sql, tokens) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// build a SQL WHERE clause string from a slice object
|
// build a SQL WHERE clause string from a slice object
|
||||||
// validates all keys against the allowed dimension column list
|
// only dimension columns are included; unrecognised keys are silently skipped
|
||||||
function buildWhere(slice, dimCols) {
|
function buildWhere(slice, dimCols) {
|
||||||
if (!slice || Object.keys(slice).length === 0) return 'TRUE';
|
if (!slice || Object.keys(slice).length === 0) return 'TRUE';
|
||||||
|
|
||||||
@ -212,9 +212,7 @@ function buildWhere(slice, dimCols) {
|
|||||||
const parts = [];
|
const parts = [];
|
||||||
|
|
||||||
for (const [col, val] of Object.entries(slice)) {
|
for (const [col, val] of Object.entries(slice)) {
|
||||||
if (!allowed.has(col)) {
|
if (!allowed.has(col)) continue;
|
||||||
throw new Error(`"${col}" is not a dimension column`);
|
|
||||||
}
|
|
||||||
if (Array.isArray(val)) {
|
if (Array.isArray(val)) {
|
||||||
const escaped = val.map(v => esc(v));
|
const escaped = val.map(v => esc(v));
|
||||||
parts.push(`"${col}" IN ('${escaped.join("', '")}')`);
|
parts.push(`"${col}" IN ('${escaped.join("', '")}')`);
|
||||||
@ -223,7 +221,7 @@ function buildWhere(slice, dimCols) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join('\nAND ');
|
return parts.length ? parts.join('\nAND ') : 'TRUE';
|
||||||
}
|
}
|
||||||
|
|
||||||
// build AND iter NOT IN (...) from a version's exclude_iters array
|
// build AND iter NOT IN (...) from a version's exclude_iters array
|
||||||
|
|||||||
@ -82,7 +82,6 @@ module.exports = function(pool) {
|
|||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'baseline');
|
const ctx = await getContext(parseInt(req.params.id), 'baseline');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
|
|
||||||
const sql = applyTokens(ctx.sql, {
|
const sql = applyTokens(ctx.sql, {
|
||||||
fc_table: ctx.table,
|
fc_table: ctx.table,
|
||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
@ -138,7 +137,6 @@ module.exports = function(pool) {
|
|||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'reference');
|
const ctx = await getContext(parseInt(req.params.id), 'reference');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
|
|
||||||
const sql = applyTokens(ctx.sql, {
|
const sql = applyTokens(ctx.sql, {
|
||||||
fc_table: ctx.table,
|
fc_table: ctx.table,
|
||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
|
|||||||
@ -4,7 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ui</title>
|
<title>Pivot Forecast</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@perspective-dev/viewer@4.4.0/dist/css/themes.css" crossorigin="anonymous">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -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() {
|
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 (
|
return (
|
||||||
<div className="p-4 text-gray-400 text-sm">Forecast — coming soon</div>
|
<div className="h-full flex flex-col">
|
||||||
|
|
||||||
|
{/* Source / version bar */}
|
||||||
|
<div className="px-3 py-2 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">Source</span>
|
||||||
|
<select value={sourceId} onChange={e => setSourceId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
|
||||||
|
{sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">Version</span>
|
||||||
|
<select value={versionId} onChange={e => setVersionId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white" disabled={!versions.length}>
|
||||||
|
{versions.length === 0
|
||||||
|
? <option value="">— no versions —</option>
|
||||||
|
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||||
|
</select>
|
||||||
|
{selectedVersion && (
|
||||||
|
<span className={`text-xs font-medium ${selectedVersion.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}>
|
||||||
|
{selectedVersion.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{msg && (
|
||||||
|
<span className={`text-xs font-medium px-2 py-0.5 rounded ml-auto ${msg.type === 'error' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>
|
||||||
|
{msg.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layout / depth bar */}
|
||||||
|
<div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-2 shrink-0 flex-wrap">
|
||||||
|
<span className="text-xs text-gray-400 uppercase tracking-wide">Layouts</span>
|
||||||
|
|
||||||
|
{layouts.map(l => (
|
||||||
|
<div key={l.id} onClick={() => 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}
|
||||||
|
<button onClick={e => deleteLayout(l.id, e)} className="text-gray-300 hover:text-red-400 text-sm leading-none ml-0.5">×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{activeLayoutId !== null && !showSaveAs && (
|
||||||
|
<button onClick={handleSaveOver} className="text-xs border border-blue-200 text-blue-500 hover:text-blue-700 rounded px-2 py-0.5">Save</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSaveAs ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input autoFocus value={saveAsName} onChange={e => setSaveAsName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
|
||||||
|
placeholder="Layout name…" className="text-xs border border-gray-300 rounded px-2 py-0.5 w-32 focus:outline-none focus:border-blue-400" />
|
||||||
|
<button onClick={handleSaveAs} className="text-xs text-blue-600 hover:text-blue-800 px-1">Save</button>
|
||||||
|
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-xs text-gray-400 px-1">Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setShowSaveAs(true)} className="text-xs border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5">
|
||||||
|
+ Save as…
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeLayoutId !== null && (
|
||||||
|
<button onClick={resetLayout} className="text-xs text-gray-300 hover:text-gray-500">reset</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Depth controls */}
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
|
<span className="text-xs text-gray-400">depth</span>
|
||||||
|
{[0, 1, 2, 3].map(d => (
|
||||||
|
<button key={d} onClick={() => applyDepth(d)}
|
||||||
|
className={`text-xs border rounded px-1.5 py-0.5 transition-colors
|
||||||
|
${expandDepthRef.current === d ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button onClick={() => initViewer(versionId, sourceId)} disabled={loading || !versionId}
|
||||||
|
className="text-xs border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40 ml-2">
|
||||||
|
{loading ? 'Loading…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main area */}
|
||||||
|
<div className="flex-1 flex min-h-0">
|
||||||
|
{/* Perspective viewer */}
|
||||||
|
<div className="relative flex-1 min-w-0">
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-50 z-10">
|
||||||
|
<span className="text-sm text-gray-400">Loading…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<perspective-viewer ref={viewerRef} style={{ position: 'absolute', inset: 0 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drag handle */}
|
||||||
|
<div onMouseDown={onDragStart} className="w-1 shrink-0 cursor-col-resize hover:bg-blue-400 bg-transparent transition-colors" />
|
||||||
|
|
||||||
|
{/* Operation panel */}
|
||||||
|
<div className="shrink-0 border-l border-gray-200 bg-white flex flex-col overflow-y-auto text-xs" style={{ width: panelWidth }}>
|
||||||
|
<div className="p-3 border-b border-gray-100">
|
||||||
|
<div className="font-medium text-gray-400 uppercase tracking-wide mb-2" style={{fontSize:'10px'}}>Slice</div>
|
||||||
|
{!hasSlice ? (
|
||||||
|
<div className="text-gray-300 italic">Click a pivot row to select a slice</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{Object.entries(slice).map(([k, v]) => (
|
||||||
|
<div key={k} className="text-gray-700">
|
||||||
|
<span className="text-gray-400">{k}</span> = <span className="font-medium">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={() => setSlice({})} className="text-gray-300 hover:text-red-500 mt-1 text-left">Clear</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasSlice && (
|
||||||
|
<>
|
||||||
|
<div className="flex border-b border-gray-100">
|
||||||
|
{['scale', 'recode', 'clone'].map(op => (
|
||||||
|
<button key={op} onClick={() => setActiveOp(op)}
|
||||||
|
className={`flex-1 py-2 capitalize ${activeOp === op ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-400 hover:text-gray-600'}`}>
|
||||||
|
{op}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 flex flex-col gap-2.5">
|
||||||
|
{activeOp === 'scale' && <>
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div className="flex rounded border border-gray-200 overflow-hidden">
|
||||||
|
{[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => (
|
||||||
|
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits('') }}
|
||||||
|
className={`flex-1 py-1 text-xs ${scaleMode === m ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value row */}
|
||||||
|
{currentTotals?.valueCol && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between text-gray-400">
|
||||||
|
<span>{currentTotals.valueCol}</span>
|
||||||
|
<span className="font-mono">{currentTotals.value?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
|
||||||
|
</div>
|
||||||
|
<input type="number" step="any" value={scaleValue}
|
||||||
|
onChange={e => setScaleValue(e.target.value)}
|
||||||
|
placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
|
||||||
|
className={inp} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Units row */}
|
||||||
|
{currentTotals?.unitsCol && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between text-gray-400">
|
||||||
|
<span>{currentTotals.unitsCol}</span>
|
||||||
|
<span className="font-mono">{currentTotals.units?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
|
||||||
|
</div>
|
||||||
|
<input type="number" step="any" value={scaleUnits}
|
||||||
|
onChange={e => setScaleUnits(e.target.value)}
|
||||||
|
placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
|
||||||
|
className={inp} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scaleMode === 'delta' && (
|
||||||
|
<label className="flex items-center gap-2 text-gray-500">
|
||||||
|
<input type="checkbox" checked={scalePct} onChange={e => setScalePct(e.target.checked)} /> % of slice
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Row label="Note"><input value={scaleNote} onChange={e => setScaleNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
||||||
|
<Submit onClick={() => submitOp('scale')}>Apply Scale</Submit>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{activeOp === 'recode' && <>
|
||||||
|
<p className="text-gray-400">New values for dimensions to replace. Leave blank to keep.</p>
|
||||||
|
{dimCols.map(c => (
|
||||||
|
<Row key={c.cname} label={c.cname}>
|
||||||
|
<input value={recodeSet[c.cname] || ''} onChange={e => setRecodeSet(s => ({ ...s, [c.cname]: e.target.value }))}
|
||||||
|
placeholder={slice[c.cname] || '—'} className={`${inp} font-mono`} />
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
<Row label="Note"><input value={recodeNote} onChange={e => setRecodeNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
||||||
|
<Submit onClick={() => submitOp('recode')}>Apply Recode</Submit>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{activeOp === 'clone' && <>
|
||||||
|
<p className="text-gray-400">Override dimensions on cloned rows. Leave blank to keep.</p>
|
||||||
|
{dimCols.map(c => (
|
||||||
|
<Row key={c.cname} label={c.cname}>
|
||||||
|
<input value={cloneSet[c.cname] || ''} onChange={e => setCloneSet(s => ({ ...s, [c.cname]: e.target.value }))}
|
||||||
|
placeholder={slice[c.cname] || '—'} className={`${inp} font-mono`} />
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
<Row label="Scale"><input type="number" step="any" value={cloneScale} onChange={e => setCloneScale(e.target.value)} className={inp} /></Row>
|
||||||
|
<Row label="Note"><input value={cloneNote} onChange={e => setCloneNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
||||||
|
<Submit onClick={() => submitOp('clone')}>Apply Clone</Submit>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inp = 'border border-gray-200 rounded px-2 py-1 text-xs flex-1 bg-white min-w-0'
|
||||||
|
|
||||||
|
function Row({ label, children }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-gray-400 w-14 shrink-0 truncate" title={label}>{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Submit({ onClick, children }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className="mt-1 bg-blue-600 text-white text-xs px-3 py-1.5 rounded hover:bg-blue-700 w-full">
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user