- 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>
521 lines
25 KiB
JavaScript
521 lines
25 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import Timeline from '../components/Timeline.jsx'
|
|
|
|
const OPERATORS = ['BETWEEN', '=', '!=', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL']
|
|
|
|
function buildFilterClause(filters) {
|
|
if (!filters.length) return null
|
|
const parts = filters.map(f => {
|
|
const col = `"${f.col}"`
|
|
const op = f.op
|
|
if (op === 'IS NULL') return `${col} IS NULL`
|
|
if (op === 'IS NOT NULL') return `${col} IS NOT NULL`
|
|
if (op === 'BETWEEN') {
|
|
const [a, b] = f.values
|
|
return `${col} BETWEEN '${a}' AND '${b}'`
|
|
}
|
|
if (op === 'IN' || op === 'NOT IN') {
|
|
const vals = f.values.join("','")
|
|
return `${col} ${op} ('${vals}')`
|
|
}
|
|
return `${col} ${op} '${f.values[0]}'`
|
|
})
|
|
return parts.join(' AND ')
|
|
}
|
|
|
|
function getDateRange(filters) {
|
|
for (const f of filters) {
|
|
if (f.op === 'BETWEEN' && f.values[0] && f.values[1]) {
|
|
return { from: f.values[0], to: f.values[1] }
|
|
}
|
|
if (f.op === '=' && f.values[0]) {
|
|
return { from: f.values[0], to: f.values[0] }
|
|
}
|
|
}
|
|
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: ['', ''] }
|
|
}
|
|
|
|
export default function Baseline() {
|
|
const [sources, setSources] = useState([])
|
|
const [sourceId, setSourceId] = useState('')
|
|
const [versions, setVersions] = useState([])
|
|
const [versionId, setVersionId] = useState('')
|
|
const [filterCols, setFilterCols] = useState([])
|
|
const [log, setLog] = useState([])
|
|
|
|
// new version form
|
|
const [showNewVersion, setShowNewVersion] = useState(false)
|
|
const [newVerName, setNewVerName] = useState('')
|
|
const [newVerDesc, setNewVerDesc] = useState('')
|
|
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)
|
|
const [offsetMo, setOffsetMo] = useState(0)
|
|
const [segNote, setSegNote] = useState('')
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
const [expandedId, setExpandedId] = useState(null)
|
|
const [msg, setMsg] = useState(null)
|
|
|
|
useEffect(() => {
|
|
fetch('/api/sources').then(r => r.json()).then(data => {
|
|
setSources(data)
|
|
if (data.length > 0) setSourceId(String(data[0].id))
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!sourceId) return
|
|
fetch(`/api/sources/${sourceId}/versions`).then(r => r.json()).then(data => {
|
|
setVersions(data)
|
|
if (data.length > 0) setVersionId(String(data[0].id))
|
|
else setVersionId('')
|
|
})
|
|
fetch(`/api/sources/${sourceId}/cols`).then(r => r.json()).then(cols => {
|
|
const fc = cols.filter(c => c.role === 'date' || c.role === 'filter')
|
|
setFilterCols(fc)
|
|
setFilters(fc.length > 0 ? [emptyFilter(fc)] : [])
|
|
})
|
|
}, [sourceId])
|
|
|
|
useEffect(() => {
|
|
if (!versionId) { setLog([]); return }
|
|
loadLog()
|
|
}, [versionId])
|
|
|
|
function loadLog() {
|
|
fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => {
|
|
setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference'))
|
|
})
|
|
}
|
|
|
|
async function createVersion() {
|
|
if (!newVerName.trim()) return
|
|
setCreatingVer(true)
|
|
try {
|
|
const res = await fetch(`/api/sources/${sourceId}/versions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: newVerName.trim(), description: newVerDesc, created_by: 'admin' })
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) { flash(data.error, 'error'); return }
|
|
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
|
setVersions(updated)
|
|
setVersionId(String(data.id))
|
|
setShowNewVersion(false)
|
|
setNewVerName('')
|
|
setNewVerDesc('')
|
|
flash(`Version "${data.name}" created`)
|
|
} catch (err) {
|
|
flash(err.message, 'error')
|
|
} finally {
|
|
setCreatingVer(false)
|
|
}
|
|
}
|
|
|
|
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() {
|
|
const clause = buildFilterClause(filters)
|
|
if (!clause) { flash('Add at least one filter', 'error'); return }
|
|
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}/${endpoint}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) { flash(data.error, 'error'); return }
|
|
flash(`Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`)
|
|
loadLog()
|
|
setDescription(''); setSegNote(''); setOffsetYr(0); setOffsetMo(0)
|
|
setFilters(filterCols.length > 0 ? [emptyFilter(filterCols)] : [])
|
|
} catch (err) {
|
|
flash(err.message, 'error')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
async function undoSegment(logid) {
|
|
await fetch(`/api/log/${logid}`, { method: 'DELETE' })
|
|
loadLog()
|
|
flash('Segment undone')
|
|
}
|
|
|
|
async function clearBaseline() {
|
|
if (!confirm('Delete all baseline rows for this version?')) return
|
|
const res = await fetch(`/api/versions/${versionId}/baseline`, { method: 'DELETE' })
|
|
const data = await res.json()
|
|
if (!res.ok) { flash(data.error, 'error'); return }
|
|
loadLog()
|
|
flash(`Cleared ${data.rows_deleted} rows`)
|
|
}
|
|
|
|
async function closeVersion() {
|
|
const res = await fetch(`/api/versions/${versionId}/close`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ pf_user: 'admin' })
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) { flash(data.error, 'error'); return }
|
|
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
|
setVersions(updated)
|
|
flash('Version closed')
|
|
}
|
|
|
|
async function reopenVersion() {
|
|
const res = await fetch(`/api/versions/${versionId}/reopen`, { method: 'POST' })
|
|
const data = await res.json()
|
|
if (!res.ok) { flash(data.error, 'error'); return }
|
|
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
|
setVersions(updated)
|
|
flash('Version reopened')
|
|
}
|
|
|
|
async function deleteVersion() {
|
|
if (!confirm(`Delete version "${selectedVersion?.name}"? This drops the forecast table and cannot be undone.`)) return
|
|
const res = await fetch(`/api/versions/${versionId}`, { method: 'DELETE' })
|
|
const data = await res.json()
|
|
if (!res.ok) { flash(data.error, 'error'); return }
|
|
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
|
setVersions(updated)
|
|
setVersionId(updated.length > 0 ? String(updated[0].id) : '')
|
|
flash('Version deleted')
|
|
}
|
|
|
|
function flash(text, type = 'ok') {
|
|
setMsg({ text, type })
|
|
setTimeout(() => setMsg(null), 3000)
|
|
}
|
|
|
|
const dateRange = getDateRange(filters)
|
|
const selectedVersion = versions.find(v => String(v.id) === versionId)
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto bg-gray-50">
|
|
<div className="p-4 flex flex-col gap-4 max-w-4xl">
|
|
|
|
{/* Flash */}
|
|
{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'}`}>
|
|
{msg.text}
|
|
</div>
|
|
)}
|
|
|
|
{/* Source + Version bar */}
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-gray-500">Source</span>
|
|
<select value={sourceId} onChange={e => setSourceId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
|
|
{sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-gray-500">Version</span>
|
|
<select value={versionId} onChange={e => setVersionId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white" disabled={versions.length === 0}>
|
|
{versions.length === 0
|
|
? <option value="">— no versions —</option>
|
|
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)
|
|
}
|
|
</select>
|
|
{versionId && (
|
|
<span className={`text-xs font-medium ${selectedVersion?.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}>
|
|
{selectedVersion?.status}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button onClick={() => setShowNewVersion(v => !v)} className="text-xs text-blue-600 hover:text-blue-700 border border-blue-200 px-2 py-1 rounded">
|
|
+ New version
|
|
</button>
|
|
{versionId && (
|
|
<div className="flex items-center gap-2 ml-2">
|
|
{selectedVersion?.status === 'open'
|
|
? <button onClick={closeVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Close</button>
|
|
: <button onClick={reopenVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Reopen</button>
|
|
}
|
|
<button onClick={deleteVersion} className="text-xs text-red-400 hover:text-red-600 border border-red-200 px-2 py-1 rounded">Delete</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* New version inline form */}
|
|
{showNewVersion && (
|
|
<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 flex-col gap-1">
|
|
<label className="text-xs text-gray-500">Name</label>
|
|
<input value={newVerName} onChange={e => setNewVerName(e.target.value)} placeholder="e.g. FY2026 Plan" className="border border-gray-200 rounded px-2 py-1 text-sm w-48" />
|
|
</div>
|
|
<div className="flex flex-col gap-1 flex-1">
|
|
<label className="text-xs text-gray-500">Description</label>
|
|
<input value={newVerDesc} onChange={e => setNewVerDesc(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1 text-sm" />
|
|
</div>
|
|
<button onClick={createVersion} disabled={creatingVer || !newVerName.trim()} className="bg-blue-600 text-white text-xs px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50 shrink-0">
|
|
{creatingVer ? 'Creating table…' : 'Create'}
|
|
</button>
|
|
<button onClick={() => setShowNewVersion(false)} className="text-gray-400 hover:text-gray-600 text-xs shrink-0">Cancel</button>
|
|
</div>
|
|
<div className="text-xs text-gray-400 border-t border-gray-100 pt-2">
|
|
Creates a forecast table <span className="font-mono text-gray-500">pf.fc_{sources.find(s=>String(s.id)===sourceId)?.tname}_<id></span> in the database from the current col meta. If col meta changes after creation the table and SQL will be out of sync — delete and recreate the version to realign.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{versionId && <>
|
|
|
|
{/* Segments loaded */}
|
|
<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 flex items-center justify-between">
|
|
<span>Segments loaded</span>
|
|
<button onClick={clearBaseline} className="text-red-400 hover:text-red-600 text-xs normal-case font-normal">Clear all baseline</button>
|
|
</div>
|
|
<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>
|
|
<th className="px-3 py-1.5 font-medium">when</th>
|
|
<th className="px-3 py-1.5 font-medium"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{log.length === 0 && (
|
|
<tr><td colSpan={6} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
|
|
)}
|
|
{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">
|
|
<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>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Add Segment */}
|
|
<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">
|
|
Add Segment
|
|
</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>
|
|
<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>
|
|
|
|
{/* Filters */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<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>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5 ml-28">
|
|
{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>)}
|
|
</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">
|
|
{OPERATORS.map(op => <option key={op} value={op}>{op}</option>)}
|
|
</select>
|
|
{f.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" />
|
|
<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" />
|
|
</>}
|
|
{(f.op === '=' || f.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" />
|
|
)}
|
|
{(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 — 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">
|
|
<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" />
|
|
<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" />
|
|
<span className="text-xs text-gray-500">mo</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Timeline */}
|
|
{dateRange && (
|
|
<div className="ml-28">
|
|
<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>
|
|
)}
|
|
|
|
{/* Note + submit */}
|
|
<div className="flex items-end gap-3">
|
|
<div className="flex flex-col gap-1 flex-1 max-w-xs">
|
|
<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" />
|
|
</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 ${segType === 'reference' ? 'Reference' : 'Segment'}`}
|
|
</button>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
</>}
|
|
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|