Combine column dimensions into composite keys instead of using only the first column dimension, enabling richer pivot tables and charts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
295 lines
9.6 KiB
TypeScript
295 lines
9.6 KiB
TypeScript
import { Fragment, useState, useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ArrowUpDown } from "lucide-react";
|
|
import type { PivotConfig, PivotResult, PivotResultRow } from "../../shared/types";
|
|
|
|
const cadFormatter = (value: number) =>
|
|
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
|
|
|
const STORAGE_KEY = "pivot-subtotals-position";
|
|
|
|
interface DynamicReportTableProps {
|
|
config: PivotConfig;
|
|
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[],
|
|
colDims: string[],
|
|
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 = colDims.length > 0
|
|
? colDims.map((d) => row.keys[d] || "").join("\0")
|
|
: "__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;
|
|
pivotedRows: PivotedRow[];
|
|
children: GroupNode[];
|
|
}
|
|
|
|
function buildGroups(rows: PivotedRow[], rowDims: string[], depth: number): GroupNode[] {
|
|
if (depth >= rowDims.length) return [];
|
|
const dim = rowDims[depth];
|
|
const map = new Map<string, PivotedRow[]>();
|
|
for (const row of rows) {
|
|
const key = row.rowKeys[dim] || "";
|
|
if (!map.has(key)) map.set(key, []);
|
|
map.get(key)!.push(row);
|
|
}
|
|
const groups: GroupNode[] = [];
|
|
for (const [key, groupRows] of map) {
|
|
groups.push({
|
|
key,
|
|
label: key,
|
|
pivotedRows: groupRows,
|
|
children: buildGroups(groupRows, rowDims, depth + 1),
|
|
});
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
function computeSubtotals(
|
|
rows: PivotedRow[],
|
|
measures: string[],
|
|
colValues: string[],
|
|
): Record<string, Record<string, number>> {
|
|
const result: Record<string, Record<string, number>> = {};
|
|
for (const colVal of colValues) {
|
|
result[colVal] = {};
|
|
for (const m of measures) {
|
|
result[colVal][m] = rows.reduce((sum, r) => sum + (r.cells[colVal]?.[m] || 0), 0);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export default function DynamicReportTable({ config, result }: DynamicReportTableProps) {
|
|
const { t } = useTranslation();
|
|
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
return stored === null ? true : stored === "top";
|
|
});
|
|
|
|
const toggleSubtotals = () => {
|
|
setSubtotalsOnTop((prev) => {
|
|
const next = !prev;
|
|
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const rowDims = config.rows;
|
|
const colDims = config.columns;
|
|
const colValues = colDims.length > 0 ? result.columnValues : ["__all__"];
|
|
const measures = config.values;
|
|
|
|
// Display label for a composite column key (joined with \0)
|
|
const colLabel = (compositeKey: string) => compositeKey.split("\0").join(" — ");
|
|
|
|
// Pivot the flat SQL rows into one PivotedRow per unique row-key combo
|
|
const pivotedRows = useMemo(
|
|
() => pivotRows(result.rows, rowDims, colDims, measures),
|
|
[result.rows, rowDims, colDims, 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")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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}`);
|
|
|
|
return (
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
|
{rowDims.length > 1 && (
|
|
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
|
<button
|
|
onClick={toggleSubtotals}
|
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
|
>
|
|
<ArrowUpDown size={13} />
|
|
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-[var(--border)]">
|
|
{rowDims.map((dim) => (
|
|
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)]">
|
|
{fieldLabel(dim)}
|
|
</th>
|
|
))}
|
|
{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)]">
|
|
{colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)} — ${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)}
|
|
</th>
|
|
))
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rowDims.length === 0 ? (
|
|
<tr className="border-b border-[var(--border)]/50">
|
|
{colValues.map((colVal) =>
|
|
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>
|
|
) : (
|
|
groups.map((group) => (
|
|
<GroupRows
|
|
key={group.key}
|
|
group={group}
|
|
colValues={colValues}
|
|
measures={measures}
|
|
rowDims={rowDims}
|
|
depth={0}
|
|
subtotalsOnTop={subtotalsOnTop}
|
|
/>
|
|
))
|
|
)}
|
|
{/* Grand total */}
|
|
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
|
|
<td colSpan={rowDims.length || 1} className="px-3 py-2">
|
|
{t("reports.pivot.total")}
|
|
</td>
|
|
{colValues.map((colVal) =>
|
|
measures.map((m) => (
|
|
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-2 border-l border-[var(--border)]/50">
|
|
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
|
|
</td>
|
|
))
|
|
)}
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GroupRows({
|
|
group,
|
|
colValues,
|
|
measures,
|
|
rowDims,
|
|
depth,
|
|
subtotalsOnTop,
|
|
}: {
|
|
group: GroupNode;
|
|
colValues: string[];
|
|
measures: string[];
|
|
rowDims: string[];
|
|
depth: number;
|
|
subtotalsOnTop: boolean;
|
|
}) {
|
|
const isLeafLevel = depth === rowDims.length - 1;
|
|
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">
|
|
<td className="px-3 py-1.5" style={{ paddingLeft: `${depth * 16 + 12}px` }}>
|
|
{group.label}
|
|
</td>
|
|
{depth < rowDims.length - 1 && <td colSpan={rowDims.length - depth - 1} />}
|
|
{colValues.map((colVal) =>
|
|
measures.map((m) => (
|
|
<td key={`sub-${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
|
{cadFormatter(subtotals[colVal]?.[m] || 0)}
|
|
</td>
|
|
))
|
|
)}
|
|
</tr>
|
|
) : null;
|
|
|
|
if (isLeafLevel) {
|
|
// Render one table row per pivoted row (already deduplicated by row keys)
|
|
return (
|
|
<>
|
|
{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}
|
|
>
|
|
{pRow.rowKeys[dim] || ""}
|
|
</td>
|
|
))}
|
|
{colValues.map((colVal) =>
|
|
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>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const childContent = group.children.map((child) => (
|
|
<GroupRows
|
|
key={child.key}
|
|
group={child}
|
|
colValues={colValues}
|
|
measures={measures}
|
|
rowDims={rowDims}
|
|
depth={depth + 1}
|
|
subtotalsOnTop={subtotalsOnTop}
|
|
/>
|
|
));
|
|
|
|
return (
|
|
<Fragment>
|
|
{subtotalsOnTop && subtotalRow}
|
|
{childContent}
|
|
{!subtotalsOnTop && subtotalRow}
|
|
</Fragment>
|
|
);
|
|
}
|