Unify Baseline segment view/edit with filter groups and SQL override
The segment form is now one component rendered in either 'view' or 'edit' mode — the expanded segment row in the list and the add/edit form below share the same layout, view mode just disables the inputs. Edit and View are visually identical so toggling between them feels like enabling fields, not switching tools. Filters become groups (conditions AND-ed inside, groups OR-ed between) with + AND condition and + Add OR group affordances. The compiled WHERE renders live below the groups so you can see what's being built. A "Switch to manual SQL" toggle flips to a textarea seeded with the compiled clause; backend baseline POST/PUT and reference POST accept raw_where alongside filters and store whichever arrived in pf.log.params for round-tripping. The Add form is hidden until you click "+ Add segment" at the bottom of the segments table; Edit also opens it. Cancel/Close returns the table to its compact state. /versions/:id/log now also returns value_total, units_total, and the column names so the segments table can show row count and value sum inline (header uses the source's actual value column name). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
74ff97400b
commit
408cb06150
@ -123,16 +123,16 @@ 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 { where_clause, date_offset, pf_user, note, filters } = req.body;
|
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
|
||||||
const dateOffset = date_offset || '0 days';
|
const dateOffset = date_offset || '0 days';
|
||||||
const filterClause = (where_clause || '').trim() || 'TRUE';
|
const filterClause = (raw_where || 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 paramsJson = JSON.stringify({
|
const paramsJson = JSON.stringify({
|
||||||
where_clause: filterClause,
|
where_clause: filterClause,
|
||||||
date_offset: dateOffset,
|
date_offset: dateOffset,
|
||||||
...(filters ? { filters } : {})
|
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
|
||||||
});
|
});
|
||||||
const sql = applyTokens(ctx.sql, {
|
const sql = applyTokens(ctx.sql, {
|
||||||
fc_table: ctx.table,
|
fc_table: ctx.table,
|
||||||
@ -158,9 +158,9 @@ module.exports = function(pool) {
|
|||||||
router.put('/versions/:id/baseline/:logid', async (req, res) => {
|
router.put('/versions/:id/baseline/:logid', async (req, res) => {
|
||||||
const versionId = parseInt(req.params.id);
|
const versionId = parseInt(req.params.id);
|
||||||
const logid = parseInt(req.params.logid);
|
const logid = parseInt(req.params.logid);
|
||||||
const { where_clause, date_offset, pf_user, note, filters } = req.body;
|
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
|
||||||
const dateOffset = date_offset || '0 days';
|
const dateOffset = date_offset || '0 days';
|
||||||
const filterClause = (where_clause || '').trim() || 'TRUE';
|
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||||
|
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@ -193,7 +193,7 @@ module.exports = function(pool) {
|
|||||||
const paramsJson = JSON.stringify({
|
const paramsJson = JSON.stringify({
|
||||||
where_clause: filterClause,
|
where_clause: filterClause,
|
||||||
...(oldLog.operation === 'baseline' ? { date_offset: dateOffset } : {}),
|
...(oldLog.operation === 'baseline' ? { date_offset: dateOffset } : {}),
|
||||||
...(filters ? { filters } : {})
|
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
|
||||||
});
|
});
|
||||||
const tokens = oldLog.operation === 'baseline'
|
const tokens = oldLog.operation === 'baseline'
|
||||||
? {
|
? {
|
||||||
@ -274,14 +274,14 @@ module.exports = function(pool) {
|
|||||||
|
|
||||||
// load reference rows from source table (additive — does not clear prior reference rows)
|
// load reference rows from source table (additive — does not clear prior reference rows)
|
||||||
router.post('/versions/:id/reference', async (req, res) => {
|
router.post('/versions/:id/reference', async (req, res) => {
|
||||||
const { where_clause, pf_user, note, filters } = req.body;
|
const { where_clause, pf_user, note, filters, raw_where } = req.body;
|
||||||
const filterClause = (where_clause || '').trim() || 'TRUE';
|
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||||
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 paramsJson = JSON.stringify({
|
const paramsJson = JSON.stringify({
|
||||||
where_clause: filterClause,
|
where_clause: filterClause,
|
||||||
...(filters ? { filters } : {})
|
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
|
||||||
});
|
});
|
||||||
const sql = applyTokens(ctx.sql, {
|
const sql = applyTokens(ctx.sql, {
|
||||||
fc_table: ctx.table,
|
fc_table: ctx.table,
|
||||||
@ -436,19 +436,36 @@ module.exports = function(pool) {
|
|||||||
const versionId = parseInt(req.params.id);
|
const versionId = parseInt(req.params.id);
|
||||||
try {
|
try {
|
||||||
const verResult = await pool.query(
|
const verResult = await pool.query(
|
||||||
`SELECT v.*, s.tname FROM pf.version v JOIN pf.source s ON s.id = v.source_id WHERE v.id = $1`,
|
`SELECT v.*, s.tname, s.id AS source_id FROM pf.version v JOIN pf.source s ON s.id = v.source_id WHERE v.id = $1`,
|
||||||
[versionId]
|
[versionId]
|
||||||
);
|
);
|
||||||
if (!verResult.rows.length) return res.status(404).json({ error: 'Version not found' });
|
if (!verResult.rows.length) return res.status(404).json({ error: 'Version not found' });
|
||||||
const table = fcTable(verResult.rows[0].tname, versionId);
|
const { tname, source_id } = verResult.rows[0];
|
||||||
|
const table = fcTable(tname, versionId);
|
||||||
|
|
||||||
|
const colMeta = await pool.query(
|
||||||
|
`SELECT cname, role FROM pf.col_meta WHERE source_id = $1 AND role IN ('value', 'units')`,
|
||||||
|
[source_id]
|
||||||
|
);
|
||||||
|
const valueCol = colMeta.rows.find(c => c.role === 'value')?.cname;
|
||||||
|
const unitsCol = colMeta.rows.find(c => c.role === 'units')?.cname;
|
||||||
|
|
||||||
|
const aggCols = [
|
||||||
|
`count(f.pf_id)::int AS row_count`,
|
||||||
|
valueCol ? `sum(f."${valueCol}")::float8 AS value_total` : `NULL::float8 AS value_total`,
|
||||||
|
unitsCol ? `sum(f."${unitsCol}")::float8 AS units_total` : `NULL::float8 AS units_total`
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT l.*, count(f.pf_id)::int AS row_count
|
SELECT l.*, ${aggCols},
|
||||||
|
$2::text AS value_col,
|
||||||
|
$3::text AS units_col
|
||||||
FROM pf.log l
|
FROM pf.log l
|
||||||
LEFT JOIN ${table} f ON f.pf_logid = l.id
|
LEFT JOIN ${table} f ON f.pf_logid = l.id
|
||||||
WHERE l.version_id = $1
|
WHERE l.version_id = $1
|
||||||
GROUP BY l.id
|
GROUP BY l.id
|
||||||
ORDER BY l.id DESC
|
ORDER BY l.id DESC
|
||||||
`, [versionId]);
|
`, [versionId, valueCol || null, unitsCol || null]);
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@ -3,44 +3,48 @@ import Timeline from '../components/Timeline.jsx'
|
|||||||
|
|
||||||
const OPERATORS = ['BETWEEN', '=', '!=', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL']
|
const OPERATORS = ['BETWEEN', '=', '!=', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL']
|
||||||
|
|
||||||
function buildFilterClause(filters) {
|
function buildCondition(c) {
|
||||||
if (!filters.length) return null
|
const col = `"${c.col}"`
|
||||||
const parts = filters.map(f => {
|
if (c.op === 'IS NULL') return `${col} IS NULL`
|
||||||
const col = `"${f.col}"`
|
if (c.op === 'IS NOT NULL') return `${col} IS NOT NULL`
|
||||||
const op = f.op
|
if (c.op === 'BETWEEN') {
|
||||||
if (op === 'IS NULL') return `${col} IS NULL`
|
const [a, b] = c.values
|
||||||
if (op === 'IS NOT NULL') return `${col} IS NOT NULL`
|
if (!a || !b) return null
|
||||||
if (op === 'BETWEEN') {
|
|
||||||
const [a, b] = f.values
|
|
||||||
return `${col} BETWEEN '${a}' AND '${b}'`
|
return `${col} BETWEEN '${a}' AND '${b}'`
|
||||||
}
|
}
|
||||||
if (op === 'IN' || op === 'NOT IN') {
|
if (c.op === 'IN' || c.op === 'NOT IN') {
|
||||||
const vals = f.values.join("','")
|
const v = (c.values[0] || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||||
return `${col} ${op} ('${vals}')`
|
if (!v.length) return null
|
||||||
|
return `${col} ${c.op} ('${v.join("','")}')`
|
||||||
}
|
}
|
||||||
return `${col} ${op} '${f.values[0]}'`
|
if (!c.values[0]) return null
|
||||||
})
|
return `${col} ${c.op} '${c.values[0]}'`
|
||||||
return parts.join(' AND ')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDateRange(filters) {
|
function buildFilterClause(groups) {
|
||||||
for (const f of filters) {
|
if (!groups?.length) return null
|
||||||
if (f.op === 'BETWEEN' && f.values[0] && f.values[1]) {
|
const parts = groups
|
||||||
return { from: f.values[0], to: f.values[1] }
|
.map(g => g.map(buildCondition).filter(Boolean).join(' AND '))
|
||||||
}
|
.filter(s => s.length > 0)
|
||||||
if (f.op === '=' && f.values[0]) {
|
.map(s => `(${s})`)
|
||||||
return { from: f.values[0], to: f.values[0] }
|
if (!parts.length) return null
|
||||||
}
|
return parts.join(' OR ')
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDateRangeFromClause(clause) {
|
function parseDateRangeFromClause(clause) {
|
||||||
if (!clause) return null
|
if (!clause) return null
|
||||||
const m = clause.match(/BETWEEN '(\d{4}-\d{2}-\d{2})' AND '(\d{4}-\d{2}-\d{2})'/)
|
const m = clause.match(/BETWEEN '(\d{4}-\d{2}-\d{2})' AND '(\d{4}-\d{2}-\d{2})'/)
|
||||||
if (m) return { from: m[1], to: m[2] }
|
if (m) return { from: m[1], to: m[2] }
|
||||||
const m2 = clause.match(/>= ?'(\d{4}-\d{2}-\d{2})'.+<= ?'(\d{4}-\d{2}-\d{2})'/)
|
return null
|
||||||
if (m2) return { from: m2[1], to: m2[2] }
|
}
|
||||||
|
|
||||||
|
function getDateRange(groups) {
|
||||||
|
for (const g of groups || []) {
|
||||||
|
for (const c of g) {
|
||||||
|
if (c.op === 'BETWEEN' && c.values[0] && c.values[1]) return { from: c.values[0], to: c.values[1] }
|
||||||
|
if (c.op === '=' && c.values[0]) return { from: c.values[0], to: c.values[0] }
|
||||||
|
}
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,9 +55,18 @@ function parseOffset(offsetStr) {
|
|||||||
return { yr, mo }
|
return { yr, mo }
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyFilter(cols) {
|
function emptyCondition(cols) {
|
||||||
return { col: cols[0]?.cname || '', op: 'BETWEEN', values: ['', ''] }
|
return { col: cols[0]?.cname || '', op: 'BETWEEN', values: ['', ''] }
|
||||||
}
|
}
|
||||||
|
function emptyGroup(cols) { return [emptyCondition(cols)] }
|
||||||
|
|
||||||
|
function normalizeFilters(stored) {
|
||||||
|
// accept legacy flat shape and wrap as one group; fall back to a blank group
|
||||||
|
if (!Array.isArray(stored) || stored.length === 0) return null
|
||||||
|
if (Array.isArray(stored[0])) return stored
|
||||||
|
if (stored[0]?.col != null) return [stored]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export default function Baseline({ sources = [], sourceId, versions = [], versionId, setVersionId, refreshVersions }) {
|
export default function Baseline({ sources = [], sourceId, versions = [], versionId, setVersionId, refreshVersions }) {
|
||||||
const [filterCols, setFilterCols] = useState([])
|
const [filterCols, setFilterCols] = useState([])
|
||||||
@ -65,15 +78,18 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
const [newVerDesc, setNewVerDesc] = useState('')
|
const [newVerDesc, setNewVerDesc] = useState('')
|
||||||
const [creatingVer, setCreatingVer] = useState(false)
|
const [creatingVer, setCreatingVer] = useState(false)
|
||||||
|
|
||||||
// add segment form
|
// segment form
|
||||||
const [segType, setSegType] = useState('baseline')
|
const [segType, setSegType] = useState('baseline')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [filters, setFilters] = useState([])
|
const [filters, setFilters] = useState([]) // [[cond,...], [cond,...]]
|
||||||
|
const [useRaw, setUseRaw] = useState(false)
|
||||||
|
const [rawSql, setRawSql] = useState('')
|
||||||
const [offsetYr, setOffsetYr] = useState(0)
|
const [offsetYr, setOffsetYr] = useState(0)
|
||||||
const [offsetMo, setOffsetMo] = useState(0)
|
const [offsetMo, setOffsetMo] = useState(0)
|
||||||
const [segNote, setSegNote] = useState('')
|
const [segNote, setSegNote] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [editingLogId, setEditingLogId] = useState(null)
|
const [editingLogId, setEditingLogId] = useState(null)
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
const [hasForecastOps, setHasForecastOps] = useState(false)
|
const [hasForecastOps, setHasForecastOps] = useState(false)
|
||||||
|
|
||||||
const [expandedId, setExpandedId] = useState(null)
|
const [expandedId, setExpandedId] = useState(null)
|
||||||
@ -84,7 +100,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
fetch(`/api/sources/${sourceId}/cols`).then(r => r.json()).then(cols => {
|
fetch(`/api/sources/${sourceId}/cols`).then(r => r.json()).then(cols => {
|
||||||
const fc = cols.filter(c => c.role === 'date' || c.role === 'filter')
|
const fc = cols.filter(c => c.role === 'date' || c.role === 'filter')
|
||||||
setFilterCols(fc)
|
setFilterCols(fc)
|
||||||
setFilters(fc.length > 0 ? [emptyFilter(fc)] : [])
|
setFilters(fc.length > 0 ? [emptyGroup(fc)] : [])
|
||||||
})
|
})
|
||||||
}, [sourceId])
|
}, [sourceId])
|
||||||
|
|
||||||
@ -124,46 +140,21 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFilter() {
|
|
||||||
setFilters(f => [...f, emptyFilter(filterCols)])
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFilter(i) {
|
|
||||||
setFilters(f => f.filter((_, idx) => idx !== i))
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFilter(i, field, value) {
|
|
||||||
setFilters(f => f.map((row, idx) => {
|
|
||||||
if (idx !== i) return row
|
|
||||||
if (field === 'op') {
|
|
||||||
const needsTwo = value === 'BETWEEN'
|
|
||||||
const needsOne = ['=', '!='].includes(value)
|
|
||||||
const needsMany = ['IN', 'NOT IN'].includes(value)
|
|
||||||
const needsNone = ['IS NULL', 'IS NOT NULL'].includes(value)
|
|
||||||
return { ...row, op: value, values: needsNone ? [] : needsTwo ? ['', ''] : needsMany ? [''] : [''] }
|
|
||||||
}
|
|
||||||
return { ...row, [field]: value }
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFilterValue(i, vi, value) {
|
|
||||||
setFilters(f => f.map((row, idx) => {
|
|
||||||
if (idx !== i) return row
|
|
||||||
const vals = [...row.values]
|
|
||||||
vals[vi] = value
|
|
||||||
return { ...row, values: vals }
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSegment() {
|
async function loadSegment() {
|
||||||
const clause = buildFilterClause(filters)
|
const clause = useRaw ? rawSql.trim() : buildFilterClause(filters)
|
||||||
if (!clause) { flash('Add at least one filter', 'error'); return }
|
if (!clause) { flash(useRaw ? 'Enter a WHERE clause' : 'Add at least one filter', 'error'); return }
|
||||||
const isRef = segType === 'reference'
|
const isRef = segType === 'reference'
|
||||||
const offsetStr = isRef ? '0 days' : ([offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days')
|
const offsetStr = isRef
|
||||||
|
? '0 days'
|
||||||
|
: ([offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days')
|
||||||
const endpoint = isRef ? 'reference' : 'baseline'
|
const endpoint = isRef ? 'reference' : 'baseline'
|
||||||
const body = isRef
|
const body = {
|
||||||
? { where_clause: clause, pf_user: 'admin', note: description || segNote, filters }
|
where_clause: clause,
|
||||||
: { where_clause: clause, date_offset: offsetStr, pf_user: 'admin', note: description || segNote, filters }
|
pf_user: 'admin',
|
||||||
|
note: description || segNote,
|
||||||
|
...(isRef ? {} : { date_offset: offsetStr }),
|
||||||
|
...(useRaw ? { raw_where: clause } : { filters }),
|
||||||
|
}
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const url = editingLogId
|
const url = editingLogId
|
||||||
@ -205,29 +196,38 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
} else {
|
} else {
|
||||||
setOffsetYr(0); setOffsetMo(0)
|
setOffsetYr(0); setOffsetMo(0)
|
||||||
}
|
}
|
||||||
if (Array.isArray(params.filters) && params.filters.length > 0) {
|
const groups = normalizeFilters(params.filters)
|
||||||
setFilters(params.filters)
|
if (groups) {
|
||||||
} else if (filterCols.length > 0) {
|
setUseRaw(false)
|
||||||
// Pre-existing segment without structured filters — fall back to a blank row.
|
setRawSql('')
|
||||||
// The original WHERE clause is shown read-only on the segment detail row.
|
setFilters(groups)
|
||||||
setFilters([emptyFilter(filterCols)])
|
} else if (params.where_clause) {
|
||||||
flash('Filters were not stored on this segment — rebuild them, or undo and re-add', 'error')
|
// legacy: only the compiled WHERE was stored. Open in raw mode.
|
||||||
|
setUseRaw(true)
|
||||||
|
setRawSql(params.where_clause)
|
||||||
|
setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : [])
|
||||||
|
} else {
|
||||||
|
setUseRaw(false)
|
||||||
|
setRawSql('')
|
||||||
|
setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : [])
|
||||||
}
|
}
|
||||||
setEditingLogId(entry.id)
|
setEditingLogId(entry.id)
|
||||||
setExpandedId(null)
|
setExpandedId(null)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const form = document.getElementById('add-segment')
|
document.getElementById('add-segment')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
form?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
setEditingLogId(null)
|
setEditingLogId(null)
|
||||||
|
setShowAddForm(false)
|
||||||
setDescription('')
|
setDescription('')
|
||||||
setSegNote('')
|
setSegNote('')
|
||||||
setOffsetYr(0)
|
setOffsetYr(0)
|
||||||
setOffsetMo(0)
|
setOffsetMo(0)
|
||||||
setFilters(filterCols.length > 0 ? [emptyFilter(filterCols)] : [])
|
setUseRaw(false)
|
||||||
|
setRawSql('')
|
||||||
|
setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : [])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function undoSegment(logid) {
|
async function undoSegment(logid) {
|
||||||
@ -279,14 +279,12 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
setTimeout(() => setMsg(null), 3000)
|
setTimeout(() => setMsg(null), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateRange = getDateRange(filters)
|
|
||||||
const selectedVersion = versions.find(v => String(v.id) === versionId)
|
const selectedVersion = versions.find(v => String(v.id) === versionId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto bg-gray-50">
|
<div className="h-full overflow-y-auto bg-gray-50">
|
||||||
<div className="p-4 flex flex-col gap-4 max-w-4xl">
|
<div className="p-4 flex flex-col gap-4 max-w-4xl">
|
||||||
|
|
||||||
{/* Flash */}
|
|
||||||
{msg && (
|
{msg && (
|
||||||
<div className={`px-3 py-2 text-xs rounded font-medium ${msg.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
|
<div className={`px-3 py-2 text-xs rounded font-medium ${msg.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
|
||||||
{msg.text}
|
{msg.text}
|
||||||
@ -309,7 +307,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New version inline form */}
|
|
||||||
{showNewVersion && (
|
{showNewVersion && (
|
||||||
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-3">
|
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-3">
|
||||||
<div className="flex items-end gap-3">
|
<div className="flex items-end gap-3">
|
||||||
@ -346,6 +343,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
<th className="px-3 py-1.5 font-medium w-6"></th>
|
<th className="px-3 py-1.5 font-medium w-6"></th>
|
||||||
<th className="px-3 py-1.5 font-medium">#</th>
|
<th className="px-3 py-1.5 font-medium">#</th>
|
||||||
<th className="px-3 py-1.5 font-medium">note</th>
|
<th className="px-3 py-1.5 font-medium">note</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium text-right">rows</th>
|
||||||
|
<th className="px-3 py-1.5 font-medium text-right">{log[0]?.value_col || 'value'}</th>
|
||||||
<th className="px-3 py-1.5 font-medium">by</th>
|
<th className="px-3 py-1.5 font-medium">by</th>
|
||||||
<th className="px-3 py-1.5 font-medium">when</th>
|
<th className="px-3 py-1.5 font-medium">when</th>
|
||||||
<th className="px-3 py-1.5 font-medium"></th>
|
<th className="px-3 py-1.5 font-medium"></th>
|
||||||
@ -353,13 +352,23 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{log.length === 0 && (
|
{log.length === 0 && (
|
||||||
<tr><td colSpan={6} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
|
<tr><td colSpan={8} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
|
||||||
|
)}
|
||||||
|
{!showAddForm && !editingLogId && (
|
||||||
|
<tr className="border-t border-gray-100">
|
||||||
|
<td colSpan={8} className="p-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddForm(true)}
|
||||||
|
className="w-full px-3 py-2 text-xs text-blue-600 hover:bg-blue-50 text-left font-medium"
|
||||||
|
>
|
||||||
|
+ Add segment
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
{log.map((entry, i) => {
|
{log.map((entry, i) => {
|
||||||
const isOpen = expandedId === entry.id
|
const isOpen = expandedId === entry.id
|
||||||
const params = entry.params || {}
|
const view = segmentValuesFor(entry, filterCols)
|
||||||
const dr = parseDateRangeFromClause(params.where_clause)
|
|
||||||
const off = parseOffset(params.date_offset)
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr
|
<tr
|
||||||
@ -367,9 +376,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
onClick={() => setExpandedId(isOpen ? null : entry.id)}
|
onClick={() => setExpandedId(isOpen ? null : entry.id)}
|
||||||
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${isOpen ? 'bg-blue-50' : ''}`}
|
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${isOpen ? 'bg-blue-50' : ''}`}
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2 text-gray-400 w-6">
|
<td className="px-3 py-2 text-gray-400 w-6"><span className="text-gray-300 text-xs">{isOpen ? '▾' : '▸'}</span></td>
|
||||||
<span className="text-gray-300 text-xs">{isOpen ? '▾' : '▸'}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-gray-400">{log.length - i}</td>
|
<td className="px-3 py-2 text-gray-400">{log.length - i}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className={`inline-block mr-2 px-1.5 py-0.5 rounded text-xs font-medium ${entry.operation === 'reference' ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600'}`}>
|
<span className={`inline-block mr-2 px-1.5 py-0.5 rounded text-xs font-medium ${entry.operation === 'reference' ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600'}`}>
|
||||||
@ -377,6 +384,12 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
</span>
|
</span>
|
||||||
{entry.note || <span className="text-gray-300">—</span>}
|
{entry.note || <span className="text-gray-300">—</span>}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-700 font-mono">
|
||||||
|
{entry.row_count != null ? entry.row_count.toLocaleString() : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-700 font-mono">
|
||||||
|
{entry.value_total != null ? entry.value_total.toLocaleString(undefined, { maximumFractionDigits: 0 }) : '—'}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 text-gray-500">{entry.pf_user}</td>
|
<td className="px-3 py-2 text-gray-500">{entry.pf_user}</td>
|
||||||
<td className="px-3 py-2 text-gray-400">{new Date(entry.stamp).toLocaleDateString()}</td>
|
<td className="px-3 py-2 text-gray-400">{new Date(entry.stamp).toLocaleDateString()}</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
@ -388,21 +401,9 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
</tr>
|
</tr>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<tr key={`${entry.id}-detail`} className="bg-blue-50 border-t border-blue-100">
|
<tr key={`${entry.id}-detail`} className="bg-blue-50 border-t border-blue-100">
|
||||||
<td colSpan={6} className="px-4 py-3">
|
<td colSpan={6} className="px-2 py-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="bg-white border border-gray-200 rounded">
|
||||||
<div className="flex items-start gap-2">
|
<SegmentForm mode="view" {...view} filterCols={filterCols} />
|
||||||
<span className="text-xs text-gray-400 w-24 shrink-0 pt-0.5">WHERE</span>
|
|
||||||
<code className="text-xs font-mono text-gray-700 bg-white border border-gray-200 rounded px-2 py-1 break-all">{params.where_clause || '—'}</code>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-gray-400 w-24 shrink-0">offset</span>
|
|
||||||
<span className="text-xs font-mono text-gray-600">{params.date_offset || '0 days'}</span>
|
|
||||||
</div>
|
|
||||||
{dr && (
|
|
||||||
<div className="mt-1">
|
|
||||||
<Timeline dateFrom={dr.from} dateTo={dr.to} offsetYr={off.yr} offsetMo={off.mo} type={entry.operation === 'reference' ? 'reference' : 'baseline'} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -415,6 +416,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add / Edit Segment */}
|
{/* Add / Edit Segment */}
|
||||||
|
{(showAddForm || editingLogId) && (
|
||||||
<div id="add-segment" className="bg-white border border-gray-200 rounded">
|
<div id="add-segment" className="bg-white border border-gray-200 rounded">
|
||||||
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
|
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
|
||||||
<span>{(() => {
|
<span>{(() => {
|
||||||
@ -424,21 +426,126 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
const label = entry.operation === 'reference' ? 'reference' : 'baseline'
|
const label = entry.operation === 'reference' ? 'reference' : 'baseline'
|
||||||
return entry.note ? `Edit ${label} — ${entry.note}` : `Edit ${label} segment`
|
return entry.note ? `Edit ${label} — ${entry.note}` : `Edit ${label} segment`
|
||||||
})()}</span>
|
})()}</span>
|
||||||
{editingLogId && (
|
<button onClick={cancelEdit} className="text-gray-400 hover:text-gray-600 normal-case font-normal">
|
||||||
<button onClick={cancelEdit} className="text-gray-400 hover:text-gray-600 normal-case font-normal">Cancel edit</button>
|
{editingLogId ? 'Cancel edit' : 'Close'}
|
||||||
)}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<SegmentForm
|
||||||
|
mode="edit"
|
||||||
|
segType={segType} setSegType={setSegType}
|
||||||
|
filters={filters} setFilters={setFilters}
|
||||||
|
useRaw={useRaw} setUseRaw={setUseRaw}
|
||||||
|
rawSql={rawSql} setRawSql={setRawSql}
|
||||||
|
description={description} setDescription={setDescription}
|
||||||
|
segNote={segNote} setSegNote={setSegNote}
|
||||||
|
offsetYr={offsetYr} setOffsetYr={setOffsetYr}
|
||||||
|
offsetMo={offsetMo} setOffsetMo={setOffsetMo}
|
||||||
|
filterCols={filterCols}
|
||||||
|
onSubmit={loadSegment}
|
||||||
|
submitting={submitting}
|
||||||
|
editing={!!editingLogId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</>}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// derive view-mode props for a saved segment
|
||||||
|
function segmentValuesFor(entry, filterCols) {
|
||||||
|
const params = entry.params || {}
|
||||||
|
const off = parseOffset(params.date_offset)
|
||||||
|
const groups = normalizeFilters(params.filters)
|
||||||
|
return {
|
||||||
|
segType: entry.operation === 'reference' ? 'reference' : 'baseline',
|
||||||
|
filters: groups || (filterCols.length > 0 ? [emptyGroup(filterCols)] : []),
|
||||||
|
useRaw: !groups && !!params.where_clause,
|
||||||
|
rawSql: params.where_clause || '',
|
||||||
|
description: '',
|
||||||
|
segNote: entry.note || '',
|
||||||
|
offsetYr: off.yr,
|
||||||
|
offsetMo: off.mo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentForm({
|
||||||
|
mode, // 'view' | 'edit'
|
||||||
|
segType, setSegType,
|
||||||
|
filters, setFilters,
|
||||||
|
useRaw, setUseRaw,
|
||||||
|
rawSql, setRawSql,
|
||||||
|
description, setDescription,
|
||||||
|
segNote, setSegNote,
|
||||||
|
offsetYr, setOffsetYr,
|
||||||
|
offsetMo, setOffsetMo,
|
||||||
|
filterCols,
|
||||||
|
onSubmit,
|
||||||
|
submitting,
|
||||||
|
editing,
|
||||||
|
}) {
|
||||||
|
const disabled = mode === 'view'
|
||||||
|
const compiled = useRaw ? rawSql : (buildFilterClause(filters) || '')
|
||||||
|
const dateRange = useRaw ? parseDateRangeFromClause(rawSql) : getDateRange(filters)
|
||||||
|
|
||||||
|
function setGroup(gi, fn) {
|
||||||
|
setFilters(prev => prev.map((g, i) => i === gi ? fn(g) : g))
|
||||||
|
}
|
||||||
|
function addCondition(gi) { setGroup(gi, g => [...g, emptyCondition(filterCols)]) }
|
||||||
|
function removeCondition(gi, ci) {
|
||||||
|
setFilters(prev => {
|
||||||
|
const next = prev.map((g, i) => i === gi ? g.filter((_, j) => j !== ci) : g)
|
||||||
|
return next.filter(g => g.length > 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function updateCondition(gi, ci, field, value) {
|
||||||
|
setGroup(gi, g => g.map((c, j) => {
|
||||||
|
if (j !== ci) return c
|
||||||
|
if (field === 'op') {
|
||||||
|
const needsTwo = value === 'BETWEEN'
|
||||||
|
const needsNone = ['IS NULL', 'IS NOT NULL'].includes(value)
|
||||||
|
return { ...c, op: value, values: needsNone ? [] : needsTwo ? ['', ''] : [''] }
|
||||||
|
}
|
||||||
|
return { ...c, [field]: value }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
function updateConditionValue(gi, ci, vi, value) {
|
||||||
|
setGroup(gi, g => g.map((c, j) => {
|
||||||
|
if (j !== ci) return c
|
||||||
|
const vals = [...c.values]; vals[vi] = value
|
||||||
|
return { ...c, values: vals }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
function addGroup() { setFilters(prev => [...prev, emptyGroup(filterCols)]) }
|
||||||
|
function removeGroup(gi) { setFilters(prev => prev.filter((_, i) => i !== gi)) }
|
||||||
|
function toggleRaw() {
|
||||||
|
if (useRaw) {
|
||||||
|
setUseRaw(false)
|
||||||
|
setRawSql('')
|
||||||
|
} else {
|
||||||
|
setRawSql(compiled)
|
||||||
|
setUseRaw(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseInp = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white disabled:bg-gray-50 disabled:text-gray-500'
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="p-4 flex flex-col gap-4">
|
<div className="p-4 flex flex-col gap-4">
|
||||||
|
|
||||||
{/* Type toggle */}
|
{/* Type */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<label className="text-xs text-gray-500 w-28 shrink-0">Type</label>
|
<label className="text-xs text-gray-500 w-28 shrink-0">Type</label>
|
||||||
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
|
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
|
||||||
{['baseline', 'reference'].map(t => (
|
{['baseline', 'reference'].map(t => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => { setSegType(t); if (t === 'reference') { setOffsetYr(0); setOffsetMo(0) } }}
|
disabled={disabled}
|
||||||
className={`px-3 py-1 capitalize ${segType === t ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}
|
onClick={() => { if (disabled) return; setSegType(t); if (t === 'reference') { setOffsetYr(0); setOffsetMo(0) } }}
|
||||||
|
className={`px-3 py-1 capitalize ${segType === t ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'} disabled:opacity-60 disabled:cursor-default`}
|
||||||
>{t}</button>
|
>{t}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -447,48 +554,107 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description (edit only) */}
|
||||||
|
{mode === 'edit' && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<label className="text-xs text-gray-500 w-28 shrink-0">Description</label>
|
<label className="text-xs text-gray-500 w-28 shrink-0">Description</label>
|
||||||
<input value={description} onChange={e => setDescription(e.target.value)} placeholder="e.g. FY25 actuals +1yr" className="border border-gray-200 rounded px-2 py-1.5 text-sm flex-1 max-w-sm" />
|
<input value={description} onChange={e => setDescription(e.target.value)} placeholder="e.g. FY25 actuals +1yr" className="border border-gray-200 rounded px-2 py-1.5 text-sm flex-1 max-w-sm" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label className="text-xs text-gray-500 w-28 shrink-0">Filters</label>
|
<label className="text-xs text-gray-500 w-28 shrink-0">Filters</label>
|
||||||
<button onClick={addFilter} className="text-blue-600 hover:text-blue-700 text-xs font-medium">+ Add filter</button>
|
{!disabled && (
|
||||||
|
<button onClick={toggleRaw} className="text-blue-600 hover:text-blue-700 text-xs font-medium">
|
||||||
|
{useRaw ? '← Back to filters' : 'Switch to manual SQL →'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5 ml-28">
|
|
||||||
{filters.map((f, i) => {
|
{!useRaw && (
|
||||||
const isDateCol = filterCols.find(c => c.cname === f.col)?.role === 'date'
|
<div className="flex flex-col gap-3 ml-28">
|
||||||
|
{filters.map((group, gi) => (
|
||||||
|
<div key={gi} className="border border-gray-200 rounded">
|
||||||
|
<div className="flex items-center justify-between px-2 py-1 bg-gray-50 border-b border-gray-100">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{gi === 0 ? 'Group 1' : `Group ${gi + 1} — OR`}
|
||||||
|
</span>
|
||||||
|
{!disabled && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => addCondition(gi)} className="text-blue-600 hover:text-blue-700 text-xs">+ AND condition</button>
|
||||||
|
{filters.length > 1 && (
|
||||||
|
<button onClick={() => removeGroup(gi)} className="text-gray-300 hover:text-red-400 text-xs">remove group</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 p-2">
|
||||||
|
{group.map((c, ci) => {
|
||||||
|
const isDateCol = filterCols.find(fc => fc.cname === c.col)?.role === 'date'
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2 flex-wrap">
|
<div key={ci} className="flex items-center gap-2 flex-wrap">
|
||||||
<select value={f.col} onChange={e => updateFilter(i, 'col', e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
|
<select disabled={disabled} value={c.col} onChange={e => updateCondition(gi, ci, 'col', e.target.value)} className={baseInp}>
|
||||||
{filterCols.map(c => <option key={c.cname} value={c.cname}>{c.cname}</option>)}
|
{filterCols.map(fc => <option key={fc.cname} value={fc.cname}>{fc.cname}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select value={f.op} onChange={e => updateFilter(i, 'op', e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
|
<select disabled={disabled} value={c.op} onChange={e => updateCondition(gi, ci, 'op', e.target.value)} className={baseInp}>
|
||||||
{OPERATORS.map(op => <option key={op} value={op}>{op}</option>)}
|
{OPERATORS.map(op => <option key={op} value={op}>{op}</option>)}
|
||||||
</select>
|
</select>
|
||||||
{f.op === 'BETWEEN' && <>
|
{c.op === 'BETWEEN' && <>
|
||||||
<input type={isDateCol ? 'date' : 'text'} value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="from" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
|
<input disabled={disabled} type={isDateCol ? 'date' : 'text'} value={c.values[0] || ''} onChange={e => updateConditionValue(gi, ci, 0, e.target.value)} placeholder="from" className={`${baseInp} w-36 font-mono`} />
|
||||||
<span className="text-gray-400 text-xs">and</span>
|
<span className="text-gray-400 text-xs">and</span>
|
||||||
<input type={isDateCol ? 'date' : 'text'} value={f.values[1]} onChange={e => updateFilterValue(i, 1, e.target.value)} placeholder="to" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
|
<input disabled={disabled} type={isDateCol ? 'date' : 'text'} value={c.values[1] || ''} onChange={e => updateConditionValue(gi, ci, 1, e.target.value)} placeholder="to" className={`${baseInp} w-36 font-mono`} />
|
||||||
</>}
|
</>}
|
||||||
{(f.op === '=' || f.op === '!=') && (
|
{(c.op === '=' || c.op === '!=') && (
|
||||||
<input type={isDateCol ? 'date' : 'text'} value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="value" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
|
<input disabled={disabled} type={isDateCol ? 'date' : 'text'} value={c.values[0] || ''} onChange={e => updateConditionValue(gi, ci, 0, e.target.value)} placeholder="value" className={`${baseInp} w-36 font-mono`} />
|
||||||
)}
|
)}
|
||||||
{(f.op === 'IN' || f.op === 'NOT IN') && (
|
{(c.op === 'IN' || c.op === 'NOT IN') && (
|
||||||
<input value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="val1, val2, …" className="border border-gray-200 rounded px-2 py-1 text-xs w-48 font-mono bg-white" />
|
<input disabled={disabled} value={c.values[0] || ''} onChange={e => updateConditionValue(gi, ci, 0, e.target.value)} placeholder="val1, val2, …" className={`${baseInp} w-48 font-mono`} />
|
||||||
|
)}
|
||||||
|
{!disabled && group.length > 1 && (
|
||||||
|
<button onClick={() => removeCondition(gi, ci)} className="text-gray-300 hover:text-red-400 text-xs">✕</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => removeFilter(i)} className="text-gray-300 hover:text-red-400 text-xs">✕</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!disabled && (
|
||||||
|
<button onClick={addGroup} className="self-start text-blue-600 hover:text-blue-700 text-xs font-medium">+ Add OR group</button>
|
||||||
|
)}
|
||||||
{filters.length === 0 && (
|
{filters.length === 0 && (
|
||||||
<span className="text-xs text-gray-300 italic">No filters — at least one is required</span>
|
<span className="text-xs text-gray-300 italic">No filters — at least one is required</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{useRaw && (
|
||||||
|
<div className="ml-28">
|
||||||
|
<textarea
|
||||||
|
disabled={disabled}
|
||||||
|
value={rawSql}
|
||||||
|
onChange={e => setRawSql(e.target.value)}
|
||||||
|
placeholder="WHERE clause body (no WHERE keyword) — e.g. (status = 'OPEN' AND order_date BETWEEN '2024-01-01' AND '2024-12-31') OR id IS NULL"
|
||||||
|
rows={3}
|
||||||
|
className={`w-full border border-gray-200 rounded px-2 py-1.5 text-xs font-mono bg-white disabled:bg-gray-50 disabled:text-gray-500`}
|
||||||
|
/>
|
||||||
|
{!disabled && (
|
||||||
|
<p className="text-xs text-amber-700 mt-1">Raw SQL is not validated. You are responsible for correctness and security.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Compiled SQL preview (only meaningful when not in raw mode) */}
|
||||||
|
{!useRaw && (
|
||||||
|
<div className="ml-28 mt-2 flex items-start gap-2">
|
||||||
|
<span className="text-xs text-gray-400 w-12 shrink-0 pt-0.5">SQL</span>
|
||||||
|
<code className="text-xs font-mono text-gray-700 bg-gray-50 border border-gray-200 rounded px-2 py-1 flex-1 break-all">
|
||||||
|
{compiled || <span className="text-gray-300 not-italic">— add a condition —</span>}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date offset — baseline only */}
|
{/* Date offset — baseline only */}
|
||||||
@ -496,9 +662,9 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<label className="text-xs text-gray-500 w-28 shrink-0">Date offset</label>
|
<label className="text-xs text-gray-500 w-28 shrink-0">Date offset</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input type="number" value={offsetYr} min={0} onChange={e => setOffsetYr(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
|
<input disabled={disabled} type="number" value={offsetYr} min={0} onChange={e => setOffsetYr(parseInt(e.target.value) || 0)} className={`${baseInp} text-sm w-16 text-center`} />
|
||||||
<span className="text-xs text-gray-500">yr</span>
|
<span className="text-xs text-gray-500">yr</span>
|
||||||
<input type="number" value={offsetMo} min={0} max={11} onChange={e => setOffsetMo(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
|
<input disabled={disabled} type="number" value={offsetMo} min={0} max={11} onChange={e => setOffsetMo(parseInt(e.target.value) || 0)} className={`${baseInp} text-sm w-16 text-center`} />
|
||||||
<span className="text-xs text-gray-500">mo</span>
|
<span className="text-xs text-gray-500">mo</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -523,22 +689,17 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
<div className="flex items-end gap-3">
|
<div className="flex items-end gap-3">
|
||||||
<div className="flex flex-col gap-1 flex-1 max-w-xs">
|
<div className="flex flex-col gap-1 flex-1 max-w-xs">
|
||||||
<label className="text-xs text-gray-500">Note</label>
|
<label className="text-xs text-gray-500">Note</label>
|
||||||
<input value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1.5 text-sm" />
|
<input disabled={disabled} value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className={`${baseInp} text-sm py-1.5`} />
|
||||||
</div>
|
</div>
|
||||||
<button onClick={loadSegment} disabled={submitting || filters.length === 0} className="bg-blue-600 text-white text-xs px-5 py-2 rounded hover:bg-blue-700 disabled:opacity-50 shrink-0">
|
{mode === 'edit' && (
|
||||||
|
<button onClick={onSubmit} disabled={submitting || (!useRaw && filters.length === 0)} className="bg-blue-600 text-white text-xs px-5 py-2 rounded hover:bg-blue-700 disabled:opacity-50 shrink-0">
|
||||||
{submitting
|
{submitting
|
||||||
? (editingLogId ? 'Saving…' : 'Loading…')
|
? (editing ? 'Saving…' : 'Loading…')
|
||||||
: (editingLogId
|
: (editing
|
||||||
? `Save ${segType === 'reference' ? 'Reference' : 'Segment'}`
|
? `Save ${segType === 'reference' ? 'Reference' : 'Segment'}`
|
||||||
: `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`)}
|
: `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</>}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user