diff --git a/lib/sql_generator.js b/lib/sql_generator.js index 2b9040b..bdb79db 100644 --- a/lib/sql_generator.js +++ b/lib/sql_generator.js @@ -26,11 +26,10 @@ function generateSQL(source, colMeta) { if (dims.length === 0) throw new Error('No dimension columns defined in col_meta'); const srcTable = `${source.schema}.${source.tname}`; - // exclude 'id' — forecast table has its own bigserial id primary key - const dataCols = [...dims, dateCol, valueCol, unitsCol].filter(c => c !== 'id'); + const dataCols = [...dims, dateCol, valueCol, unitsCol].filter(Boolean); const effectiveValue = dataCols.includes(valueCol) ? valueCol : null; const effectiveUnits = dataCols.includes(unitsCol) ? unitsCol : null; - const insertCols = [...dataCols.map(q), 'iter', 'logid', 'pf_user', 'created_at'].join(', '); + const insertCols = [...dataCols.map(q), 'pf_iter', 'pf_logid', 'pf_user', 'pf_created_at'].join(', '); const selectData = dataCols.map(q).join(', '); const dimsJoined = dims.map(q).join(', '); @@ -189,7 +188,7 @@ SELECT * FROM ins`.trim(); // This entry is a placeholder — the undo route uses it as a template reference. return ` -- step 1 (run first): -DELETE FROM {{fc_table}} WHERE logid = {{logid}}; +DELETE FROM {{fc_table}} WHERE pf_logid = {{logid}}; -- step 2 (run after step 1): DELETE FROM pf.log WHERE id = {{logid}};`.trim(); } @@ -231,7 +230,7 @@ function buildWhere(slice, dimCols) { function buildExcludeClause(excludeIters) { if (!excludeIters || excludeIters.length === 0) return ''; const list = excludeIters.map(i => `'${esc(i)}'`).join(', '); - return `AND iter NOT IN (${list})`; + return `AND pf_iter NOT IN (${list})`; } // build the dimension columns portion of a SELECT for recode/clone @@ -253,7 +252,7 @@ function buildFilterClause(filters, colMeta) { err.status = 400; throw err; } const allowed = new Set( - colMeta.filter(c => c.role === 'date' || c.role === 'filter').map(c => c.cname) + colMeta.filter(c => c.role !== 'ignore').map(c => c.cname) ); const parts = filters.map(({ col, op, values = [] }) => { if (!allowed.has(col)) { diff --git a/public/app.js b/public/app.js index 497c0c9..3852c7a 100644 --- a/public/app.js +++ b/public/app.js @@ -9,7 +9,6 @@ const state = { colMeta: [], // col_meta for selected source slice: {}, // current pivot selection loadDataOp: null, // 'baseline' | 'reference' - baselineFilterCols: [], previewSchema: null, previewTname: null, grids: { @@ -117,15 +116,20 @@ function renderTablesGrid(tables) { state.grids.tables = agGrid.createGrid(el, { columnDefs: [ - { field: 'schema', headerName: 'Schema', width: 90 }, + { field: 'schema', headerName: 'Schema', flex: 1 }, { field: 'tname', headerName: 'Table', flex: 1 }, - { field: 'row_estimate', headerName: 'Rows', width: 80, + { field: 'row_estimate', headerName: 'Rows', flex: 1, valueFormatter: p => p.value != null ? Number(p.value).toLocaleString() : '—' } ], rowData: tables, rowSelection: 'single', onRowClicked: onTableRowClicked, onRowDoubleClicked: e => showTablePreview(e.data.schema, e.data.tname), + allowContextMenuWithControlKey: true, + getContextMenuItems: e => [ + { name: 'Preview', action: () => showTablePreview(e.node.data.schema, e.node.data.tname) }, + { name: 'Register', action: () => registerTable(e.node.data.schema, e.node.data.tname) } + ], defaultColDef: { resizable: true, sortable: true }, headerHeight: 32, rowHeight: 28 }); @@ -135,6 +139,7 @@ function onTableRowClicked(e) { const { schema, tname } = e.data; state.previewSchema = schema; state.previewTname = tname; + document.getElementById('btn-preview').classList.remove('hidden'); document.getElementById('btn-register').classList.remove('hidden'); } @@ -213,13 +218,11 @@ function renderSourcesGrid(sources) { async function selectSource(source) { setSource(source); + document.getElementById('btn-delete-source').classList.remove('hidden'); try { state.colMeta = await api('GET', `/sources/${source.id}/cols`); renderColMetaGrid(state.colMeta); - document.getElementById('sources-list-grid').classList.add('hidden'); - document.getElementById('col-meta-grid').classList.remove('hidden'); document.getElementById('right-panel-title').textContent = `${source.schema}.${source.tname} — Columns`; - document.getElementById('btn-back-sources').classList.remove('hidden'); document.getElementById('btn-save-cols').classList.remove('hidden'); document.getElementById('btn-generate-sql').classList.remove('hidden'); } catch (err) { @@ -227,13 +230,24 @@ async function selectSource(source) { } } -function backToSources() { - document.getElementById('sources-list-grid').classList.remove('hidden'); - document.getElementById('col-meta-grid').classList.add('hidden'); - document.getElementById('right-panel-title').textContent = 'Registered Sources'; - document.getElementById('btn-back-sources').classList.add('hidden'); - document.getElementById('btn-save-cols').classList.add('hidden'); - document.getElementById('btn-generate-sql').classList.add('hidden'); +async function deleteSource() { + if (!state.source) return; + const { id, schema, tname } = state.source; + if (!confirm(`Delete source ${schema}.${tname}? This does not drop existing forecast tables.`)) return; + try { + await api('DELETE', `/sources/${id}`); + showStatus(`Source ${tname} deleted`, 'success'); + setSource(null); + document.getElementById('btn-delete-source').classList.add('hidden'); + document.getElementById('btn-save-cols').classList.add('hidden'); + document.getElementById('btn-generate-sql').classList.add('hidden'); + document.getElementById('right-panel-title').textContent = 'Select a source to map columns'; + state.grids.colMeta?.setGridOption('rowData', []); + const sources = await api('GET', '/sources'); + state.grids.sources?.setGridOption('rowData', sources); + } catch (err) { + showStatus(err.message, 'error'); + } } function renderColMetaGrid(colMeta) { @@ -247,9 +261,9 @@ function renderColMetaGrid(colMeta) { { field: 'role', headerName: 'Role', width: 110, editable: true, cellEditor: 'agSelectCellEditor', - cellEditorParams: { values: ['dimension', 'value', 'units', 'date', 'filter', 'ignore'] }, + cellEditorParams: { values: ['dimension', 'value', 'units', 'date', 'ignore'] }, cellStyle: p => { - const colors = { dimension: '#eaf4fb', value: '#eafaf1', units: '#fef9e7', date: '#fdf2f8', filter: '#fef5e4', ignore: '#f9f9f9' }; + const colors = { dimension: '#eaf4fb', value: '#eafaf1', units: '#fef9e7', date: '#fdf2f8', ignore: '#f9f9f9' }; return { background: colors[p.value] || '' }; } }, @@ -553,19 +567,36 @@ async function openBaselineWorkbench() { if (!state.colMeta.length && state.source) { state.colMeta = await api('GET', `/sources/${state.source.id}/cols`); } - state.baselineFilterCols = state.colMeta.filter(c => c.role === 'date' || c.role === 'filter'); + + // populate column chips + const chips = document.getElementById('seg-col-chips'); + chips.innerHTML = state.colMeta + .filter(c => c.role !== 'ignore') + .map(c => `${c.cname}`) + .join(''); + chips.querySelectorAll('.col-chip').forEach(chip => { + chip.addEventListener('click', () => insertAtCursor(document.getElementById('seg-where'), chip.dataset.col)); + }); // reset form - document.getElementById('seg-description').value = ''; + document.getElementById('seg-description').value = ''; document.getElementById('seg-offset-type').value = ''; document.getElementById('seg-offset-value').value = '0'; - document.getElementById('seg-filter-rows').innerHTML = ''; + document.getElementById('seg-where').value = ''; document.getElementById('seg-timeline').classList.add('hidden'); - addFilterRow(); // start with one empty filter row await loadBaselineSegments(); } +function insertAtCursor(textarea, text) { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const val = textarea.value; + textarea.value = val.slice(0, start) + text + val.slice(end); + textarea.selectionStart = textarea.selectionEnd = start + text.length; + textarea.focus(); +} + async function loadBaselineSegments() { if (!state.version) return; try { @@ -584,16 +615,11 @@ function renderBaselineSegments(segments) { return; } el.innerHTML = segments.map(s => { - const params = s.params || {}; - const filters = (params.filters || []) - .map(f => { - if (f.op === 'IS NULL' || f.op === 'IS NOT NULL') return `${f.col} ${f.op}`; - if (f.op === 'BETWEEN') return `${f.col} BETWEEN ${(f.values||[]).join(' AND ')}`; - if (f.op === 'IN' || f.op === 'NOT IN') return `${f.col} ${f.op} (${(f.values||[]).join(', ')})`; - return `${f.col} ${f.op} ${(f.values||[])[0] || ''}`; - }).join('\n'); - const offset = params.date_offset && params.date_offset !== '0 days' ? ` · offset: ${params.date_offset}` : ''; - const stamp = s.stamp ? new Date(s.stamp).toLocaleString() : ''; + const params = s.params || {}; + const whereClause = params.where_clause && params.where_clause !== 'TRUE' ? params.where_clause : ''; + const offset = params.date_offset && params.date_offset !== '0 days' ? `offset: ${params.date_offset}` : ''; + const paramsText = [whereClause, offset].filter(Boolean).join(' · '); + const stamp = s.stamp ? new Date(s.stamp).toLocaleString() : ''; return `