fix: pivot rows before rendering to prevent duplicate categories in dynamic report table
The table was rendering one <tr> per SQL result row instead of pivoting the column-dimension values into a single row per unique row-key combo. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
df742af8ef
commit
438b72cba2
1 changed files with 83 additions and 52 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { Fragment, useState } from "react";
|
||||
import { Fragment, useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpDown } from "lucide-react";
|
||||
import type { PivotConfig, PivotResult, PivotResultRow } from "../../shared/types";
|
||||
|
|
@ -13,19 +13,54 @@ interface DynamicReportTableProps {
|
|||
result: PivotResult;
|
||||
}
|
||||
|
||||
/** A pivoted row: one per unique combination of row dimensions */
|
||||
interface PivotedRow {
|
||||
rowKeys: Record<string, string>; // row-dimension values
|
||||
cells: Record<string, Record<string, number>>; // colValue → measure → value
|
||||
}
|
||||
|
||||
/** Pivot raw result rows into one PivotedRow per unique row-key combination */
|
||||
function pivotRows(
|
||||
rows: PivotResultRow[],
|
||||
rowDims: string[],
|
||||
colDim: string | undefined,
|
||||
measures: string[],
|
||||
): PivotedRow[] {
|
||||
const map = new Map<string, PivotedRow>();
|
||||
|
||||
for (const row of rows) {
|
||||
const rowKey = rowDims.map((d) => row.keys[d] || "").join("\0");
|
||||
let pivoted = map.get(rowKey);
|
||||
if (!pivoted) {
|
||||
const rowKeys: Record<string, string> = {};
|
||||
for (const d of rowDims) rowKeys[d] = row.keys[d] || "";
|
||||
pivoted = { rowKeys, cells: {} };
|
||||
map.set(rowKey, pivoted);
|
||||
}
|
||||
|
||||
const colKey = colDim ? (row.keys[colDim] || "") : "__all__";
|
||||
if (!pivoted.cells[colKey]) pivoted.cells[colKey] = {};
|
||||
for (const m of measures) {
|
||||
pivoted.cells[colKey][m] = (pivoted.cells[colKey][m] || 0) + (row.measures[m] || 0);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
interface GroupNode {
|
||||
key: string;
|
||||
label: string;
|
||||
rows: PivotResultRow[];
|
||||
pivotedRows: PivotedRow[];
|
||||
children: GroupNode[];
|
||||
}
|
||||
|
||||
function buildGroups(rows: PivotResultRow[], rowDims: string[], depth: number): GroupNode[] {
|
||||
function buildGroups(rows: PivotedRow[], rowDims: string[], depth: number): GroupNode[] {
|
||||
if (depth >= rowDims.length) return [];
|
||||
const dim = rowDims[depth];
|
||||
const map = new Map<string, PivotResultRow[]>();
|
||||
const map = new Map<string, PivotedRow[]>();
|
||||
for (const row of rows) {
|
||||
const key = row.keys[dim] || "";
|
||||
const key = row.rowKeys[dim] || "";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(row);
|
||||
}
|
||||
|
|
@ -34,21 +69,23 @@ function buildGroups(rows: PivotResultRow[], rowDims: string[], depth: number):
|
|||
groups.push({
|
||||
key,
|
||||
label: key,
|
||||
rows: groupRows,
|
||||
pivotedRows: groupRows,
|
||||
children: buildGroups(groupRows, rowDims, depth + 1),
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function computeSubtotals(rows: PivotResultRow[], measures: string[], colDim: string | undefined): Record<string, Record<string, number>> {
|
||||
// colValue → measure → sum
|
||||
function computeSubtotals(
|
||||
rows: PivotedRow[],
|
||||
measures: string[],
|
||||
colValues: string[],
|
||||
): Record<string, Record<string, number>> {
|
||||
const result: Record<string, Record<string, number>> = {};
|
||||
for (const row of rows) {
|
||||
const colKey = colDim ? (row.keys[colDim] || "") : "__all__";
|
||||
if (!result[colKey]) result[colKey] = {};
|
||||
for (const colVal of colValues) {
|
||||
result[colVal] = {};
|
||||
for (const m of measures) {
|
||||
result[colKey][m] = (result[colKey][m] || 0) + (row.measures[m] || 0);
|
||||
result[colVal][m] = rows.reduce((sum, r) => sum + (r.cells[colVal]?.[m] || 0), 0);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
|
@ -69,7 +106,18 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
|
|||
});
|
||||
};
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
const rowDims = config.rows;
|
||||
const colDim = config.columns[0] || undefined;
|
||||
const colValues = colDim ? result.columnValues : ["__all__"];
|
||||
const measures = config.values;
|
||||
|
||||
// Pivot the flat SQL rows into one PivotedRow per unique row-key combo
|
||||
const pivotedRows = useMemo(
|
||||
() => pivotRows(result.rows, rowDims, colDim, measures),
|
||||
[result.rows, rowDims, colDim, measures],
|
||||
);
|
||||
|
||||
if (pivotedRows.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||
{t("reports.pivot.noData")}
|
||||
|
|
@ -77,16 +125,8 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
|
|||
);
|
||||
}
|
||||
|
||||
const rowDims = config.rows;
|
||||
const colDim = config.columns[0] || undefined;
|
||||
const colValues = colDim ? result.columnValues : ["__all__"];
|
||||
const measures = config.values;
|
||||
|
||||
// Build row groups from first row dimension
|
||||
const groups = rowDims.length > 0 ? buildGroups(result.rows, rowDims, 0) : [];
|
||||
|
||||
// Grand totals
|
||||
const grandTotals = computeSubtotals(result.rows, measures, colDim);
|
||||
const groups = rowDims.length > 0 ? buildGroups(pivotedRows, rowDims, 0) : [];
|
||||
const grandTotals = computeSubtotals(pivotedRows, measures, colValues);
|
||||
|
||||
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "type" ? "categoryType" : id}`);
|
||||
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
|
||||
|
|
@ -108,17 +148,15 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border)]">
|
||||
{/* Row dimension headers */}
|
||||
{rowDims.map((dim) => (
|
||||
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)]">
|
||||
{fieldLabel(dim)}
|
||||
</th>
|
||||
))}
|
||||
{/* Column headers: colValue × measure */}
|
||||
{colValues.map((colVal) =>
|
||||
measures.map((m) => (
|
||||
<th key={`${colVal}-${m}`} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
|
||||
{colDim ? `${colVal} — ${measureLabel(m)}` : measureLabel(m)}
|
||||
{colDim ? (measures.length > 1 ? `${colVal} — ${measureLabel(m)}` : colVal) : measureLabel(m)}
|
||||
</th>
|
||||
))
|
||||
)}
|
||||
|
|
@ -126,17 +164,13 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
|
|||
</thead>
|
||||
<tbody>
|
||||
{rowDims.length === 0 ? (
|
||||
// No row dims — single row with totals
|
||||
<tr className="border-b border-[var(--border)]/50">
|
||||
{colValues.map((colVal) =>
|
||||
measures.map((m) => {
|
||||
const val = grandTotals[colVal]?.[m] || 0;
|
||||
return (
|
||||
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(val)}
|
||||
</td>
|
||||
);
|
||||
})
|
||||
measures.map((m) => (
|
||||
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
|
||||
</td>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
) : (
|
||||
|
|
@ -144,7 +178,6 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
|
|||
<GroupRows
|
||||
key={group.key}
|
||||
group={group}
|
||||
colDim={colDim}
|
||||
colValues={colValues}
|
||||
measures={measures}
|
||||
rowDims={rowDims}
|
||||
|
|
@ -175,7 +208,6 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
|
|||
|
||||
function GroupRows({
|
||||
group,
|
||||
colDim,
|
||||
colValues,
|
||||
measures,
|
||||
rowDims,
|
||||
|
|
@ -183,7 +215,6 @@ function GroupRows({
|
|||
subtotalsOnTop,
|
||||
}: {
|
||||
group: GroupNode;
|
||||
colDim: string | undefined;
|
||||
colValues: string[];
|
||||
measures: string[];
|
||||
rowDims: string[];
|
||||
|
|
@ -191,7 +222,7 @@ function GroupRows({
|
|||
subtotalsOnTop: boolean;
|
||||
}) {
|
||||
const isLeafLevel = depth === rowDims.length - 1;
|
||||
const subtotals = computeSubtotals(group.rows, measures, colDim);
|
||||
const subtotals = computeSubtotals(group.pivotedRows, measures, colValues);
|
||||
|
||||
const subtotalRow = rowDims.length > 1 && !isLeafLevel ? (
|
||||
<tr className="bg-[var(--muted)]/30 font-semibold border-b border-[var(--border)]/50">
|
||||
|
|
@ -210,25 +241,26 @@ function GroupRows({
|
|||
) : null;
|
||||
|
||||
if (isLeafLevel) {
|
||||
// Render leaf rows: one per unique combination of remaining keys
|
||||
// Render one table row per pivoted row (already deduplicated by row keys)
|
||||
return (
|
||||
<>
|
||||
{group.rows.map((row, i) => (
|
||||
{group.pivotedRows.map((pRow, i) => (
|
||||
<tr key={i} className="border-b border-[var(--border)]/50">
|
||||
{rowDims.map((dim, di) => (
|
||||
<td key={dim} className="px-3 py-1.5" style={di === 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}>
|
||||
{di === depth ? row.keys[dim] || "" : di > depth ? row.keys[dim] || "" : ""}
|
||||
<td
|
||||
key={dim}
|
||||
className="px-3 py-1.5"
|
||||
style={di === 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}
|
||||
>
|
||||
{pRow.rowKeys[dim] || ""}
|
||||
</td>
|
||||
))}
|
||||
{colValues.map((colVal) =>
|
||||
measures.map((m) => {
|
||||
const matchesCol = !colDim || row.keys[colDim] === colVal;
|
||||
return (
|
||||
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||
{matchesCol ? cadFormatter(row.measures[m] || 0) : ""}
|
||||
</td>
|
||||
);
|
||||
})
|
||||
measures.map((m) => (
|
||||
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(pRow.cells[colVal]?.[m] || 0)}
|
||||
</td>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -240,7 +272,6 @@ function GroupRows({
|
|||
<GroupRows
|
||||
key={child.key}
|
||||
group={child}
|
||||
colDim={colDim}
|
||||
colValues={colValues}
|
||||
measures={measures}
|
||||
rowDims={rowDims}
|
||||
|
|
|
|||
Loading…
Reference in a new issue