pf_app/ui/src/views/Baseline.jsx
Paul Trowbridge 953ae2709f Reference offsets, edit visual cues, todo updates
Reference segments can now apply a date offset just like baselines.
SQL template gains the {{date_offset}} token; both POST /reference and
PUT baseline/:logid pass it through. Existing sources need to
regenerate SQL to pick up the new template — old stored reference SQL
ignores the token (preserving prior verbatim behavior). The Baseline
form drops the "dates land verbatim" hint and shows the offset
control for both segment types.

Editing a segment now color-codes the source row amber with a ring
and tints the form border + header amber so the active connection is
visually obvious. Header label reads "Edit segment #3 — baseline —
note" instead of just "#43" (the internal log id).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 11:08:49 -04:00

700 lines
31 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 buildCondition(c) {
const col = `"${c.col}"`
if (c.op === 'IS NULL') return `${col} IS NULL`
if (c.op === 'IS NOT NULL') return `${col} IS NOT NULL`
if (c.op === 'BETWEEN') {
const [a, b] = c.values
if (!a || !b) return null
return `${col} BETWEEN '${a}' AND '${b}'`
}
if (c.op === 'IN' || c.op === 'NOT IN') {
const v = (c.values[0] || '').split(',').map(s => s.trim()).filter(Boolean)
if (!v.length) return null
return `${col} ${c.op} ('${v.join("','")}')`
}
if (!c.values[0]) return null
return `${col} ${c.op} '${c.values[0]}'`
}
function buildFilterClause(groups) {
if (!groups?.length) return null
const parts = groups
.map(g => g.map(buildCondition).filter(Boolean).join(' AND '))
.filter(s => s.length > 0)
.map(s => `(${s})`)
if (!parts.length) return null
return parts.join(' OR ')
}
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] }
return null
}
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
}
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 emptyCondition(cols) {
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 }) {
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)
// segment form
const [segType, setSegType] = useState('baseline')
const [description, setDescription] = useState('')
const [filters, setFilters] = useState([]) // [[cond,...], [cond,...]]
const [useRaw, setUseRaw] = useState(false)
const [rawSql, setRawSql] = useState('')
const [offsetYr, setOffsetYr] = useState(0)
const [offsetMo, setOffsetMo] = useState(0)
const [segNote, setSegNote] = useState('')
const [submitting, setSubmitting] = useState(false)
const [editingLogId, setEditingLogId] = useState(null)
const [showAddForm, setShowAddForm] = useState(false)
const [hasForecastOps, setHasForecastOps] = useState(false)
const [expandedId, setExpandedId] = useState(null)
const [msg, setMsg] = useState(null)
useEffect(() => {
if (!sourceId) return
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 ? [emptyGroup(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'))
setHasForecastOps(data.some(e => ['scale', 'recode', 'clone'].includes(e.operation)))
})
}
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 }
await refreshVersions(sourceId)
setVersionId(String(data.id))
setShowNewVersion(false)
setNewVerName('')
setNewVerDesc('')
flash(`Version "${data.name}" created`)
} catch (err) {
flash(err.message, 'error')
} finally {
setCreatingVer(false)
}
}
async function loadSegment() {
const clause = useRaw ? rawSql.trim() : buildFilterClause(filters)
if (!clause) { flash(useRaw ? 'Enter a WHERE clause' : 'Add at least one filter', 'error'); return }
const isRef = segType === 'reference'
const offsetStr = [offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days'
const endpoint = isRef ? 'reference' : 'baseline'
const body = {
where_clause: clause,
pf_user: 'admin',
note: description || segNote,
date_offset: offsetStr,
...(useRaw ? { raw_where: clause } : { filters }),
}
setSubmitting(true)
try {
const url = editingLogId
? `/api/versions/${versionId}/baseline/${editingLogId}`
: `/api/versions/${versionId}/${endpoint}`
const method = editingLogId ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
flash(editingLogId
? `Updated — ${data.rows_deleted} rows replaced with ${data.rows_affected}`
: `Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`)
loadLog()
cancelEdit()
} catch (err) {
flash(err.message, 'error')
} finally {
setSubmitting(false)
}
}
function startEdit(entry) {
if (hasForecastOps) {
flash('Undo forecast operations first to edit segments', 'error')
return
}
const params = entry.params || {}
setSegType(entry.operation)
setSegNote(entry.note || '')
setDescription('')
const off = parseOffset(params.date_offset)
setOffsetYr(off.yr)
setOffsetMo(off.mo)
const groups = normalizeFilters(params.filters)
if (groups) {
setUseRaw(false)
setRawSql('')
setFilters(groups)
} else if (params.where_clause) {
// 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)
setExpandedId(null)
setTimeout(() => {
document.getElementById('add-segment')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 0)
}
function cancelEdit() {
setEditingLogId(null)
setShowAddForm(false)
setDescription('')
setSegNote('')
setOffsetYr(0)
setOffsetMo(0)
setUseRaw(false)
setRawSql('')
setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : [])
}
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 }
await refreshVersions(sourceId)
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 }
await refreshVersions(sourceId)
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 refreshVersions(sourceId)
setVersionId(updated.length > 0 ? String(updated[0].id) : '')
flash('Version deleted')
}
function flash(text, type = 'ok') {
setMsg({ text, type })
setTimeout(() => setMsg(null), 3000)
}
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">
{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>
)}
{/* Version actions */}
<div className="flex items-center gap-3 flex-wrap">
<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">
{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>
{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}_&lt;id&gt;</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 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">when</th>
<th className="px-3 py-1.5 font-medium"></th>
</tr>
</thead>
<tbody>
{log.length === 0 && (
<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) => {
const isOpen = expandedId === entry.id
const view = segmentValuesFor(entry, filterCols)
return (
<>
<tr
key={entry.id}
onClick={() => setExpandedId(isOpen ? null : entry.id)}
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${editingLogId === entry.id ? 'bg-amber-50 ring-1 ring-amber-300 ring-inset' : 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-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-400">{new Date(entry.stamp).toLocaleDateString()}</td>
<td className="px-3 py-2 text-right">
{!hasForecastOps && (
<button onClick={e => { e.stopPropagation(); startEdit(entry) }} className="text-gray-400 hover:text-blue-600 text-xs mr-3">Edit</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-2 py-2">
<div className="bg-white border border-gray-200 rounded">
<SegmentForm mode="view" {...view} filterCols={filterCols} />
</div>
</td>
</tr>
)}
</>
)
})}
</tbody>
</table>
</div>
{/* Add / Edit Segment */}
{(showAddForm || editingLogId) && (
<div id="add-segment" className={`bg-white border rounded ${editingLogId ? 'border-amber-300' : 'border-gray-200'}`}>
<div className={`px-3 py-2 border-b text-xs font-medium uppercase tracking-wide flex items-center justify-between ${editingLogId ? 'bg-amber-50 border-amber-200 text-amber-800' : 'bg-white border-gray-100 text-gray-500'}`}>
<span>{(() => {
if (!editingLogId) return 'Add Segment'
const idx = log.findIndex(e => e.id === editingLogId)
if (idx < 0) return 'Edit Segment'
const entry = log[idx]
const segNum = log.length - idx
const label = entry.operation === 'reference' ? 'reference' : 'baseline'
return entry.note
? `Edit segment #${segNum}${label}${entry.note}`
: `Edit segment #${segNum}${label}`
})()}</span>
<button onClick={cancelEdit} className="text-gray-400 hover:text-gray-600 normal-case font-normal">
{editingLogId ? 'Cancel edit' : 'Close'}
</button>
</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">
{/* Type */}
<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}
disabled={disabled}
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>
))}
</div>
</div>
{/* Description (edit only) */}
{mode === 'edit' && (
<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>
{!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>
{!useRaw && (
<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 (
<div key={ci} className="flex items-center gap-2 flex-wrap">
<select disabled={disabled} value={c.col} onChange={e => updateCondition(gi, ci, 'col', e.target.value)} className={baseInp}>
{filterCols.map(fc => <option key={fc.cname} value={fc.cname}>{fc.cname}</option>)}
</select>
<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>)}
</select>
{c.op === 'BETWEEN' && <>
<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>
<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`} />
</>}
{(c.op === '=' || c.op === '!=') && (
<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`} />
)}
{(c.op === 'IN' || c.op === 'NOT IN') && (
<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>
)}
</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 && (
<span className="text-xs text-gray-300 italic">No filters at least one is required</span>
)}
</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>
{/* Date offset */}
<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 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>
<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>
</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={offsetYr}
offsetMo={offsetMo}
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 disabled={disabled} value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className={`${baseInp} text-sm py-1.5`} />
</div>
{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
? (editing ? 'Saving…' : 'Loading…')
: (editing
? `Save ${segType === 'reference' ? 'Reference' : 'Segment'}`
: `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`)}
</button>
)}
</div>
</div>
)
}