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 <noreply@anthropic.com>
This commit is contained in:
parent
bcf7f0a2d0
commit
d06153f472
6 changed files with 125 additions and 60 deletions
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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]
|
## [0.3.9]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { X } from "lucide-react";
|
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";
|
import { getDynamicFilterValues } from "../../services/reportService";
|
||||||
|
|
||||||
const ALL_FIELDS: PivotFieldId[] = ["year", "month", "type", "level1", "level2"];
|
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<Record<string, string[]>>({});
|
const [filterValues, setFilterValues] = useState<Record<string, string[]>>({});
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Fields currently assigned somewhere
|
// A field is only "exhausted" if it's in all 3 zones (rows + columns + filters)
|
||||||
const assignedFields = new Set([...config.rows, ...config.columns, ...Object.keys(config.filters) as PivotFieldId[]]);
|
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 assignedMeasures = new Set(config.values);
|
||||||
const availableFields = ALL_FIELDS.filter((f) => !assignedFields.has(f));
|
const availableFields = ALL_FIELDS.filter((f) => !assignedFields.has(f));
|
||||||
const availableMeasures = ALL_MEASURES.filter((m) => !assignedMeasures.has(m));
|
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;
|
const fieldId = menuTarget.id as PivotFieldId;
|
||||||
if (zone === "rows") next.rows = [...next.rows, fieldId];
|
if (zone === "rows") next.rows = [...next.rows, fieldId];
|
||||||
else if (zone === "columns") next.columns = [...next.columns, 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);
|
setMenuTarget(null);
|
||||||
|
|
@ -84,21 +89,42 @@ export default function DynamicReportPanel({ config, onChange, dateFrom, dateTo
|
||||||
onChange(next);
|
onChange(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleFilterValue = (fieldId: string, value: string) => {
|
const toggleFilterInclude = (fieldId: string, value: string) => {
|
||||||
const current = config.filters[fieldId] || [];
|
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
|
||||||
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
|
const isIncluded = entry.include.includes(value);
|
||||||
onChange({ ...config, filters: { ...config.filters, [fieldId]: next } });
|
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 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}`);
|
||||||
|
|
||||||
const zoneOptions: { zone: PivotZone; label: string; forMeasure: boolean }[] = [
|
// Context menu only shows zones where the field is NOT already assigned
|
||||||
{ zone: "rows", label: t("reports.pivot.rows"), forMeasure: false },
|
const getAvailableZones = (fieldId: string): PivotZone[] => {
|
||||||
{ zone: "columns", label: t("reports.pivot.columns"), forMeasure: false },
|
const zones: PivotZone[] = [];
|
||||||
{ zone: "filters", label: t("reports.pivot.filters"), forMeasure: false },
|
if (!inRows.has(fieldId as PivotFieldId)) zones.push("rows");
|
||||||
{ zone: "values", label: t("reports.pivot.values"), forMeasure: true },
|
if (!inColumns.has(fieldId as PivotFieldId)) zones.push("columns");
|
||||||
];
|
if (!inFilters.has(fieldId as PivotFieldId)) zones.push("filters");
|
||||||
|
return zones;
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneLabels: Record<PivotZone, string> = {
|
||||||
|
rows: t("reports.pivot.rows"),
|
||||||
|
columns: t("reports.pivot.columns"),
|
||||||
|
filters: t("reports.pivot.filters"),
|
||||||
|
values: t("reports.pivot.values"),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-64 shrink-0 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 space-y-4 text-sm h-fit sticky top-4">
|
<div className="w-64 shrink-0 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 space-y-4 text-sm h-fit sticky top-4">
|
||||||
|
|
@ -153,7 +179,10 @@ export default function DynamicReportPanel({ config, onChange, dateFrom, dateTo
|
||||||
<span className="text-xs text-[var(--muted-foreground)]">—</span>
|
<span className="text-xs text-[var(--muted-foreground)]">—</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filterFieldIds.map((fieldId) => (
|
{filterFieldIds.map((fieldId) => {
|
||||||
|
const entry = config.filters[fieldId] || { include: [], exclude: [] };
|
||||||
|
const hasActive = entry.include.length > 0 || entry.exclude.length > 0;
|
||||||
|
return (
|
||||||
<div key={fieldId}>
|
<div key={fieldId}>
|
||||||
<div className="flex items-center gap-1 mb-1">
|
<div className="flex items-center gap-1 mb-1">
|
||||||
<span className="text-xs font-medium">{fieldLabel(fieldId)}</span>
|
<span className="text-xs font-medium">{fieldLabel(fieldId)}</span>
|
||||||
|
|
@ -163,19 +192,26 @@ export default function DynamicReportPanel({ config, onChange, dateFrom, dateTo
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{(filterValues[fieldId] || []).map((val) => {
|
{(filterValues[fieldId] || []).map((val) => {
|
||||||
const selected = (config.filters[fieldId] || []).includes(val);
|
const isIncluded = entry.include.includes(val);
|
||||||
const isActiveFilter = (config.filters[fieldId] || []).length > 0;
|
const isExcluded = entry.exclude.includes(val);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={val}
|
key={val}
|
||||||
onClick={() => toggleFilterValue(fieldId, val)}
|
onClick={() => toggleFilterInclude(fieldId, val)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleFilterExclude(fieldId, val);
|
||||||
|
}}
|
||||||
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
||||||
selected
|
isIncluded
|
||||||
? "bg-[var(--primary)] text-white"
|
? "bg-[var(--primary)] text-white"
|
||||||
: isActiveFilter
|
: isExcluded
|
||||||
|
? "bg-[var(--negative)] text-white line-through"
|
||||||
|
: hasActive
|
||||||
? "bg-[var(--muted)] text-[var(--muted-foreground)] opacity-50"
|
? "bg-[var(--muted)] text-[var(--muted-foreground)] opacity-50"
|
||||||
: "bg-[var(--muted)] text-[var(--foreground)]"
|
: "bg-[var(--muted)] text-[var(--foreground)]"
|
||||||
}`}
|
}`}
|
||||||
|
title={t("reports.pivot.rightClickExclude")}
|
||||||
>
|
>
|
||||||
{val}
|
{val}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -183,7 +219,8 @@ export default function DynamicReportPanel({ config, onChange, dateFrom, dateTo
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,17 +242,24 @@ export default function DynamicReportPanel({ config, onChange, dateFrom, dateTo
|
||||||
style={{ left: menuTarget.x, top: menuTarget.y }}
|
style={{ left: menuTarget.x, top: menuTarget.y }}
|
||||||
>
|
>
|
||||||
<div className="px-3 py-1 text-xs text-[var(--muted-foreground)]">{t("reports.pivot.addTo")}</div>
|
<div className="px-3 py-1 text-xs text-[var(--muted-foreground)]">{t("reports.pivot.addTo")}</div>
|
||||||
{zoneOptions
|
{menuTarget.type === "measure" ? (
|
||||||
.filter((opt) => (menuTarget.type === "measure") === opt.forMeasure)
|
|
||||||
.map((opt) => (
|
|
||||||
<button
|
<button
|
||||||
key={opt.zone}
|
onClick={() => assignTo("values")}
|
||||||
onClick={() => assignTo(opt.zone)}
|
|
||||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
|
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
|
||||||
>
|
>
|
||||||
{opt.label}
|
{zoneLabels.values}
|
||||||
</button>
|
</button>
|
||||||
))}
|
) : (
|
||||||
|
getAvailableZones(menuTarget.id).map((zone) => (
|
||||||
|
<button
|
||||||
|
key={zone}
|
||||||
|
onClick={() => assignTo(zone)}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
{zoneLabels[zone]}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -380,7 +380,8 @@
|
||||||
"noConfig": "Add fields to generate the report",
|
"noConfig": "Add fields to generate the report",
|
||||||
"noData": "No data for this configuration",
|
"noData": "No data for this configuration",
|
||||||
"fullscreen": "Full screen",
|
"fullscreen": "Full screen",
|
||||||
"exitFullscreen": "Exit full screen"
|
"exitFullscreen": "Exit full screen",
|
||||||
|
"rightClickExclude": "Right-click to exclude"
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "How to use Reports",
|
"title": "How to use Reports",
|
||||||
|
|
|
||||||
|
|
@ -380,7 +380,8 @@
|
||||||
"noConfig": "Ajoutez des champs pour générer le rapport",
|
"noConfig": "Ajoutez des champs pour générer le rapport",
|
||||||
"noData": "Aucune donnée pour cette configuration",
|
"noData": "Aucune donnée pour cette configuration",
|
||||||
"fullscreen": "Plein écran",
|
"fullscreen": "Plein écran",
|
||||||
"exitFullscreen": "Quitter plein écran"
|
"exitFullscreen": "Quitter plein écran",
|
||||||
|
"rightClickExclude": "Clic-droit pour exclure"
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Comment utiliser les Rapports",
|
"title": "Comment utiliser les Rapports",
|
||||||
|
|
|
||||||
|
|
@ -213,18 +213,28 @@ export async function getDynamicReportData(
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply filter values
|
// Apply filter values (include / exclude)
|
||||||
for (const fieldId of filterFields) {
|
for (const fieldId of filterFields) {
|
||||||
const values = config.filters[fieldId];
|
const entry = config.filters[fieldId];
|
||||||
if (values && values.length > 0) {
|
if (!entry) continue;
|
||||||
const def = FIELD_SQL[fieldId as PivotFieldId];
|
const def = FIELD_SQL[fieldId as PivotFieldId];
|
||||||
const placeholders = values.map(() => {
|
if (entry.include && entry.include.length > 0) {
|
||||||
|
const placeholders = entry.include.map(() => {
|
||||||
const p = `$${paramIndex}`;
|
const p = `$${paramIndex}`;
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
whereClauses.push(`${def.select} IN (${placeholders.join(", ")})`);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -282,10 +282,15 @@ export type PivotFieldId = "year" | "month" | "type" | "level1" | "level2";
|
||||||
export type PivotMeasureId = "periodic" | "ytd";
|
export type PivotMeasureId = "periodic" | "ytd";
|
||||||
export type PivotZone = "rows" | "columns" | "filters" | "values";
|
export type PivotZone = "rows" | "columns" | "filters" | "values";
|
||||||
|
|
||||||
|
export interface PivotFilterEntry {
|
||||||
|
include: string[]; // included values (empty = all)
|
||||||
|
exclude: string[]; // excluded values
|
||||||
|
}
|
||||||
|
|
||||||
export interface PivotConfig {
|
export interface PivotConfig {
|
||||||
rows: PivotFieldId[];
|
rows: PivotFieldId[];
|
||||||
columns: PivotFieldId[];
|
columns: PivotFieldId[];
|
||||||
filters: Record<string, string[]>; // field → selected values (empty = all)
|
filters: Record<string, PivotFilterEntry>; // field → include/exclude entries
|
||||||
values: PivotMeasureId[];
|
values: PivotMeasureId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue