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})
|
||||
SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
|
||||
FROM ${srcTable}
|
||||
WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}'
|
||||
WHERE {{filter_clause}}
|
||||
RETURNING *
|
||||
)
|
||||
SELECT * FROM ins`.trim();
|
||||
SELECT count(*) AS rows_affected FROM ins`.trim();
|
||||
}
|
||||
|
||||
function buildScale() {
|
||||
|
||||
@ -133,10 +133,8 @@ module.exports = function(pool) {
|
||||
|
||||
// load reference rows from source table (additive — does not clear prior reference rows)
|
||||
router.post('/versions/:id/reference', async (req, res) => {
|
||||
const { date_from, date_to, pf_user, note } = req.body;
|
||||
if (!date_from || !date_to) {
|
||||
return res.status(400).json({ error: 'date_from and date_to are required' });
|
||||
}
|
||||
const { where_clause, pf_user, note } = req.body;
|
||||
const filterClause = (where_clause || '').trim() || 'TRUE';
|
||||
try {
|
||||
const ctx = await getContext(parseInt(req.params.id), 'reference');
|
||||
if (!guardOpen(ctx.version, res)) return;
|
||||
@ -146,13 +144,12 @@ module.exports = function(pool) {
|
||||
version_id: ctx.version.id,
|
||||
pf_user: esc(pf_user || ''),
|
||||
note: esc(note || ''),
|
||||
params: esc(JSON.stringify({ date_from, date_to })),
|
||||
date_from: esc(date_from),
|
||||
date_to: esc(date_to)
|
||||
params: esc(JSON.stringify({ where_clause: filterClause })),
|
||||
filter_clause: filterClause
|
||||
});
|
||||
|
||||
const result = await runSQL(sql);
|
||||
res.json({ rows: result.rows, rows_affected: result.rows.length });
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(err.status || 500).json({ error: err.message });
|
||||
|
||||
@ -32,83 +32,96 @@ function roundRect(ctx, x, y, w, h, r, fill, 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 offsetMoTotal = (offsetYr || 0) * 12 + (offsetMo || 0)
|
||||
const twoBands = type === 'baseline' && offsetMoTotal > 0
|
||||
const canvasH = twoBands ? 90 : 52
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
let raf
|
||||
const draw = () => {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const W = canvas.offsetWidth || 500
|
||||
canvas.width = W * dpr
|
||||
canvas.height = 90 * dpr
|
||||
canvas.height = canvasH * dpr
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
const H = 90
|
||||
const PAD = { l: 8, r: 8 }
|
||||
const trackH = 22
|
||||
const srcY = 20
|
||||
const projY = srcY + trackH + 10
|
||||
const drawW = W - PAD.l - PAD.r
|
||||
const bandY = twoBands ? 20 : (canvasH - trackH) / 2
|
||||
const projY = bandY + trackH + 10
|
||||
|
||||
const srcStart = parseDate(dateFrom)
|
||||
const srcEnd = parseDate(dateTo)
|
||||
if (!srcStart || !srcEnd || isNaN(srcStart) || isNaN(srcEnd)) return
|
||||
|
||||
const offsetMoTotal = (offsetYr || 0) * 12 + (offsetMo || 0)
|
||||
const projStart = addMonths(srcStart, offsetMoTotal)
|
||||
const projEnd = addMonths(srcEnd, offsetMoTotal)
|
||||
|
||||
const winStart = addMonths(srcStart, -1)
|
||||
const winEnd = addMonths(projEnd, 1)
|
||||
const winEnd = addMonths(twoBands ? projEnd : srcEnd, 1)
|
||||
const winMs = winEnd - winStart
|
||||
|
||||
function xOf(date) {
|
||||
return PAD.l + ((date - winStart) / winMs) * drawW
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
ctx.clearRect(0, 0, W, canvasH)
|
||||
|
||||
// axis
|
||||
ctx.strokeStyle = '#e5e7eb'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(PAD.l, srcY - 8)
|
||||
ctx.lineTo(PAD.l + drawW, srcY - 8)
|
||||
ctx.moveTo(PAD.l, bandY - 8)
|
||||
ctx.lineTo(PAD.l + drawW, bandY - 8)
|
||||
ctx.stroke()
|
||||
|
||||
// month ticks + year labels
|
||||
const tickStart = new Date(winStart.getFullYear(), winStart.getMonth(), 1)
|
||||
const tickBottom = twoBands ? projY + trackH : bandY + trackH
|
||||
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, srcY - 8)
|
||||
ctx.lineTo(x, projY + trackH)
|
||||
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, srcY - 10)
|
||||
ctx.fillText(d.getFullYear(), x, bandY - 10)
|
||||
}
|
||||
}
|
||||
|
||||
// source band
|
||||
// first band
|
||||
const sx1 = xOf(srcStart), sx2 = xOf(srcEnd)
|
||||
if (type === 'reference') {
|
||||
ctx.fillStyle = '#f3e8ff'
|
||||
ctx.strokeStyle = '#d8b4fe'
|
||||
} else {
|
||||
ctx.fillStyle = '#dbeafe'
|
||||
ctx.strokeStyle = '#93c5fd'
|
||||
}
|
||||
ctx.lineWidth = 1
|
||||
roundRect(ctx, sx1, srcY, Math.max(sx2 - sx1, 4), trackH, 4, true, true)
|
||||
ctx.fillStyle = '#1d4ed8'
|
||||
roundRect(ctx, sx1, bandY, Math.max(sx2 - sx1, 4), trackH, 4, true, true)
|
||||
|
||||
ctx.fillStyle = type === 'reference' ? '#7c3aed' : '#1d4ed8'
|
||||
ctx.font = '10px system-ui'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText('Source ' + dateFrom + ' → ' + dateTo, sx1 + 6, srcY + 14)
|
||||
const bandLabel = type === 'reference' ? 'Reference' : 'Source'
|
||||
ctx.fillText(bandLabel + ' ' + dateFrom + ' → ' + dateTo, sx1 + 6, bandY + 14)
|
||||
|
||||
if (offsetMoTotal > 0) {
|
||||
// projected band + arrow (baseline only, when offset > 0)
|
||||
if (twoBands) {
|
||||
const px1 = xOf(projStart), px2 = xOf(projEnd)
|
||||
ctx.fillStyle = '#dcfce7'
|
||||
ctx.strokeStyle = '#86efac'
|
||||
@ -118,8 +131,7 @@ export default function Timeline({ dateFrom, dateTo, offsetYr, offsetMo }) {
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText('Projected ' + fmtDate(projStart) + ' → ' + fmtDate(projEnd), px1 + 6, projY + 14)
|
||||
|
||||
// arrow
|
||||
const arrowY = srcY + trackH / 2
|
||||
const arrowY = bandY + trackH / 2
|
||||
ctx.strokeStyle = '#94a3b8'
|
||||
ctx.lineWidth = 1
|
||||
ctx.setLineDash([3, 3])
|
||||
@ -135,13 +147,16 @@ export default function Timeline({ dateFrom, dateTo, offsetYr, offsetMo }) {
|
||||
ctx.lineTo(px1 - 4, arrowY + 4)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
const label = '+' + (offsetYr ? offsetYr + 'yr ' : '') + (offsetMo ? offsetMo + 'mo' : '')
|
||||
const offsetLabel = '+' + (offsetYr ? offsetYr + 'yr ' : '') + (offsetMo ? offsetMo + 'mo' : '')
|
||||
ctx.fillStyle = '#64748b'
|
||||
ctx.font = '9px system-ui'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(label.trim(), (sx1 + px1) / 2, arrowY - 5)
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
return { col: cols[0]?.cname || '', op: 'BETWEEN', values: ['', ''] }
|
||||
}
|
||||
@ -54,6 +70,7 @@ export default function Baseline() {
|
||||
const [creatingVer, setCreatingVer] = useState(false)
|
||||
|
||||
// add segment form
|
||||
const [segType, setSegType] = useState('baseline')
|
||||
const [description, setDescription] = useState('')
|
||||
const [filters, setFilters] = useState([])
|
||||
const [offsetYr, setOffsetYr] = useState(0)
|
||||
@ -61,12 +78,7 @@ export default function Baseline() {
|
||||
const [segNote, setSegNote] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// reference form
|
||||
const [refFrom, setRefFrom] = useState('')
|
||||
const [refTo, setRefTo] = useState('')
|
||||
const [refNote, setRefNote] = useState('')
|
||||
const [loadingRef, setLoadingRef] = useState(false)
|
||||
|
||||
const [expandedId, setExpandedId] = useState(null)
|
||||
const [msg, setMsg] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
@ -97,7 +109,7 @@ export default function Baseline() {
|
||||
|
||||
function loadLog() {
|
||||
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() {
|
||||
const clause = buildFilterClause(filters)
|
||||
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)
|
||||
try {
|
||||
const res = await fetch(`/api/versions/${versionId}/baseline`, {
|
||||
const res = await fetch(`/api/versions/${versionId}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
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()
|
||||
if (!res.ok) { flash(data.error, 'error'); return }
|
||||
flash(`Loaded ${data.row_count ?? ''} rows`)
|
||||
flash(`Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`)
|
||||
loadLog()
|
||||
setDescription(''); setSegNote(''); setOffsetYr(0); setOffsetMo(0)
|
||||
setFilters(filterCols.length > 0 ? [emptyFilter(filterCols)] : [])
|
||||
@ -196,26 +213,6 @@ export default function Baseline() {
|
||||
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() {
|
||||
const res = await fetch(`/api/versions/${versionId}/close`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
@ -337,6 +334,7 @@ export default function Baseline() {
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50">
|
||||
<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">note</th>
|
||||
<th className="px-3 py-1.5 font-medium">by</th>
|
||||
@ -346,19 +344,60 @@ export default function Baseline() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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) => (
|
||||
<tr key={entry.id} className="border-t border-gray-50">
|
||||
{log.map((entry, i) => {
|
||||
const isOpen = expandedId === entry.id
|
||||
const params = entry.params || {}
|
||||
const dr = parseDateRangeFromClause(params.where_clause)
|
||||
const off = parseOffset(params.date_offset)
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
key={entry.id}
|
||||
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">{entry.note || <span className="text-gray-300">—</span>}</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={() => undoSegment(entry.id)} className="text-gray-400 hover:text-red-500 text-xs">Undo</button>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
@ -370,6 +409,23 @@ export default function Baseline() {
|
||||
</div>
|
||||
<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 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
<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>)}
|
||||
@ -392,26 +450,28 @@ export default function Baseline() {
|
||||
{OPERATORS.map(op => <option key={op} value={op}>{op}</option>)}
|
||||
</select>
|
||||
{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>
|
||||
<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 === '!=') && (
|
||||
<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') && (
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{filters.length === 0 && (
|
||||
<span className="text-xs text-gray-300 italic">No filters — at least one is required</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date offset */}
|
||||
{/* Date offset — baseline only */}
|
||||
{segType === 'baseline' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-gray-500 w-28 shrink-0">Date offset</label>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -421,12 +481,19 @@ export default function Baseline() {
|
||||
<span className="text-xs text-gray-500">mo</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{dateRange && (
|
||||
<div className="ml-28">
|
||||
<div className="bg-white border border-gray-200 rounded p-3">
|
||||
<Timeline dateFrom={dateRange.from} dateTo={dateRange.to} offsetYr={offsetYr} offsetMo={offsetMo} />
|
||||
<div className="bg-gray-50 border border-gray-200 rounded p-3">
|
||||
<Timeline
|
||||
dateFrom={dateRange.from}
|
||||
dateTo={dateRange.to}
|
||||
offsetYr={segType === 'baseline' ? offsetYr : 0}
|
||||
offsetMo={segType === 'baseline' ? offsetMo : 0}
|
||||
type={segType}
|
||||
/>
|
||||
</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" />
|
||||
</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">
|
||||
{submitting ? 'Loading…' : 'Load Segment'}
|
||||
{submitting ? 'Loading…' : `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user