Unify baseline/reference into one form; fix timeline for both types
- Baseline.jsx: merge Reference section into Add Segment form with baseline/reference toggle; segment rows now clickable to expand stored WHERE clause + timeline; date filter inputs use type="date" for date-role columns
- Timeline.jsx: add type prop ('baseline'|'reference'); reference band uses purple; single-band height shrinks to 52px; canvas uses requestAnimationFrame to fix offsetWidth=0 on mount
- operations.js: reference route now accepts where_clause like baseline (drops date_from/date_to)
- sql_generator.js: reference SQL template uses {{filter_clause}} instead of hardcoded BETWEEN
Note: existing sources need Generate SQL re-run to pick up the new reference template.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dc090fe394
commit
af52845523
@ -80,10 +80,10 @@ ilog AS (
|
|||||||
INSERT INTO {{fc_table}} (${insertCols})
|
INSERT INTO {{fc_table}} (${insertCols})
|
||||||
SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
|
SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
|
||||||
FROM ${srcTable}
|
FROM ${srcTable}
|
||||||
WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
|
WHERE {{filter_clause}}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT * FROM ins`.trim();
|
SELECT count(*) AS rows_affected FROM ins`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildScale() {
|
function buildScale() {
|
||||||
|
|||||||
@ -133,26 +133,23 @@ 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 { date_from, date_to, pf_user, note } = req.body;
|
const { where_clause, pf_user, note } = req.body;
|
||||||
if (!date_from || !date_to) {
|
const filterClause = (where_clause || '').trim() || 'TRUE';
|
||||||
return res.status(400).json({ error: 'date_from and date_to are required' });
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'reference');
|
const ctx = await getContext(parseInt(req.params.id), 'reference');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
|
|
||||||
const sql = applyTokens(ctx.sql, {
|
const sql = applyTokens(ctx.sql, {
|
||||||
fc_table: ctx.table,
|
fc_table: ctx.table,
|
||||||
version_id: ctx.version.id,
|
version_id: ctx.version.id,
|
||||||
pf_user: esc(pf_user || ''),
|
pf_user: esc(pf_user || ''),
|
||||||
note: esc(note || ''),
|
note: esc(note || ''),
|
||||||
params: esc(JSON.stringify({ date_from, date_to })),
|
params: esc(JSON.stringify({ where_clause: filterClause })),
|
||||||
date_from: esc(date_from),
|
filter_clause: filterClause
|
||||||
date_to: esc(date_to)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSQL(sql);
|
const result = await runSQL(sql);
|
||||||
res.json({ rows: result.rows, rows_affected: result.rows.length });
|
res.json(result.rows[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(err.status || 500).json({ error: err.message });
|
res.status(err.status || 500).json({ error: err.message });
|
||||||
|
|||||||
@ -32,116 +32,131 @@ function roundRect(ctx, x, y, w, h, r, fill, stroke) {
|
|||||||
if (stroke) ctx.stroke()
|
if (stroke) ctx.stroke()
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Timeline({ dateFrom, dateTo, offsetYr, offsetMo }) {
|
export default function Timeline({ dateFrom, dateTo, offsetYr, offsetMo, type = 'baseline' }) {
|
||||||
const canvasRef = useRef(null)
|
const canvasRef = useRef(null)
|
||||||
|
|
||||||
|
const offsetMoTotal = (offsetYr || 0) * 12 + (offsetMo || 0)
|
||||||
|
const twoBands = type === 'baseline' && offsetMoTotal > 0
|
||||||
|
const canvasH = twoBands ? 90 : 52
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
const dpr = window.devicePixelRatio || 1
|
let raf
|
||||||
const W = canvas.offsetWidth || 500
|
const draw = () => {
|
||||||
canvas.width = W * dpr
|
const dpr = window.devicePixelRatio || 1
|
||||||
canvas.height = 90 * dpr
|
const W = canvas.offsetWidth || 500
|
||||||
const ctx = canvas.getContext('2d')
|
canvas.width = W * dpr
|
||||||
ctx.scale(dpr, dpr)
|
canvas.height = canvasH * dpr
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
|
||||||
const H = 90
|
const PAD = { l: 8, r: 8 }
|
||||||
const PAD = { l: 8, r: 8 }
|
const trackH = 22
|
||||||
const trackH = 22
|
const drawW = W - PAD.l - PAD.r
|
||||||
const srcY = 20
|
const bandY = twoBands ? 20 : (canvasH - trackH) / 2
|
||||||
const projY = srcY + trackH + 10
|
const projY = bandY + trackH + 10
|
||||||
const drawW = W - PAD.l - PAD.r
|
|
||||||
|
|
||||||
const srcStart = parseDate(dateFrom)
|
const srcStart = parseDate(dateFrom)
|
||||||
const srcEnd = parseDate(dateTo)
|
const srcEnd = parseDate(dateTo)
|
||||||
if (!srcStart || !srcEnd || isNaN(srcStart) || isNaN(srcEnd)) return
|
if (!srcStart || !srcEnd || isNaN(srcStart) || isNaN(srcEnd)) return
|
||||||
|
|
||||||
const offsetMoTotal = (offsetYr || 0) * 12 + (offsetMo || 0)
|
const projStart = addMonths(srcStart, offsetMoTotal)
|
||||||
const projStart = addMonths(srcStart, offsetMoTotal)
|
const projEnd = addMonths(srcEnd, offsetMoTotal)
|
||||||
const projEnd = addMonths(srcEnd, offsetMoTotal)
|
|
||||||
|
|
||||||
const winStart = addMonths(srcStart, -1)
|
const winStart = addMonths(srcStart, -1)
|
||||||
const winEnd = addMonths(projEnd, 1)
|
const winEnd = addMonths(twoBands ? projEnd : srcEnd, 1)
|
||||||
const winMs = winEnd - winStart
|
const winMs = winEnd - winStart
|
||||||
|
|
||||||
function xOf(date) {
|
function xOf(date) {
|
||||||
return PAD.l + ((date - winStart) / winMs) * drawW
|
return PAD.l + ((date - winStart) / winMs) * drawW
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.clearRect(0, 0, W, H)
|
ctx.clearRect(0, 0, W, canvasH)
|
||||||
|
|
||||||
// axis
|
// axis
|
||||||
ctx.strokeStyle = '#e5e7eb'
|
ctx.strokeStyle = '#e5e7eb'
|
||||||
ctx.lineWidth = 1
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(PAD.l, srcY - 8)
|
|
||||||
ctx.lineTo(PAD.l + drawW, srcY - 8)
|
|
||||||
ctx.stroke()
|
|
||||||
|
|
||||||
// month ticks + year labels
|
|
||||||
const tickStart = new Date(winStart.getFullYear(), winStart.getMonth(), 1)
|
|
||||||
for (let d = new Date(tickStart); d <= winEnd; d = addMonths(d, 1)) {
|
|
||||||
const x = xOf(d)
|
|
||||||
if (x < PAD.l || x > PAD.l + drawW) continue
|
|
||||||
ctx.strokeStyle = '#f3f4f6'
|
|
||||||
ctx.lineWidth = 1
|
ctx.lineWidth = 1
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.moveTo(x, srcY - 8)
|
ctx.moveTo(PAD.l, bandY - 8)
|
||||||
ctx.lineTo(x, projY + trackH)
|
ctx.lineTo(PAD.l + drawW, bandY - 8)
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
if (d.getMonth() === 0) {
|
|
||||||
ctx.fillStyle = '#6b7280'
|
// month ticks + year labels
|
||||||
ctx.font = 'bold 9px system-ui'
|
const tickStart = new Date(winStart.getFullYear(), winStart.getMonth(), 1)
|
||||||
ctx.textAlign = 'center'
|
const tickBottom = twoBands ? projY + trackH : bandY + trackH
|
||||||
ctx.fillText(d.getFullYear(), x, srcY - 10)
|
for (let d = new Date(tickStart); d <= winEnd; d = addMonths(d, 1)) {
|
||||||
|
const x = xOf(d)
|
||||||
|
if (x < PAD.l || x > PAD.l + drawW) continue
|
||||||
|
ctx.strokeStyle = '#f3f4f6'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, bandY - 8)
|
||||||
|
ctx.lineTo(x, tickBottom)
|
||||||
|
ctx.stroke()
|
||||||
|
if (d.getMonth() === 0) {
|
||||||
|
ctx.fillStyle = '#6b7280'
|
||||||
|
ctx.font = 'bold 9px system-ui'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(d.getFullYear(), x, bandY - 10)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// source band
|
// first band
|
||||||
const sx1 = xOf(srcStart), sx2 = xOf(srcEnd)
|
const sx1 = xOf(srcStart), sx2 = xOf(srcEnd)
|
||||||
ctx.fillStyle = '#dbeafe'
|
if (type === 'reference') {
|
||||||
ctx.strokeStyle = '#93c5fd'
|
ctx.fillStyle = '#f3e8ff'
|
||||||
ctx.lineWidth = 1
|
ctx.strokeStyle = '#d8b4fe'
|
||||||
roundRect(ctx, sx1, srcY, Math.max(sx2 - sx1, 4), trackH, 4, true, true)
|
} else {
|
||||||
ctx.fillStyle = '#1d4ed8'
|
ctx.fillStyle = '#dbeafe'
|
||||||
ctx.font = '10px system-ui'
|
ctx.strokeStyle = '#93c5fd'
|
||||||
ctx.textAlign = 'left'
|
}
|
||||||
ctx.fillText('Source ' + dateFrom + ' → ' + dateTo, sx1 + 6, srcY + 14)
|
ctx.lineWidth = 1
|
||||||
|
roundRect(ctx, sx1, bandY, Math.max(sx2 - sx1, 4), trackH, 4, true, true)
|
||||||
|
|
||||||
if (offsetMoTotal > 0) {
|
ctx.fillStyle = type === 'reference' ? '#7c3aed' : '#1d4ed8'
|
||||||
const px1 = xOf(projStart), px2 = xOf(projEnd)
|
|
||||||
ctx.fillStyle = '#dcfce7'
|
|
||||||
ctx.strokeStyle = '#86efac'
|
|
||||||
roundRect(ctx, px1, projY, Math.max(px2 - px1, 4), trackH, 4, true, true)
|
|
||||||
ctx.fillStyle = '#15803d'
|
|
||||||
ctx.font = '10px system-ui'
|
ctx.font = '10px system-ui'
|
||||||
ctx.textAlign = 'left'
|
ctx.textAlign = 'left'
|
||||||
ctx.fillText('Projected ' + fmtDate(projStart) + ' → ' + fmtDate(projEnd), px1 + 6, projY + 14)
|
const bandLabel = type === 'reference' ? 'Reference' : 'Source'
|
||||||
|
ctx.fillText(bandLabel + ' ' + dateFrom + ' → ' + dateTo, sx1 + 6, bandY + 14)
|
||||||
|
|
||||||
// arrow
|
// projected band + arrow (baseline only, when offset > 0)
|
||||||
const arrowY = srcY + trackH / 2
|
if (twoBands) {
|
||||||
ctx.strokeStyle = '#94a3b8'
|
const px1 = xOf(projStart), px2 = xOf(projEnd)
|
||||||
ctx.lineWidth = 1
|
ctx.fillStyle = '#dcfce7'
|
||||||
ctx.setLineDash([3, 3])
|
ctx.strokeStyle = '#86efac'
|
||||||
ctx.beginPath()
|
roundRect(ctx, px1, projY, Math.max(px2 - px1, 4), trackH, 4, true, true)
|
||||||
ctx.moveTo(sx1, arrowY)
|
ctx.fillStyle = '#15803d'
|
||||||
ctx.lineTo(px1 - 2, arrowY)
|
ctx.font = '10px system-ui'
|
||||||
ctx.stroke()
|
ctx.textAlign = 'left'
|
||||||
ctx.setLineDash([])
|
ctx.fillText('Projected ' + fmtDate(projStart) + ' → ' + fmtDate(projEnd), px1 + 6, projY + 14)
|
||||||
ctx.fillStyle = '#94a3b8'
|
|
||||||
ctx.beginPath()
|
const arrowY = bandY + trackH / 2
|
||||||
ctx.moveTo(px1 + 4, arrowY)
|
ctx.strokeStyle = '#94a3b8'
|
||||||
ctx.lineTo(px1 - 4, arrowY - 4)
|
ctx.lineWidth = 1
|
||||||
ctx.lineTo(px1 - 4, arrowY + 4)
|
ctx.setLineDash([3, 3])
|
||||||
ctx.closePath()
|
ctx.beginPath()
|
||||||
ctx.fill()
|
ctx.moveTo(sx1, arrowY)
|
||||||
const label = '+' + (offsetYr ? offsetYr + 'yr ' : '') + (offsetMo ? offsetMo + 'mo' : '')
|
ctx.lineTo(px1 - 2, arrowY)
|
||||||
ctx.fillStyle = '#64748b'
|
ctx.stroke()
|
||||||
ctx.font = '9px system-ui'
|
ctx.setLineDash([])
|
||||||
ctx.textAlign = 'center'
|
ctx.fillStyle = '#94a3b8'
|
||||||
ctx.fillText(label.trim(), (sx1 + px1) / 2, arrowY - 5)
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(px1 + 4, arrowY)
|
||||||
|
ctx.lineTo(px1 - 4, arrowY - 4)
|
||||||
|
ctx.lineTo(px1 - 4, arrowY + 4)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
const offsetLabel = '+' + (offsetYr ? offsetYr + 'yr ' : '') + (offsetMo ? offsetMo + 'mo' : '')
|
||||||
|
ctx.fillStyle = '#64748b'
|
||||||
|
ctx.font = '9px system-ui'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(offsetLabel.trim(), (sx1 + px1) / 2, arrowY - 5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [dateFrom, dateTo, offsetYr, offsetMo])
|
raf = requestAnimationFrame(draw)
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [dateFrom, dateTo, offsetYr, offsetMo, type, twoBands, canvasH])
|
||||||
|
|
||||||
return <canvas ref={canvasRef} height={90} style={{ width: '100%', display: 'block' }} />
|
return <canvas ref={canvasRef} height={canvasH} style={{ width: '100%', display: 'block' }} />
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,22 @@ function getDateRange(filters) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDateRangeFromClause(clause) {
|
||||||
|
if (!clause) return null
|
||||||
|
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] }
|
||||||
|
const m2 = clause.match(/>= ?'(\d{4}-\d{2}-\d{2})'.+<= ?'(\d{4}-\d{2}-\d{2})'/)
|
||||||
|
if (m2) return { from: m2[1], to: m2[2] }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOffset(offsetStr) {
|
||||||
|
if (!offsetStr || offsetStr === '0 days') return { yr: 0, mo: 0 }
|
||||||
|
const yr = parseInt(offsetStr.match(/(\d+)\s+year/)?.[1] || 0)
|
||||||
|
const mo = parseInt(offsetStr.match(/(\d+)\s+month/)?.[1] || 0)
|
||||||
|
return { yr, mo }
|
||||||
|
}
|
||||||
|
|
||||||
function emptyFilter(cols) {
|
function emptyFilter(cols) {
|
||||||
return { col: cols[0]?.cname || '', op: 'BETWEEN', values: ['', ''] }
|
return { col: cols[0]?.cname || '', op: 'BETWEEN', values: ['', ''] }
|
||||||
}
|
}
|
||||||
@ -54,6 +70,7 @@ export default function Baseline() {
|
|||||||
const [creatingVer, setCreatingVer] = useState(false)
|
const [creatingVer, setCreatingVer] = useState(false)
|
||||||
|
|
||||||
// add segment form
|
// add segment form
|
||||||
|
const [segType, setSegType] = useState('baseline')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [filters, setFilters] = useState([])
|
const [filters, setFilters] = useState([])
|
||||||
const [offsetYr, setOffsetYr] = useState(0)
|
const [offsetYr, setOffsetYr] = useState(0)
|
||||||
@ -61,12 +78,7 @@ export default function Baseline() {
|
|||||||
const [segNote, setSegNote] = useState('')
|
const [segNote, setSegNote] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
// reference form
|
const [expandedId, setExpandedId] = useState(null)
|
||||||
const [refFrom, setRefFrom] = useState('')
|
|
||||||
const [refTo, setRefTo] = useState('')
|
|
||||||
const [refNote, setRefNote] = useState('')
|
|
||||||
const [loadingRef, setLoadingRef] = useState(false)
|
|
||||||
|
|
||||||
const [msg, setMsg] = useState(null)
|
const [msg, setMsg] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -97,7 +109,7 @@ export default function Baseline() {
|
|||||||
|
|
||||||
function loadLog() {
|
function loadLog() {
|
||||||
fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => {
|
fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => {
|
||||||
setLog(data.filter(e => e.operation === 'baseline'))
|
setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference'))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,17 +172,22 @@ export default function Baseline() {
|
|||||||
async function loadSegment() {
|
async function loadSegment() {
|
||||||
const clause = buildFilterClause(filters)
|
const clause = buildFilterClause(filters)
|
||||||
if (!clause) { flash('Add at least one filter', 'error'); return }
|
if (!clause) { flash('Add at least one filter', 'error'); return }
|
||||||
const offsetStr = [offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days'
|
const isRef = segType === 'reference'
|
||||||
|
const offsetStr = isRef ? '0 days' : ([offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days')
|
||||||
|
const endpoint = isRef ? 'reference' : 'baseline'
|
||||||
|
const body = isRef
|
||||||
|
? { where_clause: clause, pf_user: 'admin', note: description || segNote }
|
||||||
|
: { where_clause: clause, date_offset: offsetStr, pf_user: 'admin', note: description || segNote }
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/versions/${versionId}/baseline`, {
|
const res = await fetch(`/api/versions/${versionId}/${endpoint}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ where_clause: clause, date_offset: offsetStr, pf_user: 'admin', note: description || segNote })
|
body: JSON.stringify(body)
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) { flash(data.error, 'error'); return }
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
flash(`Loaded ${data.row_count ?? ''} rows`)
|
flash(`Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`)
|
||||||
loadLog()
|
loadLog()
|
||||||
setDescription(''); setSegNote(''); setOffsetYr(0); setOffsetMo(0)
|
setDescription(''); setSegNote(''); setOffsetYr(0); setOffsetMo(0)
|
||||||
setFilters(filterCols.length > 0 ? [emptyFilter(filterCols)] : [])
|
setFilters(filterCols.length > 0 ? [emptyFilter(filterCols)] : [])
|
||||||
@ -196,26 +213,6 @@ export default function Baseline() {
|
|||||||
flash(`Cleared ${data.rows_deleted} rows`)
|
flash(`Cleared ${data.rows_deleted} rows`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadReference() {
|
|
||||||
if (!refFrom || !refTo) { flash('Enter a date range', 'error'); return }
|
|
||||||
setLoadingRef(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/versions/${versionId}/reference`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ date_from: refFrom, date_to: refTo, pf_user: 'admin', note: refNote })
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) { flash(data.error, 'error'); return }
|
|
||||||
flash(`Loaded ${data.row_count ?? ''} reference rows`)
|
|
||||||
setRefFrom(''); setRefTo(''); setRefNote('')
|
|
||||||
} catch (err) {
|
|
||||||
flash(err.message, 'error')
|
|
||||||
} finally {
|
|
||||||
setLoadingRef(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeVersion() {
|
async function closeVersion() {
|
||||||
const res = await fetch(`/api/versions/${versionId}/close`, {
|
const res = await fetch(`/api/versions/${versionId}/close`, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
@ -337,6 +334,7 @@ export default function Baseline() {
|
|||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr className="text-left text-gray-400 border-b border-gray-100">
|
<tr className="text-left text-gray-400 border-b border-gray-100">
|
||||||
|
<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">by</th>
|
<th className="px-3 py-1.5 font-medium">by</th>
|
||||||
@ -346,19 +344,60 @@ export default function Baseline() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{log.length === 0 && (
|
{log.length === 0 && (
|
||||||
<tr><td colSpan={5} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
|
<tr><td colSpan={6} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
|
||||||
)}
|
)}
|
||||||
{log.map((entry, i) => (
|
{log.map((entry, i) => {
|
||||||
<tr key={entry.id} className="border-t border-gray-50">
|
const isOpen = expandedId === entry.id
|
||||||
<td className="px-3 py-2 text-gray-400">{log.length - i}</td>
|
const params = entry.params || {}
|
||||||
<td className="px-3 py-2">{entry.note || <span className="text-gray-300">—</span>}</td>
|
const dr = parseDateRangeFromClause(params.where_clause)
|
||||||
<td className="px-3 py-2 text-gray-500">{entry.pf_user}</td>
|
const off = parseOffset(params.date_offset)
|
||||||
<td className="px-3 py-2 text-gray-400">{new Date(entry.stamp).toLocaleDateString()}</td>
|
return (
|
||||||
<td className="px-3 py-2 text-right">
|
<>
|
||||||
<button onClick={() => undoSegment(entry.id)} className="text-gray-400 hover:text-red-500 text-xs">Undo</button>
|
<tr
|
||||||
</td>
|
key={entry.id}
|
||||||
</tr>
|
onClick={() => setExpandedId(isOpen ? null : entry.id)}
|
||||||
))}
|
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">
|
||||||
|
<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">
|
||||||
|
<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'}`}>
|
||||||
|
{entry.operation}
|
||||||
|
</span>
|
||||||
|
{entry.note || <span className="text-gray-300">—</span>}
|
||||||
|
</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-right">
|
||||||
|
<button onClick={e => { e.stopPropagation(); undoSegment(entry.id) }} className="text-gray-400 hover:text-red-500 text-xs">Undo</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isOpen && (
|
||||||
|
<tr key={`${entry.id}-detail`} className="bg-blue-50 border-t border-blue-100">
|
||||||
|
<td colSpan={6} className="px-4 py-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -370,6 +409,23 @@ export default function Baseline() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4 flex flex-col gap-4">
|
<div className="p-4 flex flex-col gap-4">
|
||||||
|
|
||||||
|
{/* Type toggle */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<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">
|
||||||
|
{['baseline', 'reference'].map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => { 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'}`}
|
||||||
|
>{t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{segType === 'reference' && (
|
||||||
|
<span className="text-xs text-gray-400">dates land verbatim — no offset applied</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<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>
|
||||||
@ -383,7 +439,9 @@ export default function Baseline() {
|
|||||||
<button onClick={addFilter} className="text-blue-600 hover:text-blue-700 text-xs font-medium">+ Add filter</button>
|
<button onClick={addFilter} className="text-blue-600 hover:text-blue-700 text-xs font-medium">+ Add filter</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5 ml-28">
|
<div className="flex flex-col gap-1.5 ml-28">
|
||||||
{filters.map((f, i) => (
|
{filters.map((f, i) => {
|
||||||
|
const isDateCol = filterCols.find(c => c.cname === f.col)?.role === 'date'
|
||||||
|
return (
|
||||||
<div key={i} className="flex items-center gap-2 flex-wrap">
|
<div key={i} 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 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">
|
||||||
{filterCols.map(c => <option key={c.cname} value={c.cname}>{c.cname}</option>)}
|
{filterCols.map(c => <option key={c.cname} value={c.cname}>{c.cname}</option>)}
|
||||||
@ -392,41 +450,50 @@ export default function Baseline() {
|
|||||||
{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' && <>
|
{f.op === 'BETWEEN' && <>
|
||||||
<input 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-28 font-mono bg-white" />
|
<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" />
|
||||||
<span className="text-gray-400 text-xs">and</span>
|
<span className="text-gray-400 text-xs">and</span>
|
||||||
<input 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-28 font-mono bg-white" />
|
<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" />
|
||||||
</>}
|
</>}
|
||||||
{(f.op === '=' || f.op === '!=') && (
|
{(f.op === '=' || f.op === '!=') && (
|
||||||
<input 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 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" />
|
||||||
)}
|
)}
|
||||||
{(f.op === 'IN' || f.op === 'NOT IN') && (
|
{(f.op === 'IN' || f.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 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" />
|
||||||
)}
|
)}
|
||||||
<button onClick={() => removeFilter(i)} 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>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date offset */}
|
{/* Date offset — baseline only */}
|
||||||
<div className="flex items-center gap-3">
|
{segType === 'baseline' && (
|
||||||
<label className="text-xs text-gray-500 w-28 shrink-0">Date offset</label>
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<label className="text-xs text-gray-500 w-28 shrink-0">Date offset</label>
|
||||||
<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" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-gray-500">yr</span>
|
<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 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" />
|
<span className="text-xs text-gray-500">yr</span>
|
||||||
<span className="text-xs text-gray-500">mo</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" />
|
||||||
|
<span className="text-xs text-gray-500">mo</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
{dateRange && (
|
{dateRange && (
|
||||||
<div className="ml-28">
|
<div className="ml-28">
|
||||||
<div className="bg-white border border-gray-200 rounded p-3">
|
<div className="bg-gray-50 border border-gray-200 rounded p-3">
|
||||||
<Timeline dateFrom={dateRange.from} dateTo={dateRange.to} offsetYr={offsetYr} offsetMo={offsetMo} />
|
<Timeline
|
||||||
|
dateFrom={dateRange.from}
|
||||||
|
dateTo={dateRange.to}
|
||||||
|
offsetYr={segType === 'baseline' ? offsetYr : 0}
|
||||||
|
offsetMo={segType === 'baseline' ? offsetMo : 0}
|
||||||
|
type={segType}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -438,37 +505,13 @@ export default function Baseline() {
|
|||||||
<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 value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1.5 text-sm" />
|
||||||
</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">
|
<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">
|
||||||
{submitting ? 'Loading…' : 'Load Segment'}
|
{submitting ? 'Loading…' : `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reference */}
|
|
||||||
<div 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">
|
|
||||||
Reference <span className="text-gray-300 font-normal normal-case">optional — prior-period rows for comparison</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 flex items-end gap-3 flex-wrap">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs text-gray-500">Date range</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input value={refFrom} onChange={e => setRefFrom(e.target.value)} placeholder="2024-01-01" className="border border-gray-200 rounded px-2 py-1 text-xs w-28 font-mono" />
|
|
||||||
<span className="text-xs text-gray-400">to</span>
|
|
||||||
<input value={refTo} onChange={e => setRefTo(e.target.value)} placeholder="2024-12-31" className="border border-gray-200 rounded px-2 py-1 text-xs w-28 font-mono" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 flex-1 max-w-xs">
|
|
||||||
<label className="text-xs text-gray-500">Note</label>
|
|
||||||
<input value={refNote} onChange={e => setRefNote(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1 text-sm" />
|
|
||||||
</div>
|
|
||||||
<button onClick={loadReference} disabled={loadingRef} className="border border-gray-200 text-gray-600 text-xs px-4 py-1.5 rounded hover:bg-gray-50 disabled:opacity-50 shrink-0">
|
|
||||||
{loadingRef ? 'Loading…' : 'Load Reference'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user