diff --git a/lib/sql_generator.js b/lib/sql_generator.js index a6b0076..4a9873d 100644 --- a/lib/sql_generator.js +++ b/lib/sql_generator.js @@ -151,6 +151,10 @@ SELECT ,CASE WHEN l.operation IN ('baseline','reference') THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)') ELSE '(adjustment)' END AS pf_segment + ,COALESCE(NULLIF(l.bucket, ''), + CASE WHEN l.operation IN ('baseline','reference') + THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)') + ELSE '(adjustment)' END) AS pf_bucket ,CASE WHEN l.operation IN ('baseline','reference') THEN NULL ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, '')) END AS pf_note @@ -162,7 +166,8 @@ GROUP BY ${grain.groupCols('t.').join('\n ,')} ,l.operation ,l.tag - ,l.note`.trim(); + ,l.note + ,l.bucket`.trim(); } // grain columns + pf_gkey + summed measures, in the leading-comma style the diff --git a/routes/log.js b/routes/log.js index 46772c1..8d2d703 100644 --- a/routes/log.js +++ b/routes/log.js @@ -131,22 +131,24 @@ module.exports = function(pool) { // a closed version, where relabelling history is still legitimate. router.patch('/log/:logid', async (req, res) => { const logId = parseInt(req.params.logid); - const { note, tag } = req.body; - if (note === undefined && tag === undefined) { - return res.status(400).json({ error: 'Nothing to update — send note and/or tag' }); + const { note, tag, bucket } = req.body; + if (note === undefined && tag === undefined && bucket === undefined) { + return res.status(400).json({ error: 'Nothing to update — send note, tag and/or bucket' }); } try { // COALESCE on the flag, not the value: an explicit null or '' must be // able to clear a field, which COALESCE on the value alone would ignore const result = await pool.query( `UPDATE pf.log SET - note = CASE WHEN $2::bool THEN $3::text ELSE note END, - tag = CASE WHEN $4::bool THEN $5::text ELSE tag END + note = CASE WHEN $2::bool THEN $3::text ELSE note END, + tag = CASE WHEN $4::bool THEN $5::text ELSE tag END, + bucket = CASE WHEN $6::bool THEN $7::text ELSE bucket END WHERE id = $1 RETURNING *`, [ logId, - note !== undefined, note === undefined ? null : (String(note).trim() || null), - tag !== undefined, tag === undefined ? null : (String(tag).trim() || null), + note !== undefined, note === undefined ? null : (String(note).trim() || null), + tag !== undefined, tag === undefined ? null : (String(tag).trim() || null), + bucket !== undefined, bucket === undefined ? null : (String(bucket).trim() || null), ] ); if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' }); diff --git a/routes/operations.js b/routes/operations.js index 2c97cbb..3050444 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -319,6 +319,12 @@ module.exports = function(pool) { ,CASE WHEN l.operation IN ('baseline','reference') THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)') ELSE '(adjustment)' END AS pf_segment + -- what the segment counts towards, falling back to the segment + -- itself so an unlabelled one still reads as something + ,COALESCE(NULLIF(l.bucket, ''), + CASE WHEN l.operation IN ('baseline','reference') + THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)') + ELSE '(adjustment)' END) AS pf_bucket ,CASE WHEN l.operation IN ('baseline','reference') THEN NULL ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, '')) END AS pf_note @@ -637,7 +643,7 @@ module.exports = function(pool) { await client.query('COMMIT'); committed = true; const opLabel = (req.body.tag || '').trim() || note || null; - const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_note: opLabel, pf_op: 'scale' })); + const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_bucket: '(adjustment)', pf_note: opLabel, pf_op: 'scale' })); res.json({ rows, rows_affected: rows.length, @@ -697,7 +703,7 @@ module.exports = function(pool) { await client.query('COMMIT'); committed = true; const opLabel = (req.body.tag || '').trim() || note || null; - const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_note: opLabel, pf_op: 'recode' })); + const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_bucket: '(adjustment)', pf_note: opLabel, pf_op: 'recode' })); res.json({ rows, rows_affected: rows.length, slices_applied: units.length }); } finally { if (!committed) try { await client.query('ROLLBACK'); } catch {} @@ -754,7 +760,7 @@ module.exports = function(pool) { await client.query('COMMIT'); committed = true; const opLabel = (req.body.tag || '').trim() || note || null; - const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_note: opLabel, pf_op: 'clone' })); + const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_bucket: '(adjustment)', pf_note: opLabel, pf_op: 'clone' })); res.json({ rows, rows_affected: rows.length, slices_applied: units.length }); } finally { if (!committed) try { await client.query('ROLLBACK'); } catch {} diff --git a/setup_sql/01_schema.sql b/setup_sql/01_schema.sql index 0ea789c..6315893 100644 --- a/setup_sql/01_schema.sql +++ b/setup_sql/01_schema.sql @@ -81,6 +81,19 @@ WHERE TRUE AND note <> '' AND operation IN ('baseline', 'reference'); +-- What a segment contributes to, independent of pf_iter. +-- +-- pf_iter answers "can operations write to these rows"; bucket answers "does this +-- belong in the forecast number". Those are not the same question -- Open Orders is +-- loaded as reference so nothing adjusts it, yet it is part of the forecast -- so +-- neither can be derived from the other. +-- +-- Free text with suggested values (Forecast / Prior Year / Prior Prior Year / Plan) +-- rather than an enum, so a new banner does not need a migration. Blank by default: +-- until a segment is labelled, the pivot falls back to showing its own name. +ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS bucket text; +CREATE INDEX IF NOT EXISTS log_bucket_idx ON pf.log (bucket) WHERE bucket IS NOT NULL; + -- Master data for a dim_group: one row per key value, with its sibling columns. -- -- The source is transactional and often a view over all history, so deriving a diff --git a/ui/src/views/Baseline.jsx b/ui/src/views/Baseline.jsx index 7913c78..cab5835 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -113,6 +113,28 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio loadLog() }, [versionId]) + // A segment's banner: what it counts toward, independent of pf_iter. Held + // locally while typing so the field does not fight the fetched value, and + // written on blur. + const [buckets, setBuckets] = useState({}) + + async function saveBucket(entry, value) { + const next = value.trim() + if (next === (entry.bucket || '')) return + try { + const res = await fetch(`/api/log/${entry.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bucket: next }), + }) + if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return } + loadLog() + flash(next ? `Counts toward ${next} — reload the Forecast view to see it` : 'Banner cleared') + } catch (err) { + flash(err.message, 'error') + } + } + function loadLog() { fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => { setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference')) @@ -335,11 +357,18 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio + + + @@ -349,11 +378,11 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio {log.length === 0 && ( - + )} {!showAddForm && !editingLogId && ( - + @@ -398,7 +437,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio {isOpen && ( -
# notecounts toward rows {log[0]?.value_col || 'value'} by
No segments loaded yet
No segments loaded yet
+ e.stopPropagation()}> + setBuckets(b => ({ ...b, [entry.id]: e.target.value }))} + onBlur={e => saveBucket(entry, e.target.value)} + placeholder="—" + className="w-full border border-transparent hover:border-gray-200 focus:border-blue-400 + rounded px-1 py-0.5 text-xs focus:outline-none bg-transparent" /> + {entry.row_count != null ? entry.row_count.toLocaleString() : '—'}
+
diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index e1718d5..e28c4f9 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -763,11 +763,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio ? [ ...grainMeta.map(c => c.cname), ...meta.filter(c => ['value','units'].includes(c.role)).map(c => c.cname), - 'pf_gkey', 'pf_iter', 'pf_logid', 'pf_segment', 'pf_note', 'pf_op', + 'pf_gkey', 'pf_iter', 'pf_logid', 'pf_segment', 'pf_bucket', 'pf_note', 'pf_op', ] : [ ...meta.filter(c => ['dimension','value','units','date'].includes(c.role)).map(c => c.cname), - 'pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'created_at', 'pf_segment', 'pf_note', 'pf_op', + 'pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'created_at', 'pf_segment', 'pf_bucket', 'pf_note', 'pf_op', ]) const tableName = `fc_${vid}`