From d06153f472304054ebff30b74277a4a357e24c67 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 22 Feb 2026 09:10:15 -0500 Subject: [PATCH] feat: allow reusable pivot fields across zones and right-click filter exclusion Fields can now be assigned to multiple zones simultaneously (e.g. rows + filters). Right-clicking a filter value excludes it (NOT IN), shown with strikethrough in red. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 4 + src/components/reports/DynamicReportPanel.tsx | 146 ++++++++++++------ src/i18n/locales/en.json | 3 +- src/i18n/locales/fr.json | 3 +- src/services/reportService.ts | 22 ++- src/shared/types/index.ts | 7 +- 6 files changed, 125 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3843f5b..e523bca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added +- Dynamic Report: fields can now be used in multiple zones simultaneously (rows + filters, columns + filters) +- Dynamic Report: right-click on a filter value to exclude it (shown with strikethrough in red) + ## [0.3.9] ### Added diff --git a/src/components/reports/DynamicReportPanel.tsx b/src/components/reports/DynamicReportPanel.tsx index 618c07e..c443b20 100644 --- a/src/components/reports/DynamicReportPanel.tsx +++ b/src/components/reports/DynamicReportPanel.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { X } from "lucide-react"; -import type { PivotConfig, PivotFieldId, PivotMeasureId, PivotZone } from "../../shared/types"; +import type { PivotConfig, PivotFieldId, PivotFilterEntry, PivotMeasureId, PivotZone } from "../../shared/types"; import { getDynamicFilterValues } from "../../services/reportService"; const ALL_FIELDS: PivotFieldId[] = ["year", "month", "type", "level1", "level2"]; @@ -20,8 +20,13 @@ export default function DynamicReportPanel({ config, onChange, dateFrom, dateTo const [filterValues, setFilterValues] = useState>({}); const menuRef = useRef(null); - // Fields currently assigned somewhere - const assignedFields = new Set([...config.rows, ...config.columns, ...Object.keys(config.filters) as PivotFieldId[]]); + // A field is only "exhausted" if it's in all 3 zones (rows + columns + filters) + const inRows = new Set(config.rows); + const inColumns = new Set(config.columns); + const inFilters = new Set(Object.keys(config.filters) as PivotFieldId[]); + const assignedFields = new Set( + ALL_FIELDS.filter((f) => inRows.has(f) && inColumns.has(f) && inFilters.has(f)) + ); const assignedMeasures = new Set(config.values); const availableFields = ALL_FIELDS.filter((f) => !assignedFields.has(f)); const availableMeasures = ALL_MEASURES.filter((m) => !assignedMeasures.has(m)); @@ -66,7 +71,7 @@ export default function DynamicReportPanel({ config, onChange, dateFrom, dateTo const fieldId = menuTarget.id as PivotFieldId; if (zone === "rows") next.rows = [...next.rows, fieldId]; else if (zone === "columns") next.columns = [...next.columns, fieldId]; - else if (zone === "filters") next.filters = { ...next.filters, [fieldId]: [] }; + else if (zone === "filters") next.filters = { ...next.filters, [fieldId]: { include: [], exclude: [] } }; } setMenuTarget(null); @@ -84,21 +89,42 @@ export default function DynamicReportPanel({ config, onChange, dateFrom, dateTo onChange(next); }; - const toggleFilterValue = (fieldId: string, value: string) => { - const current = config.filters[fieldId] || []; - const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value]; - onChange({ ...config, filters: { ...config.filters, [fieldId]: next } }); + const toggleFilterInclude = (fieldId: string, value: string) => { + const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] }; + const isIncluded = entry.include.includes(value); + const newInclude = isIncluded ? entry.include.filter((v) => v !== value) : [...entry.include, value]; + // Remove from exclude if adding to include + const newExclude = isIncluded ? entry.exclude : entry.exclude.filter((v) => v !== value); + onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } }); + }; + + const toggleFilterExclude = (fieldId: string, value: string) => { + const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] }; + const isExcluded = entry.exclude.includes(value); + const newExclude = isExcluded ? entry.exclude.filter((v) => v !== value) : [...entry.exclude, value]; + // Remove from include if adding to exclude + const newInclude = isExcluded ? entry.include : entry.include.filter((v) => v !== value); + onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } }); }; 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 zoneOptions: { zone: PivotZone; label: string; forMeasure: boolean }[] = [ - { zone: "rows", label: t("reports.pivot.rows"), forMeasure: false }, - { zone: "columns", label: t("reports.pivot.columns"), forMeasure: false }, - { zone: "filters", label: t("reports.pivot.filters"), forMeasure: false }, - { zone: "values", label: t("reports.pivot.values"), forMeasure: true }, - ]; + // Context menu only shows zones where the field is NOT already assigned + const getAvailableZones = (fieldId: string): PivotZone[] => { + const zones: PivotZone[] = []; + if (!inRows.has(fieldId as PivotFieldId)) zones.push("rows"); + if (!inColumns.has(fieldId as PivotFieldId)) zones.push("columns"); + if (!inFilters.has(fieldId as PivotFieldId)) zones.push("filters"); + return zones; + }; + + const zoneLabels: Record = { + rows: t("reports.pivot.rows"), + columns: t("reports.pivot.columns"), + filters: t("reports.pivot.filters"), + values: t("reports.pivot.values"), + }; return (
@@ -153,37 +179,48 @@ export default function DynamicReportPanel({ config, onChange, dateFrom, dateTo ) : (
- {filterFieldIds.map((fieldId) => ( -
-
- {fieldLabel(fieldId)} - + {filterFieldIds.map((fieldId) => { + const entry = config.filters[fieldId] || { include: [], exclude: [] }; + const hasActive = entry.include.length > 0 || entry.exclude.length > 0; + return ( +
+
+ {fieldLabel(fieldId)} + +
+
+ {(filterValues[fieldId] || []).map((val) => { + const isIncluded = entry.include.includes(val); + const isExcluded = entry.exclude.includes(val); + return ( + + ); + })} +
-
- {(filterValues[fieldId] || []).map((val) => { - const selected = (config.filters[fieldId] || []).includes(val); - const isActiveFilter = (config.filters[fieldId] || []).length > 0; - return ( - - ); - })} -
-
- ))} + ); + })}
)}
@@ -205,17 +242,24 @@ export default function DynamicReportPanel({ config, onChange, dateFrom, dateTo style={{ left: menuTarget.x, top: menuTarget.y }} >
{t("reports.pivot.addTo")}
- {zoneOptions - .filter((opt) => (menuTarget.type === "measure") === opt.forMeasure) - .map((opt) => ( + {menuTarget.type === "measure" ? ( + + ) : ( + getAvailableZones(menuTarget.id).map((zone) => ( - ))} + )) + )}
)} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 55108b2..eb544e1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -380,7 +380,8 @@ "noConfig": "Add fields to generate the report", "noData": "No data for this configuration", "fullscreen": "Full screen", - "exitFullscreen": "Exit full screen" + "exitFullscreen": "Exit full screen", + "rightClickExclude": "Right-click to exclude" }, "help": { "title": "How to use Reports", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 5fb02d9..55d0cb8 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -380,7 +380,8 @@ "noConfig": "Ajoutez des champs pour générer le rapport", "noData": "Aucune donnée pour cette configuration", "fullscreen": "Plein écran", - "exitFullscreen": "Quitter plein écran" + "exitFullscreen": "Quitter plein écran", + "rightClickExclude": "Clic-droit pour exclure" }, "help": { "title": "Comment utiliser les Rapports", diff --git a/src/services/reportService.ts b/src/services/reportService.ts index cf4a267..7eb6cec 100644 --- a/src/services/reportService.ts +++ b/src/services/reportService.ts @@ -213,18 +213,28 @@ export async function getDynamicReportData( paramIndex++; } - // Apply filter values + // Apply filter values (include / exclude) for (const fieldId of filterFields) { - const values = config.filters[fieldId]; - if (values && values.length > 0) { - const def = FIELD_SQL[fieldId as PivotFieldId]; - const placeholders = values.map(() => { + const entry = config.filters[fieldId]; + if (!entry) continue; + const def = FIELD_SQL[fieldId as PivotFieldId]; + if (entry.include && entry.include.length > 0) { + const placeholders = entry.include.map(() => { const p = `$${paramIndex}`; paramIndex++; return p; }); whereClauses.push(`${def.select} IN (${placeholders.join(", ")})`); - params.push(...values); + params.push(...entry.include); + } + if (entry.exclude && entry.exclude.length > 0) { + const placeholders = entry.exclude.map(() => { + const p = `$${paramIndex}`; + paramIndex++; + return p; + }); + whereClauses.push(`${def.select} NOT IN (${placeholders.join(", ")})`); + params.push(...entry.exclude); } } diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 8c3b416..a952871 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -282,10 +282,15 @@ export type PivotFieldId = "year" | "month" | "type" | "level1" | "level2"; export type PivotMeasureId = "periodic" | "ytd"; export type PivotZone = "rows" | "columns" | "filters" | "values"; +export interface PivotFilterEntry { + include: string[]; // included values (empty = all) + exclude: string[]; // excluded values +} + export interface PivotConfig { rows: PivotFieldId[]; columns: PivotFieldId[]; - filters: Record; // field → selected values (empty = all) + filters: Record; // field → include/exclude entries values: PivotMeasureId[]; }