Multi-slice operations, tagged bridge, and a reworked adjustment panel
Forecast operations could only act on one clicked row at a time, and the
panel that drove them separated the numbers you were reading from the
inputs that changed them. This reworks both, and adds initiative tags so
a version's history can be read as a bridge.
Operations
- Accept `slices` (array) alongside the legacy single `slice`, with
apply_mode 'prorate' (one pool) or 'each' (independent per slice).
- buildWhereAny() ORs the slices into one predicate. A union of slices
cannot be flattened into per-column IN lists without over-selecting,
and the result is parenthesised so the appended exclude clause does not
bind wrong.
- resolveIncrs() now resolves each measure independently: target, percent
or change amount per measure, so a target on value and a percent on
units can be submitted together. Replaces the single global `mode`.
- target_basis chooses what a target measures against: only the rows an
operation can write, or everything the pivot shows for the slice.
Excluded iters are visible in the grid but immovable, so a target set
against the visible total previously overshot by their contribution.
Two latent bugs surfaced by the above, both pre-existing:
- A slice naming no filterable column reduced to TRUE and applied the
operation to the entire version. Now rejected on all three operations.
- Prorating across a pool that nets to ~zero multiplies each row's share
by an exploding factor, sending rows to extreme opposite values to hit
the target. Refused when the net falls below 1% of gross.
Tags and the bridge
- pf.log gains a nullable `tag`, written by a follow-up UPDATE rather
than through the generated SQL: those templates are stored per source
in pf.sql, so a {{tag}} token would strand any source that had not
re-run "Generate SQL".
- Tag is editable after the fact in the change log, with completion from
tags already used on the source. PATCH branches on whether a field was
sent, so a tag can be cleared as well as set.
- BridgeView renders the walk from baseline to current as a waterfall,
one step per tag, scoped to the selection, the pivot's filters, or the
whole version. Computed from the loaded Perspective table so the
figures always reconcile with what is on screen; overlapping slices are
deduped by pf_id to match the OR semantics operations use.
- Colour is a polarity job, so it uses the validated diverging pair
(blue/red, CVD dE 21.6) with neutral anchors, not categorical hues.
Every bar is directly labelled and a table view is available.
Panel
- Extracted to OperationPanel; the scale form is one continuous ledger:
baseline, each adjustment, current, then New value / Change / % change
as three interchangeable editable rows. Typing in any one derives the
others, which removes the target/delta/percent mode toggle entirely.
- Dockable bottom, right, or floating (drag to move, grip to resize), and
closable via header, Esc, or the toolbar. Placement persists.
- Controls no longer stretch to the dock width, and text contrast now
clears WCAG AA against white throughout.
Also: the status bar names the physical table writes land in, with live
row counts; and the pivot's expand depth is re-applied when the tab
regains focus, since Perspective rebuilds its view on redraw and a
ROLLUP view with no depth set renders fully expanded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1TQiBYZbbWWkMNoCtUd8M
This commit is contained in:
parent
99375bb534
commit
55814ee0d5
@ -251,6 +251,35 @@ function buildWhere(slice, dimCols) {
|
||||
return parts.length ? parts.join('\nAND ') : 'TRUE';
|
||||
}
|
||||
|
||||
// build a WHERE clause spanning several slices — an OR of AND-groups.
|
||||
// A union of slices cannot be flattened into one IN list per column: slices
|
||||
// {Region:East, State:NY} and {Region:West, State:CA} would become
|
||||
// Region IN (East,West) AND State IN (NY,CA), which also matches East/CA.
|
||||
function buildWhereAny(slices, dimCols) {
|
||||
const list = (slices || []).filter(s => s && Object.keys(s).length > 0);
|
||||
if (list.length === 0) return 'TRUE';
|
||||
if (list.length === 1) return buildWhere(list[0], dimCols);
|
||||
|
||||
const groups = list
|
||||
.map(s => buildWhere(s, dimCols))
|
||||
.filter(w => w !== 'TRUE');
|
||||
|
||||
// any slice that reduced to TRUE selects everything, so the union does too
|
||||
if (groups.length !== list.length) return 'TRUE';
|
||||
|
||||
// outer parens matter: the caller appends `AND pf_iter NOT IN (...)`,
|
||||
// and AND binds tighter than OR
|
||||
return `(${groups.map(g => `(${g.replace(/\n/g, ' ')})`).join('\n OR ')})`;
|
||||
}
|
||||
|
||||
// the bare predicate for "this row participates in operations", for use in a
|
||||
// FILTER clause where the excluded rows still need to be counted separately
|
||||
function buildExcludePredicate(excludeIters) {
|
||||
if (!excludeIters || excludeIters.length === 0) return 'TRUE';
|
||||
const list = excludeIters.map(i => `'${esc(i)}'`).join(', ');
|
||||
return `pf_iter NOT IN (${list})`;
|
||||
}
|
||||
|
||||
// build AND iter NOT IN (...) from a version's exclude_iters array
|
||||
function buildExcludeClause(excludeIters) {
|
||||
if (!excludeIters || excludeIters.length === 0) return '';
|
||||
@ -309,4 +338,4 @@ function esc(val) {
|
||||
return String(val).replace(/'/g, "''");
|
||||
}
|
||||
|
||||
module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, buildFilterClause, esc };
|
||||
module.exports = { generateSQL, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc };
|
||||
|
||||
@ -85,13 +85,28 @@ module.exports = function(pool) {
|
||||
}
|
||||
});
|
||||
|
||||
// update the note on a log entry
|
||||
// update the note and/or tag on a log entry. Both are annotations — they never
|
||||
// affect the forecast rows — so they stay editable after the fact, including on
|
||||
// a closed version, where relabelling history is still legitimate.
|
||||
router.patch('/log/:logid', async (req, res) => {
|
||||
const logId = parseInt(req.params.logid);
|
||||
const { note } = req.body;
|
||||
const { note, tag } = req.body;
|
||||
if (note === undefined && tag === undefined) {
|
||||
return res.status(400).json({ error: 'Nothing to update — send note and/or tag' });
|
||||
}
|
||||
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 = $1 WHERE id = $2 RETURNING *`, [note ?? null, logId]
|
||||
`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
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[
|
||||
logId,
|
||||
note !== undefined, note === undefined ? null : (String(note).trim() || null),
|
||||
tag !== undefined, tag === undefined ? null : (String(tag).trim() || null),
|
||||
]
|
||||
);
|
||||
if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' });
|
||||
res.json(result.rows[0]);
|
||||
|
||||
@ -1,14 +1,189 @@
|
||||
const express = require('express');
|
||||
const { tableFromArrays, tableToIPC } = require('apache-arrow');
|
||||
const { applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc } = require('../lib/sql_generator');
|
||||
const { applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, esc } = require('../lib/sql_generator');
|
||||
const { fcTable } = require('../lib/utils');
|
||||
|
||||
module.exports = function(pool) {
|
||||
const router = express.Router();
|
||||
|
||||
async function runSQL(sql) {
|
||||
async function runSQL(sql, client) {
|
||||
console.log('--- SQL ---\n', sql, '\n--- END SQL ---');
|
||||
return pool.query(sql);
|
||||
return (client || pool).query(sql);
|
||||
}
|
||||
|
||||
// accept either the legacy single `slice` object or the newer `slices` array,
|
||||
// and drop any empty entries so an empty selection can never widen to TRUE
|
||||
function normalizeSlices(body) {
|
||||
const raw = Array.isArray(body.slices) && body.slices.length ? body.slices : [body.slice];
|
||||
return raw.filter(s => s && typeof s === 'object' && Object.keys(s).length > 0);
|
||||
}
|
||||
|
||||
// Stamp the tag onto the log entry the operation just created.
|
||||
// Done as a follow-up UPDATE rather than inside the generated SQL: those
|
||||
// templates live in pf.sql per source, so adding a {{tag}} token would strand
|
||||
// every source that has not re-run "Generate SQL".
|
||||
async function tagLog(client, rows, tag) {
|
||||
const clean = (tag || '').trim();
|
||||
if (!clean) return null;
|
||||
const ids = [...new Set(rows.map(r => r.pf_logid).filter(id => id != null))];
|
||||
if (ids.length === 0) return null;
|
||||
await client.query(`UPDATE pf.log SET tag = $1 WHERE id = ANY($2::bigint[])`, [clean, ids]);
|
||||
return clean;
|
||||
}
|
||||
|
||||
// A slice is only meaningful if at least one of its keys is a filterable
|
||||
// column. buildWhere silently drops unknown keys, so {"typo": "x"} would
|
||||
// otherwise reduce to TRUE and apply the operation to the whole version.
|
||||
// Refuse rather than let a malformed selection rewrite every row.
|
||||
function assertSelective(slices, ctx) {
|
||||
const allowed = new Set(ctx.filterCols);
|
||||
slices.forEach((sl, i) => {
|
||||
const hits = Object.keys(sl).filter(k => allowed.has(k));
|
||||
if (hits.length === 0) {
|
||||
const err = new Error(
|
||||
`Slice ${i + 1} does not name any filterable column ` +
|
||||
`(${JSON.stringify(sl)}). Expected one of: ${ctx.filterCols.join(', ')}.`
|
||||
);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// echo back what the caller asked for, for the audit log
|
||||
function pickIntent(body) {
|
||||
const keys = ['mode', 'target_basis', 'value_incr', 'units_incr', 'value_pct', 'units_pct', 'pct',
|
||||
'target_value', 'target_units', 'target_price'];
|
||||
const out = {};
|
||||
for (const k of keys) if (body[k] !== undefined && body[k] !== null && body[k] !== '') out[k] = body[k];
|
||||
return out;
|
||||
}
|
||||
|
||||
// Totals for a WHERE clause, split into the rows operations can change and the
|
||||
// rows they cannot. Excluded iters (typically 'reference') are still visible in
|
||||
// the pivot, so their contribution has to be reported rather than dropped —
|
||||
// otherwise a target set against what the grid shows lands somewhere else.
|
||||
async function sliceTotals(client, ctx, whereClause, excludeClause) {
|
||||
const pred = buildExcludePredicate(ctx.version.exclude_iters);
|
||||
const agg = (col, filter) => col ? `sum(${col}) FILTER (WHERE ${filter})` : 'NULL';
|
||||
const v = ctx.valueCol ? `"${ctx.valueCol}"` : null;
|
||||
const u = ctx.unitsCol ? `"${ctx.unitsCol}"` : null;
|
||||
|
||||
const r = await client.query(`
|
||||
SELECT ${agg(v, pred)} AS total_value,
|
||||
${agg(u, pred)} AS total_units,
|
||||
${agg(v ? `abs(${v})` : null, pred)} AS abs_value,
|
||||
${agg(u ? `abs(${u})` : null, pred)} AS abs_units,
|
||||
${agg(v, `NOT (${pred})`)} AS excl_value,
|
||||
${agg(u, `NOT (${pred})`)} AS excl_units
|
||||
FROM ${ctx.table} WHERE ${whereClause}
|
||||
`);
|
||||
const n = (x) => parseFloat(r.rows[0][x]) || 0;
|
||||
return {
|
||||
value: n('total_value'), units: n('total_units'),
|
||||
absValue: n('abs_value'), absUnits: n('abs_units'),
|
||||
exclValue: n('excl_value'), exclUnits: n('excl_units'),
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve each measure independently into the increment the scale SQL expects.
|
||||
// Value and units each accept exactly one of: an absolute target, a change
|
||||
// amount, or a percentage — whichever the caller sent. They are resolved
|
||||
// separately so a target on one measure and a percentage on the other can be
|
||||
// submitted together. Everything is measured against the totals of *this*
|
||||
// WHERE clause, which is what makes apply_mode 'each' land per slice.
|
||||
async function resolveIncrs(client, ctx, whereClause, excludeClause, body) {
|
||||
const num = (v) => (v === undefined || v === null || v === '') ? null : parseFloat(v);
|
||||
|
||||
const tValue = num(body.target_value);
|
||||
const tUnits = num(body.target_units);
|
||||
const tPrice = num(body.target_price);
|
||||
const vIncr = num(body.value_incr);
|
||||
const uIncr = num(body.units_incr);
|
||||
let vPct = num(body.value_pct);
|
||||
let uPct = num(body.units_pct);
|
||||
|
||||
// legacy shape: a single `pct` flag meaning "the increments are percentages"
|
||||
if (body.pct) {
|
||||
if (vPct === null && vIncr !== null) vPct = vIncr;
|
||||
if (uPct === null && uIncr !== null) uPct = uIncr;
|
||||
}
|
||||
const legacyPct = !!body.pct;
|
||||
|
||||
const anyInput = [tValue, tUnits, tPrice, vIncr, uIncr, vPct, uPct].some(v => v !== null);
|
||||
if (!anyInput) return { value: 0, units: 0 };
|
||||
|
||||
const totals = await sliceTotals(client, ctx, whereClause, excludeClause);
|
||||
|
||||
// What the number is measured against:
|
||||
// 'adjustable' — only the rows this operation can write (the default, and
|
||||
// what every earlier version of this API did)
|
||||
// 'selected' — everything the pivot shows for the slice, excluded rows
|
||||
// included. Those rows cannot move, so reaching the target
|
||||
// means the adjustable rows absorb the whole difference.
|
||||
const basis = body.target_basis === 'selected' ? 'selected' : 'adjustable';
|
||||
const fixedValue = basis === 'selected' ? totals.exclValue : 0;
|
||||
const fixedUnits = basis === 'selected' ? totals.exclUnits : 0;
|
||||
|
||||
// one measure: target wins, then percentage, then a plain change amount
|
||||
const resolve = (target, pct, incr, current, fixed) => {
|
||||
// subtract the immovable part: current + incr + fixed === target
|
||||
if (target !== null) return (target - fixed) - current;
|
||||
// a percentage of the basis, which may include the immovable part
|
||||
if (pct !== null) return (current + fixed) * pct / 100;
|
||||
if (incr !== null && !legacyPct) return incr;
|
||||
return 0;
|
||||
};
|
||||
|
||||
let value = resolve(tValue, vPct, vIncr, totals.value, fixedValue);
|
||||
const units = resolve(tUnits, uPct, uIncr, totals.units, fixedUnits);
|
||||
|
||||
// a price target holds units constant: new value = price x current units.
|
||||
// An explicit value target outranks it.
|
||||
if (tPrice !== null && tValue === null) {
|
||||
value = (tPrice * (totals.units + fixedUnits)) - (totals.value + fixedValue);
|
||||
}
|
||||
|
||||
// the scale SQL divides by the slice total; with no rows there is
|
||||
// nothing to prorate across and the increment would vanish anyway
|
||||
if (totals.value === 0 && totals.units === 0) return { value: 0, units: 0 };
|
||||
|
||||
// Refuse to prorate across a pool that nets to ~zero. Each row's new value is
|
||||
// (row / total) * increment, so as the net approaches zero the multiplier
|
||||
// explodes and rows fly apart in opposite directions to hit the target — a
|
||||
// mathematically faithful, practically useless result. Selecting slices that
|
||||
// offset each other is the usual cause, and 'each' handles that correctly.
|
||||
assertProratable(totals, value, units);
|
||||
|
||||
return { value: round(value, 6), units: round(units, 6) };
|
||||
}
|
||||
|
||||
// a pool is proratable only if its net is a meaningful fraction of its gross
|
||||
const NET_TO_GROSS_FLOOR = 0.01;
|
||||
|
||||
function assertProratable(totals, value, units) {
|
||||
const check = (net, gross, incr, label) => {
|
||||
if (!incr) return;
|
||||
if (gross === 0) return;
|
||||
if (Math.abs(net) >= gross * NET_TO_GROSS_FLOOR) return;
|
||||
const err = new Error(
|
||||
`Cannot prorate ${label} across this selection: the rows net to ` +
|
||||
`${net.toFixed(2)} against a gross of ${gross.toFixed(2)}, so they very ` +
|
||||
`nearly cancel out. Scaling to a target would push them to extreme ` +
|
||||
`opposite values. Use "Each" to scale every slice on its own, or narrow ` +
|
||||
`the selection so it does not mix offsetting rows.`
|
||||
);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
};
|
||||
check(totals.value, totals.absValue, value, 'value');
|
||||
check(totals.units, totals.absUnits, units, 'units');
|
||||
}
|
||||
|
||||
function round(n, dp) {
|
||||
if (!isFinite(n)) return 0;
|
||||
const f = Math.pow(10, dp);
|
||||
return Math.round(n * f) / f;
|
||||
}
|
||||
|
||||
// fetch everything needed to execute an operation:
|
||||
@ -299,131 +474,196 @@ module.exports = function(pool) {
|
||||
}
|
||||
});
|
||||
|
||||
// scale a slice — adjust value and/or units by absolute amount or percentage
|
||||
// scale one or more slices — adjust value and/or units toward an absolute
|
||||
// target or by an increment. With several slices selected, apply_mode decides
|
||||
// whether they are treated as one pool ('prorate') or independently ('each').
|
||||
router.post('/versions/:id/scale', async (req, res) => {
|
||||
const { pf_user, note, slice, value_incr, units_incr, pct } = req.body;
|
||||
if (!slice || Object.keys(slice).length === 0) {
|
||||
return res.status(400).json({ error: 'slice is required' });
|
||||
}
|
||||
const { pf_user, note, apply_mode } = req.body;
|
||||
const slices = normalizeSlices(req.body);
|
||||
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
|
||||
|
||||
const applyMode = apply_mode === 'each' ? 'each' : 'prorate';
|
||||
|
||||
try {
|
||||
const ctx = await getContext(parseInt(req.params.id), 'scale');
|
||||
if (!guardOpen(ctx.version, res)) return;
|
||||
assertSelective(slices, ctx);
|
||||
|
||||
const whereClause = buildWhere(slice, ctx.filterCols);
|
||||
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
||||
|
||||
let absValueIncr = value_incr || 0;
|
||||
let absUnitsIncr = units_incr || 0;
|
||||
// 'prorate' pools every slice into one WHERE and lets the SQL's
|
||||
// sum() OVER () distribute the increment across the whole pool.
|
||||
// 'each' runs the same statement once per slice, so every slice
|
||||
// reaches the target on its own and gets its own log entry.
|
||||
const units = applyMode === 'each'
|
||||
? slices.map(sl => ({ slices: [sl], where: buildWhere(sl, ctx.filterCols) }))
|
||||
: [{ slices, where: buildWhereAny(slices, ctx.filterCols) }];
|
||||
|
||||
// pct mode: run a quick totals query, convert percentages to absolutes
|
||||
if (pct && (value_incr || units_incr)) {
|
||||
const totals = await pool.query(`
|
||||
SELECT
|
||||
sum("${ctx.valueCol}") AS total_value,
|
||||
sum("${ctx.unitsCol}") AS total_units
|
||||
FROM ${ctx.table}
|
||||
WHERE ${whereClause}
|
||||
${excludeClause}
|
||||
`);
|
||||
const { total_value, total_units } = totals.rows[0];
|
||||
if (value_incr) absValueIncr = (parseFloat(total_value) || 0) * value_incr / 100;
|
||||
if (units_incr) absUnitsIncr = (parseFloat(total_units) || 0) * units_incr / 100;
|
||||
const client = await pool.connect();
|
||||
let committed = false;
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const allRows = [];
|
||||
let applied = 0;
|
||||
const skipped = [];
|
||||
|
||||
for (const unit of units) {
|
||||
const incr = await resolveIncrs(client, ctx, unit.where, excludeClause, req.body);
|
||||
// no rows, or already at the target — nothing to write for this slice
|
||||
if (incr.value === 0 && incr.units === 0) { skipped.push(...unit.slices); continue; }
|
||||
applied++;
|
||||
|
||||
const loggedSlice = unit.slices.length === 1 ? unit.slices[0] : unit.slices;
|
||||
const sql = applyTokens(ctx.sql, {
|
||||
fc_table: ctx.table,
|
||||
version_id: ctx.version.id,
|
||||
pf_user: esc(pf_user || ''),
|
||||
note: esc(note || ''),
|
||||
params: esc(JSON.stringify({
|
||||
slices: unit.slices,
|
||||
apply_mode: applyMode,
|
||||
...pickIntent(req.body),
|
||||
resolved: { value_incr: incr.value, units_incr: incr.units }
|
||||
})),
|
||||
slice: esc(JSON.stringify(loggedSlice)),
|
||||
where_clause: unit.where,
|
||||
exclude_clause: excludeClause,
|
||||
value_incr: incr.value,
|
||||
units_incr: incr.units
|
||||
});
|
||||
const result = await runSQL(sql, client);
|
||||
await tagLog(client, result.rows, req.body.tag);
|
||||
allRows.push(...result.rows);
|
||||
}
|
||||
|
||||
if (allRows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({
|
||||
error: 'Nothing to scale — the target matches the current total, or the increment is zero'
|
||||
});
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
committed = true;
|
||||
const rows = allRows.map(r => ({ ...r, pf_note: note || null, pf_op: 'scale' }));
|
||||
res.json({
|
||||
rows,
|
||||
rows_affected: rows.length,
|
||||
slices_applied: applied,
|
||||
...(skipped.length ? { slices_skipped: skipped } : {})
|
||||
});
|
||||
} finally {
|
||||
if (!committed) try { await client.query('ROLLBACK'); } catch {}
|
||||
client.release();
|
||||
}
|
||||
|
||||
if (absValueIncr === 0 && absUnitsIncr === 0) {
|
||||
return res.status(400).json({ error: 'value_incr and/or units_incr must be non-zero' });
|
||||
}
|
||||
|
||||
const sql = applyTokens(ctx.sql, {
|
||||
fc_table: ctx.table,
|
||||
version_id: ctx.version.id,
|
||||
pf_user: esc(pf_user || ''),
|
||||
note: esc(note || ''),
|
||||
params: esc(JSON.stringify({ slice, value_incr, units_incr, pct })),
|
||||
slice: esc(JSON.stringify(slice)),
|
||||
where_clause: whereClause,
|
||||
exclude_clause: excludeClause,
|
||||
value_incr: absValueIncr,
|
||||
units_incr: absUnitsIncr
|
||||
});
|
||||
|
||||
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 });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(err.status || 500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// recode dimension values on a slice
|
||||
// recode dimension values on one or more slices
|
||||
// inserts negative rows to zero out the original, positive rows with new dimension values
|
||||
router.post('/versions/:id/recode', async (req, res) => {
|
||||
const { pf_user, note, slice, set } = req.body;
|
||||
if (!slice || Object.keys(slice).length === 0) return res.status(400).json({ error: 'slice is required' });
|
||||
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
|
||||
const { pf_user, note, set, apply_mode } = req.body;
|
||||
const slices = normalizeSlices(req.body);
|
||||
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
|
||||
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
|
||||
|
||||
try {
|
||||
const ctx = await getContext(parseInt(req.params.id), 'recode');
|
||||
if (!guardOpen(ctx.version, res)) return;
|
||||
assertSelective(slices, ctx);
|
||||
|
||||
const whereClause = buildWhere(slice, ctx.filterCols);
|
||||
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
||||
const setClause = buildSetClause(ctx.dimCols, set);
|
||||
const units = sliceUnits(slices, ctx, apply_mode);
|
||||
|
||||
const sql = applyTokens(ctx.sql, {
|
||||
fc_table: ctx.table,
|
||||
version_id: ctx.version.id,
|
||||
pf_user: esc(pf_user || ''),
|
||||
note: esc(note || ''),
|
||||
params: esc(JSON.stringify({ slice, set })),
|
||||
slice: esc(JSON.stringify(slice)),
|
||||
where_clause: whereClause,
|
||||
exclude_clause: excludeClause,
|
||||
set_clause: setClause
|
||||
});
|
||||
|
||||
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 });
|
||||
const client = await pool.connect();
|
||||
let committed = false;
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const allRows = [];
|
||||
for (const unit of units) {
|
||||
const loggedSlice = unit.slices.length === 1 ? unit.slices[0] : unit.slices;
|
||||
const sql = applyTokens(ctx.sql, {
|
||||
fc_table: ctx.table,
|
||||
version_id: ctx.version.id,
|
||||
pf_user: esc(pf_user || ''),
|
||||
note: esc(note || ''),
|
||||
params: esc(JSON.stringify({ slices: unit.slices, set, apply_mode: unit.mode })),
|
||||
slice: esc(JSON.stringify(loggedSlice)),
|
||||
where_clause: unit.where,
|
||||
exclude_clause: excludeClause,
|
||||
set_clause: setClause
|
||||
});
|
||||
const result = await runSQL(sql, client);
|
||||
await tagLog(client, result.rows, req.body.tag);
|
||||
allRows.push(...result.rows);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
committed = true;
|
||||
const rows = allRows.map(r => ({ ...r, pf_note: note || null, pf_op: 'recode' }));
|
||||
res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
|
||||
} finally {
|
||||
if (!committed) try { await client.query('ROLLBACK'); } catch {}
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(err.status || 500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// clone a slice as new business under new dimension values
|
||||
// clone one or more slices as new business under new dimension values
|
||||
// does not offset the original slice
|
||||
router.post('/versions/:id/clone', async (req, res) => {
|
||||
const { pf_user, note, slice, set, scale } = req.body;
|
||||
if (!slice || Object.keys(slice).length === 0) return res.status(400).json({ error: 'slice is required' });
|
||||
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
|
||||
const { pf_user, note, set, scale, apply_mode } = req.body;
|
||||
const slices = normalizeSlices(req.body);
|
||||
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
|
||||
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
|
||||
|
||||
try {
|
||||
const ctx = await getContext(parseInt(req.params.id), 'clone');
|
||||
if (!guardOpen(ctx.version, res)) return;
|
||||
assertSelective(slices, ctx);
|
||||
|
||||
const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0;
|
||||
const whereClause = buildWhere(slice, ctx.filterCols);
|
||||
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
||||
const setClause = buildSetClause(ctx.dimCols, set);
|
||||
const units = sliceUnits(slices, ctx, apply_mode);
|
||||
|
||||
const sql = applyTokens(ctx.sql, {
|
||||
fc_table: ctx.table,
|
||||
version_id: ctx.version.id,
|
||||
pf_user: esc(pf_user || ''),
|
||||
note: esc(note || ''),
|
||||
params: esc(JSON.stringify({ slice, set, scale: scaleFactor })),
|
||||
slice: esc(JSON.stringify(slice)),
|
||||
where_clause: whereClause,
|
||||
exclude_clause: excludeClause,
|
||||
set_clause: setClause,
|
||||
scale_factor: scaleFactor
|
||||
});
|
||||
|
||||
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 });
|
||||
const client = await pool.connect();
|
||||
let committed = false;
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const allRows = [];
|
||||
for (const unit of units) {
|
||||
const loggedSlice = unit.slices.length === 1 ? unit.slices[0] : unit.slices;
|
||||
const sql = applyTokens(ctx.sql, {
|
||||
fc_table: ctx.table,
|
||||
version_id: ctx.version.id,
|
||||
pf_user: esc(pf_user || ''),
|
||||
note: esc(note || ''),
|
||||
params: esc(JSON.stringify({ slices: unit.slices, set, scale: scaleFactor, apply_mode: unit.mode })),
|
||||
slice: esc(JSON.stringify(loggedSlice)),
|
||||
where_clause: unit.where,
|
||||
exclude_clause: excludeClause,
|
||||
set_clause: setClause,
|
||||
scale_factor: scaleFactor
|
||||
});
|
||||
const result = await runSQL(sql, client);
|
||||
await tagLog(client, result.rows, req.body.tag);
|
||||
allRows.push(...result.rows);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
committed = true;
|
||||
const rows = allRows.map(r => ({ ...r, pf_note: note || null, pf_op: 'clone' }));
|
||||
res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
|
||||
} finally {
|
||||
if (!committed) try { await client.query('ROLLBACK'); } catch {}
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(err.status || 500).json({ error: err.message });
|
||||
|
||||
@ -114,6 +114,125 @@ ${colDefs},
|
||||
}
|
||||
});
|
||||
|
||||
// where this version's writes actually land: the physical forecast table,
|
||||
// its current row count, and the source table rows are read from.
|
||||
// Surfaced in the status bar so the write target is never a mystery.
|
||||
router.get('/versions/:id/table-info', async (req, res) => {
|
||||
try {
|
||||
const verResult = await pool.query(`
|
||||
SELECT v.id, v.name, v.status, s.schema, s.tname
|
||||
FROM pf.version v
|
||||
JOIN pf.source s ON s.id = v.source_id
|
||||
WHERE v.id = $1
|
||||
`, [req.params.id]);
|
||||
if (verResult.rows.length === 0) return res.status(404).json({ error: 'Version not found' });
|
||||
|
||||
const v = verResult.rows[0];
|
||||
const fc = fcTable(v.tname, v.id);
|
||||
const [schema, table] = fc.split('.');
|
||||
|
||||
const existsResult = await pool.query(
|
||||
`SELECT to_regclass($1) IS NOT NULL AS exists`, [fc]
|
||||
);
|
||||
const exists = existsResult.rows[0].exists;
|
||||
|
||||
let rows = null, byIter = [];
|
||||
if (exists) {
|
||||
const countResult = await pool.query(
|
||||
`SELECT pf_iter, count(*)::int AS n FROM ${fc} GROUP BY pf_iter ORDER BY pf_iter`
|
||||
);
|
||||
byIter = countResult.rows;
|
||||
rows = byIter.reduce((a, r) => a + r.n, 0);
|
||||
}
|
||||
|
||||
res.json({
|
||||
version_id: v.id,
|
||||
version_name: v.name,
|
||||
status: v.status,
|
||||
source: `${v.schema}.${v.tname}`,
|
||||
fc_table: fc,
|
||||
fc_schema: schema,
|
||||
fc_tname: table,
|
||||
exists,
|
||||
rows,
|
||||
by_iter: byIter
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Tags already used on this source, newest first — feeds the tag autocomplete.
|
||||
// Scoped to the source rather than the version so an initiative name carries
|
||||
// across versions, which is the point of naming it.
|
||||
router.get('/sources/:id/tags', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT l.tag,
|
||||
count(*)::int AS uses,
|
||||
max(l.stamp) AS last_used
|
||||
FROM pf.log l
|
||||
JOIN pf.version v ON v.id = l.version_id
|
||||
WHERE v.source_id = $1 AND l.tag IS NOT NULL AND l.tag <> ''
|
||||
GROUP BY l.tag
|
||||
ORDER BY max(l.stamp) DESC
|
||||
`, [req.params.id]);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Bridge: how this version got from its baseline to where it stands, grouped by
|
||||
// initiative. Amounts come from the version's own forecast table, so the figures
|
||||
// reconcile with the pivot rather than being recomputed from the log's params.
|
||||
router.get('/versions/:id/bridge', async (req, res) => {
|
||||
try {
|
||||
const verResult = await pool.query(`
|
||||
SELECT v.id, v.exclude_iters, s.schema, 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
|
||||
`, [req.params.id]);
|
||||
if (verResult.rows.length === 0) return res.status(404).json({ error: 'Version not found' });
|
||||
const v = verResult.rows[0];
|
||||
const fc = fcTable(v.tname, v.id);
|
||||
|
||||
const exists = await pool.query(`SELECT to_regclass($1) IS NOT NULL AS ok`, [fc]);
|
||||
if (!exists.rows[0].ok) return res.json({ fc_table: fc, exists: false, rows: [] });
|
||||
|
||||
const colResult = await pool.query(
|
||||
`SELECT cname, role FROM pf.col_meta WHERE source_id = $1`, [v.source_id]);
|
||||
const valueCol = colResult.rows.find(c => c.role === 'value')?.cname;
|
||||
const unitsCol = colResult.rows.find(c => c.role === 'units')?.cname;
|
||||
if (!valueCol) return res.status(400).json({ error: 'No value column configured' });
|
||||
|
||||
const excl = (v.exclude_iters || []).length
|
||||
? `t.pf_iter NOT IN (${v.exclude_iters.map(i => `'${String(i).replace(/'/g, "''")}'`).join(', ')})`
|
||||
: 'TRUE';
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT CASE WHEN t.pf_iter = 'baseline' THEN '(baseline)'
|
||||
ELSE coalesce(nullif(l.tag, ''), '(untagged)') END AS tag,
|
||||
bool_or(t.pf_iter = 'baseline') AS is_baseline,
|
||||
count(DISTINCT l.id)::int AS entries,
|
||||
count(*)::int AS row_count,
|
||||
round(sum(t."${valueCol}")::numeric, 2) AS value
|
||||
${unitsCol ? `, round(sum(t."${unitsCol}")::numeric, 2) AS units` : ''}
|
||||
FROM ${fc} t
|
||||
LEFT JOIN pf.log l ON l.id = t.pf_logid
|
||||
WHERE ${excl}
|
||||
GROUP BY 1
|
||||
ORDER BY bool_or(t.pf_iter = 'baseline') DESC, min(l.id)
|
||||
`);
|
||||
res.json({ fc_table: fc, exists: true, value_col: valueCol, units_col: unitsCol, rows: result.rows });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// update version name, description, or exclude_iters
|
||||
router.put('/versions/:id', async (req, res) => {
|
||||
const { name, description, exclude_iters } = req.body;
|
||||
|
||||
@ -55,9 +55,15 @@ CREATE TABLE IF NOT EXISTS pf.log (
|
||||
operation text NOT NULL, -- baseline | reference | scale | recode | clone
|
||||
slice jsonb,
|
||||
params jsonb,
|
||||
note text
|
||||
note text,
|
||||
tag text -- initiative label, e.g. 'reduce_spend'; groups
|
||||
-- adjustments into a bridge from baseline to current
|
||||
);
|
||||
|
||||
-- adding tags to an install that predates them
|
||||
ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS tag text;
|
||||
CREATE INDEX IF NOT EXISTS log_tag_idx ON pf.log (tag) WHERE tag IS NOT NULL;
|
||||
|
||||
-- generated operation SQL per source, stored after col_meta is configured
|
||||
CREATE TABLE IF NOT EXISTS pf.sql (
|
||||
id serial PRIMARY KEY,
|
||||
|
||||
402
ui/src/components/BridgeView.jsx
Normal file
402
ui/src/components/BridgeView.jsx
Normal file
@ -0,0 +1,402 @@
|
||||
// Bridge (waterfall): how a version got from its baseline to where it stands,
|
||||
// one step per initiative tag.
|
||||
//
|
||||
// Data is computed from the Perspective table already in the browser rather than
|
||||
// from the /bridge endpoint, so the figures always reconcile with what the pivot
|
||||
// is showing — including when the view is scoped to the pivot's current filters.
|
||||
//
|
||||
// Colour is a POLARITY job, not a categorical one: increases and decreases are two
|
||||
// poles of one scale, with baseline and current as neutral anchors. Blue/red is the
|
||||
// validated diverging pair (CVD ΔE 21.6, normal-vision 32.3 against white);
|
||||
// green/red is avoided precisely because it is the classic CVD failure.
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
const UP = '#2a78d6' // increase
|
||||
const DOWN = '#e34948' // decrease
|
||||
const ANCHOR = '#6b7280' // baseline / current — neutral, 4.83:1 on white
|
||||
const GRID = '#e5e7eb'
|
||||
const INK = '#374151'
|
||||
const INK_DIM = '#6b7280'
|
||||
|
||||
const fmt = (n, dp = 2) =>
|
||||
n == null || !isFinite(n) ? '—'
|
||||
: n.toLocaleString(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp })
|
||||
|
||||
const fmtSigned = (n, dp = 2) =>
|
||||
n == null || !isFinite(n) ? '—' : `${n > 0 ? '+' : n < 0 ? '−' : ''}${fmt(Math.abs(n), dp)}`
|
||||
|
||||
// compact axis ticks — full precision belongs on the marks and in the table
|
||||
function fmtAxis(n) {
|
||||
const a = Math.abs(n)
|
||||
if (a >= 1e9) return `${(n / 1e9).toFixed(1)}B`
|
||||
if (a >= 1e6) return `${(n / 1e6).toFixed(1)}M`
|
||||
if (a >= 1e3) return `${(n / 1e3).toFixed(1)}k`
|
||||
return String(Math.round(n))
|
||||
}
|
||||
|
||||
function niceTicks(min, max, count = 5) {
|
||||
if (!isFinite(min) || !isFinite(max) || min === max) return [min || 0]
|
||||
const span = max - min
|
||||
const raw = span / count
|
||||
const mag = Math.pow(10, Math.floor(Math.log10(raw)))
|
||||
const step = [1, 2, 2.5, 5, 10].map(m => m * mag).find(s => s >= raw) || mag * 10
|
||||
const out = []
|
||||
for (let t = Math.ceil(min / step) * step; t <= max + 1e-9; t += step) out.push(t)
|
||||
return out
|
||||
}
|
||||
|
||||
// Turn raw forecast rows into the walk: baseline anchor, one floating step per
|
||||
// initiative tag, current anchor. Pure and exported so the arithmetic can be
|
||||
// checked against real data without a browser.
|
||||
export function buildSteps(rows, { valueCol, unitsCol, logMeta = {}, excludeIters = ['reference'] }) {
|
||||
const excl = new Set(excludeIters)
|
||||
const baseline = { value: 0, units: 0, rows: 0 }
|
||||
const byTag = new Map()
|
||||
|
||||
for (const r of rows) {
|
||||
const iter = r.pf_iter
|
||||
if (excl.has(iter)) continue
|
||||
const v = parseFloat(r[valueCol]) || 0
|
||||
const u = unitsCol ? (parseFloat(r[unitsCol]) || 0) : 0
|
||||
|
||||
if (iter === 'baseline') {
|
||||
baseline.value += v; baseline.units += u; baseline.rows += 1
|
||||
continue
|
||||
}
|
||||
const meta = logMeta[r.pf_logid] || {}
|
||||
const tag = (meta.tag || '').trim()
|
||||
const label = tag || (meta.note || '').trim() ||
|
||||
`${(meta.operation || iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}`
|
||||
const key = tag ? `tag:${tag}` : `log:${r.pf_logid}`
|
||||
const g = byTag.get(key) ||
|
||||
{ key, label, tagged: !!tag, value: 0, units: 0, rows: 0, logIds: new Set(), first: r.pf_logid }
|
||||
g.value += v; g.units += u; g.rows += 1
|
||||
if (r.pf_logid != null) { g.logIds.add(r.pf_logid); g.first = Math.min(g.first ?? r.pf_logid, r.pf_logid) }
|
||||
byTag.set(key, g)
|
||||
}
|
||||
|
||||
const mid = [...byTag.values()].sort((a, b) => (a.first ?? 0) - (b.first ?? 0))
|
||||
|
||||
let running = baseline.value
|
||||
const out = [{
|
||||
key: 'baseline', label: 'Baseline', kind: 'anchor',
|
||||
delta: baseline.value, start: 0, end: baseline.value,
|
||||
units: baseline.units, rows: baseline.rows, entries: 1,
|
||||
}]
|
||||
for (const g of mid) {
|
||||
const start = running
|
||||
running += g.value
|
||||
out.push({ ...g, kind: 'step', delta: g.value, start, end: running, entries: g.logIds.size })
|
||||
}
|
||||
out.push({
|
||||
key: 'current', label: 'Current', kind: 'anchor',
|
||||
delta: running, start: 0, end: running,
|
||||
units: out.reduce((a, s) => a + (s.kind === 'anchor' ? 0 : s.units || 0), baseline.units),
|
||||
rows: rows.filter(r => !excl.has(r.pf_iter)).length,
|
||||
entries: mid.reduce((a, g) => a + g.logIds.size, 0) + 1,
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Plot geometry, also pure: given the steps and a canvas size, where does each
|
||||
// bar and label land? Exported so collisions and overflow can be checked.
|
||||
export function layoutSteps(steps, width, H = 340, PAD = { t: 24, r: 16, b: 64, l: 68 }) {
|
||||
const plotW = Math.max(120, width - PAD.l - PAD.r)
|
||||
const plotH = H - PAD.t - PAD.b
|
||||
const values = steps.flatMap(s => [s.start, s.end])
|
||||
const rawMin = Math.min(0, ...values)
|
||||
const rawMax = Math.max(0, ...values)
|
||||
const span = (rawMax - rawMin) || 1
|
||||
const yMin = rawMin - span * 0.08
|
||||
const yMax = rawMax + span * 0.12
|
||||
const y = (v) => PAD.t + plotH - ((v - yMin) / (yMax - yMin)) * plotH
|
||||
|
||||
const n = steps.length || 1
|
||||
const band = plotW / n
|
||||
const barW = Math.max(10, Math.min(64, band - 14))
|
||||
const bars = steps.map((s, i) => {
|
||||
const x = PAD.l + band * i + (band - barW) / 2
|
||||
const top = y(Math.max(s.start, s.end))
|
||||
const bot = y(Math.min(s.start, s.end))
|
||||
return { key: s.key, x, w: barW, top, h: Math.max(2, bot - top), labelY: top - 6 }
|
||||
})
|
||||
return { PAD, plotW, plotH, yMin, yMax, y, band, barW, bars, H, width }
|
||||
}
|
||||
|
||||
export default function BridgeView({
|
||||
open, onClose, tableRef, viewerRef, logMeta = {},
|
||||
valueCol, unitsCol, colMeta = [], slices = [],
|
||||
excludeIters = ['reference'], versionName,
|
||||
}) {
|
||||
const hasSelection = slices.length > 0
|
||||
// 'selection' | 'filtered' | 'all'
|
||||
const [scope, setScope] = useState(hasSelection ? 'selection' : 'filtered')
|
||||
const [asTable, setAsTable] = useState(false)
|
||||
const [steps, setSteps] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [hover, setHover] = useState(null)
|
||||
const [width, setWidth] = useState(880)
|
||||
const boxRef = useRef(null)
|
||||
|
||||
// Build the steps: baseline anchor, one floating step per tag, current anchor.
|
||||
const compute = useCallback(async () => {
|
||||
if (!tableRef?.current || !valueCol) return
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
let rows
|
||||
if (scope === 'selection') {
|
||||
// The union of the selected slices — the same reach an operation would
|
||||
// have. Perspective view filters are AND-only, so each slice needs its own
|
||||
// view; rows matching more than one slice are counted once.
|
||||
const dimNames = new Set(colMeta.filter(c => c.role === 'dimension').map(c => c.cname))
|
||||
const dateNames = new Set(colMeta.filter(c => c.role === 'date').map(c => c.cname))
|
||||
const seen = new Set()
|
||||
rows = []
|
||||
for (const sl of slices) {
|
||||
const f = [
|
||||
...Object.entries(sl).filter(([c]) => dimNames.has(c)).map(([c, v]) => [c, '==', v]),
|
||||
...Object.entries(sl).filter(([c]) => dateNames.has(c)).map(([c, v]) => [c, '==', Number(v)]),
|
||||
]
|
||||
if (!f.length) continue
|
||||
const view = await tableRef.current.view({ filter: f })
|
||||
const part = await view.to_json()
|
||||
await view.delete()
|
||||
for (const r of part) {
|
||||
if (r.pf_id != null && seen.has(r.pf_id)) continue
|
||||
if (r.pf_id != null) seen.add(r.pf_id)
|
||||
rows.push(r)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let filter = []
|
||||
if (scope === 'filtered' && viewerRef?.current) {
|
||||
const cfg = await viewerRef.current.save()
|
||||
filter = (cfg.filter || []).filter(f => Array.isArray(f) && f.length >= 2)
|
||||
}
|
||||
const view = await tableRef.current.view(filter.length ? { filter } : {})
|
||||
rows = await view.to_json()
|
||||
await view.delete()
|
||||
}
|
||||
|
||||
setSteps(buildSteps(rows, { valueCol, unitsCol, logMeta, excludeIters }))
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
setSteps(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [tableRef, viewerRef, scope, logMeta, valueCol, unitsCol, excludeIters, slices, colMeta])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSelection && scope === 'selection') setScope('filtered')
|
||||
}, [hasSelection, scope])
|
||||
|
||||
useEffect(() => { if (open) compute() }, [open, compute])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !boxRef.current) return
|
||||
const ro = new ResizeObserver(([e]) => setWidth(Math.max(420, e.contentRect.width)))
|
||||
ro.observe(boxRef.current)
|
||||
return () => ro.disconnect()
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const geom = layoutSteps(steps || [{ start: 0, end: 0 }], width)
|
||||
const { PAD, plotW, plotH, yMin, yMax, y, barW, H, bars } = geom
|
||||
const xOf = (i) => bars[i]?.x ?? PAD.l
|
||||
|
||||
const ticks = niceTicks(yMin, yMax, 5)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-5xl mx-4 flex flex-col max-h-[88vh]"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-gray-700 text-sm">Bridge</span>
|
||||
{versionName && <span className="text-gray-600 text-xs">{versionName}</span>}
|
||||
<span className="text-gray-600 text-xs">
|
||||
· {scope === 'selection'
|
||||
? `${slices.length} selected slice${slices.length === 1 ? '' : 's'}`
|
||||
: scope === 'filtered' ? "pivot's filters" : 'whole version'}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-600 hover:text-gray-800 text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{/* controls — one row above the chart */}
|
||||
<div className="flex items-center gap-3 px-5 py-2 border-b border-gray-100 shrink-0 text-xs flex-wrap">
|
||||
<span className="text-gray-600">Scope</span>
|
||||
<div className="inline-flex rounded border border-gray-200 overflow-hidden">
|
||||
{[
|
||||
['selection', hasSelection ? `Selection (${slices.length})` : 'Selection',
|
||||
hasSelection ? 'The slices selected in the operation panel'
|
||||
: 'Select one or more pivot rows first'],
|
||||
['filtered', "Pivot's filters", 'Everything the pivot currently shows'],
|
||||
['all', 'Whole version', 'Every row in the version, filters ignored'],
|
||||
].map(([v, l, title]) => (
|
||||
<button key={v} onClick={() => setScope(v)} title={title}
|
||||
disabled={v === 'selection' && !hasSelection}
|
||||
className={`px-3 py-1 disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
scope === v ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-px h-4 bg-gray-200" />
|
||||
<button onClick={() => setAsTable(t => !t)}
|
||||
className="border border-gray-200 rounded px-2 py-1 text-gray-700 hover:bg-gray-50">
|
||||
{asTable ? 'Show chart' : 'Show table'}
|
||||
</button>
|
||||
<button onClick={compute} disabled={loading}
|
||||
className="border border-gray-200 rounded px-2 py-1 text-gray-700 hover:bg-gray-50 disabled:opacity-40">
|
||||
{loading ? 'Computing…' : 'Refresh'}
|
||||
</button>
|
||||
|
||||
{/* legend — identity is never colour alone, but say it anyway */}
|
||||
<div className="ml-auto flex items-center gap-3 text-gray-700">
|
||||
{[['Increase', UP], ['Decrease', DOWN], ['Total', ANCHOR]].map(([l, c]) => (
|
||||
<span key={l} className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: c }} />
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto p-5" ref={boxRef}>
|
||||
{error && <p className="text-red-600">{error}</p>}
|
||||
{!error && !steps && <p className="text-gray-600">Computing…</p>}
|
||||
{!error && steps && steps.length <= 2 && (
|
||||
<p className="text-gray-600">
|
||||
No adjustments in scope — the bridge shows the walk from baseline to current,
|
||||
and this selection has only a baseline.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!error && steps && steps.length > 2 && !asTable && (
|
||||
<div className="relative">
|
||||
<svg width={width} height={H} role="img"
|
||||
aria-label={`Bridge from baseline ${fmt(steps[0].end)} to current ${fmt(steps[steps.length - 1].end)}`}>
|
||||
{/* recessive grid */}
|
||||
{ticks.map(t => (
|
||||
<g key={t}>
|
||||
<line x1={PAD.l} x2={PAD.l + plotW} y1={y(t)} y2={y(t)}
|
||||
stroke={t === 0 ? '#d1d5db' : GRID} strokeWidth={t === 0 ? 1.5 : 1} />
|
||||
<text x={PAD.l - 8} y={y(t) + 3} textAnchor="end" fontSize="10" fill={INK_DIM}>
|
||||
{fmtAxis(t)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{steps.map((s, i) => {
|
||||
const isAnchor = s.kind === 'anchor'
|
||||
const up = s.delta >= 0
|
||||
const fill = isAnchor ? ANCHOR : (up ? UP : DOWN)
|
||||
const top = y(Math.max(s.start, s.end))
|
||||
const bot = y(Math.min(s.start, s.end))
|
||||
const h = Math.max(2, bot - top)
|
||||
const x = xOf(i)
|
||||
const on = hover?.key === s.key
|
||||
return (
|
||||
<g key={s.key}
|
||||
onMouseEnter={() => setHover({ ...s, x: x + barW / 2, y: top })}
|
||||
onMouseLeave={() => setHover(null)}>
|
||||
{/* connector to the next bar, drawn behind */}
|
||||
{i < steps.length - 1 && (
|
||||
<line x1={x + barW} x2={xOf(i + 1)} y1={y(s.end)} y2={y(s.end)}
|
||||
stroke="#cbd5e1" strokeWidth="1" strokeDasharray="2 2" />
|
||||
)}
|
||||
{/* hit target larger than the mark */}
|
||||
<rect x={x - 6} y={PAD.t} width={barW + 12} height={plotH} fill="transparent" />
|
||||
<rect x={x} y={top} width={barW} height={h} rx="4" fill={fill}
|
||||
opacity={on ? 1 : 0.92}
|
||||
stroke="#ffffff" strokeWidth="2" />
|
||||
{/* direct label: few bars, so every one is labelled */}
|
||||
<text x={x + barW / 2} y={top - 6} textAnchor="middle" fontSize="10"
|
||||
fill={INK} fontWeight="500">
|
||||
{isAnchor ? fmt(s.end, 0) : fmtSigned(s.delta, 0)}
|
||||
</text>
|
||||
<text x={x + barW / 2} y={PAD.t + plotH + 16} textAnchor="middle" fontSize="10" fill={INK}>
|
||||
{s.label.length > 12 ? `${s.label.slice(0, 11)}…` : s.label}
|
||||
</text>
|
||||
{!isAnchor && s.entries > 1 && (
|
||||
<text x={x + barW / 2} y={PAD.t + plotH + 29} textAnchor="middle" fontSize="9" fill={INK_DIM}>
|
||||
×{s.entries}
|
||||
</text>
|
||||
)}
|
||||
{!s.tagged && !isAnchor && (
|
||||
<text x={x + barW / 2} y={PAD.t + plotH + 29} textAnchor="middle" fontSize="9" fill={INK_DIM}>
|
||||
untagged
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{hover && (
|
||||
<div className="absolute pointer-events-none bg-white border border-gray-300 rounded shadow-lg px-2.5 py-1.5 text-xs"
|
||||
style={{ left: Math.min(hover.x + 10, width - 190), top: Math.max(0, hover.y - 10) }}>
|
||||
<div className="font-medium text-gray-800">{hover.label}</div>
|
||||
<div className="text-gray-700 font-mono tabular-nums">
|
||||
{hover.kind === 'anchor' ? fmt(hover.end) : fmtSigned(hover.delta)}
|
||||
</div>
|
||||
{hover.kind === 'step' && (
|
||||
<div className="text-gray-600">
|
||||
running → <span className="font-mono tabular-nums">{fmt(hover.end)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-gray-600">
|
||||
{hover.rows} row{hover.rows === 1 ? '' : 's'}
|
||||
{hover.entries > 1 ? ` · ${hover.entries} adjustments` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* table view — the same numbers, at full precision */}
|
||||
{!error && steps && steps.length > 2 && asTable && (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-600 border-b border-gray-200">
|
||||
<th className="text-left py-1.5 pr-3 font-medium">Step</th>
|
||||
<th className="text-right py-1.5 px-2 font-medium">{valueCol}</th>
|
||||
{unitsCol && <th className="text-right py-1.5 px-2 font-medium">{unitsCol}</th>}
|
||||
<th className="text-right py-1.5 px-2 font-medium">Running</th>
|
||||
<th className="text-right py-1.5 px-2 font-medium">Adjustments</th>
|
||||
<th className="text-right py-1.5 pl-2 font-medium">Rows</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{steps.map(s => (
|
||||
<tr key={s.key} className="border-b border-gray-100">
|
||||
<td className="py-1.5 pr-3 text-gray-800">
|
||||
{s.label}{!s.tagged && s.kind === 'step' && <span className="text-gray-600"> · untagged</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-800">
|
||||
{s.kind === 'anchor' ? fmt(s.end) : fmtSigned(s.delta)}
|
||||
</td>
|
||||
{unitsCol && (
|
||||
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-700">
|
||||
{s.kind === 'anchor' ? fmt(s.units) : fmtSigned(s.units)}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-1.5 px-2 text-right font-mono tabular-nums text-gray-700">{fmt(s.end)}</td>
|
||||
<td className="py-1.5 px-2 text-right text-gray-700">{s.kind === 'step' ? s.entries : '—'}</td>
|
||||
<td className="py-1.5 pl-2 text-right text-gray-700">{s.rows}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
683
ui/src/components/OperationPanel.jsx
Normal file
683
ui/src/components/OperationPanel.jsx
Normal file
@ -0,0 +1,683 @@
|
||||
// The operation workbench: what is selected, what it currently totals, and the
|
||||
// scale / recode / clone forms. Rendered by Forecast into one of three shells
|
||||
// (bottom dock, right rail, floating window).
|
||||
//
|
||||
// Two layout rules drive this file:
|
||||
// 1. Every value you are replacing sits on the same row as the input that
|
||||
// replaces it — current on the left, new on the right, delta after it.
|
||||
// Reading a total in one place and typing its replacement somewhere else
|
||||
// is what made the old panel hard to follow.
|
||||
// 2. Controls never stretch. The bottom dock is as wide as the window, so
|
||||
// flex-1 buttons grew to absurd sizes; everything here is fixed-width and
|
||||
// left-aligned instead.
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
const INPUT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-28 text-right font-mono tabular-nums'
|
||||
const TEXT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-40 font-mono'
|
||||
|
||||
function fmtNum(n, decimals = 2) {
|
||||
if (n == null || !isFinite(n)) return '—'
|
||||
return n.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
}
|
||||
|
||||
function fmtDelta(n, decimals = 2) {
|
||||
if (n == null || !isFinite(n) || n === 0) return null
|
||||
const sign = n > 0 ? '+' : '−'
|
||||
return `${sign}${fmtNum(Math.abs(n), decimals)}`
|
||||
}
|
||||
|
||||
function sliceLabel(s) {
|
||||
const entries = Object.entries(s)
|
||||
return entries.length ? entries.map(([k, v]) => `${k}=${v}`).join(' · ') : '—'
|
||||
}
|
||||
|
||||
// A light grouping with an optional caption — a rule between blocks in the
|
||||
// bottom dock, nothing but spacing elsewhere.
|
||||
function Block({ title, hint, horizontal, grow, children }) {
|
||||
return (
|
||||
<section className={[
|
||||
horizontal ? 'pl-5 border-l border-gray-200 first:pl-0 first:border-l-0' : '',
|
||||
grow ? 'flex-1 min-w-0' : 'shrink-0',
|
||||
].join(' ')}>
|
||||
{title && (
|
||||
<header className="flex items-baseline gap-1.5 mb-2">
|
||||
<h3 className="font-semibold text-gray-600 uppercase tracking-wide" style={{ fontSize: '10px' }}>{title}</h3>
|
||||
{hint && <span className="text-gray-600" style={{ fontSize: '10px' }}>{hint}</span>}
|
||||
</header>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Button({ onClick, active, children, title }) {
|
||||
return (
|
||||
<button onClick={onClick} title={title}
|
||||
className={`px-3 py-1 rounded text-xs whitespace-nowrap transition-colors ${
|
||||
active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Segmented({ options, value, onChange }) {
|
||||
return (
|
||||
<div className="inline-flex rounded border border-gray-200 overflow-hidden w-auto self-start">
|
||||
{options.map(([val, label, title]) => (
|
||||
<Button key={val} onClick={() => onChange(val)} active={value === val} title={title}>{label}</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Submit({ onClick, children, disabled }) {
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
className="self-start px-4 py-1.5 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700
|
||||
disabled:bg-gray-200 disabled:text-gray-600 disabled:cursor-not-allowed whitespace-nowrap">
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 1. Selection ────────────────────────────────────────────────────────────
|
||||
function SelectionList({ slices, currentTotals, onRemove, onClear }) {
|
||||
const multi = slices.length > 1
|
||||
const perSlice = currentTotals?.perSlice || []
|
||||
const valueCol = currentTotals?.valueCol
|
||||
|
||||
if (!slices.length) {
|
||||
return (
|
||||
<p className="text-gray-600 italic leading-relaxed">
|
||||
Click a pivot row to select a slice.<br />
|
||||
<span className="text-gray-500">Ctrl/⌘-click to add more.</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="overflow-auto max-h-36 -mx-1 px-1">
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{slices.map((s, i) => (
|
||||
<tr key={i} className="align-top hover:bg-gray-50">
|
||||
<td className="py-0.5 pr-2">
|
||||
{multi
|
||||
? <span className="font-mono text-gray-700">{sliceLabel(s)}</span>
|
||||
: (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{Object.entries(s).map(([k, v]) => (
|
||||
<div key={k} className="whitespace-nowrap">
|
||||
<span className="text-gray-600">{k}</span>
|
||||
<span className="text-gray-500"> = </span>
|
||||
<span className="font-medium text-gray-700 font-mono">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{multi && valueCol && (
|
||||
<td className="py-0.5 pl-2 text-right font-mono tabular-nums text-gray-600 whitespace-nowrap">
|
||||
{fmtNum(perSlice[i]?.total?.value)}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-0.5 pl-1 text-right">
|
||||
<button onClick={() => onRemove(i)} title="Remove from selection"
|
||||
className="text-gray-500 hover:text-red-500 leading-none px-1">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button onClick={onClear} className="text-gray-600 hover:text-red-500 mt-1.5">Clear selection</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Rows by pf_iter — useful context, but secondary to the numbers you are editing,
|
||||
// so it collapses out of the way.
|
||||
function IterBreakdown({ currentTotals }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const rows = currentTotals?.byIter || []
|
||||
if (rows.length < 2) return null
|
||||
const { valueCol, unitsCol } = currentTotals
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<button onClick={() => setOpen(o => !o)} className="text-gray-600 hover:text-gray-600">
|
||||
{open ? '▾' : '▸'} breakdown by iter
|
||||
</button>
|
||||
{open && (
|
||||
<table className="mt-1 text-gray-500">
|
||||
<tbody>
|
||||
{rows.map(r => (
|
||||
<tr key={r.iter}>
|
||||
<td className="capitalize pr-3">{r.iter}</td>
|
||||
{valueCol && <td className="text-right font-mono tabular-nums pl-2">{fmtNum(r.value)}</td>}
|
||||
{unitsCol && <td className="text-right font-mono tabular-nums pl-2">{fmtNum(r.units)}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 2. Scale ledger ─────────────────────────────────────────────────────────
|
||||
// One continuous statement: where the number came from, what it is now, and what
|
||||
// you want it to be — with the edit attached to the bottom of the same table
|
||||
// rather than lifted into a separate block.
|
||||
//
|
||||
// Baseline 1,000.00
|
||||
// Scale -20.00
|
||||
// ─────────────────────────
|
||||
// Current 1,070.00
|
||||
// ─────────────────────────
|
||||
// New value [ 2,000 ]
|
||||
// Change [ 930 ]
|
||||
// % change [ 86.9 ]
|
||||
//
|
||||
// The last three rows are all editable and all describe the same change: type in
|
||||
// any one and the other two follow. Whichever you typed in is what gets sent.
|
||||
const FIELDS = [
|
||||
['new', 'New value'],
|
||||
['change', 'Change'],
|
||||
['pct', '% change'],
|
||||
]
|
||||
|
||||
// given the active edit for a measure, what do the three rows read?
|
||||
function derive(current, edit) {
|
||||
const blank = { new: '', change: '', pct: '' }
|
||||
if (!edit || edit.raw === '' || edit.raw == null) return blank
|
||||
const n = parseFloat(edit.raw)
|
||||
if (!isFinite(n)) return { ...blank, [edit.field]: edit.raw }
|
||||
|
||||
let next
|
||||
if (edit.field === 'new') next = n
|
||||
else if (edit.field === 'change') next = current + n
|
||||
else next = current + current * n / 100
|
||||
|
||||
const change = next - current
|
||||
const pct = current === 0 ? null : (change / Math.abs(current)) * 100
|
||||
const out = {
|
||||
new: fmtNum(next),
|
||||
change: fmtNum(change),
|
||||
pct: pct == null ? '—' : fmtNum(pct, 1),
|
||||
}
|
||||
out[edit.field] = edit.raw // keep what you typed exactly as typed
|
||||
return out
|
||||
}
|
||||
|
||||
function LedgerInput({ value, active, onChange, onFocus, suffix }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="text" inputMode="decimal" value={value} onChange={e => onChange(e.target.value)}
|
||||
onFocus={onFocus} placeholder="—"
|
||||
className={`border rounded px-2 py-0.5 text-xs w-24 text-right font-mono tabular-nums
|
||||
${active ? 'border-blue-400 bg-blue-50/40 text-gray-800' : 'border-gray-200 bg-white text-gray-700'}`} />
|
||||
{suffix && <span className="text-gray-500" style={{ fontSize: '10px' }}>{suffix}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode }) {
|
||||
const valueCol = currentTotals?.valueCol
|
||||
const unitsCol = currentTotals?.unitsCol
|
||||
const total = currentTotals?.total || { value: 0, units: 0 }
|
||||
const curPrice = total.units ? total.value / total.units : null
|
||||
const entries = currentTotals?.byEntry || []
|
||||
|
||||
// The bridge: baseline, then one line per initiative, then whatever is untagged.
|
||||
// Adjustments sharing a tag collapse into a single line, so the ledger reads as a
|
||||
// walk from baseline to current rather than a list of log ids.
|
||||
const lines = (() => {
|
||||
const baseline = []
|
||||
const loose = []
|
||||
const byTag = new Map()
|
||||
for (const e of entries) {
|
||||
if (e.key === 'baseline') { baseline.push({ ...e, label: 'Baseline', kind: 'baseline' }); continue }
|
||||
const meta = logMeta[e.logid] || {}
|
||||
const tag = (meta.tag || '').trim()
|
||||
if (tag) {
|
||||
const g = byTag.get(tag) ||
|
||||
{ key: `tag:${tag}`, label: tag, kind: 'tag', value: 0, units: 0, count: 0, first: e.logid }
|
||||
g.value += e.value || 0
|
||||
g.units += e.units || 0
|
||||
g.count += 1
|
||||
g.first = Math.min(g.first ?? e.logid, e.logid ?? g.first)
|
||||
byTag.set(tag, g)
|
||||
} else {
|
||||
const op = meta.operation || e.iter || 'adjustment'
|
||||
loose.push({
|
||||
...e, kind: 'entry', count: 1,
|
||||
label: (meta.note || '').trim() || `${op.charAt(0).toUpperCase()}${op.slice(1)} #${e.logid}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
const tagged = [...byTag.values()].sort((a, b) => (a.first ?? 0) - (b.first ?? 0))
|
||||
return [...baseline, ...tagged, ...loose]
|
||||
})()
|
||||
const perSlice = multi && applyMode === 'each'
|
||||
|
||||
// rows the pivot shows but this operation cannot write
|
||||
const excl = currentTotals?.excluded || { value: 0, units: 0, rows: 0 }
|
||||
const hasExcl = excl.rows > 0 && (excl.value !== 0 || excl.units !== 0)
|
||||
const onTotal = hasExcl && targetBasis !== 'adjustable' // 'selected total' is the default
|
||||
const grand = { value: total.value + excl.value, units: total.units + excl.units }
|
||||
const exclName = (currentTotals?.excludedIters || []).join(' / ') || 'excluded'
|
||||
|
||||
// the basis decides which line the editable rows are measured from
|
||||
const basisOf = (key) => {
|
||||
if (!onTotal) return key === 'price' ? curPrice : total[key]
|
||||
if (key === 'price') return grand.units ? grand.value / grand.units : null
|
||||
return grand[key]
|
||||
}
|
||||
|
||||
// measure columns, in ledger order
|
||||
const measures = [
|
||||
valueCol && { key: 'value', label: valueCol, current: total.value, dp: 2 },
|
||||
unitsCol && { key: 'units', label: unitsCol, current: total.units, dp: 2 },
|
||||
(valueCol && unitsCol) && { key: 'price', label: 'price', current: curPrice, dp: 4, hint: 'holds units' },
|
||||
].filter(Boolean)
|
||||
|
||||
const derived = Object.fromEntries(
|
||||
measures.map(m => [m.key, derive(basisOf(m.key) ?? 0, scaleInputs[m.key])])
|
||||
)
|
||||
|
||||
const setEdit = (key, field, raw) =>
|
||||
setScaleInputs(prev => ({ ...prev, [key]: { field, raw } }))
|
||||
|
||||
// focusing a different row hands that measure's edit to the focused row,
|
||||
// carrying across whatever it currently reads
|
||||
const focusRow = (key, field) =>
|
||||
setScaleInputs(prev => {
|
||||
const cur = prev[key]
|
||||
if (cur && cur.field === field) return prev
|
||||
const shown = derived[key]?.[field] ?? ''
|
||||
const raw = shown === '—' ? '' : String(shown).replace(/,/g, '')
|
||||
return { ...prev, [key]: { field, raw: cur ? raw : '' } }
|
||||
})
|
||||
|
||||
const numCell = 'text-right font-mono tabular-nums whitespace-nowrap px-2'
|
||||
const rule = <td className="p-0"><div className="border-t border-gray-300 my-1" /></td>
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<table>
|
||||
<thead>
|
||||
<tr className="text-gray-600" style={{ fontSize: '10px' }}>
|
||||
<th className="text-left font-normal pb-1 pr-3"></th>
|
||||
{measures.map(m => (
|
||||
<th key={m.key} className="text-right font-normal pb-1 px-2 whitespace-nowrap">
|
||||
{m.label}
|
||||
{m.hint && <span className="text-gray-500"> · {m.hint}</span>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* the walk from baseline to current, by initiative */}
|
||||
{lines.map(e => (
|
||||
<tr key={e.key} className="text-gray-600">
|
||||
<td className="pr-3 whitespace-nowrap max-w-[14rem] truncate" title={e.label}>
|
||||
{e.kind === 'tag' && <span className="text-blue-600">▪ </span>}
|
||||
{e.label}
|
||||
{e.kind === 'tag' && e.count > 1 && <span className="text-gray-500"> ×{e.count}</span>}
|
||||
</td>
|
||||
{measures.map(m => (
|
||||
<td key={m.key} className={`${numCell} text-gray-600`}>
|
||||
{m.key === 'price' ? '' : fmtNum(e[m.key], m.dp)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
<tr>{rule}{measures.map(m => <td key={m.key} className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>)}</tr>
|
||||
|
||||
<tr className={onTotal ? 'text-gray-600' : 'font-semibold text-gray-700'}>
|
||||
<td className="pr-3 whitespace-nowrap">
|
||||
{hasExcl ? 'Adjustable' : 'Current'}{perSlice ? ' (all)' : ''}
|
||||
</td>
|
||||
{measures.map(m => (
|
||||
<td key={m.key} className={numCell}>{fmtNum(m.current, m.dp)}</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* Rows the pivot shows but operations cannot write. Listed so the
|
||||
panel's figures reconcile with what the grid displays. */}
|
||||
{hasExcl && (
|
||||
<tr className="text-gray-600">
|
||||
<td className="pr-3 whitespace-nowrap">
|
||||
{exclName} <span className="text-gray-500">· fixed</span>
|
||||
</td>
|
||||
{measures.map(m => (
|
||||
<td key={m.key} className={numCell}>
|
||||
{m.key === 'price' ? '' : fmtNum(excl[m.key], m.dp)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{hasExcl && (
|
||||
<tr className={onTotal ? 'font-semibold text-gray-700' : 'text-gray-600'}>
|
||||
<td className="pr-3 whitespace-nowrap">Selected total</td>
|
||||
{measures.map(m => (
|
||||
<td key={m.key} className={numCell}>
|
||||
{m.key === 'price'
|
||||
? fmtNum(grand.units ? grand.value / grand.units : null, m.dp)
|
||||
: fmtNum(grand[m.key], m.dp)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
|
||||
<tr>{rule}{measures.map(m => <td key={m.key} className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>)}</tr>
|
||||
|
||||
{/* the edit — three equivalent ways to say the same thing */}
|
||||
{FIELDS.map(([field, label]) => (
|
||||
<tr key={field}>
|
||||
<td className="pr-3 py-0.5 text-gray-500 whitespace-nowrap">
|
||||
{label}{perSlice && field === 'new' ? ' (each)' : ''}
|
||||
</td>
|
||||
{measures.map(m => {
|
||||
const active = scaleInputs[m.key]?.field === field
|
||||
return (
|
||||
<td key={m.key} className="px-2 py-0.5 text-right">
|
||||
<LedgerInput
|
||||
value={derived[m.key]?.[field] ?? ''}
|
||||
active={active}
|
||||
onChange={(raw) => setEdit(m.key, field, raw)}
|
||||
onFocus={() => focusRow(m.key, field)}
|
||||
suffix={field === 'pct' ? '%' : null}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{hasExcl && (
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-gray-600 whitespace-nowrap">Target applies to</span>
|
||||
<Segmented
|
||||
value={onTotal ? 'selected' : 'adjustable'}
|
||||
onChange={setTargetBasis}
|
||||
options={[
|
||||
['adjustable', 'Adjustable', 'Measure against only the rows this operation can write'],
|
||||
['selected', 'Selected total', 'Measure against everything the pivot shows, fixed rows included'],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-gray-600 leading-snug max-w-md">
|
||||
{onTotal
|
||||
? `The ${exclName} rows cannot change, so the adjustable rows absorb the whole difference — the pivot will show your target.`
|
||||
: `${exclName} rows are ignored. The pivot will show your target plus ${fmtNum(excl.value)}.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{perSlice && (
|
||||
<p className="text-gray-600 leading-snug mt-2 max-w-md">
|
||||
Applied to each slice separately — the figures above are combined totals,
|
||||
so each slice's own change will differ.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 2b. Recode / clone form ─────────────────────────────────────────────────
|
||||
// Same pairing: the dimension's current value sits beside the box that replaces it.
|
||||
function DimForm({ dimCols, setObj, setSet, slices, lookupDerivedCols, extra }) {
|
||||
const multi = slices.length > 1
|
||||
const first = slices[0] || {}
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5 min-w-0">
|
||||
<table>
|
||||
<thead>
|
||||
<tr className="text-gray-600" style={{ fontSize: '10px' }}>
|
||||
<th className="text-left font-normal pb-1 pr-3">dimension</th>
|
||||
<th className="text-left font-normal pb-1 px-2">current</th>
|
||||
<th className="text-left font-normal pb-1 pl-2">new value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dimCols.map(c => {
|
||||
const cur = multi
|
||||
? (new Set(slices.map(s => s[c.cname])).size > 1 ? '(varies)' : (first[c.cname] ?? '—'))
|
||||
: (first[c.cname] ?? '—')
|
||||
return (
|
||||
<tr key={c.cname}>
|
||||
<td className="pr-3 py-0.5 text-gray-500 whitespace-nowrap" title={c.cname}>{c.label || c.cname}</td>
|
||||
<td className="px-2 py-0.5 font-mono text-gray-600 max-w-[10rem] truncate" title={String(cur)}>{cur}</td>
|
||||
<td className="pl-2 py-0.5">
|
||||
<input
|
||||
value={setObj[c.cname] || ''}
|
||||
onChange={e => setSet(s => ({ ...s, [c.cname]: e.target.value }))}
|
||||
onBlur={c.is_key && c.dim_group
|
||||
? e => lookupDerivedCols(c.cname, e.target.value, setSet)
|
||||
: undefined}
|
||||
placeholder="keep"
|
||||
className={TEXT} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{extra}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Recode and clone change dimensions rather than amounts, but you still want to
|
||||
// see how much is on the move — and for clone, what it becomes after scaling.
|
||||
function MovingTotal({ currentTotals, verb, factor }) {
|
||||
const t = currentTotals?.total
|
||||
if (!t) return null
|
||||
const { valueCol, unitsCol } = currentTotals
|
||||
const scaled = factor != null && factor !== 1
|
||||
return (
|
||||
<p className="text-gray-500">
|
||||
{verb}{' '}
|
||||
{valueCol && <span className="font-mono tabular-nums text-gray-700">{fmtNum(t.value)}</span>}
|
||||
{valueCol && <span className="text-gray-600"> {valueCol}</span>}
|
||||
{unitsCol && <>
|
||||
<span className="text-gray-500"> · </span>
|
||||
<span className="font-mono tabular-nums text-gray-700">{fmtNum(t.units)}</span>
|
||||
<span className="text-gray-600"> {unitsCol}</span>
|
||||
</>}
|
||||
{scaled && valueCol && <>
|
||||
<span className="text-gray-500"> → </span>
|
||||
<span className="font-mono tabular-nums text-gray-700">{fmtNum(t.value * factor)}</span>
|
||||
</>}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Apply mode ──────────────────────────────────────────────────────────────
|
||||
function ApplyModeChooser({ op, applyMode, setApplyMode, count }) {
|
||||
const options = op === 'scale'
|
||||
? [
|
||||
['prorate', 'Together', `One pool of ${count} slices — a target is the new combined total, and each slice keeps its share of the mix.`],
|
||||
['each', 'Each', `All ${count} slices independently — every slice reaches the target on its own.`],
|
||||
]
|
||||
: [
|
||||
['prorate', 'Together', `One operation over all ${count} slices — a single log entry.`],
|
||||
['each', 'Each', `One operation per slice — ${count} log entries, undoable separately.`],
|
||||
]
|
||||
const active = options.find(([v]) => v === applyMode)
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Segmented value={applyMode} onChange={setApplyMode}
|
||||
options={options.map(([v, l, t]) => [v, l, t])} />
|
||||
{active && <p className="text-gray-600 leading-snug max-w-xs">{active[2]}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RequestPreview({ payload }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (!payload) return null
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setOpen(o => !o)} className="text-gray-600 hover:text-gray-600">
|
||||
{open ? '▾' : '▸'} request
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="mt-1 font-mono text-gray-600 bg-gray-50 border border-gray-100 rounded p-2 overflow-auto max-h-40 leading-relaxed">
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OperationPanel({
|
||||
dock,
|
||||
slices, setSlices, distinctSlices,
|
||||
applyMode, setApplyMode,
|
||||
currentTotals,
|
||||
activeOp, setActiveOp,
|
||||
scaleInputs, setScaleInputs,
|
||||
targetBasis, setTargetBasis,
|
||||
opTag, setOpTag, knownTags = [], logMeta = {},
|
||||
scaleNote, setScaleNote,
|
||||
recodeSet, setRecodeSet,
|
||||
recodeNote, setRecodeNote,
|
||||
cloneSet, setCloneSet,
|
||||
cloneScale, setCloneScale,
|
||||
cloneNote, setCloneNote,
|
||||
dimCols, lookupDerivedCols,
|
||||
buildPayload, submitOp,
|
||||
}) {
|
||||
const hasSlice = slices.length > 0
|
||||
const multi = slices.length > 1
|
||||
const horizontal = dock === 'bottom'
|
||||
|
||||
const note = activeOp === 'scale' ? scaleNote : activeOp === 'recode' ? recodeNote : cloneNote
|
||||
const setNote = activeOp === 'scale' ? setScaleNote : activeOp === 'recode' ? setRecodeNote : setCloneNote
|
||||
const OP_LABEL = { scale: 'Apply Scale', recode: 'Apply Recode', clone: 'Apply Clone' }
|
||||
|
||||
return (
|
||||
<div className={horizontal ? 'flex flex-row items-start p-3 gap-5 min-w-0' : 'flex flex-col p-3 gap-3 min-w-0'}>
|
||||
|
||||
<Block title="Slice" hint={hasSlice ? `${slices.length} selected` : null}
|
||||
horizontal={horizontal} grow>
|
||||
{/* Cells that differ only by a column operations cannot filter on collapse
|
||||
to the same slice — say so, rather than implying more reach than there is */}
|
||||
{distinctSlices != null && distinctSlices < slices.length && (
|
||||
<p className="text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 mb-2 leading-snug">
|
||||
{slices.length} cells selected, but they cover {distinctSlices} distinct{' '}
|
||||
{distinctSlices === 1 ? 'slice' : 'slices'} — some differ only by a column
|
||||
operations cannot target. The duplicates are applied once.
|
||||
</p>
|
||||
)}
|
||||
<SelectionList
|
||||
slices={slices}
|
||||
currentTotals={currentTotals}
|
||||
onRemove={(i) => setSlices(prev => prev.filter((_, x) => x !== i))}
|
||||
onClear={() => setSlices([])}
|
||||
/>
|
||||
</Block>
|
||||
|
||||
{hasSlice && (
|
||||
<Block horizontal={horizontal} grow>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="inline-flex rounded border border-gray-200 overflow-hidden">
|
||||
{['scale', 'recode', 'clone'].map(op => (
|
||||
<Button key={op} onClick={() => setActiveOp(op)} active={activeOp === op}>
|
||||
<span className="capitalize">{op}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{multi && (
|
||||
<ApplyModeChooser op={activeOp} applyMode={applyMode} setApplyMode={setApplyMode} count={slices.length} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeOp === 'scale' && (
|
||||
<ScaleLedger
|
||||
currentTotals={currentTotals}
|
||||
scaleInputs={scaleInputs} setScaleInputs={setScaleInputs}
|
||||
targetBasis={targetBasis} setTargetBasis={setTargetBasis}
|
||||
logMeta={logMeta}
|
||||
multi={multi} applyMode={applyMode}
|
||||
/>
|
||||
)}
|
||||
{activeOp === 'recode' && (
|
||||
<DimForm dimCols={dimCols} setObj={recodeSet} setSet={setRecodeSet}
|
||||
slices={slices} lookupDerivedCols={lookupDerivedCols}
|
||||
extra={<MovingTotal currentTotals={currentTotals} verb="Moving" />} />
|
||||
)}
|
||||
{activeOp === 'clone' && (
|
||||
<DimForm dimCols={dimCols} setObj={cloneSet} setSet={setCloneSet}
|
||||
slices={slices} lookupDerivedCols={lookupDerivedCols}
|
||||
extra={
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-500">scale cloned rows by</span>
|
||||
<input type="number" step="any" value={cloneScale}
|
||||
onChange={e => setCloneScale(e.target.value)} className={INPUT} />
|
||||
</div>
|
||||
<MovingTotal currentTotals={currentTotals} verb="Copying"
|
||||
factor={parseFloat(cloneScale) || 1} />
|
||||
</div>
|
||||
} />
|
||||
)}
|
||||
</div>
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{hasSlice && (
|
||||
<Block horizontal={horizontal}>
|
||||
<div className="flex flex-col gap-2.5 min-w-0">
|
||||
{/* Tag first: it is the field that gives an adjustment meaning later,
|
||||
in the ledger and in the bridge. Completes from initiatives already
|
||||
used on this source; free text is still accepted. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-600 whitespace-nowrap w-9">tag</span>
|
||||
<input
|
||||
value={opTag} onChange={e => setOpTag(e.target.value)}
|
||||
list="pf-tag-options" placeholder="initiative, e.g. reduce_spend"
|
||||
className={`${TEXT} w-48`} />
|
||||
{opTag.trim() && (
|
||||
<button onClick={() => setOpTag('')} title="Clear tag"
|
||||
className="text-gray-500 hover:text-red-500 leading-none px-1">×</button>
|
||||
)}
|
||||
</div>
|
||||
{knownTags.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{knownTags.slice(0, 6).map(t => (
|
||||
<button key={t.tag} onClick={() => setOpTag(t.tag)}
|
||||
title={`${t.uses} previous use${t.uses === 1 ? '' : 's'}`}
|
||||
className={`px-2 py-0.5 rounded-full border text-xs whitespace-nowrap ${
|
||||
opTag.trim() === t.tag
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:border-blue-400 hover:text-blue-700'}`}>
|
||||
{t.tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-600 whitespace-nowrap w-9">note</span>
|
||||
<input value={note} onChange={e => setNote(e.target.value)} placeholder="optional" className={TEXT} />
|
||||
</div>
|
||||
<Submit onClick={() => submitOp(activeOp)}>{OP_LABEL[activeOp]}</Submit>
|
||||
<RequestPreview payload={buildPayload(activeOp)} />
|
||||
</div>
|
||||
</Block>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import useTheme from '../theme.jsx'
|
||||
|
||||
export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId, setVersionId }) {
|
||||
@ -5,8 +6,40 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
|
||||
const showVersion = view === 'baseline' || view === 'forecast'
|
||||
const selectedVersion = versions.find(v => String(v.id) === String(versionId))
|
||||
|
||||
const [info, setInfo] = useState(null)
|
||||
const [showInfo, setShow] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const refreshInfo = useCallback(async () => {
|
||||
if (!versionId || !showVersion) { setInfo(null); return }
|
||||
try {
|
||||
const r = await fetch(`/api/versions/${versionId}/table-info`)
|
||||
setInfo(r.ok ? await r.json() : null)
|
||||
} catch { setInfo(null) }
|
||||
}, [versionId, showVersion])
|
||||
|
||||
useEffect(() => { refreshInfo() }, [refreshInfo])
|
||||
|
||||
// operations broadcast this after a write so the row count stays honest
|
||||
useEffect(() => {
|
||||
const onChange = () => refreshInfo()
|
||||
window.addEventListener('pf-data-changed', onChange)
|
||||
return () => window.removeEventListener('pf-data-changed', onChange)
|
||||
}, [refreshInfo])
|
||||
|
||||
async function copyTable() {
|
||||
if (!info?.fc_table) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(info.fc_table)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1200)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const fmt = (n) => n == null ? '—' : n.toLocaleString()
|
||||
|
||||
return (
|
||||
<div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs">
|
||||
<div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs relative">
|
||||
<span className="text-gray-400">Source</span>
|
||||
<select
|
||||
value={sourceId || ''}
|
||||
@ -38,6 +71,57 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
|
||||
{selectedVersion.status}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* write target — the physical table every operation appends to */}
|
||||
{info && (
|
||||
<>
|
||||
<span className="text-gray-200">|</span>
|
||||
<span className="text-gray-400" title="Operations append to this table">writes to</span>
|
||||
<button
|
||||
onClick={copyTable}
|
||||
onMouseEnter={() => setShow(true)}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
className={`font-mono px-1.5 py-0.5 rounded border hover:bg-gray-50 ${
|
||||
info.exists ? 'text-gray-700 border-gray-200' : 'text-amber-700 border-amber-200 bg-amber-50'
|
||||
}`}
|
||||
title={info.exists ? 'Click to copy table name' : 'Table does not exist yet'}
|
||||
>
|
||||
{copied ? 'copied!' : info.fc_table}
|
||||
</button>
|
||||
<span className="text-gray-400 font-mono">
|
||||
{info.exists ? `${fmt(info.rows)} rows` : 'not created'}
|
||||
</span>
|
||||
|
||||
{showInfo && (
|
||||
<div className="absolute top-9 left-0 z-30 bg-white border border-gray-200 rounded shadow-lg p-3 text-xs min-w-[260px]">
|
||||
<div className="text-gray-400 uppercase tracking-wide mb-2" style={{ fontSize: '10px' }}>Write target</div>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
<span className="text-gray-400">table</span>
|
||||
<span className="font-mono text-gray-700">{info.fc_table}</span>
|
||||
<span className="text-gray-400">reads from</span>
|
||||
<span className="font-mono text-gray-700">{info.source}</span>
|
||||
<span className="text-gray-400">total rows</span>
|
||||
<span className="font-mono text-gray-700">{fmt(info.rows)}</span>
|
||||
</div>
|
||||
{info.by_iter?.length > 0 && (
|
||||
<>
|
||||
<div className="text-gray-400 uppercase tracking-wide mt-3 mb-1" style={{ fontSize: '10px' }}>Rows by iter</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{info.by_iter.map(r => (
|
||||
<tr key={r.pf_iter}>
|
||||
<td className="text-gray-500 capitalize pr-3">{r.pf_iter}</td>
|
||||
<td className="text-right font-mono text-gray-700">{fmt(r.n)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user