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; // row-dimension values cells: Record>; // 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(); 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 = {}; 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(); 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> { const result: Record> = {}; 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 (
{t("reports.pivot.noData")}
); } 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 (
{rowDims.length > 1 && (
)}
{rowDims.map((dim) => ( ))} {colValues.map((colVal) => measures.map((m) => ( )) )} {rowDims.length === 0 ? ( {colValues.map((colVal) => measures.map((m) => ( )) )} ) : ( groups.map((group) => ( )) )} {/* Grand total */} {colValues.map((colVal) => measures.map((m) => ( )) )}
{fieldLabel(dim)} {colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)} — ${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)}
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
{t("reports.pivot.total")} {cadFormatter(grandTotals[colVal]?.[m] || 0)}
); } 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 ? ( {group.label} {depth < rowDims.length - 1 && } {colValues.map((colVal) => measures.map((m) => ( {cadFormatter(subtotals[colVal]?.[m] || 0)} )) )} ) : null; if (isLeafLevel) { // Render one table row per pivoted row (already deduplicated by row keys) return ( <> {group.pivotedRows.map((pRow, i) => ( {rowDims.map((dim, di) => ( {pRow.rowKeys[dim] || ""} ))} {colValues.map((colVal) => measures.map((m) => ( {cadFormatter(pRow.cells[colVal]?.[m] || 0)} )) )} ))} ); } const childContent = group.children.map((child) => ( )); return ( {subtotalsOnTop && subtotalRow} {childContent} {!subtotalsOnTop && subtotalRow} ); }