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:
Paul Trowbridge 2026-04-03 00:47:57 -04:00
parent d49aac70e4
commit 1df37a5ff1
7 changed files with 173 additions and 255 deletions

View File

@ -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)) {

View File

@ -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');
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 => `<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
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 {
@ -585,14 +616,9 @@ function renderBaselineSegments(segments) {
}
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 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 `
<div class="segment-card">
@ -601,187 +627,25 @@ function renderBaselineSegments(segments) {
<span class="segment-card-meta">${s.pf_user} ${stamp}</span>
<button class="btn btn-sm btn-danger" data-logid="${s.id}">Undo</button>
</div>
<div class="segment-card-params">${filters}${offset}</div>
${paramsText ? `<div class="segment-card-params">${paramsText}</div>` : ''}
</div>`;
}).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() {
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 offsetValue = parseInt(document.getElementById('seg-offset-value').value) || 0;
function applyOffset(d) {
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;
}
if (!offsetType || !offsetValue) { el.classList.add('hidden'); return; }
const projFrom = applyOffset(from);
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.innerHTML = `<div class="timeline-offset-indicator">+ ${offsetValue} ${offsetType}${offsetValue !== 1 ? 's' : ''} applied to date column</div>`;
el.classList.remove('hidden');
}
async function submitBaselineSegment() {
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 offsetValue = parseInt(document.getElementById('seg-offset-value').value) || 0;
@ -790,7 +654,7 @@ async function submitBaselineSegment() {
: '0 days';
const body = {
filters,
where_clause: document.getElementById('seg-where').value.trim() || undefined,
date_offset,
pf_user: getPfUser(),
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);
showStatus(`Segment loaded — ${result.rows_affected} rows`, 'success');
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');
addFilterRow();
await loadBaselineSegments();
} catch (err) {
showStatus(err.message, 'error');
@ -901,10 +766,10 @@ function buildPivotColDefs() {
}
});
// always include iter for grouping context
defs.push({ field: 'iter', headerName: 'Iter', enableRowGroup: true, enablePivot: true, width: 90 });
// always include pf_iter for grouping context
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: '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() : '' });
return defs;
@ -1221,12 +1086,17 @@ document.addEventListener('DOMContentLoaded', () => {
});
// 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', () => {
if (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-generate-sql').addEventListener('click', generateSQL);
@ -1283,7 +1153,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
// baseline workbench
document.getElementById('btn-add-filter-row').addEventListener('click', addFilterRow);
document.getElementById('btn-load-segment').addEventListener('click', submitBaselineSegment);
document.getElementById('btn-clear-baseline').addEventListener('click', clearBaseline);
document.getElementById('seg-offset-type').addEventListener('change', updateTimelinePreview);
@ -1299,6 +1168,16 @@ document.addEventListener('DOMContentLoaded', () => {
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
initSourcesView().catch(err => showStatus(err.message, 'error'));
});

View File

@ -44,27 +44,43 @@
<!-- ===== SOURCES VIEW ===== -->
<div id="view-sources" class="view active">
<div class="two-col-layout">
<div class="panel">
<div class="sources-layout">
<!-- Left column: two stacked panels -->
<div class="sources-left-col">
<div class="panel sources-tables-panel">
<div class="panel-header">
<span>Database Tables</span>
<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 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 class="panel">
<div id="tables-grid" class="ag-theme-alpine tables-grid"></div>
</div>
<div class="panel sources-list-panel">
<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">
<button id="btn-back-sources" class="btn hidden">← Sources</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>
</div>
</div>
<div id="sources-list-grid" class="ag-theme-alpine grid-fill"></div>
<div id="col-meta-grid" class="ag-theme-alpine grid-fill hidden"></div>
<div class="tables-search-wrap">
<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>
@ -124,10 +140,10 @@
<input type="number" id="seg-offset-value" min="0" value="0" />
</label>
</div>
<div class="filter-section">
<div class="filter-section-label">Filters <span class="required-star">*</span></div>
<div id="seg-filter-rows"></div>
<button id="btn-add-filter-row" class="btn btn-sm">+ Add Filter</button>
<div class="where-section">
<div class="filter-section-label">WHERE clause</div>
<div id="seg-col-chips" class="col-chips"></div>
<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 id="seg-timeline" class="timeline-preview hidden"></div>
<button id="btn-load-segment" class="btn btn-primary">Load Segment</button>

View File

@ -83,9 +83,18 @@ body {
.view.hidden { display: none !important; }
/* ============================================================
SOURCES VIEW two-column
SOURCES VIEW
============================================================ */
.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 {
flex: 1;
background: white;
@ -491,13 +500,34 @@ body {
.offset-row { display: flex; gap: 10px; }
.offset-row label { flex: 1; }
.filter-section { display: flex; flex-direction: column; gap: 6px; }
.filter-section-label {
/* WHERE clause builder */
.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;
color: #555;
font-weight: 600;
color: #2471a3;
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 {
display: flex;

View File

@ -43,7 +43,7 @@ module.exports = function(pool) {
await client.query('BEGIN');
// delete forecast rows first (logid has no FK constraint — managed by app)
const del = await client.query(
`DELETE FROM ${table} WHERE logid = $1 RETURNING id`,
`DELETE FROM ${table} WHERE pf_logid = $1 RETURNING pf_id`,
[logid]
);
await client.query(`DELETE FROM pf.log WHERE id = $1`, [logid]);

View File

@ -1,5 +1,5 @@
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');
module.exports = function(pool) {
@ -76,26 +76,19 @@ module.exports = function(pool) {
// load baseline rows from source table — additive, no delete
router.post('/versions/:id/baseline', async (req, res) => {
const { filters, date_offset, pf_user, note, replay } = 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 { where_clause, date_offset, pf_user, note } = req.body;
const dateOffset = date_offset || '0 days';
const filterClause = (where_clause || '').trim() || 'TRUE';
try {
const ctx = await getContext(parseInt(req.params.id), 'baseline');
if (!guardOpen(ctx.version, res)) return;
const filterClause = buildFilterClause(filters, ctx.colMeta);
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(JSON.stringify({ filters, date_offset: dateOffset })),
params: esc(JSON.stringify({ where_clause: filterClause, date_offset: dateOffset })),
filter_clause: filterClause,
date_offset: esc(dateOffset)
});
@ -118,7 +111,7 @@ module.exports = function(pool) {
try {
await client.query('BEGIN');
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(
`DELETE FROM pf.log WHERE version_id = $1 AND operation = 'baseline'`,

View File

@ -79,8 +79,9 @@ module.exports = function(pool) {
// build CREATE TABLE DDL using col_meta + mapped data types
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
.filter(c => c.cname !== 'id')
.filter(c => !systemCols.has(c.cname))
.map(c => {
const pgType = mapType(c.data_type, c.numeric_precision, c.numeric_scale);
const quoted = `"${c.cname}"`;
@ -89,12 +90,12 @@ module.exports = function(pool) {
const ddl = `
CREATE TABLE ${table} (
id bigserial PRIMARY KEY,
pf_id bigserial PRIMARY KEY,
${colDefs},
iter text NOT NULL,
logid bigint NOT NULL,
pf_iter text NOT NULL,
pf_logid bigint NOT NULL,
pf_user text,
created_at timestamptz NOT NULL DEFAULT now()
pf_created_at timestamptz NOT NULL DEFAULT now()
)
`;
await client.query(ddl);