Compare commits
No commits in common. "39335bca758a24b9107a5e78b7c90477af89a234" and "e279a510d83446ca6f94e87473e4d445308befe0" have entirely different histories.
39335bca75
...
e279a510d8
@ -69,9 +69,6 @@ SELECT count(*) AS rows_affected FROM ins`.trim();
|
||||
}
|
||||
|
||||
function buildReference() {
|
||||
const referenceSelect = dataCols.map(c =>
|
||||
c === dateCol ? `(${q(c)} + '{{date_offset}}'::interval)::date` : q(c)
|
||||
).join(', ');
|
||||
return `
|
||||
WITH
|
||||
ilog AS (
|
||||
@ -81,7 +78,7 @@ ilog AS (
|
||||
)
|
||||
,ins AS (
|
||||
INSERT INTO {{fc_table}} (${insertCols})
|
||||
SELECT ${referenceSelect}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
|
||||
SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
|
||||
FROM ${srcTable}
|
||||
WHERE {{filter_clause}}
|
||||
RETURNING *
|
||||
|
||||
@ -123,23 +123,18 @@ module.exports = function(pool) {
|
||||
|
||||
// load baseline rows from source table — additive, no delete
|
||||
router.post('/versions/:id/baseline', async (req, res) => {
|
||||
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
|
||||
const { where_clause, date_offset, pf_user, note } = req.body;
|
||||
const dateOffset = date_offset || '0 days';
|
||||
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||
const filterClause = (where_clause || '').trim() || 'TRUE';
|
||||
try {
|
||||
const ctx = await getContext(parseInt(req.params.id), 'baseline');
|
||||
if (!guardOpen(ctx.version, res)) return;
|
||||
const paramsJson = JSON.stringify({
|
||||
where_clause: filterClause,
|
||||
date_offset: dateOffset,
|
||||
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
|
||||
});
|
||||
const sql = applyTokens(ctx.sql, {
|
||||
fc_table: ctx.table,
|
||||
version_id: ctx.version.id,
|
||||
pf_user: esc(pf_user || ''),
|
||||
note: esc(note || ''),
|
||||
params: esc(paramsJson),
|
||||
params: esc(JSON.stringify({ where_clause: filterClause, date_offset: dateOffset })),
|
||||
filter_clause: filterClause,
|
||||
date_offset: esc(dateOffset)
|
||||
});
|
||||
@ -152,82 +147,6 @@ module.exports = function(pool) {
|
||||
}
|
||||
});
|
||||
|
||||
// edit a baseline or reference segment in place — only allowed before any
|
||||
// scale/recode/clone has been applied on this version, since those would
|
||||
// have been calibrated against the old segment's totals.
|
||||
router.put('/versions/:id/baseline/:logid', async (req, res) => {
|
||||
const versionId = parseInt(req.params.id);
|
||||
const logid = parseInt(req.params.logid);
|
||||
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
|
||||
const dateOffset = date_offset || '0 days';
|
||||
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const logResult = await client.query(
|
||||
`SELECT * FROM pf.log WHERE id = $1 AND version_id = $2`,
|
||||
[logid, versionId]
|
||||
);
|
||||
if (logResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Log entry not found' });
|
||||
}
|
||||
const oldLog = logResult.rows[0];
|
||||
if (!['baseline', 'reference'].includes(oldLog.operation)) {
|
||||
return res.status(400).json({ error: 'Only baseline or reference segments can be edited' });
|
||||
}
|
||||
|
||||
const opsResult = await client.query(
|
||||
`SELECT COUNT(*)::int AS n FROM pf.log
|
||||
WHERE version_id = $1 AND operation IN ('scale', 'recode', 'clone')`,
|
||||
[versionId]
|
||||
);
|
||||
if (opsResult.rows[0].n > 0) {
|
||||
return res.status(409).json({
|
||||
error: 'Cannot edit segments after forecast operations have been applied. Undo the operations first.'
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = await getContext(versionId, oldLog.operation);
|
||||
if (!guardOpen(ctx.version, res)) return;
|
||||
|
||||
const paramsJson = JSON.stringify({
|
||||
where_clause: filterClause,
|
||||
date_offset: dateOffset,
|
||||
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
|
||||
});
|
||||
const sql = applyTokens(ctx.sql, {
|
||||
fc_table: ctx.table,
|
||||
version_id: ctx.version.id,
|
||||
pf_user: esc(pf_user || ''),
|
||||
note: esc(note || ''),
|
||||
params: esc(paramsJson),
|
||||
filter_clause: filterClause,
|
||||
date_offset: esc(dateOffset)
|
||||
});
|
||||
|
||||
await client.query('BEGIN');
|
||||
const delRows = await client.query(
|
||||
`DELETE FROM ${ctx.table} WHERE pf_logid = $1 RETURNING pf_id`,
|
||||
[logid]
|
||||
);
|
||||
await client.query(`DELETE FROM pf.log WHERE id = $1`, [logid]);
|
||||
const insResult = await client.query(sql);
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.json({
|
||||
rows_deleted: delRows.rowCount,
|
||||
pf_ids: delRows.rows.map(r => r.pf_id),
|
||||
rows_affected: insResult.rows[0]?.rows_affected ?? 0
|
||||
});
|
||||
} catch (err) {
|
||||
try { await client.query('ROLLBACK'); } catch {}
|
||||
console.error(err);
|
||||
res.status(err.status || 500).json({ error: err.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// delete all baseline rows and log entries for a version
|
||||
router.delete('/versions/:id/baseline', async (req, res) => {
|
||||
const versionId = parseInt(req.params.id);
|
||||
@ -264,25 +183,18 @@ module.exports = function(pool) {
|
||||
|
||||
// load reference rows from source table (additive — does not clear prior reference rows)
|
||||
router.post('/versions/:id/reference', async (req, res) => {
|
||||
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
|
||||
const dateOffset = date_offset || '0 days';
|
||||
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||
const { where_clause, pf_user, note } = req.body;
|
||||
const filterClause = (where_clause || '').trim() || 'TRUE';
|
||||
try {
|
||||
const ctx = await getContext(parseInt(req.params.id), 'reference');
|
||||
if (!guardOpen(ctx.version, res)) return;
|
||||
const paramsJson = JSON.stringify({
|
||||
where_clause: filterClause,
|
||||
date_offset: dateOffset,
|
||||
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
|
||||
});
|
||||
const sql = applyTokens(ctx.sql, {
|
||||
fc_table: ctx.table,
|
||||
version_id: ctx.version.id,
|
||||
pf_user: esc(pf_user || ''),
|
||||
note: esc(note || ''),
|
||||
params: esc(paramsJson),
|
||||
filter_clause: filterClause,
|
||||
date_offset: esc(dateOffset)
|
||||
params: esc(JSON.stringify({ where_clause: filterClause })),
|
||||
filter_clause: filterClause
|
||||
});
|
||||
|
||||
const result = await runSQL(sql);
|
||||
@ -342,8 +254,7 @@ module.exports = function(pool) {
|
||||
});
|
||||
|
||||
const result = await runSQL(sql);
|
||||
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'scale' }));
|
||||
res.json({ rows, rows_affected: rows.length });
|
||||
res.json({ rows: result.rows, rows_affected: result.rows.length });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(err.status || 500).json({ error: err.message });
|
||||
@ -378,8 +289,7 @@ module.exports = function(pool) {
|
||||
});
|
||||
|
||||
const result = await runSQL(sql);
|
||||
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'recode' }));
|
||||
res.json({ rows, rows_affected: rows.length });
|
||||
res.json({ rows: result.rows, rows_affected: result.rows.length });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(err.status || 500).json({ error: err.message });
|
||||
@ -416,8 +326,7 @@ module.exports = function(pool) {
|
||||
});
|
||||
|
||||
const result = await runSQL(sql);
|
||||
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'clone' }));
|
||||
res.json({ rows, rows_affected: rows.length });
|
||||
res.json({ rows: result.rows, rows_affected: result.rows.length });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(err.status || 500).json({ error: err.message });
|
||||
@ -429,36 +338,19 @@ module.exports = function(pool) {
|
||||
const versionId = parseInt(req.params.id);
|
||||
try {
|
||||
const verResult = await pool.query(
|
||||
`SELECT v.*, s.tname, s.id AS source_id FROM pf.version v JOIN pf.source s ON s.id = v.source_id WHERE v.id = $1`,
|
||||
`SELECT v.*, s.tname FROM pf.version v JOIN pf.source s ON s.id = v.source_id WHERE v.id = $1`,
|
||||
[versionId]
|
||||
);
|
||||
if (!verResult.rows.length) return res.status(404).json({ error: 'Version not found' });
|
||||
const { tname, source_id } = verResult.rows[0];
|
||||
const table = fcTable(tname, versionId);
|
||||
|
||||
const colMeta = await pool.query(
|
||||
`SELECT cname, role FROM pf.col_meta WHERE source_id = $1 AND role IN ('value', 'units')`,
|
||||
[source_id]
|
||||
);
|
||||
const valueCol = colMeta.rows.find(c => c.role === 'value')?.cname;
|
||||
const unitsCol = colMeta.rows.find(c => c.role === 'units')?.cname;
|
||||
|
||||
const aggCols = [
|
||||
`count(f.pf_id)::int AS row_count`,
|
||||
valueCol ? `sum(f."${valueCol}")::float8 AS value_total` : `NULL::float8 AS value_total`,
|
||||
unitsCol ? `sum(f."${unitsCol}")::float8 AS units_total` : `NULL::float8 AS units_total`
|
||||
].join(', ');
|
||||
|
||||
const table = fcTable(verResult.rows[0].tname, versionId);
|
||||
const result = await pool.query(`
|
||||
SELECT l.*, ${aggCols},
|
||||
$2::text AS value_col,
|
||||
$3::text AS units_col
|
||||
SELECT l.*, count(f.pf_id)::int AS row_count
|
||||
FROM pf.log l
|
||||
LEFT JOIN ${table} f ON f.pf_logid = l.id
|
||||
WHERE l.version_id = $1
|
||||
GROUP BY l.id
|
||||
ORDER BY l.id DESC
|
||||
`, [versionId, valueCol || null, unitsCol || null]);
|
||||
`, [versionId]);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@ -222,24 +222,6 @@ module.exports = function(pool) {
|
||||
}
|
||||
});
|
||||
|
||||
// set or clear the default Perspective layout for a source.
|
||||
// Body: a Perspective view config (group_by, split_by, columns, plugin_config, …).
|
||||
// Pass null or {} to clear.
|
||||
router.put('/sources/:id/default-layout', async (req, res) => {
|
||||
try {
|
||||
const layout = req.body && Object.keys(req.body).length > 0 ? req.body : null;
|
||||
const result = await pool.query(
|
||||
`UPDATE pf.source SET default_layout = $1 WHERE id = $2 RETURNING *`,
|
||||
[layout, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Source not found' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// deregister a source — does not drop existing forecast tables
|
||||
router.delete('/sources/:id', async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -9,15 +9,11 @@ CREATE TABLE IF NOT EXISTS pf.source (
|
||||
tname text NOT NULL,
|
||||
label text,
|
||||
status text NOT NULL DEFAULT 'active', -- active | archived
|
||||
default_layout jsonb, -- Perspective view config used as the per-source default
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by text,
|
||||
UNIQUE (schema, tname)
|
||||
);
|
||||
|
||||
-- backfill column for existing installs
|
||||
ALTER TABLE pf.source ADD COLUMN IF NOT EXISTS default_layout jsonb;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pf.col_meta (
|
||||
id serial PRIMARY KEY,
|
||||
source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE,
|
||||
|
||||
113
todo.md
113
todo.md
@ -1,113 +0,0 @@
|
||||
- [ ] when you enter the forecast, be able to enter in a context so you dont have to open the whole thing (should show in status bar and be a filter for SQL and spi calls)
|
||||
|
||||
|
||||
- [x] should be able to edit and revise forecase segments that constitute baseline or reference. if you edit, maybe a warning that your forecast values wont mean a lot, and have an option to delete them.
|
||||
|
||||
Notes: A baseline/reference segment is a `pf.log` row plus the
|
||||
forecast rows it produced (joined by pf_logid). Editing has the
|
||||
shape of a delete-then-replay: drop the rows by pf_logid, drop the
|
||||
log entry, re-run the segment with the new params (offset, filter,
|
||||
iter type), insert the new log entry. New endpoint:
|
||||
`PUT /versions/:id/baseline/:logid` (and the same for reference).
|
||||
UI: an Edit button on each segment in Baseline view, populating the
|
||||
form with the original `params`.
|
||||
|
||||
Cascade warning: if any scale/recode/clone log entries exist *after*
|
||||
this segment was added, those operations were calibrated against
|
||||
the old totals and will no longer reconcile cleanly. Show a banner
|
||||
like "3 forecast operations applied after this segment may be
|
||||
invalidated. View / Delete / Continue." Probably want a CASCADE
|
||||
option that deletes downstream forecast entries too, plus a plain
|
||||
"edit only" option for the user who knows what they're doing.
|
||||
|
||||
Implementation order: API + cascade detection first (compare
|
||||
pf.log.stamp ordering); UI second.
|
||||
|
||||
- [~] be able to copy an existing forecast and it's segments to adjust some parameters without having to start from scrath.
|
||||
|
||||
Notes: A version is the unit of copy. Need a `POST /versions/:id/copy`
|
||||
endpoint that creates a new pf.version row with the same source/
|
||||
col_meta, creates the new fc_<tname>_<id> table via the same DDL
|
||||
path, and replays each pf.log entry's INSERT against the new table
|
||||
(preserving stamp ordering). Each log entry gets re-inserted
|
||||
pointing at the new version_id; the new pf_logid feeds the row
|
||||
inserts. Notes/users come along.
|
||||
|
||||
UI: "Copy" button next to each version in Baseline. Copy modal
|
||||
asks for a new name and optional description, then runs the API
|
||||
call (likely 5–30s for a 350k-row version since every segment is
|
||||
re-evaluated). Show progress.
|
||||
|
||||
Two design questions worth deciding up front:
|
||||
- Copy as-of-now (re-fetch source data, so freshly-arrived rows
|
||||
show up in baseline)? Or freeze (replay from existing forecast
|
||||
rows, i.e. clone the forecast table directly)? Different
|
||||
semantics, different SQL — pick one before building.
|
||||
- Should the copy track its origin? A `parent_version_id` column
|
||||
on pf.version makes "show me variants of FY2026 Plan" easy.
|
||||
|
||||
- [x] need the list of filters to have an and/or specification
|
||||
|
||||
Notes: Spec already covers this in `pf_spec.md:245` — `filters` is
|
||||
an array of groups; conditions within a group are AND-ed, groups
|
||||
OR-ed. Backend has `buildFilterClause` in
|
||||
`lib/sql_generator.js:247` but it's not wired into the routes
|
||||
(baseline currently takes raw `where_clause`). Wiring + UI is the
|
||||
remaining work.
|
||||
|
||||
UI: each group is a card with a header ("Group 1", "Group 2 — OR"),
|
||||
rows of `column / operator / values`, a `+ Add condition` link,
|
||||
and a `+ Add OR group` button at the bottom. The Baseline view
|
||||
already has a single-group filter builder; extend it to wrap the
|
||||
current rows in a group container and allow adding more groups.
|
||||
|
||||
- [x] the filters should have the option to just write the WHERE clause SQL
|
||||
|
||||
Notes: Spec covers this too (`pf_spec.md:251`, `:454`) as the
|
||||
`raw_where` admin-only escape hatch. The current baseline endpoint
|
||||
*already* takes `where_clause` as a raw string — so the API is
|
||||
effectively in "raw only" mode today; it's the structured side
|
||||
that's missing. Two things to add:
|
||||
|
||||
- Once structured `filters` is wired in, gate `raw_where` behind
|
||||
an admin check (`pf_user` in admin list — needs admin list
|
||||
config) and reject 400 if both are sent.
|
||||
- UI toggle: a "Switch to manual SQL" link in the Baseline filter
|
||||
builder swaps the structured rows for a `<textarea>`; warning
|
||||
banner: "Raw SQL is not validated. You are responsible for
|
||||
correctness and security."
|
||||
|
||||
- [ ] load status bar is super jittery and the numbers wildly change
|
||||
|
||||
Notes: `setLoadProgress` fires per chunk in the body-stream reader
|
||||
(Forecast.jsx:155–161). On localhost or fast connections the
|
||||
reader yields chunks in tight bursts and React re-renders the
|
||||
overlay text on each — the visible bytes value flickers because
|
||||
paints land out of order with respect to setState batching.
|
||||
|
||||
Fix: throttle to ~10 updates/sec. Either `if (now - lastUpdate >
|
||||
100)` before `setLoadProgress`, or accumulate received bytes into
|
||||
a ref and flush on `requestAnimationFrame`. Five-line change.
|
||||
|
||||
- [ ] default layout for the pivot should be sales_usd group by pending_rep, split by pf_iter
|
||||
|
||||
Notes: Default-layout logic lives in `initViewer` (Forecast.jsx
|
||||
~line 240) and currently picks `group_by = first 2 dimensions`,
|
||||
`split_by = date column`. `sales_usd` / `pending_rep` are
|
||||
source-specific, so hardcoding them in the view would break for
|
||||
any other source.
|
||||
|
||||
Two paths:
|
||||
- **Quick**: hardcode for the current source. Cheap, but rots
|
||||
the moment a second source comes along.
|
||||
- **Right**: store a default-layout config on `pf.source` (e.g., a
|
||||
JSON `default_view` column with `{ group_by, split_by, columns }`)
|
||||
and let initViewer read it. Setup view gets a small "Default
|
||||
pivot" editor — pick a value column, group_by columns, split_by
|
||||
column.
|
||||
|
||||
Suggest the right version since the spec already implies
|
||||
per-source customization in col_meta, and you're going to want
|
||||
this for any future source you register. The col_meta path is
|
||||
even tighter: extend col_meta with role flags like `default_value`,
|
||||
`default_group`, `default_split` that initViewer reads.
|
||||
@ -3,48 +3,44 @@ 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
|
||||
function buildFilterClause(filters) {
|
||||
if (!filters.length) return null
|
||||
const parts = filters.map(f => {
|
||||
const col = `"${f.col}"`
|
||||
const op = f.op
|
||||
if (op === 'IS NULL') return `${col} IS NULL`
|
||||
if (op === 'IS NOT NULL') return `${col} IS NOT NULL`
|
||||
if (op === 'BETWEEN') {
|
||||
const [a, b] = f.values
|
||||
return `${col} BETWEEN '${a}' AND '${b}'`
|
||||
}
|
||||
if (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 (op === 'IN' || op === 'NOT IN') {
|
||||
const vals = f.values.join("','")
|
||||
return `${col} ${op} ('${vals}')`
|
||||
}
|
||||
if (!c.values[0]) return null
|
||||
return `${col} ${c.op} '${c.values[0]}'`
|
||||
return `${col} ${op} '${f.values[0]}'`
|
||||
})
|
||||
return parts.join(' AND ')
|
||||
}
|
||||
|
||||
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 getDateRange(filters) {
|
||||
for (const f of filters) {
|
||||
if (f.op === 'BETWEEN' && f.values[0] && f.values[1]) {
|
||||
return { from: f.values[0], to: f.values[1] }
|
||||
}
|
||||
if (f.op === '=' && f.values[0]) {
|
||||
return { from: f.values[0], to: f.values[0] }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseDateRangeFromClause(clause) {
|
||||
if (!clause) return null
|
||||
const m = clause.match(/BETWEEN '(\d{4}-\d{2}-\d{2})' AND '(\d{4}-\d{2}-\d{2})'/)
|
||||
if (m) return { from: m[1], to: m[2] }
|
||||
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] }
|
||||
}
|
||||
}
|
||||
const m2 = clause.match(/>= ?'(\d{4}-\d{2}-\d{2})'.+<= ?'(\d{4}-\d{2}-\d{2})'/)
|
||||
if (m2) return { from: m2[1], to: m2[2] }
|
||||
return null
|
||||
}
|
||||
|
||||
@ -55,18 +51,9 @@ function parseOffset(offsetStr) {
|
||||
return { yr, mo }
|
||||
}
|
||||
|
||||
function emptyCondition(cols) {
|
||||
function emptyFilter(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([])
|
||||
@ -78,19 +65,14 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
const [newVerDesc, setNewVerDesc] = useState('')
|
||||
const [creatingVer, setCreatingVer] = useState(false)
|
||||
|
||||
// segment form
|
||||
// add 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 [filters, setFilters] = 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)
|
||||
@ -100,7 +82,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
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)] : [])
|
||||
setFilters(fc.length > 0 ? [emptyFilter(fc)] : [])
|
||||
})
|
||||
}, [sourceId])
|
||||
|
||||
@ -112,7 +94,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
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)))
|
||||
})
|
||||
}
|
||||
|
||||
@ -140,37 +121,59 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
}
|
||||
}
|
||||
|
||||
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 }),
|
||||
function addFilter() {
|
||||
setFilters(f => [...f, emptyFilter(filterCols)])
|
||||
}
|
||||
|
||||
function removeFilter(i) {
|
||||
setFilters(f => f.filter((_, idx) => idx !== i))
|
||||
}
|
||||
|
||||
function updateFilter(i, field, value) {
|
||||
setFilters(f => f.map((row, idx) => {
|
||||
if (idx !== i) return row
|
||||
if (field === 'op') {
|
||||
const needsTwo = value === 'BETWEEN'
|
||||
const needsOne = ['=', '!='].includes(value)
|
||||
const needsMany = ['IN', 'NOT IN'].includes(value)
|
||||
const needsNone = ['IS NULL', 'IS NOT NULL'].includes(value)
|
||||
return { ...row, op: value, values: needsNone ? [] : needsTwo ? ['', ''] : needsMany ? [''] : [''] }
|
||||
}
|
||||
return { ...row, [field]: value }
|
||||
}))
|
||||
}
|
||||
|
||||
function updateFilterValue(i, vi, value) {
|
||||
setFilters(f => f.map((row, idx) => {
|
||||
if (idx !== i) return row
|
||||
const vals = [...row.values]
|
||||
vals[vi] = value
|
||||
return { ...row, values: vals }
|
||||
}))
|
||||
}
|
||||
|
||||
async function loadSegment() {
|
||||
const clause = buildFilterClause(filters)
|
||||
if (!clause) { flash('Add at least one filter', 'error'); return }
|
||||
const isRef = segType === 'reference'
|
||||
const offsetStr = isRef ? '0 days' : ([offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days')
|
||||
const endpoint = isRef ? 'reference' : 'baseline'
|
||||
const body = isRef
|
||||
? { where_clause: clause, pf_user: 'admin', note: description || segNote }
|
||||
: { where_clause: clause, date_offset: offsetStr, pf_user: 'admin', note: description || segNote }
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const url = editingLogId
|
||||
? `/api/versions/${versionId}/baseline/${editingLogId}`
|
||||
: `/api/versions/${versionId}/${endpoint}`
|
||||
const method = editingLogId ? 'PUT' : 'POST'
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
const res = await fetch(`/api/versions/${versionId}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { flash(data.error, 'error'); return }
|
||||
flash(editingLogId
|
||||
? `Updated — ${data.rows_deleted} rows replaced with ${data.rows_affected}`
|
||||
: `Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`)
|
||||
flash(`Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`)
|
||||
loadLog()
|
||||
cancelEdit()
|
||||
setDescription(''); setSegNote(''); setOffsetYr(0); setOffsetMo(0)
|
||||
setFilters(filterCols.length > 0 ? [emptyFilter(filterCols)] : [])
|
||||
} catch (err) {
|
||||
flash(err.message, 'error')
|
||||
} finally {
|
||||
@ -178,52 +181,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@ -273,12 +230,14 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
setTimeout(() => setMsg(null), 3000)
|
||||
}
|
||||
|
||||
const dateRange = getDateRange(filters)
|
||||
const selectedVersion = versions.find(v => String(v.id) === versionId)
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-gray-50">
|
||||
<div className="p-4 flex flex-col gap-4 max-w-4xl">
|
||||
|
||||
{/* Flash */}
|
||||
{msg && (
|
||||
<div className={`px-3 py-2 text-xs rounded font-medium ${msg.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
|
||||
{msg.text}
|
||||
@ -301,6 +260,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New version inline form */}
|
||||
{showNewVersion && (
|
||||
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-3">
|
||||
<div className="flex items-end gap-3">
|
||||
@ -337,8 +297,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
<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>
|
||||
@ -346,31 +304,23 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
</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>
|
||||
<tr><td colSpan={6} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
|
||||
)}
|
||||
{log.map((entry, i) => {
|
||||
const isOpen = expandedId === entry.id
|
||||
const view = segmentValuesFor(entry, filterCols)
|
||||
const params = entry.params || {}
|
||||
const dr = parseDateRangeFromClause(params.where_clause)
|
||||
const off = parseOffset(params.date_offset)
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
key={entry.id}
|
||||
onClick={() => setExpandedId(isOpen ? null : entry.id)}
|
||||
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${editingLogId === entry.id ? 'bg-amber-50 ring-1 ring-amber-300 ring-inset' : isOpen ? 'bg-blue-50' : ''}`}
|
||||
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${isOpen ? 'bg-blue-50' : ''}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-gray-400 w-6"><span className="text-gray-300 text-xs">{isOpen ? '▾' : '▸'}</span></td>
|
||||
<td className="px-3 py-2 text-gray-400 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'}`}>
|
||||
@ -378,26 +328,29 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
</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} />
|
||||
<td colSpan={6} className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs text-gray-400 w-24 shrink-0 pt-0.5">WHERE</span>
|
||||
<code className="text-xs font-mono text-gray-700 bg-white border border-gray-200 rounded px-2 py-1 break-all">{params.where_clause || '—'}</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 w-24 shrink-0">offset</span>
|
||||
<span className="text-xs font-mono text-gray-600">{params.date_offset || '0 days'}</span>
|
||||
</div>
|
||||
{dr && (
|
||||
<div className="mt-1">
|
||||
<Timeline dateFrom={dr.from} dateTo={dr.to} offsetYr={off.yr} offsetMo={off.mo} type={entry.operation === 'reference' ? 'reference' : 'baseline'} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -409,259 +362,86 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
</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>
|
||||
{/* Add Segment */}
|
||||
<div className="bg-white border border-gray-200 rounded">
|
||||
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Add Segment
|
||||
</div>
|
||||
<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 */}
|
||||
{/* Type toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-gray-500 w-28 shrink-0">Type</label>
|
||||
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
|
||||
{['baseline', 'reference'].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
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`}
|
||||
onClick={() => { setSegType(t); if (t === 'reference') { setOffsetYr(0); setOffsetMo(0) } }}
|
||||
className={`px-3 py-1 capitalize ${segType === t ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}
|
||||
>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
{segType === 'reference' && (
|
||||
<span className="text-xs text-gray-400">dates land verbatim — no offset applied</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description (edit only) */}
|
||||
{mode === 'edit' && (
|
||||
{/* Description */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-gray-500 w-28 shrink-0">Description</label>
|
||||
<input value={description} onChange={e => setDescription(e.target.value)} placeholder="e.g. FY25 actuals +1yr" className="border border-gray-200 rounded px-2 py-1.5 text-sm flex-1 max-w-sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs text-gray-500 w-28 shrink-0">Filters</label>
|
||||
{!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>
|
||||
)}
|
||||
<button onClick={addFilter} className="text-blue-600 hover:text-blue-700 text-xs font-medium">+ Add filter</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'
|
||||
<div className="flex flex-col gap-1.5 ml-28">
|
||||
{filters.map((f, i) => {
|
||||
const isDateCol = filterCols.find(c => c.cname === f.col)?.role === 'date'
|
||||
return (
|
||||
<div key={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>)}
|
||||
<div key={i} className="flex items-center gap-2 flex-wrap">
|
||||
<select value={f.col} onChange={e => updateFilter(i, 'col', e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
|
||||
{filterCols.map(c => <option key={c.cname} value={c.cname}>{c.cname}</option>)}
|
||||
</select>
|
||||
<select disabled={disabled} value={c.op} onChange={e => updateCondition(gi, ci, 'op', e.target.value)} className={baseInp}>
|
||||
<select value={f.op} onChange={e => updateFilter(i, 'op', e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
|
||||
{OPERATORS.map(op => <option key={op} value={op}>{op}</option>)}
|
||||
</select>
|
||||
{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`} />
|
||||
{f.op === 'BETWEEN' && <>
|
||||
<input type={isDateCol ? 'date' : 'text'} value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="from" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
|
||||
<span className="text-gray-400 text-xs">and</span>
|
||||
<input 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`} />
|
||||
<input type={isDateCol ? 'date' : 'text'} value={f.values[1]} onChange={e => updateFilterValue(i, 1, e.target.value)} placeholder="to" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
|
||||
</>}
|
||||
{(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`} />
|
||||
{(f.op === '=' || f.op === '!=') && (
|
||||
<input type={isDateCol ? 'date' : 'text'} value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="value" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
|
||||
)}
|
||||
{(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>
|
||||
{(f.op === 'IN' || f.op === 'NOT IN') && (
|
||||
<input value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="val1, val2, …" className="border border-gray-200 rounded px-2 py-1 text-xs w-48 font-mono bg-white" />
|
||||
)}
|
||||
<button onClick={() => removeFilter(i)} className="text-gray-300 hover:text-red-400 text-xs">✕</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</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 */}
|
||||
{/* Date offset — baseline only */}
|
||||
{segType === 'baseline' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-gray-500 w-28 shrink-0">Date offset</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input disabled={disabled} type="number" value={offsetYr} min={0} onChange={e => setOffsetYr(parseInt(e.target.value) || 0)} className={`${baseInp} text-sm w-16 text-center`} />
|
||||
<input type="number" value={offsetYr} min={0} onChange={e => setOffsetYr(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
|
||||
<span className="text-xs text-gray-500">yr</span>
|
||||
<input 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`} />
|
||||
<input type="number" value={offsetMo} min={0} max={11} onChange={e => setOffsetMo(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
|
||||
<span className="text-xs text-gray-500">mo</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{dateRange && (
|
||||
@ -670,8 +450,8 @@ function SegmentForm({
|
||||
<Timeline
|
||||
dateFrom={dateRange.from}
|
||||
dateTo={dateRange.to}
|
||||
offsetYr={offsetYr}
|
||||
offsetMo={offsetMo}
|
||||
offsetYr={segType === 'baseline' ? offsetYr : 0}
|
||||
offsetMo={segType === 'baseline' ? offsetMo : 0}
|
||||
type={segType}
|
||||
/>
|
||||
</div>
|
||||
@ -682,17 +462,18 @@ function SegmentForm({
|
||||
<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`} />
|
||||
<input value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1.5 text-sm" />
|
||||
</div>
|
||||
{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 onClick={loadSegment} disabled={submitting || filters.length === 0} className="bg-blue-600 text-white text-xs px-5 py-2 rounded hover:bg-blue-700 disabled:opacity-50 shrink-0">
|
||||
{submitting ? 'Loading…' : `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -29,7 +29,7 @@ function cleanLayout(cfg, validCols) {
|
||||
return c
|
||||
}
|
||||
|
||||
export default function Forecast({ sources = [], sourceId, versionId, refreshSources }) {
|
||||
export default function Forecast({ sourceId, versionId }) {
|
||||
const { dark } = useTheme()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [largeDataset, setLargeDataset] = useState(false)
|
||||
@ -49,7 +49,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
const [scaleMode, setScaleMode] = useState('target') // 'target' | 'delta'
|
||||
const [scaleValue, setScaleValue] = useState('')
|
||||
const [scaleUnits, setScaleUnits] = useState('')
|
||||
const [scalePrice, setScalePrice] = useState('')
|
||||
const [scalePct, setScalePct] = useState(false)
|
||||
const [scaleNote, setScaleNote] = useState('')
|
||||
const [recodeSet, setRecodeSet] = useState({})
|
||||
@ -72,7 +71,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
const tableRef = useRef(null)
|
||||
const colMetaRef = useRef([])
|
||||
const expandDepthRef = useRef(null)
|
||||
const initIdRef = useRef(0)
|
||||
|
||||
function onDragStart(e) {
|
||||
e.preventDefault()
|
||||
@ -102,7 +100,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
setCloneSet(blank)
|
||||
setScaleValue('')
|
||||
setScaleUnits('')
|
||||
setScalePrice('')
|
||||
if (Object.keys(slice).length > 0) fetchCurrentTotals(slice)
|
||||
else setCurrentTotals(null)
|
||||
}, [slice])
|
||||
@ -123,22 +120,8 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
const view = await tableRef.current.view({ filter: filters })
|
||||
const rows = await view.to_json()
|
||||
await view.delete()
|
||||
const buckets = new Map()
|
||||
for (const r of rows) {
|
||||
const k = r.pf_iter || '?'
|
||||
const t = buckets.get(k) || { value: 0, units: 0 }
|
||||
if (valueCol) t.value += parseFloat(r[valueCol]) || 0
|
||||
if (unitsCol) t.units += parseFloat(r[unitsCol]) || 0
|
||||
buckets.set(k, t)
|
||||
}
|
||||
const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone']
|
||||
const byIter = Array.from(buckets, ([iter, t]) => ({ iter, ...t }))
|
||||
.sort((a, b) => {
|
||||
const ai = ITER_ORDER.indexOf(a.iter), bi = ITER_ORDER.indexOf(b.iter)
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
|
||||
})
|
||||
const total = byIter.reduce((s, r) => ({ value: s.value + (r.value || 0), units: s.units + (r.units || 0) }), { value: 0, units: 0 })
|
||||
setCurrentTotals({ byIter, total, valueCol, unitsCol })
|
||||
const sum = (col) => col ? rows.reduce((s, r) => s + (parseFloat(r[col]) || 0), 0) : null
|
||||
setCurrentTotals({ value: sum(valueCol), units: sum(unitsCol), valueCol, unitsCol })
|
||||
} catch {
|
||||
setCurrentTotals(null)
|
||||
}
|
||||
@ -153,7 +136,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
async function initViewer(vid, sid) {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer) return
|
||||
const myId = ++initIdRef.current
|
||||
setLoading(true)
|
||||
setLargeDataset(false)
|
||||
setLoadProgress(null)
|
||||
@ -195,37 +177,12 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
|
||||
if (rowCount >= 500000) setLargeDataset(true)
|
||||
|
||||
if (myId !== initIdRef.current) return
|
||||
|
||||
if (!workerRef.current) workerRef.current = await perspective.worker()
|
||||
const worker = workerRef.current
|
||||
|
||||
if (tableRef.current) {
|
||||
try { await tableRef.current.delete() } catch {}
|
||||
tableRef.current = null
|
||||
}
|
||||
|
||||
const opts = { name: tableName, index: 'pf_id' }
|
||||
const makeTable = async () => rowCount > 0 ? worker.table(buffer, opts) : worker.table([], opts)
|
||||
try {
|
||||
tableRef.current = await makeTable()
|
||||
} catch (err) {
|
||||
if (/already exists/i.test(String(err?.message || err))) {
|
||||
try {
|
||||
const existing = await worker.open_table(tableName)
|
||||
if (existing) await existing.delete()
|
||||
} catch {}
|
||||
tableRef.current = await makeTable()
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
if (myId !== initIdRef.current) {
|
||||
try { await tableRef.current.delete() } catch {}
|
||||
tableRef.current = null
|
||||
return
|
||||
}
|
||||
if (workerRef.current) { try { workerRef.current.terminate() } catch {} }
|
||||
const worker = await perspective.worker()
|
||||
workerRef.current = worker
|
||||
tableRef.current = rowCount > 0
|
||||
? await worker.table(buffer, { name: tableName, index: 'pf_id' })
|
||||
: await worker.table([], { name: tableName, index: 'pf_id' })
|
||||
|
||||
await viewer.load(worker)
|
||||
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
|
||||
@ -234,39 +191,21 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
const saved = localStorage.getItem(LAYOUT_KEY(vid))
|
||||
if (saved) {
|
||||
const cfg = cleanLayout(JSON.parse(saved), validCols)
|
||||
cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
|
||||
await viewer.restore(cfg)
|
||||
const plugin = await viewer.getPlugin()
|
||||
await plugin.restore({ edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) })
|
||||
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
||||
} else {
|
||||
const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout
|
||||
let cfg
|
||||
if (sourceDefault && Object.keys(sourceDefault).length > 0) {
|
||||
cfg = cleanLayout(sourceDefault, validCols)
|
||||
cfg.table = tableName
|
||||
cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
|
||||
} else {
|
||||
const valueCol = meta.find(c => c.role === 'value')?.cname
|
||||
cfg = {
|
||||
table: tableName,
|
||||
settings: false,
|
||||
group_by: ['pf_iter'],
|
||||
columns: valueCol ? [valueCol] : [],
|
||||
plugin_config: { edit_mode: 'SELECT_REGION' }
|
||||
}
|
||||
}
|
||||
const dims = meta.filter(c => c.role === 'dimension').map(c => c.cname)
|
||||
const dateCol = meta.find(c => c.role === 'date')?.cname
|
||||
const cfg = { table: tableName, settings: false, plugin_config: { edit_mode: 'SELECT_REGION' } }
|
||||
if (dims.length) cfg.group_by = dims.slice(0, 2)
|
||||
if (dateCol) cfg.split_by = [dateCol]
|
||||
await viewer.restore(cfg)
|
||||
const plugin = await viewer.getPlugin()
|
||||
await plugin.restore({ edit_mode: 'SELECT_REGION' })
|
||||
}
|
||||
|
||||
// auto-persist viewer state (formatting, columns, etc.) to the last-used cache
|
||||
if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate)
|
||||
viewer._pspUpdate = async () => {
|
||||
try {
|
||||
const cfg = await captureConfig()
|
||||
if (cfg) await persistLayout(vid, cfg)
|
||||
} catch {}
|
||||
}
|
||||
viewer.addEventListener('perspective-config-update', viewer._pspUpdate)
|
||||
|
||||
// click → slice via event filters (Perspective encodes row position as [col,'==',val] triples)
|
||||
if (viewer._pspClick) viewer.removeEventListener('perspective-click', viewer._pspClick)
|
||||
viewer._pspClick = async (e) => {
|
||||
@ -304,8 +243,9 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
async function captureConfig() {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer) return null
|
||||
const cfg = await viewer.save()
|
||||
return { ...cfg, expand_depth: expandDepthRef.current }
|
||||
const plugin = await viewer.getPlugin()
|
||||
const [cfg, pluginCfg] = await Promise.all([viewer.save(), plugin.save()])
|
||||
return { ...cfg, plugin_config: pluginCfg, expand_depth: expandDepthRef.current }
|
||||
}
|
||||
|
||||
async function persistLayout(vid, cfg) {
|
||||
@ -328,22 +268,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
flash('Saved')
|
||||
}
|
||||
|
||||
async function saveAsSourceDefault() {
|
||||
const cfg = await captureConfig()
|
||||
if (!cfg) return
|
||||
const { table, expand_depth, ...rest } = cfg
|
||||
try {
|
||||
const res = await fetch(`/api/sources/${sourceId}/default-layout`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(rest)
|
||||
})
|
||||
if (!res.ok) { const data = await res.json(); flash(data.error || 'Failed', 'error'); return }
|
||||
if (refreshSources) await refreshSources()
|
||||
flash('Saved as source default')
|
||||
} catch (err) { flash(err.message, 'error') }
|
||||
}
|
||||
|
||||
async function handleSaveOver() {
|
||||
const layout = layouts.find(l => l.id === activeLayoutId)
|
||||
if (!layout) return
|
||||
@ -359,10 +283,13 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
async function applyLayout(layout) {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer) return
|
||||
const validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : [])
|
||||
const validCols = new Set(tableRef.current ? Object.keys(await (await tableRef.current.view()).schema()) : [])
|
||||
const cfg = cleanLayout(layout.config, validCols)
|
||||
cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
|
||||
await viewer.restore(cfg)
|
||||
if (cfg.plugin_config) {
|
||||
const plugin = await viewer.getPlugin()
|
||||
await plugin.restore(cfg.plugin_config)
|
||||
}
|
||||
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
||||
setActiveLayoutId(layout.id)
|
||||
await persistLayout(versionId, cfg)
|
||||
@ -390,16 +317,10 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
if (op === 'scale') {
|
||||
let vi = null, ui = null
|
||||
if (scaleMode === 'target') {
|
||||
const curValue = currentTotals?.total?.value
|
||||
const curUnits = currentTotals?.total?.units
|
||||
if (scalePrice !== '' && curUnits != null && curValue != null) {
|
||||
// hold units constant; new value = price × current units
|
||||
vi = (parseFloat(scalePrice) * curUnits) - curValue
|
||||
}
|
||||
if (scaleValue !== '' && curValue != null)
|
||||
vi = parseFloat(scaleValue) - curValue
|
||||
if (scaleUnits !== '' && curUnits != null)
|
||||
ui = parseFloat(scaleUnits) - curUnits
|
||||
if (scaleValue !== '' && currentTotals?.value != null)
|
||||
vi = parseFloat(scaleValue) - currentTotals.value
|
||||
if (scaleUnits !== '' && currentTotals?.units != null)
|
||||
ui = parseFloat(scaleUnits) - currentTotals.units
|
||||
} else {
|
||||
if (scaleValue !== '') vi = scalePct ? parseFloat(scaleValue) : parseFloat(scaleValue)
|
||||
if (scaleUnits !== '') ui = scalePct ? parseFloat(scaleUnits) : parseFloat(scaleUnits)
|
||||
@ -424,7 +345,7 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
if (!res.ok) { flash(data.error, 'error'); return }
|
||||
if (data.rows?.length && tableRef.current) await tableRef.current.update(data.rows)
|
||||
flash(`${op}: ${data.rows_affected ?? data.rows?.length ?? ''} rows`)
|
||||
if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScalePrice(''); setScaleNote(''); fetchCurrentTotals(slice) }
|
||||
if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScaleNote(''); fetchCurrentTotals(slice) }
|
||||
if (op === 'recode') { setRecodeNote('') }
|
||||
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
|
||||
} catch (err) { flash(err.message, 'error') }
|
||||
@ -516,11 +437,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
<button onClick={() => setShowSaveAs(true)} className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5">
|
||||
Save as…
|
||||
</button>
|
||||
<button onClick={saveAsSourceDefault} disabled={!sourceId}
|
||||
className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5 disabled:opacity-40"
|
||||
title="Use this layout as the default for new versions of this source">
|
||||
Set source default
|
||||
</button>
|
||||
{activeLayoutId !== null && (
|
||||
<button onClick={resetLayout} className="text-gray-300 hover:text-red-400">Reset</button>
|
||||
)}
|
||||
@ -694,40 +610,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasSlice && currentTotals?.byIter?.length > 0 && (
|
||||
<div className="px-3 py-2 border-b border-gray-100">
|
||||
<div className="font-medium text-gray-400 uppercase tracking-wide mb-1.5" style={{fontSize:'10px'}}>Current</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-400" style={{fontSize:'10px'}}>
|
||||
<th className="text-left font-normal pb-1"></th>
|
||||
{currentTotals.valueCol && <th className="text-right font-normal pb-1 pl-1">{currentTotals.valueCol}</th>}
|
||||
{currentTotals.unitsCol && <th className="text-right font-normal pb-1 pl-1">{currentTotals.unitsCol}</th>}
|
||||
{currentTotals.valueCol && currentTotals.unitsCol && <th className="text-right font-normal pb-1 pl-1">price</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentTotals.byIter.map(r => (
|
||||
<tr key={r.iter}>
|
||||
<td className="text-gray-500 capitalize pr-1">{r.iter}</td>
|
||||
{currentTotals.valueCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.value)}</td>}
|
||||
{currentTotals.unitsCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.units)}</td>}
|
||||
{currentTotals.valueCol && currentTotals.unitsCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.units ? r.value / r.units : null, 4)}</td>}
|
||||
</tr>
|
||||
))}
|
||||
{currentTotals.byIter.length > 1 && (
|
||||
<tr className="border-t border-gray-100">
|
||||
<td className="text-gray-600 font-medium pt-1 pr-1">total</td>
|
||||
{currentTotals.valueCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.value)}</td>}
|
||||
{currentTotals.unitsCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.units)}</td>}
|
||||
{currentTotals.valueCol && currentTotals.unitsCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.units ? currentTotals.total.value / currentTotals.total.units : null, 4)}</td>}
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSlice && (
|
||||
<>
|
||||
<div className="flex border-b border-gray-100">
|
||||
@ -744,38 +626,39 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
||||
{/* Mode toggle */}
|
||||
<div className="flex rounded border border-gray-200 overflow-hidden">
|
||||
{[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => (
|
||||
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits(''); setScalePrice('') }}
|
||||
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits('') }}
|
||||
className={`flex-1 py-1 text-xs ${scaleMode === m ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Value row */}
|
||||
{currentTotals?.valueCol && (
|
||||
<Row label={currentTotals.valueCol}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-gray-400">
|
||||
<span>{currentTotals.valueCol}</span>
|
||||
<span className="font-mono">{currentTotals.value?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
|
||||
</div>
|
||||
<input type="number" step="any" value={scaleValue}
|
||||
onChange={e => setScaleValue(e.target.value)}
|
||||
placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
|
||||
className={inp} />
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Units row */}
|
||||
{currentTotals?.unitsCol && (
|
||||
<Row label={currentTotals.unitsCol}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-gray-400">
|
||||
<span>{currentTotals.unitsCol}</span>
|
||||
<span className="font-mono">{currentTotals.units?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
|
||||
</div>
|
||||
<input type="number" step="any" value={scaleUnits}
|
||||
onChange={e => setScaleUnits(e.target.value)}
|
||||
placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
|
||||
className={inp} />
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{scaleMode === 'target' && currentTotals?.valueCol && currentTotals?.unitsCol && (
|
||||
<Row label="price">
|
||||
<input type="number" step="any" value={scalePrice}
|
||||
onChange={e => setScalePrice(e.target.value)}
|
||||
placeholder="target price (holds units)"
|
||||
className={inp} />
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scaleMode === 'delta' && (
|
||||
@ -829,11 +712,6 @@ function fmtBytes(n) {
|
||||
return `${(n / 1048576).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function fmtNum(n, decimals = 2) {
|
||||
if (n == null || !isFinite(n)) return '—'
|
||||
return n.toLocaleString(undefined, { maximumFractionDigits: decimals })
|
||||
}
|
||||
|
||||
function fmtStamp(stamp) {
|
||||
return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user