Refactor sources UI, rename pf_ system cols, replace filter builder with raw SQL
- Sources page: left column with stacked DB tables + registered sources panels, right column as full-height column mapping workbench - Add compact table search, column search, table preview button, delete source button - Rename fc_table system columns to pf_ prefix (pf_id, pf_iter, pf_logid, pf_created_at) to avoid collisions with source table columns like 'id' - Remove 'filter' col_meta role — any non-ignore column usable in baseline filters - Replace structured filter row builder with free-form SQL WHERE clause textarea and clickable column chips for insertion; fully flexible AND/OR logic - Baseline segment cards now display raw WHERE clause text + offset Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d49aac70e4
commit
1df37a5ff1
@ -26,11 +26,10 @@ function generateSQL(source, colMeta) {
|
|||||||
if (dims.length === 0) throw new Error('No dimension columns defined in col_meta');
|
if (dims.length === 0) throw new Error('No dimension columns defined in col_meta');
|
||||||
|
|
||||||
const srcTable = `${source.schema}.${source.tname}`;
|
const srcTable = `${source.schema}.${source.tname}`;
|
||||||
// exclude 'id' — forecast table has its own bigserial id primary key
|
const dataCols = [...dims, dateCol, valueCol, unitsCol].filter(Boolean);
|
||||||
const dataCols = [...dims, dateCol, valueCol, unitsCol].filter(c => c !== 'id');
|
|
||||||
const effectiveValue = dataCols.includes(valueCol) ? valueCol : null;
|
const effectiveValue = dataCols.includes(valueCol) ? valueCol : null;
|
||||||
const effectiveUnits = dataCols.includes(unitsCol) ? unitsCol : 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 selectData = dataCols.map(q).join(', ');
|
||||||
const dimsJoined = dims.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.
|
// This entry is a placeholder — the undo route uses it as a template reference.
|
||||||
return `
|
return `
|
||||||
-- step 1 (run first):
|
-- 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):
|
-- step 2 (run after step 1):
|
||||||
DELETE FROM pf.log WHERE id = {{logid}};`.trim();
|
DELETE FROM pf.log WHERE id = {{logid}};`.trim();
|
||||||
}
|
}
|
||||||
@ -231,7 +230,7 @@ function buildWhere(slice, dimCols) {
|
|||||||
function buildExcludeClause(excludeIters) {
|
function buildExcludeClause(excludeIters) {
|
||||||
if (!excludeIters || excludeIters.length === 0) return '';
|
if (!excludeIters || excludeIters.length === 0) return '';
|
||||||
const list = excludeIters.map(i => `'${esc(i)}'`).join(', ');
|
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
|
// build the dimension columns portion of a SELECT for recode/clone
|
||||||
@ -253,7 +252,7 @@ function buildFilterClause(filters, colMeta) {
|
|||||||
err.status = 400; throw err;
|
err.status = 400; throw err;
|
||||||
}
|
}
|
||||||
const allowed = new Set(
|
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 = [] }) => {
|
const parts = filters.map(({ col, op, values = [] }) => {
|
||||||
if (!allowed.has(col)) {
|
if (!allowed.has(col)) {
|
||||||
|
|||||||
273
public/app.js
273
public/app.js
@ -9,7 +9,6 @@ const state = {
|
|||||||
colMeta: [], // col_meta for selected source
|
colMeta: [], // col_meta for selected source
|
||||||
slice: {}, // current pivot selection
|
slice: {}, // current pivot selection
|
||||||
loadDataOp: null, // 'baseline' | 'reference'
|
loadDataOp: null, // 'baseline' | 'reference'
|
||||||
baselineFilterCols: [],
|
|
||||||
previewSchema: null,
|
previewSchema: null,
|
||||||
previewTname: null,
|
previewTname: null,
|
||||||
grids: {
|
grids: {
|
||||||
@ -117,15 +116,20 @@ function renderTablesGrid(tables) {
|
|||||||
|
|
||||||
state.grids.tables = agGrid.createGrid(el, {
|
state.grids.tables = agGrid.createGrid(el, {
|
||||||
columnDefs: [
|
columnDefs: [
|
||||||
{ field: 'schema', headerName: 'Schema', width: 90 },
|
{ field: 'schema', headerName: 'Schema', flex: 1 },
|
||||||
{ field: 'tname', headerName: 'Table', 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() : '—' }
|
valueFormatter: p => p.value != null ? Number(p.value).toLocaleString() : '—' }
|
||||||
],
|
],
|
||||||
rowData: tables,
|
rowData: tables,
|
||||||
rowSelection: 'single',
|
rowSelection: 'single',
|
||||||
onRowClicked: onTableRowClicked,
|
onRowClicked: onTableRowClicked,
|
||||||
onRowDoubleClicked: e => showTablePreview(e.data.schema, e.data.tname),
|
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 },
|
defaultColDef: { resizable: true, sortable: true },
|
||||||
headerHeight: 32, rowHeight: 28
|
headerHeight: 32, rowHeight: 28
|
||||||
});
|
});
|
||||||
@ -135,6 +139,7 @@ function onTableRowClicked(e) {
|
|||||||
const { schema, tname } = e.data;
|
const { schema, tname } = e.data;
|
||||||
state.previewSchema = schema;
|
state.previewSchema = schema;
|
||||||
state.previewTname = tname;
|
state.previewTname = tname;
|
||||||
|
document.getElementById('btn-preview').classList.remove('hidden');
|
||||||
document.getElementById('btn-register').classList.remove('hidden');
|
document.getElementById('btn-register').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,13 +218,11 @@ function renderSourcesGrid(sources) {
|
|||||||
|
|
||||||
async function selectSource(source) {
|
async function selectSource(source) {
|
||||||
setSource(source);
|
setSource(source);
|
||||||
|
document.getElementById('btn-delete-source').classList.remove('hidden');
|
||||||
try {
|
try {
|
||||||
state.colMeta = await api('GET', `/sources/${source.id}/cols`);
|
state.colMeta = await api('GET', `/sources/${source.id}/cols`);
|
||||||
renderColMetaGrid(state.colMeta);
|
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('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-save-cols').classList.remove('hidden');
|
||||||
document.getElementById('btn-generate-sql').classList.remove('hidden');
|
document.getElementById('btn-generate-sql').classList.remove('hidden');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -227,13 +230,24 @@ async function selectSource(source) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function backToSources() {
|
async function deleteSource() {
|
||||||
document.getElementById('sources-list-grid').classList.remove('hidden');
|
if (!state.source) return;
|
||||||
document.getElementById('col-meta-grid').classList.add('hidden');
|
const { id, schema, tname } = state.source;
|
||||||
document.getElementById('right-panel-title').textContent = 'Registered Sources';
|
if (!confirm(`Delete source ${schema}.${tname}? This does not drop existing forecast tables.`)) return;
|
||||||
document.getElementById('btn-back-sources').classList.add('hidden');
|
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-save-cols').classList.add('hidden');
|
||||||
document.getElementById('btn-generate-sql').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) {
|
function renderColMetaGrid(colMeta) {
|
||||||
@ -247,9 +261,9 @@ function renderColMetaGrid(colMeta) {
|
|||||||
{
|
{
|
||||||
field: 'role', headerName: 'Role', width: 110, editable: true,
|
field: 'role', headerName: 'Role', width: 110, editable: true,
|
||||||
cellEditor: 'agSelectCellEditor',
|
cellEditor: 'agSelectCellEditor',
|
||||||
cellEditorParams: { values: ['dimension', 'value', 'units', 'date', 'filter', 'ignore'] },
|
cellEditorParams: { values: ['dimension', 'value', 'units', 'date', 'ignore'] },
|
||||||
cellStyle: p => {
|
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] || '' };
|
return { background: colors[p.value] || '' };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -553,19 +567,36 @@ async function openBaselineWorkbench() {
|
|||||||
if (!state.colMeta.length && state.source) {
|
if (!state.colMeta.length && state.source) {
|
||||||
state.colMeta = await api('GET', `/sources/${state.source.id}/cols`);
|
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 => `<span class="col-chip" data-col="${c.cname}">${c.cname}</span>`)
|
||||||
|
.join('');
|
||||||
|
chips.querySelectorAll('.col-chip').forEach(chip => {
|
||||||
|
chip.addEventListener('click', () => insertAtCursor(document.getElementById('seg-where'), chip.dataset.col));
|
||||||
|
});
|
||||||
|
|
||||||
// reset form
|
// reset form
|
||||||
document.getElementById('seg-description').value = '';
|
document.getElementById('seg-description').value = '';
|
||||||
document.getElementById('seg-offset-type').value = '';
|
document.getElementById('seg-offset-type').value = '';
|
||||||
document.getElementById('seg-offset-value').value = '0';
|
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');
|
document.getElementById('seg-timeline').classList.add('hidden');
|
||||||
addFilterRow(); // start with one empty filter row
|
|
||||||
|
|
||||||
await loadBaselineSegments();
|
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() {
|
async function loadBaselineSegments() {
|
||||||
if (!state.version) return;
|
if (!state.version) return;
|
||||||
try {
|
try {
|
||||||
@ -585,14 +616,9 @@ function renderBaselineSegments(segments) {
|
|||||||
}
|
}
|
||||||
el.innerHTML = segments.map(s => {
|
el.innerHTML = segments.map(s => {
|
||||||
const params = s.params || {};
|
const params = s.params || {};
|
||||||
const filters = (params.filters || [])
|
const whereClause = params.where_clause && params.where_clause !== 'TRUE' ? params.where_clause : '';
|
||||||
.map(f => {
|
const offset = params.date_offset && params.date_offset !== '0 days' ? `offset: ${params.date_offset}` : '';
|
||||||
if (f.op === 'IS NULL' || f.op === 'IS NOT NULL') return `${f.col} ${f.op}`;
|
const paramsText = [whereClause, offset].filter(Boolean).join(' · ');
|
||||||
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 stamp = s.stamp ? new Date(s.stamp).toLocaleString() : '';
|
||||||
return `
|
return `
|
||||||
<div class="segment-card">
|
<div class="segment-card">
|
||||||
@ -601,187 +627,25 @@ function renderBaselineSegments(segments) {
|
|||||||
<span class="segment-card-meta">${s.pf_user} — ${stamp}</span>
|
<span class="segment-card-meta">${s.pf_user} — ${stamp}</span>
|
||||||
<button class="btn btn-sm btn-danger" data-logid="${s.id}">Undo</button>
|
<button class="btn btn-sm btn-danger" data-logid="${s.id}">Undo</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="segment-card-params">${filters}${offset}</div>
|
${paramsText ? `<div class="segment-card-params">${paramsText}</div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFilterRow() {
|
|
||||||
const container = document.getElementById('seg-filter-rows');
|
|
||||||
const idx = container.children.length;
|
|
||||||
const colOptions = state.baselineFilterCols
|
|
||||||
.map(c => `<option value="${c.cname}">${c.label || c.cname}</option>`)
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'filter-row';
|
|
||||||
div.dataset.index = idx;
|
|
||||||
div.innerHTML = `
|
|
||||||
<select class="filter-col-select">
|
|
||||||
<option value="">— column —</option>
|
|
||||||
${colOptions}
|
|
||||||
</select>
|
|
||||||
<select class="filter-op-select">
|
|
||||||
<option value="=">=</option>
|
|
||||||
<option value="!=">!=</option>
|
|
||||||
<option value="IN">IN</option>
|
|
||||||
<option value="NOT IN">NOT IN</option>
|
|
||||||
<option value="BETWEEN" selected>BETWEEN</option>
|
|
||||||
<option value="IS NULL">IS NULL</option>
|
|
||||||
<option value="IS NOT NULL">IS NOT NULL</option>
|
|
||||||
</select>
|
|
||||||
<div class="filter-value-container"></div>
|
|
||||||
<button class="btn btn-sm btn-danger filter-remove-btn">×</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
div.querySelector('.filter-op-select').addEventListener('change', () => {
|
|
||||||
updateFilterValueInputs(div);
|
|
||||||
updateTimelinePreview();
|
|
||||||
});
|
|
||||||
div.querySelector('.filter-col-select').addEventListener('change', () => {
|
|
||||||
updateTimelinePreview();
|
|
||||||
});
|
|
||||||
div.querySelector('.filter-remove-btn').addEventListener('click', () => {
|
|
||||||
div.remove();
|
|
||||||
updateTimelinePreview();
|
|
||||||
});
|
|
||||||
|
|
||||||
updateFilterValueInputs(div);
|
|
||||||
container.appendChild(div);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFilterValueInputs(row) {
|
|
||||||
const op = row.querySelector('.filter-op-select').value;
|
|
||||||
const container = row.querySelector('.filter-value-container');
|
|
||||||
|
|
||||||
if (op === 'IS NULL' || op === 'IS NOT NULL') {
|
|
||||||
container.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (op === 'BETWEEN') {
|
|
||||||
container.innerHTML = `
|
|
||||||
<input type="text" class="filter-val-from" placeholder="from" />
|
|
||||||
<span class="filter-between-sep">and</span>
|
|
||||||
<input type="text" class="filter-val-to" placeholder="to" />
|
|
||||||
`;
|
|
||||||
container.querySelectorAll('input').forEach(i => i.addEventListener('input', updateTimelinePreview));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (op === 'IN' || op === 'NOT IN') {
|
|
||||||
container.innerHTML = `<input type="text" class="filter-val-list" placeholder="a, b, c" />`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// = or !=
|
|
||||||
container.innerHTML = `<input type="text" class="filter-val-single" placeholder="value" />`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFilterRows() {
|
|
||||||
const rows = [];
|
|
||||||
document.querySelectorAll('#seg-filter-rows .filter-row').forEach(row => {
|
|
||||||
const col = row.querySelector('.filter-col-select').value;
|
|
||||||
const op = row.querySelector('.filter-op-select').value;
|
|
||||||
if (!col) return;
|
|
||||||
|
|
||||||
const filter = { col, op, values: [] };
|
|
||||||
|
|
||||||
if (op === 'BETWEEN') {
|
|
||||||
const from = row.querySelector('.filter-val-from')?.value.trim();
|
|
||||||
const to = row.querySelector('.filter-val-to')?.value.trim();
|
|
||||||
if (from) filter.values.push(from);
|
|
||||||
if (to) filter.values.push(to);
|
|
||||||
} else if (op === 'IN' || op === 'NOT IN') {
|
|
||||||
const raw = row.querySelector('.filter-val-list')?.value.trim();
|
|
||||||
if (raw) filter.values = raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
||||||
} else if (op !== 'IS NULL' && op !== 'IS NOT NULL') {
|
|
||||||
const val = row.querySelector('.filter-val-single')?.value.trim();
|
|
||||||
if (val) filter.values.push(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.push(filter);
|
|
||||||
});
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTimelinePreview() {
|
function updateTimelinePreview() {
|
||||||
const el = document.getElementById('seg-timeline');
|
const el = document.getElementById('seg-timeline');
|
||||||
|
|
||||||
// find first BETWEEN filter on a date-role column
|
|
||||||
let dateFrom = null, dateTo = null;
|
|
||||||
document.querySelectorAll('#seg-filter-rows .filter-row').forEach(row => {
|
|
||||||
if (dateFrom) return;
|
|
||||||
const col = row.querySelector('.filter-col-select').value;
|
|
||||||
const op = row.querySelector('.filter-op-select').value;
|
|
||||||
if (op !== 'BETWEEN') return;
|
|
||||||
const meta = state.baselineFilterCols.find(c => c.cname === col);
|
|
||||||
if (!meta || meta.role !== 'date') return;
|
|
||||||
const from = row.querySelector('.filter-val-from')?.value.trim();
|
|
||||||
const to = row.querySelector('.filter-val-to')?.value.trim();
|
|
||||||
if (from && to) { dateFrom = from; dateTo = to; }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dateFrom || !dateTo) { el.classList.add('hidden'); return; }
|
|
||||||
|
|
||||||
const from = new Date(dateFrom + 'T00:00:00');
|
|
||||||
const to = new Date(dateTo + 'T00:00:00');
|
|
||||||
if (isNaN(from) || isNaN(to) || from > to) { el.classList.add('hidden'); return; }
|
|
||||||
|
|
||||||
const months = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth()) + 1;
|
|
||||||
const fmt = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' });
|
|
||||||
const offsetType = document.getElementById('seg-offset-type').value;
|
const offsetType = document.getElementById('seg-offset-type').value;
|
||||||
const offsetValue = parseInt(document.getElementById('seg-offset-value').value) || 0;
|
const offsetValue = parseInt(document.getElementById('seg-offset-value').value) || 0;
|
||||||
|
|
||||||
function applyOffset(d) {
|
if (!offsetType || !offsetValue) { el.classList.add('hidden'); return; }
|
||||||
const r = new Date(d);
|
|
||||||
if (!offsetType || !offsetValue) return r;
|
|
||||||
switch (offsetType) {
|
|
||||||
case 'year': r.setFullYear(r.getFullYear() + offsetValue); break;
|
|
||||||
case 'month': r.setMonth(r.getMonth() + offsetValue); break;
|
|
||||||
case 'week': r.setDate(r.getDate() + offsetValue * 7); break;
|
|
||||||
case 'day': r.setDate(r.getDate() + offsetValue); break;
|
|
||||||
}
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projFrom = applyOffset(from);
|
el.innerHTML = `<div class="timeline-offset-indicator">+ ${offsetValue} ${offsetType}${offsetValue !== 1 ? 's' : ''} applied to date column</div>`;
|
||||||
const projTo = applyOffset(to);
|
|
||||||
|
|
||||||
let html = `
|
|
||||||
<div class="timeline-row">
|
|
||||||
<div class="timeline-row-label">Source</div>
|
|
||||||
<div class="timeline-bar-wrap">
|
|
||||||
<div class="timeline-bar"></div>
|
|
||||||
<div class="timeline-bar-labels">
|
|
||||||
<span>${fmt.format(from)}</span>
|
|
||||||
<span>${months} month${months !== 1 ? 's' : ''}</span>
|
|
||||||
<span>${fmt.format(to)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
if (offsetType && offsetValue) {
|
|
||||||
html += `
|
|
||||||
<div class="timeline-offset-indicator">+ ${offsetValue} ${offsetType}${offsetValue !== 1 ? 's' : ''} →</div>
|
|
||||||
<div class="timeline-row">
|
|
||||||
<div class="timeline-row-label">Projected</div>
|
|
||||||
<div class="timeline-bar-wrap">
|
|
||||||
<div class="timeline-bar timeline-bar-projected"></div>
|
|
||||||
<div class="timeline-bar-labels">
|
|
||||||
<span>${fmt.format(projFrom)}</span>
|
|
||||||
<span>${months} month${months !== 1 ? 's' : ''}</span>
|
|
||||||
<span>${fmt.format(projTo)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
el.innerHTML = html;
|
|
||||||
el.classList.remove('hidden');
|
el.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitBaselineSegment() {
|
async function submitBaselineSegment() {
|
||||||
if (!state.version) return;
|
if (!state.version) return;
|
||||||
const filters = getFilterRows();
|
|
||||||
if (filters.length === 0) { showStatus('Add at least one filter', 'error'); return; }
|
|
||||||
|
|
||||||
const offsetType = document.getElementById('seg-offset-type').value;
|
const offsetType = document.getElementById('seg-offset-type').value;
|
||||||
const offsetValue = parseInt(document.getElementById('seg-offset-value').value) || 0;
|
const offsetValue = parseInt(document.getElementById('seg-offset-value').value) || 0;
|
||||||
@ -790,7 +654,7 @@ async function submitBaselineSegment() {
|
|||||||
: '0 days';
|
: '0 days';
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
filters,
|
where_clause: document.getElementById('seg-where').value.trim() || undefined,
|
||||||
date_offset,
|
date_offset,
|
||||||
pf_user: getPfUser(),
|
pf_user: getPfUser(),
|
||||||
note: document.getElementById('seg-description').value.trim() || undefined
|
note: document.getElementById('seg-description').value.trim() || undefined
|
||||||
@ -801,9 +665,10 @@ async function submitBaselineSegment() {
|
|||||||
const result = await api('POST', `/versions/${state.version.id}/baseline`, body);
|
const result = await api('POST', `/versions/${state.version.id}/baseline`, body);
|
||||||
showStatus(`Segment loaded — ${result.rows_affected} rows`, 'success');
|
showStatus(`Segment loaded — ${result.rows_affected} rows`, 'success');
|
||||||
document.getElementById('seg-description').value = '';
|
document.getElementById('seg-description').value = '';
|
||||||
document.getElementById('seg-filter-rows').innerHTML = '';
|
document.getElementById('seg-where').value = '';
|
||||||
|
document.getElementById('seg-offset-type').value = '';
|
||||||
|
document.getElementById('seg-offset-value').value = '0';
|
||||||
document.getElementById('seg-timeline').classList.add('hidden');
|
document.getElementById('seg-timeline').classList.add('hidden');
|
||||||
addFilterRow();
|
|
||||||
await loadBaselineSegments();
|
await loadBaselineSegments();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showStatus(err.message, 'error');
|
showStatus(err.message, 'error');
|
||||||
@ -901,10 +766,10 @@ function buildPivotColDefs() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// always include iter for grouping context
|
// always include pf_iter for grouping context
|
||||||
defs.push({ field: 'iter', headerName: 'Iter', enableRowGroup: true, enablePivot: true, width: 90 });
|
defs.push({ field: 'pf_iter', headerName: 'Iter', enableRowGroup: true, enablePivot: true, width: 90 });
|
||||||
defs.push({ field: 'pf_user', headerName: 'User', width: 90, hide: true });
|
defs.push({ field: 'pf_user', headerName: 'User', width: 90, hide: true });
|
||||||
defs.push({ field: 'created_at', headerName: 'Created', width: 130, hide: true,
|
defs.push({ field: 'pf_created_at', headerName: 'Created', width: 130, hide: true,
|
||||||
valueFormatter: p => p.value ? new Date(p.value).toLocaleDateString() : '' });
|
valueFormatter: p => p.value ? new Date(p.value).toLocaleDateString() : '' });
|
||||||
|
|
||||||
return defs;
|
return defs;
|
||||||
@ -1221,12 +1086,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// sources view buttons
|
// sources view buttons
|
||||||
|
document.getElementById('btn-preview').addEventListener('click', () => {
|
||||||
|
if (state.previewSchema && state.previewTname) {
|
||||||
|
showTablePreview(state.previewSchema, state.previewTname);
|
||||||
|
}
|
||||||
|
});
|
||||||
document.getElementById('btn-register').addEventListener('click', () => {
|
document.getElementById('btn-register').addEventListener('click', () => {
|
||||||
if (state.previewSchema && state.previewTname) {
|
if (state.previewSchema && state.previewTname) {
|
||||||
registerTable(state.previewSchema, state.previewTname);
|
registerTable(state.previewSchema, state.previewTname);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.getElementById('btn-back-sources').addEventListener('click', backToSources);
|
document.getElementById('btn-delete-source').addEventListener('click', deleteSource);
|
||||||
document.getElementById('btn-save-cols').addEventListener('click', saveColMeta);
|
document.getElementById('btn-save-cols').addEventListener('click', saveColMeta);
|
||||||
document.getElementById('btn-generate-sql').addEventListener('click', generateSQL);
|
document.getElementById('btn-generate-sql').addEventListener('click', generateSQL);
|
||||||
|
|
||||||
@ -1283,7 +1153,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// baseline workbench
|
// baseline workbench
|
||||||
document.getElementById('btn-add-filter-row').addEventListener('click', addFilterRow);
|
|
||||||
document.getElementById('btn-load-segment').addEventListener('click', submitBaselineSegment);
|
document.getElementById('btn-load-segment').addEventListener('click', submitBaselineSegment);
|
||||||
document.getElementById('btn-clear-baseline').addEventListener('click', clearBaseline);
|
document.getElementById('btn-clear-baseline').addEventListener('click', clearBaseline);
|
||||||
document.getElementById('seg-offset-type').addEventListener('change', updateTimelinePreview);
|
document.getElementById('seg-offset-type').addEventListener('change', updateTimelinePreview);
|
||||||
@ -1299,6 +1168,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
await loadBaselineSegments();
|
await loadBaselineSegments();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// tables search
|
||||||
|
document.getElementById('tables-search').addEventListener('input', e => {
|
||||||
|
state.grids.tables?.setGridOption('quickFilterText', e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// columns search
|
||||||
|
document.getElementById('cols-search').addEventListener('input', e => {
|
||||||
|
state.grids.colMeta?.setGridOption('quickFilterText', e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
// init sources view
|
// init sources view
|
||||||
initSourcesView().catch(err => showStatus(err.message, 'error'));
|
initSourcesView().catch(err => showStatus(err.message, 'error'));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -44,27 +44,43 @@
|
|||||||
|
|
||||||
<!-- ===== SOURCES VIEW ===== -->
|
<!-- ===== SOURCES VIEW ===== -->
|
||||||
<div id="view-sources" class="view active">
|
<div id="view-sources" class="view active">
|
||||||
<div class="two-col-layout">
|
<div class="sources-layout">
|
||||||
<div class="panel">
|
<!-- Left column: two stacked panels -->
|
||||||
|
<div class="sources-left-col">
|
||||||
|
<div class="panel sources-tables-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span>Database Tables</span>
|
<span>Database Tables</span>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="btn-register" class="btn btn-primary hidden">Register Table</button>
|
<button id="btn-preview" class="btn btn-sm hidden">Preview</button>
|
||||||
|
<button id="btn-register" class="btn btn-primary btn-sm hidden">Register</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tables-grid" class="ag-theme-alpine grid-fill"></div>
|
<div class="tables-search-wrap">
|
||||||
|
<input type="text" id="tables-search" placeholder="Search…" />
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div id="tables-grid" class="ag-theme-alpine tables-grid"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel sources-list-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span id="right-panel-title">Registered Sources</span>
|
<span>Registered Sources</span>
|
||||||
|
<button id="btn-delete-source" class="btn btn-danger btn-sm hidden">Delete</button>
|
||||||
|
</div>
|
||||||
|
<div id="sources-list-grid" class="ag-theme-alpine grid-fill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Right column: column mapping workbench -->
|
||||||
|
<div class="panel sources-mapping-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span id="right-panel-title">Select a source to map columns</span>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="btn-back-sources" class="btn hidden">← Sources</button>
|
|
||||||
<button id="btn-save-cols" class="btn hidden">Save Columns</button>
|
<button id="btn-save-cols" class="btn hidden">Save Columns</button>
|
||||||
<button id="btn-generate-sql" class="btn btn-primary hidden">Generate SQL</button>
|
<button id="btn-generate-sql" class="btn btn-primary hidden">Generate SQL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="sources-list-grid" class="ag-theme-alpine grid-fill"></div>
|
<div class="tables-search-wrap">
|
||||||
<div id="col-meta-grid" class="ag-theme-alpine grid-fill hidden"></div>
|
<input type="text" id="cols-search" placeholder="Search columns…" />
|
||||||
|
</div>
|
||||||
|
<div id="col-meta-grid" class="ag-theme-alpine grid-fill"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -124,10 +140,10 @@
|
|||||||
<input type="number" id="seg-offset-value" min="0" value="0" />
|
<input type="number" id="seg-offset-value" min="0" value="0" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-section">
|
<div class="where-section">
|
||||||
<div class="filter-section-label">Filters <span class="required-star">*</span></div>
|
<div class="filter-section-label">WHERE clause</div>
|
||||||
<div id="seg-filter-rows"></div>
|
<div id="seg-col-chips" class="col-chips"></div>
|
||||||
<button id="btn-add-filter-row" class="btn btn-sm">+ Add Filter</button>
|
<textarea id="seg-where" class="where-textarea" rows="4" placeholder="e.g. order_date BETWEEN '2024-01-01' AND '2024-12-31' AND region = 'East'"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div id="seg-timeline" class="timeline-preview hidden"></div>
|
<div id="seg-timeline" class="timeline-preview hidden"></div>
|
||||||
<button id="btn-load-segment" class="btn btn-primary">Load Segment</button>
|
<button id="btn-load-segment" class="btn btn-primary">Load Segment</button>
|
||||||
|
|||||||
@ -83,9 +83,18 @@ body {
|
|||||||
.view.hidden { display: none !important; }
|
.view.hidden { display: none !important; }
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
SOURCES VIEW — two-column
|
SOURCES VIEW
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.two-col-layout { display: flex; gap: 10px; flex: 1; overflow: hidden; }
|
.two-col-layout { display: flex; gap: 10px; flex: 1; overflow: hidden; }
|
||||||
|
.sources-layout { display: flex; gap: 10px; flex: 1; overflow: hidden; }
|
||||||
|
.sources-left-col { width: 420px; flex-shrink: 0; display: flex; flex-direction: column; gap: 10px; overflow: hidden; }
|
||||||
|
.sources-tables-panel { flex-shrink: 0; }
|
||||||
|
.sources-list-panel { flex: 1; min-height: 0; }
|
||||||
|
.sources-mapping-panel { flex: 1; min-width: 0; }
|
||||||
|
.tables-search-wrap { padding: 6px 8px; border-bottom: 1px solid #e8ecf0; flex-shrink: 0; }
|
||||||
|
.tables-search-wrap input { width: 100%; padding: 4px 7px; border: 1px solid #d0d7de; border-radius: 4px; font-size: 12px; outline: none; }
|
||||||
|
.tables-search-wrap input:focus { border-color: #3498db; }
|
||||||
|
.tables-grid { height: 172px; flex-shrink: 0; }
|
||||||
.panel {
|
.panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: white;
|
background: white;
|
||||||
@ -491,13 +500,34 @@ body {
|
|||||||
.offset-row { display: flex; gap: 10px; }
|
.offset-row { display: flex; gap: 10px; }
|
||||||
.offset-row label { flex: 1; }
|
.offset-row label { flex: 1; }
|
||||||
|
|
||||||
.filter-section { display: flex; flex-direction: column; gap: 6px; }
|
/* WHERE clause builder */
|
||||||
.filter-section-label {
|
.where-section { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.filter-section-label { font-size: 11px; color: #555; font-weight: 600; }
|
||||||
|
.col-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.col-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: #eaf4fb;
|
||||||
|
border: 1px solid #aed6f1;
|
||||||
|
border-radius: 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #555;
|
color: #2471a3;
|
||||||
font-weight: 600;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
.required-star { color: #e74c3c; }
|
.col-chip:hover { background: #d6eaf8; border-color: #2471a3; }
|
||||||
|
.where-textarea {
|
||||||
|
width: 100%;
|
||||||
|
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
border-radius: 4px;
|
||||||
|
resize: vertical;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.where-textarea:focus { outline: none; border-color: #3498db; }
|
||||||
|
|
||||||
.filter-row {
|
.filter-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -43,7 +43,7 @@ module.exports = function(pool) {
|
|||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
// delete forecast rows first (logid has no FK constraint — managed by app)
|
// delete forecast rows first (logid has no FK constraint — managed by app)
|
||||||
const del = await client.query(
|
const del = await client.query(
|
||||||
`DELETE FROM ${table} WHERE logid = $1 RETURNING id`,
|
`DELETE FROM ${table} WHERE pf_logid = $1 RETURNING pf_id`,
|
||||||
[logid]
|
[logid]
|
||||||
);
|
);
|
||||||
await client.query(`DELETE FROM pf.log WHERE id = $1`, [logid]);
|
await client.query(`DELETE FROM pf.log WHERE id = $1`, [logid]);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { applyTokens, buildWhere, buildExcludeClause, buildSetClause, buildFilterClause, esc } = require('../lib/sql_generator');
|
const { applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc } = require('../lib/sql_generator');
|
||||||
const { fcTable } = require('../lib/utils');
|
const { fcTable } = require('../lib/utils');
|
||||||
|
|
||||||
module.exports = function(pool) {
|
module.exports = function(pool) {
|
||||||
@ -76,26 +76,19 @@ module.exports = function(pool) {
|
|||||||
|
|
||||||
// load baseline rows from source table — additive, no delete
|
// load baseline rows from source table — additive, no delete
|
||||||
router.post('/versions/:id/baseline', async (req, res) => {
|
router.post('/versions/:id/baseline', async (req, res) => {
|
||||||
const { filters, date_offset, pf_user, note, replay } = req.body;
|
const { where_clause, date_offset, pf_user, note } = req.body;
|
||||||
if (!filters || filters.length === 0) {
|
|
||||||
return res.status(400).json({ error: 'filters are required' });
|
|
||||||
}
|
|
||||||
if (replay) {
|
|
||||||
return res.status(501).json({ error: 'replay is not yet implemented' });
|
|
||||||
}
|
|
||||||
const dateOffset = date_offset || '0 days';
|
const dateOffset = date_offset || '0 days';
|
||||||
|
const filterClause = (where_clause || '').trim() || 'TRUE';
|
||||||
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 filterClause = buildFilterClause(filters, ctx.colMeta);
|
|
||||||
|
|
||||||
const sql = applyTokens(ctx.sql, {
|
const sql = applyTokens(ctx.sql, {
|
||||||
fc_table: ctx.table,
|
fc_table: ctx.table,
|
||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
params: esc(JSON.stringify({ filters, date_offset: dateOffset })),
|
params: esc(JSON.stringify({ where_clause: filterClause, date_offset: dateOffset })),
|
||||||
filter_clause: filterClause,
|
filter_clause: filterClause,
|
||||||
date_offset: esc(dateOffset)
|
date_offset: esc(dateOffset)
|
||||||
});
|
});
|
||||||
@ -118,7 +111,7 @@ module.exports = function(pool) {
|
|||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
const delRows = await client.query(
|
const delRows = await client.query(
|
||||||
`DELETE FROM ${ctx.table} WHERE iter = 'baseline' RETURNING id`
|
`DELETE FROM ${ctx.table} WHERE pf_iter = 'baseline' RETURNING pf_id`
|
||||||
);
|
);
|
||||||
const delLog = await client.query(
|
const delLog = await client.query(
|
||||||
`DELETE FROM pf.log WHERE version_id = $1 AND operation = 'baseline'`,
|
`DELETE FROM pf.log WHERE version_id = $1 AND operation = 'baseline'`,
|
||||||
|
|||||||
@ -79,8 +79,9 @@ module.exports = function(pool) {
|
|||||||
|
|
||||||
// build CREATE TABLE DDL using col_meta + mapped data types
|
// build CREATE TABLE DDL using col_meta + mapped data types
|
||||||
const table = fcTable(source.tname, version.id);
|
const table = fcTable(source.tname, version.id);
|
||||||
|
const systemCols = new Set(['pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'pf_created_at']);
|
||||||
const colDefs = colResult.rows
|
const colDefs = colResult.rows
|
||||||
.filter(c => c.cname !== 'id')
|
.filter(c => !systemCols.has(c.cname))
|
||||||
.map(c => {
|
.map(c => {
|
||||||
const pgType = mapType(c.data_type, c.numeric_precision, c.numeric_scale);
|
const pgType = mapType(c.data_type, c.numeric_precision, c.numeric_scale);
|
||||||
const quoted = `"${c.cname}"`;
|
const quoted = `"${c.cname}"`;
|
||||||
@ -89,12 +90,12 @@ module.exports = function(pool) {
|
|||||||
|
|
||||||
const ddl = `
|
const ddl = `
|
||||||
CREATE TABLE ${table} (
|
CREATE TABLE ${table} (
|
||||||
id bigserial PRIMARY KEY,
|
pf_id bigserial PRIMARY KEY,
|
||||||
${colDefs},
|
${colDefs},
|
||||||
iter text NOT NULL,
|
pf_iter text NOT NULL,
|
||||||
logid bigint NOT NULL,
|
pf_logid bigint NOT NULL,
|
||||||
pf_user text,
|
pf_user text,
|
||||||
created_at timestamptz NOT NULL DEFAULT now()
|
pf_created_at timestamptz NOT NULL DEFAULT now()
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
await client.query(ddl);
|
await client.query(ddl);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user