pf_app/ui/src/views/Baseline.jsx
Paul Trowbridge 0cabe9bcd2 Put the fallback display names on the version
adjustment_segment, adjustment_bucket and unlabeled_load are columns on
pf.version now, edited under "Fallback names" on the Baseline page, with the
constants in sql_generator left as the built-in for a version that sets none.

Read through a join, not substituted at generation: pf.sql is keyed on
(source_id, operation) and shared by every version of a source, so a baked-in
value could not vary by version and regenerating for one would change the
others. The join costs three more GROUP BY columns on /agg, all functionally
dependent on a version id that is already fixed for the whole query.

The built-ins stay a convention guess -- ADJUSTMENT_BUCKET's "04 - " suits one
numbering -- which is now a default to override rather than the only answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:47:17 -04:00

881 lines
41 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
}
// The offset is stored and sent as a Postgres interval, so it is typed as one --
// "4 months", "1 year", "-90 days". This only parses it far enough to draw the
// timeline preview; Postgres remains the authority on what is valid, and the
// server rejects anything it will not accept.
//
// It replaced a pair of year/month number spinners, which could not express days
// at all and were clamped at zero, so a segment could only ever be shifted
// forward in whole months.
export function parseInterval(str) {
let months = 0, days = 0
if (!str) return { months, days }
const re = /([+-]?\d+(?:\.\d+)?)\s*(years?|yrs?|y|months?|mons?|mo|weeks?|wks?|w|days?|d)\b/gi
for (const [, n, unit] of str.matchAll(re)) {
const v = parseFloat(n)
const u = unit.toLowerCase()
if (u.startsWith('y')) months += v * 12
else if (u.startsWith('mo') || u === 'mons' || u === 'mon') months += v
else if (u.startsWith('w')) days += v * 7
else if (u.startsWith('d')) days += v
else months += v // 'm' alone reads as months here
}
return { months, days }
}
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 [filters, setFilters] = useState([]) // [[cond,...], [cond,...]]
const [useRaw, setUseRaw] = useState(false)
const [rawSql, setRawSql] = useState('')
const [offset, setOffset] = useState('0 days')
const [segNote, setSegNote] = useState('')
// Presentation, not definition: what the segment counts toward and how it is
// labelled in the pivot. Safe to set at any time, unlike its filters.
const [segBucket, setSegBucket] = useState('')
const [segLabel, setSegLabel] = 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 => {
// What a load is filtered by and what the pivot groups by are separate
// concerns: a dimension is exactly the sort of thing a segment is cut on
// (sseas, channel_new), and forcing it to role 'filter' to get it here
// would cost it its place on the pivot. Only measures are excluded.
const fc = cols.filter(c => ['date', 'filter', 'dimension'].includes(c.role))
setFilterCols(fc)
setFilters(fc.length > 0 ? [emptyGroup(fc)] : [])
})
}, [sourceId])
useEffect(() => {
if (!versionId) { setLog([]); return }
loadLog()
}, [versionId])
// A segment's banner: what it counts toward, independent of pf_iter. Held
// locally while typing so the field does not fight the fetched value, and
// written on blur.
const [buckets, setBuckets] = useState({})
// Buckets already in use on this version, for the datalist. There is no stored
// bucket order any more: the order is whatever the typed text sorts as, so a
// bucket is named "02 - Forecast" and that is the whole mechanism.
const [bucketsInUse, setBucketsInUse] = useState([])
// Label and bucket are presentation, not definition: they change what the pivot
// shows and what the segment counts toward, never which rows were loaded. So
// they stay editable after adjustments exist, unlike the filters and offset,
// where an edit would silently recalibrate scales sized against the old rows.
//
// Both are only read at load time, hence the reload in the confirmation: the
// label is part of the aggregated row the pivot holds, not something it can
// re-derive in place.
async function saveLogField(entry, field, value) {
const next = value.trim()
if (next === (entry[field] || '')) return
try {
const res = await fetch(`/api/log/${entry.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: next }),
})
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
loadLog()
flash(next ? `Saved — reload the Forecast view to see it` : 'Cleared')
} catch (err) { flash(err.message, 'error') }
}
// Column widths read off the content rather than guessed. ch is the width of a
// '0', so for proportional text it runs slightly generous -- which is what is
// wanted for an input you are about to type a longer name into. The floors keep
// an empty table from collapsing its headers; the ceilings keep one long note
// from pushing the numbers off the side.
function widthCh(values, min, max) {
const longest = values.reduce((n, v) => Math.max(n, String(v || '').length), 0)
return `${Math.min(max, Math.max(min, longest + 2))}ch`
}
const labelW = widthCh(log.map(e => e.label || e.tag || e.note), 18, 34)
const bucketW = widthCh(log.map(e => e.bucket), 16, 24)
const noteW = widthCh(log.map(e => e.note), 22, 44)
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)))
// Every bucket in use, adjustments included: typing one on a new segment
// should offer the ones already there rather than inviting a near-miss
// spelling, which would silently split the column in two.
setBucketsInUse([...new Set(data.map(e => (e.bucket || '').trim()).filter(Boolean))].sort())
})
}
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 = offset.trim() || '0 days'
const endpoint = isRef ? 'reference' : 'baseline'
const body = {
where_clause: clause,
note: segNote,
date_offset: offsetStr,
label: segLabel.trim(),
bucket: segBucket.trim(),
...(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 || '')
setSegLabel(entry.label || '')
setSegBucket(entry.bucket || '')
setOffset(params.date_offset || '0 days')
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)
setSegNote('')
setSegLabel('')
setSegBucket('')
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({})
})
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)
}
// The names a row falls back to when nobody has named it. Blank means "use the
// built-in", so these save on blur like the segment fields and an empty box is
// a meaningful value rather than a missing one.
async function saveVersionName(field, value) {
const next = value.trim()
if (next === (selectedVersion?.[field] || '')) return
try {
const res = await fetch(`/api/versions/${versionId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: next }),
})
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
await refreshVersions(sourceId)
flash('Saved — reload the Forecast view to see it')
} catch (err) { flash(err.message, 'error') }
}
const selectedVersion = versions.find(v => String(v.id) === versionId)
return (
<div className="h-full overflow-y-auto bg-gray-50">
{/* No page-wide cap and no stretching: at max-w-4xl (896px) the eleven-column
segment table always had something smashed, and uncapped it ran to the
window. items-start makes each block as wide as its own content needs,
which for the table is the measured column widths below. */}
<div className="p-4 flex flex-col gap-4 items-start max-w-full">
{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>
{/* Fallback names. Not part of any segment -- they are what the pivot shows
for rows nobody has named, so they belong to the version rather than to
a log entry. Blank falls back to the built-in in sql_generator's
DISPLAY DEFAULTS block. */}
{versionId && (
<div className="bg-white border border-gray-200 rounded p-3 flex items-end gap-3 flex-wrap">
<span className="text-xs text-gray-500 uppercase tracking-wide w-full">Fallback names</span>
{[
['adjustment_segment', 'Adjustment segment', '99 - Adjustments'],
['adjustment_bucket', 'Adjustment bucket', '04 - Forecast'],
['unlabeled_load', 'Unlabeled load', 'Unlabeled'],
].map(([field, label, builtin]) => (
<div key={field} className="flex flex-col gap-1">
<label className="text-xs text-gray-500">{label}</label>
<input
key={`${field}-${versionId}-${selectedVersion?.[field] || ''}`}
defaultValue={selectedVersion?.[field] || ''}
onBlur={e => saveVersionName(field, e.target.value)}
placeholder={builtin}
className="border border-gray-200 rounded px-2 py-1 text-sm w-48" />
</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 max-w-full">
<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>
<datalist id="pf-bucket-options">
{[...new Set([...bucketsInUse,
'01 - Prior Prior Year', '02 - Prior Year',
'03 - Plan', '04 - Forecast'])].map(b => (
<option key={b} value={b} />
))}
</datalist>
<table className="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 w-20">kind</th>
<th className="px-3 py-1.5 font-medium" style={{ width: labelW }}>label</th>
<th className="px-3 py-1.5 font-medium" style={{ width: noteW }}>note</th>
<th className="px-3 py-1.5 font-medium" style={{ width: bucketW }}>counts toward</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={11} 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={11} 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>
{/* The operation badge gets its own column. Sharing one with
the note put "reference" hard against "YTD Sales" as soon
as the note column lost width to label and bucket. */}
<td className="px-3 py-2">
<span className={`inline-block 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>
</td>
<td className="px-3 py-2" onClick={e => e.stopPropagation()}>
<input
defaultValue={entry.label || ''}
key={`label-${entry.id}-${entry.label || ''}`}
onBlur={e => saveLogField(entry, 'label', e.target.value)}
placeholder={entry.tag || entry.note || '—'}
className="w-full border border-transparent hover:border-gray-200
focus:border-blue-400 rounded px-1 py-0.5 text-xs
focus:outline-none bg-transparent" />
</td>
{/* One line, clipped against the measured width above. The
note is provenance and can run long, so left to itself it
wrapped and pushed every row to two or three lines.
Expanding the row shows it in full. The cap is on the div,
not the cell: a max-width on a cell in an auto-layout table
is only a hint, and the column can still collapse to
min-content or grow past it. */}
<td className="px-3 py-2">
{entry.note
? <div className="truncate" style={{ maxWidth: noteW }} title={entry.note}>{entry.note}</div>
: <span className="text-gray-300"></span>}
</td>
<td className="px-3 py-2" onClick={e => e.stopPropagation()}>
<input
value={buckets[entry.id] ?? entry.bucket ?? ''}
list="pf-bucket-options"
onChange={e => setBuckets(b => ({ ...b, [entry.id]: e.target.value }))}
onBlur={e => saveLogField(entry, 'bucket', e.target.value)}
placeholder="—"
className="w-full border border-transparent hover:border-gray-200 focus:border-blue-400
rounded px-1 py-0.5 text-xs focus:outline-none bg-transparent" />
</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={9} 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}
segNote={segNote} setSegNote={setSegNote}
segBucket={segBucket} setSegBucket={setSegBucket}
segLabel={segLabel} setSegLabel={setSegLabel}
offset={offset} setOffset={setOffset}
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 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 || '',
segNote: entry.note || '',
segBucket: entry.bucket || '',
segLabel: entry.label || '',
offset: params.date_offset || '0 days',
}
}
function SegmentForm({
mode, // 'view' | 'edit'
segType, setSegType,
filters, setFilters,
useRaw, setUseRaw,
rawSql, setRawSql,
segNote, setSegNote,
segBucket, setSegBucket,
segLabel, setSegLabel,
offset, setOffset,
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>
{/* 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} value={offset} list="pf-offset-options"
onChange={e => setOffset(e.target.value)}
placeholder="0 days"
className={`${baseInp} text-sm w-32`} />
<datalist id="pf-offset-options">
<option value="12 months" />
<option value="1 year" />
<option value="4 months" />
<option value="-90 days" />
<option value="-12 months" />
<option value="0 days" />
</datalist>
<span className="text-xs text-gray-400">any Postgres interval</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}
offsetMonths={parseInterval(offset).months}
offsetDays={parseInterval(offset).days}
type={segType}
/>
</div>
</div>
)}
{/* Label, bucket, note + submit.
Label and bucket are presentation: the label is what the pivot shows for
this segment, the bucket is what it counts toward. Both are free text and
both sort by what is typed, so a leading "01 - " is how ordering is set —
which is why they belong here, at the point the segment is defined, as
well as being editable in the list afterwards. */}
<div className="flex items-end gap-3 flex-wrap">
<div className="flex flex-col gap-1 max-w-xs">
<label className="text-xs text-gray-500">Label</label>
<input disabled={disabled} value={segLabel} onChange={e => setSegLabel(e.target.value)}
placeholder="defaults to the note" className={`${baseInp} text-sm py-1.5`} />
</div>
<div className="flex flex-col gap-1 max-w-xs">
<label className="text-xs text-gray-500">Counts toward</label>
<input disabled={disabled} value={segBucket} onChange={e => setSegBucket(e.target.value)}
list="pf-bucket-options" placeholder="e.g. 02 - Forecast"
className={`${baseInp} text-sm py-1.5`} />
</div>
<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>
)
}