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:
le king fu 2026-02-22 08:52:22 -05:00
parent df742af8ef
commit 438b72cba2

View file

@ -1,4 +1,4 @@
import { Fragment, useState } from "react"; import { Fragment, useState, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react"; import { ArrowUpDown } from "lucide-react";
import type { PivotConfig, PivotResult, PivotResultRow } from "../../shared/types"; import type { PivotConfig, PivotResult, PivotResultRow } from "../../shared/types";
@ -13,19 +13,54 @@ interface DynamicReportTableProps {
result: PivotResult; 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 { interface GroupNode {
key: string; key: string;
label: string; label: string;
rows: PivotResultRow[]; pivotedRows: PivotedRow[];
children: GroupNode[]; 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 []; if (depth >= rowDims.length) return [];
const dim = rowDims[depth]; const dim = rowDims[depth];
const map = new Map<string, PivotResultRow[]>(); const map = new Map<string, PivotedRow[]>();
for (const row of rows) { for (const row of rows) {
const key = row.keys[dim] || ""; const key = row.rowKeys[dim] || "";
if (!map.has(key)) map.set(key, []); if (!map.has(key)) map.set(key, []);
map.get(key)!.push(row); map.get(key)!.push(row);
} }
@ -34,21 +69,23 @@ function buildGroups(rows: PivotResultRow[], rowDims: string[], depth: number):
groups.push({ groups.push({
key, key,
label: key, label: key,
rows: groupRows, pivotedRows: groupRows,
children: buildGroups(groupRows, rowDims, depth + 1), children: buildGroups(groupRows, rowDims, depth + 1),
}); });
} }
return groups; return groups;
} }
function computeSubtotals(rows: PivotResultRow[], measures: string[], colDim: string | undefined): Record<string, Record<string, number>> { function computeSubtotals(
// colValue → measure → sum rows: PivotedRow[],
measures: string[],
colValues: string[],
): Record<string, Record<string, number>> {
const result: Record<string, Record<string, number>> = {}; const result: Record<string, Record<string, number>> = {};
for (const row of rows) { for (const colVal of colValues) {
const colKey = colDim ? (row.keys[colDim] || "") : "__all__"; result[colVal] = {};
if (!result[colKey]) result[colKey] = {};
for (const m of measures) { 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; 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 ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("reports.pivot.noData")} {t("reports.pivot.noData")}
@ -77,16 +125,8 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
); );
} }
const rowDims = config.rows; const groups = rowDims.length > 0 ? buildGroups(pivotedRows, rowDims, 0) : [];
const colDim = config.columns[0] || undefined; const grandTotals = computeSubtotals(pivotedRows, measures, colValues);
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 fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "type" ? "categoryType" : id}`); 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}`); 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"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-[var(--border)]"> <tr className="border-b border-[var(--border)]">
{/* Row dimension headers */}
{rowDims.map((dim) => ( {rowDims.map((dim) => (
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)]"> <th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)]">
{fieldLabel(dim)} {fieldLabel(dim)}
</th> </th>
))} ))}
{/* Column headers: colValue × measure */}
{colValues.map((colVal) => {colValues.map((colVal) =>
measures.map((m) => ( 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)]"> <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> </th>
)) ))
)} )}
@ -126,17 +164,13 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
</thead> </thead>
<tbody> <tbody>
{rowDims.length === 0 ? ( {rowDims.length === 0 ? (
// No row dims — single row with totals
<tr className="border-b border-[var(--border)]/50"> <tr className="border-b border-[var(--border)]/50">
{colValues.map((colVal) => {colValues.map((colVal) =>
measures.map((m) => { 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"> <td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{cadFormatter(val)} {cadFormatter(grandTotals[colVal]?.[m] || 0)}
</td> </td>
); ))
})
)} )}
</tr> </tr>
) : ( ) : (
@ -144,7 +178,6 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
<GroupRows <GroupRows
key={group.key} key={group.key}
group={group} group={group}
colDim={colDim}
colValues={colValues} colValues={colValues}
measures={measures} measures={measures}
rowDims={rowDims} rowDims={rowDims}
@ -175,7 +208,6 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
function GroupRows({ function GroupRows({
group, group,
colDim,
colValues, colValues,
measures, measures,
rowDims, rowDims,
@ -183,7 +215,6 @@ function GroupRows({
subtotalsOnTop, subtotalsOnTop,
}: { }: {
group: GroupNode; group: GroupNode;
colDim: string | undefined;
colValues: string[]; colValues: string[];
measures: string[]; measures: string[];
rowDims: string[]; rowDims: string[];
@ -191,7 +222,7 @@ function GroupRows({
subtotalsOnTop: boolean; subtotalsOnTop: boolean;
}) { }) {
const isLeafLevel = depth === rowDims.length - 1; 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 ? ( const subtotalRow = rowDims.length > 1 && !isLeafLevel ? (
<tr className="bg-[var(--muted)]/30 font-semibold border-b border-[var(--border)]/50"> <tr className="bg-[var(--muted)]/30 font-semibold border-b border-[var(--border)]/50">
@ -210,25 +241,26 @@ function GroupRows({
) : null; ) : null;
if (isLeafLevel) { 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 ( return (
<> <>
{group.rows.map((row, i) => ( {group.pivotedRows.map((pRow, i) => (
<tr key={i} className="border-b border-[var(--border)]/50"> <tr key={i} className="border-b border-[var(--border)]/50">
{rowDims.map((dim, di) => ( {rowDims.map((dim, di) => (
<td key={dim} className="px-3 py-1.5" style={di === 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}> <td
{di === depth ? row.keys[dim] || "" : di > depth ? row.keys[dim] || "" : ""} key={dim}
className="px-3 py-1.5"
style={di === 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}
>
{pRow.rowKeys[dim] || ""}
</td> </td>
))} ))}
{colValues.map((colVal) => {colValues.map((colVal) =>
measures.map((m) => { 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"> <td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{matchesCol ? cadFormatter(row.measures[m] || 0) : ""} {cadFormatter(pRow.cells[colVal]?.[m] || 0)}
</td> </td>
); ))
})
)} )}
</tr> </tr>
))} ))}
@ -240,7 +272,6 @@ function GroupRows({
<GroupRows <GroupRows
key={child.key} key={child.key}
group={child} group={child}
colDim={colDim}
colValues={colValues} colValues={colValues}
measures={measures} measures={measures}
rowDims={rowDims} rowDims={rowDims}