A tag was only ever a value typed on a pf.log entry, so the only way to change one was to find every entry carrying it in the change log and edit each. Nothing showed what the list was, and the bridge groups on this column -- so "Pull Forward" and "Pulled Forward" read as two initiatives with no way to make them one. PATCH /versions/:id/tags rewrites every entry carrying `from`. A `to` that already exists merges the two, an empty one clears the tag; the panel confirms both, since neither is visible as a diff afterwards. Permission is the pf.log rule applied to every entry the tag is on rather than most of them: a half-applied rename leaves the initiative split, which is what the rename was for. GET /versions/:id/tags now returns owners and can_edit so the panel can grey the row out instead of offering a click that answers 403. On the Baseline page, next to the other version-level settings -- the Forecast page edits one entry's tag, this edits the column. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1346 lines
64 KiB
JavaScript
1346 lines
64 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import Timeline from '../components/Timeline.jsx'
|
|
import useAuth from '../auth.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 { user: me } = useAuth()
|
|
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)
|
|
const [cloneFrom, setCloneFrom] = useState('')
|
|
// Planned segments: a version's definition before it has been run. Separate
|
|
// from `log`, which is what has actually happened.
|
|
const [plans, setPlans] = useState([])
|
|
const [editingPlanId, setEditingPlanId] = useState(null)
|
|
const [runningAll, setRunningAll] = useState(false)
|
|
|
|
// Tags in use on this version, and the rename in progress. A tag is not a row
|
|
// anywhere -- only a value repeated across pf.log -- so the list is derived
|
|
// and editing one rewrites every entry that carries it.
|
|
const [tags, setTags] = useState([])
|
|
const [editingTag, setEditingTag] = useState(null) // { from, text }
|
|
const [savingTag, setSavingTag] = 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([]); setPlans([]); setTags([]); return }
|
|
loadLog()
|
|
loadPlans()
|
|
loadTags()
|
|
}, [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.
|
|
// Same rule the server enforces: your own entries, or an admin's. A segment's
|
|
// label and bucket name the pivot's columns for everyone in the version, so
|
|
// they are not the private annotation they look like.
|
|
const canEdit = (entry) => !!me && (me.is_admin || entry.pf_user === me.username)
|
|
|
|
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())
|
|
})
|
|
}
|
|
|
|
function loadTags() {
|
|
fetch(`/api/versions/${versionId}/tags`)
|
|
.then(r => r.ok ? r.json() : [])
|
|
.then(d => setTags(Array.isArray(d) ? d : []))
|
|
.catch(() => setTags([]))
|
|
}
|
|
|
|
// Renaming onto a tag that already exists merges the two, which is usually
|
|
// the reason to be here at all -- so say so before it happens rather than
|
|
// reporting it afterwards.
|
|
async function saveTag(from, raw) {
|
|
const to = (raw || '').trim()
|
|
if (to === from) { setEditingTag(null); return }
|
|
const merges = to && tags.some(t => t.tag === to)
|
|
if (merges && !confirm(`Merge "${from}" into "${to}"? Both become one initiative in the bridge.`)) return
|
|
if (!to && !confirm(`Clear "${from}" from every entry that carries it?`)) return
|
|
setSavingTag(true)
|
|
try {
|
|
const res = await fetch(`/api/versions/${versionId}/tags`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ from, to }),
|
|
})
|
|
const d = await res.json()
|
|
if (!res.ok) { flash(d.error, 'error'); return }
|
|
setEditingTag(null)
|
|
loadTags(); loadLog()
|
|
flash(to
|
|
? `${d.rows_affected} ${d.rows_affected === 1 ? 'entry' : 'entries'} now tagged "${to}"`
|
|
: `Cleared from ${d.rows_affected} ${d.rows_affected === 1 ? 'entry' : 'entries'}`)
|
|
} catch (err) { flash(err.message, 'error') }
|
|
finally { setSavingTag(false) }
|
|
}
|
|
|
|
function loadPlans() {
|
|
fetch(`/api/versions/${versionId}/plans`)
|
|
.then(r => r.ok ? r.json() : [])
|
|
.then(setPlans)
|
|
.catch(() => setPlans([]))
|
|
}
|
|
|
|
// Run one plan: the same call the Add Segment form makes, then stamp the log
|
|
// id back onto the plan so the list can tell run from unrun. Returns the
|
|
// error text, or null on success, so Run all can stop at the first failure
|
|
// rather than carrying on down a definition that has gone wrong.
|
|
async function runPlan(plan) {
|
|
const body = {
|
|
where_clause: plan.raw_where || buildFilterClause(normalizeFilters(plan.filters) || []),
|
|
note: plan.note || '',
|
|
date_offset: plan.date_offset || '0 days',
|
|
label: plan.label || '',
|
|
bucket: plan.bucket || '',
|
|
...(plan.raw_where ? { raw_where: plan.raw_where } : { filters: plan.filters }),
|
|
}
|
|
if (!body.where_clause) return 'No filters on this plan'
|
|
try {
|
|
const res = await fetch(`/api/versions/${versionId}/${plan.kind}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) return data.error || 'Failed'
|
|
const logId = data.log_id ?? data.id ?? null
|
|
if (logId) {
|
|
await fetch(`/api/plans/${plan.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ run_logid: logId }),
|
|
})
|
|
}
|
|
return null
|
|
} catch (err) { return err.message }
|
|
}
|
|
|
|
async function runOnePlan(plan) {
|
|
setSubmitting(true)
|
|
const err = await runPlan(plan)
|
|
setSubmitting(false)
|
|
loadLog(); loadPlans()
|
|
flash(err || `Loaded “${plan.label || plan.tag || plan.kind}”`, err ? 'error' : 'ok')
|
|
}
|
|
|
|
// In seq order, and stopping at the first failure: the segments of a
|
|
// definition are not independent -- a baseline followed by references that
|
|
// date-shift off it -- so carrying on past a broken one builds something
|
|
// nobody described.
|
|
async function runAllPlans() {
|
|
const pending = plans.filter(p => !p.run_logid)
|
|
if (!pending.length) return
|
|
setRunningAll(true)
|
|
let done = 0
|
|
for (const plan of pending) {
|
|
const err = await runPlan(plan)
|
|
if (err) {
|
|
setRunningAll(false)
|
|
loadLog(); loadPlans()
|
|
flash(`Stopped after ${done} of ${pending.length}: ${err}`, 'error')
|
|
return
|
|
}
|
|
done++
|
|
}
|
|
setRunningAll(false)
|
|
loadLog(); loadPlans()
|
|
flash(`Loaded ${done} segment${done === 1 ? '' : 's'}`)
|
|
}
|
|
|
|
async function deletePlan(plan) {
|
|
await fetch(`/api/plans/${plan.id}`, { method: 'DELETE' })
|
|
if (editingPlanId === plan.id) cancelEdit()
|
|
loadPlans()
|
|
}
|
|
|
|
// A plan opens in the same form a segment does; the difference is only which
|
|
// call the submit makes.
|
|
function startEditPlan(plan) {
|
|
setSegType(plan.kind)
|
|
setSegNote(plan.note || '')
|
|
setSegLabel(plan.label || '')
|
|
setSegBucket(plan.bucket || '')
|
|
setOffset(plan.date_offset || '0 days')
|
|
const groups = normalizeFilters(plan.filters)
|
|
if (groups) { setUseRaw(false); setRawSql(''); setFilters(groups) }
|
|
else if (plan.raw_where) { setUseRaw(true); setRawSql(plan.raw_where) }
|
|
else { setUseRaw(false); setRawSql(''); setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : []) }
|
|
setEditingLogId(null)
|
|
setEditingPlanId(plan.id)
|
|
setExpandedId(null)
|
|
setTimeout(() => {
|
|
document.getElementById('add-segment')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
}, 0)
|
|
}
|
|
|
|
// Save the definition without running it — the whole point of a plan.
|
|
async function savePlan(opts = {}) {
|
|
const plan = plans.find(p => p.id === editingPlanId)
|
|
if (!plan) return null
|
|
setSubmitting(true)
|
|
try {
|
|
const res = await fetch(`/api/plans/${editingPlanId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
kind: segType,
|
|
label: segLabel.trim(),
|
|
bucket: segBucket.trim(),
|
|
note: segNote.trim(),
|
|
date_offset: offset.trim() || '0 days',
|
|
...(useRaw ? { raw_where: rawSql.trim(), filters: null }
|
|
: { filters, raw_where: null }),
|
|
}),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) { flash(data.error, 'error'); return null }
|
|
loadPlans()
|
|
cancelEdit()
|
|
if (!opts.returnRow) flash('Plan saved — not loaded yet')
|
|
return data
|
|
} catch (err) { flash(err.message, 'error'); return null }
|
|
finally { setSubmitting(false) }
|
|
}
|
|
|
|
// Save what is in the form, then run what was saved. Reading the plan back
|
|
// from the PATCH rather than reusing the row in state: the form is what the
|
|
// user just edited, and running the stale copy would load the old definition
|
|
// while showing the new one.
|
|
async function saveAndRunPlan() {
|
|
const saved = await savePlan({ returnRow: true })
|
|
if (!saved) return
|
|
await runOnePlan(saved)
|
|
}
|
|
|
|
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' },
|
|
// created_by is ignored by the server, which takes the account from the
|
|
// session; sending it would let any client claim to be anyone.
|
|
body: JSON.stringify({
|
|
name: newVerName.trim(),
|
|
description: newVerDesc,
|
|
...(cloneFrom ? { clone_from: parseInt(cloneFrom) } : {}),
|
|
})
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) { flash(data.error, 'error'); return }
|
|
await refreshVersions(sourceId)
|
|
setVersionId(String(data.id))
|
|
setShowNewVersion(false)
|
|
setNewVerName('')
|
|
setNewVerDesc('')
|
|
setCloneFrom('')
|
|
flash(data.cloned
|
|
? `Version "${data.name}" created from "${data.cloned.from_name}" — ${data.cloned.plans} segment${data.cloned.plans === 1 ? '' : 's'} planned, nothing loaded yet`
|
|
: `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)
|
|
setEditingPlanId(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, opts = {}) {
|
|
const next = value.trim()
|
|
if (next === (selectedVersion?.[field] || '')) return
|
|
// The route COALESCEs a falsy name back to the existing one, so a blank box
|
|
// would look saved and change nothing. Say so instead.
|
|
if (field === 'name' && !next) {
|
|
flash('A version needs a name', 'error')
|
|
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)
|
|
// A rename shows everywhere the version is listed and needs no reload; a
|
|
// fallback name is baked into the aggregated row, so the pivot cannot
|
|
// re-derive it in place.
|
|
flash(opts.message || 'Saved — reload the Forecast view to see it')
|
|
} catch (err) { flash(err.message, 'error') }
|
|
}
|
|
|
|
// The default is the source's, not this version's: setting it here clears it
|
|
// wherever it was. Admin only, server-side as well as here.
|
|
async function saveDefaultVersion(checked) {
|
|
try {
|
|
const res = await fetch(`/api/versions/${versionId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ is_default: checked }),
|
|
})
|
|
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
|
|
await refreshVersions(sourceId)
|
|
flash(checked
|
|
? 'Default forecast — this is where anyone without a saved selection lands'
|
|
: 'No default forecast — the newest one is used instead')
|
|
} 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>
|
|
|
|
{/* Name and description. Nothing depends on the name -- the forecast table
|
|
is fc_{tname}_{version_id}, pf.log and pf.layout key on the id, and the
|
|
UI's localStorage keys are id-based -- so it is safe to change at any
|
|
time, including on a closed version, where correcting a name is still
|
|
legitimate. Saves on blur, like the fallback names below. */}
|
|
{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">This forecast</span>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-xs text-gray-500">Name</label>
|
|
<input
|
|
key={`name-${versionId}-${selectedVersion?.name || ''}`}
|
|
defaultValue={selectedVersion?.name || ''}
|
|
onBlur={e => saveVersionName('name', e.target.value, { message: 'Renamed' })}
|
|
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
|
className="border border-gray-200 rounded px-2 py-1 text-sm w-64" />
|
|
</div>
|
|
<div className="flex flex-col gap-1 flex-1 min-w-[16rem]">
|
|
<label className="text-xs text-gray-500">Description</label>
|
|
<input
|
|
key={`desc-${versionId}-${selectedVersion?.description || ''}`}
|
|
defaultValue={selectedVersion?.description || ''}
|
|
onBlur={e => saveVersionName('description', e.target.value, { message: 'Saved' })}
|
|
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
|
placeholder="optional"
|
|
className="border border-gray-200 rounded px-2 py-1 text-sm w-full" />
|
|
</div>
|
|
{/* Where an account with no saved selection of its own lands. One per
|
|
source, so ticking it here unticks it wherever it was. */}
|
|
<label className={`flex items-center gap-2 text-sm pb-1 ${me?.is_admin ? '' : 'opacity-50'}`}
|
|
title={me?.is_admin
|
|
? 'Where anyone without a saved selection lands'
|
|
: 'Only an admin can set the default forecast'}>
|
|
<input
|
|
type="checkbox"
|
|
checked={!!selectedVersion?.is_default}
|
|
disabled={!me?.is_admin}
|
|
onChange={e => saveDefaultVersion(e.target.checked)} />
|
|
Default forecast
|
|
</label>
|
|
</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>
|
|
)}
|
|
|
|
{/* Tags. Not a table anywhere -- a tag is a value repeated across this
|
|
version's pf.log entries, and the bridge groups on it. So renaming
|
|
one here rewrites every entry that carries it, and renaming onto an
|
|
existing tag merges two initiatives that were only ever one. Editing
|
|
a single entry's tag is still the Change log, on the Forecast page;
|
|
this is the whole-version view of the same column. */}
|
|
{versionId && (
|
|
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-2">
|
|
<span className="text-xs text-gray-500 uppercase tracking-wide">
|
|
Tags <span className="normal-case tracking-normal text-gray-400">— the initiatives named on this forecast</span>
|
|
</span>
|
|
{tags.length === 0 ? (
|
|
<span className="text-sm text-gray-500">
|
|
No tags yet. One is created by typing it on a scale, recode or clone in the Operations panel.
|
|
</span>
|
|
) : (
|
|
<table className="text-sm">
|
|
<thead>
|
|
<tr className="text-xs text-gray-500 text-left">
|
|
<th className="font-medium pb-1 pr-4">Tag</th>
|
|
<th className="font-medium pb-1 pr-4 text-right">Adjustments</th>
|
|
<th className="font-medium pb-1 pr-4">Last used</th>
|
|
<th className="font-medium pb-1"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{tags.map(t => (
|
|
<tr key={t.tag} className="border-t border-gray-100">
|
|
<td className="py-1 pr-4">
|
|
{editingTag?.from === t.tag ? (
|
|
<input
|
|
autoFocus
|
|
value={editingTag.text}
|
|
disabled={savingTag}
|
|
list="pf-version-tags"
|
|
onChange={e => setEditingTag({ from: t.tag, text: e.target.value })}
|
|
onBlur={e => saveTag(t.tag, e.target.value)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') e.currentTarget.blur()
|
|
if (e.key === 'Escape') setEditingTag(null)
|
|
}}
|
|
className="border border-gray-200 rounded px-2 py-0.5 text-sm w-64" />
|
|
) : (
|
|
<button
|
|
disabled={!t.can_edit}
|
|
onClick={() => setEditingTag({ from: t.tag, text: t.tag })}
|
|
title={t.can_edit
|
|
? 'Rename across every entry — type an existing tag to merge'
|
|
: `Also on entries made by ${(t.owners || []).join(', ')} — only they or an administrator can change it`}
|
|
className={`text-left ${t.can_edit ? 'hover:underline' : 'opacity-50 cursor-default'}`}>
|
|
{t.tag}
|
|
</button>
|
|
)}
|
|
</td>
|
|
<td className="py-1 pr-4 text-right tabular-nums text-gray-600">{t.uses}</td>
|
|
<td className="py-1 pr-4 text-gray-500">
|
|
{t.last_used ? new Date(t.last_used).toLocaleDateString() : ''}
|
|
</td>
|
|
<td className="py-1">
|
|
<button
|
|
disabled={!t.can_edit || savingTag}
|
|
onClick={() => saveTag(t.tag, '')}
|
|
title="Clear this tag from every entry that carries it"
|
|
className={`text-xs ${t.can_edit ? 'text-gray-400 hover:text-red-600' : 'text-gray-300 cursor-default'}`}>
|
|
clear
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
{/* Completing a rename against the list is what makes a merge
|
|
deliberate rather than a near-miss spelling. */}
|
|
<datalist id="pf-version-tags">
|
|
{tags.map(t => <option key={t.tag} value={t.tag} />)}
|
|
</datalist>
|
|
</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">
|
|
<label className="text-xs text-gray-500">Copy definition from</label>
|
|
<select value={cloneFrom} onChange={e => setCloneFrom(e.target.value)}
|
|
className="border border-gray-200 rounded px-2 py-1 text-sm w-48">
|
|
<option value="">— start empty —</option>
|
|
{versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
|
|
</select>
|
|
</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">
|
|
{cloneFrom
|
|
? <>Copies that version's settings, its saved layouts, and its load segments as <em>plans</em> — <strong>nothing is loaded</strong>, so the dates can be changed before anything runs. Adjustments are not copied: a scale's target was sized against the rows that were there. </>
|
|
: null}
|
|
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 && <>
|
|
|
|
{/* Planned segments — the definition, before it has been run. Only
|
|
shown when there are any: a version built by hand has none, and an
|
|
empty panel would be one more thing to read past. */}
|
|
{plans.length > 0 && (
|
|
<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 gap-3 flex-wrap">
|
|
<span>Planned segments</span>
|
|
<span className="normal-case font-normal text-gray-400">
|
|
{plans.filter(p => !p.run_logid).length} of {plans.length} not yet loaded
|
|
</span>
|
|
<div className="ml-auto flex items-center gap-3 normal-case font-normal">
|
|
<button onClick={runAllPlans}
|
|
disabled={runningAll || submitting || !plans.some(p => !p.run_logid)}
|
|
className="border border-blue-200 text-blue-600 hover:text-blue-700 rounded
|
|
px-2 py-0.5 disabled:opacity-40">
|
|
{runningAll ? 'Loading…' : 'Run all'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<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">#</th>
|
|
<th className="px-3 py-1.5 font-medium w-20">kind</th>
|
|
<th className="px-3 py-1.5 font-medium">label</th>
|
|
<th className="px-3 py-1.5 font-medium">counts toward</th>
|
|
<th className="px-3 py-1.5 font-medium">offset</th>
|
|
<th className="px-3 py-1.5 font-medium">where</th>
|
|
<th className="px-3 py-1.5 font-medium text-right">rows</th>
|
|
<th className="px-3 py-1.5 font-medium"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{plans.map((p, i) => {
|
|
const where = p.raw_where
|
|
|| buildFilterClause(normalizeFilters(p.filters) || [])
|
|
|| '—'
|
|
return (
|
|
<tr key={p.id}
|
|
className={`border-t border-gray-50 ${editingPlanId === p.id ? 'bg-amber-50 ring-1 ring-amber-300 ring-inset' : ''}`}>
|
|
<td className="px-3 py-1.5 text-gray-400">{i + 1}</td>
|
|
<td className="px-3 py-1.5 text-gray-500">{p.kind}</td>
|
|
<td className="px-3 py-1.5">{p.label || <span className="text-gray-300 italic">unnamed</span>}</td>
|
|
<td className="px-3 py-1.5 text-gray-500">{p.bucket || '—'}</td>
|
|
<td className="px-3 py-1.5 font-mono text-gray-500">{p.date_offset}</td>
|
|
<td className="px-3 py-1.5 font-mono text-gray-500 max-w-md truncate" title={where}>{where}</td>
|
|
<td className="px-3 py-1.5 text-right tabular-nums text-gray-500">
|
|
{p.run_logid ? (p.run_rows ?? '').toLocaleString?.() || p.run_rows : ''}
|
|
</td>
|
|
<td className="px-3 py-1.5 whitespace-nowrap">
|
|
{p.run_logid ? (
|
|
<span className="text-green-600" title={`Loaded ${p.run_at ? new Date(p.run_at).toLocaleString() : ''}`}>loaded</span>
|
|
) : (
|
|
<>
|
|
<button onClick={() => runOnePlan(p)} disabled={submitting || runningAll}
|
|
className="text-blue-600 hover:text-blue-700 mr-3 disabled:opacity-40">Run</button>
|
|
<button onClick={() => startEditPlan(p)}
|
|
className="text-gray-400 hover:text-blue-600 mr-3">Edit</button>
|
|
<button onClick={() => deletePlan(p)}
|
|
className="text-gray-300 hover:text-red-500">✕</button>
|
|
</>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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)}
|
|
readOnly={!canEdit(entry)}
|
|
title={canEdit(entry) ? '' : `${entry.pf_user || 'Another account'} made this segment`}
|
|
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)}
|
|
readOnly={!canEdit(entry)}
|
|
title={canEdit(entry) ? '' : `${entry.pf_user || 'Another account'} made this segment`}
|
|
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 || editingPlanId) && (
|
|
<div id="add-segment" className={`bg-white border rounded ${editingLogId || editingPlanId ? '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 || editingPlanId ? 'bg-amber-50 border-amber-200 text-amber-800' : 'bg-white border-gray-100 text-gray-500'}`}>
|
|
<span>{(() => {
|
|
if (editingPlanId) {
|
|
const idx = plans.findIndex(p => p.id === editingPlanId)
|
|
const plan = plans[idx]
|
|
return plan
|
|
? `Edit plan ${idx + 1} — ${plan.kind}${plan.label ? ` — ${plan.label}` : ''} — not loaded yet`
|
|
: 'Edit plan'
|
|
}
|
|
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 || editingPlanId ? '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={editingPlanId ? savePlan : loadSegment}
|
|
submitting={submitting}
|
|
editing={!!editingLogId}
|
|
submitLabel={editingPlanId ? 'Save plan' : null}
|
|
onSecondary={editingPlanId ? saveAndRunPlan : null}
|
|
secondaryLabel="Save and run"
|
|
/>
|
|
</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,
|
|
// A plan saves without running, so its primary action is named for that and
|
|
// running is the secondary. Both null for an ordinary segment, where loading
|
|
// is the only thing submitting can mean.
|
|
submitLabel = null,
|
|
onSecondary = null,
|
|
secondaryLabel = null,
|
|
}) {
|
|
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 || submitLabel ? 'Saving…' : 'Loading…')
|
|
: (submitLabel
|
|
|| (editing
|
|
? `Save ${segType === 'reference' ? 'Reference' : 'Segment'}`
|
|
: `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`))}
|
|
</button>
|
|
)}
|
|
{mode === 'edit' && onSecondary && (
|
|
<button onClick={onSecondary} disabled={submitting || (!useRaw && filters.length === 0)}
|
|
title="Save this plan and load it now"
|
|
className="border border-blue-200 text-blue-600 hover:text-blue-700 text-xs px-4 py-2
|
|
rounded disabled:opacity-50 shrink-0">
|
|
{secondaryLabel}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|