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>
700 lines
31 KiB
JavaScript
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}_<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 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>
|
|
)
|
|
}
|