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 { 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;
|
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||||
return (
|
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
|
||||||
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
</td>
|
||||||
{cadFormatter(val)}
|
))
|
||||||
</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;
|
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||||
return (
|
{cadFormatter(pRow.cells[colVal]?.[m] || 0)}
|
||||||
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
</td>
|
||||||
{matchesCol ? cadFormatter(row.measures[m] || 0) : ""}
|
))
|
||||||
</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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue