fix: remove expense filter from Category Over Time report (#41) #42
9 changed files with 70 additions and 12 deletions
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- Rapport Catégorie dans le temps : suppression du filtre codé en dur sur les dépenses, affiche maintenant tous les types de transactions par défaut (#41)
|
||||||
|
- Rapport Catégorie dans le temps : ajout d'un filtre par type (dépense/revenu/transfert) dans le panneau de filtre à droite (#41)
|
||||||
|
|
||||||
## [0.6.6]
|
## [0.6.6]
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Category Over Time report: removed hard-coded expense-only filter, now shows all transaction types by default (#41)
|
||||||
|
- Category Over Time report: added type filter (expense/income/transfer) in the right filter panel (#41)
|
||||||
|
|
||||||
## [0.6.6]
|
## [0.6.6]
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,7 @@ Visualisez vos données financières avec des graphiques interactifs et comparez
|
||||||
|
|
||||||
- Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)
|
- Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)
|
||||||
- Dépenses par catégorie : répartition des dépenses (graphique circulaire)
|
- Dépenses par catégorie : répartition des dépenses (graphique circulaire)
|
||||||
- Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en ligne)
|
- Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en barres empilées), avec filtre par type (dépense/revenu/transfert)
|
||||||
- Budget vs Réel : tableau comparatif mensuel et cumul annuel
|
- Budget vs Réel : tableau comparatif mensuel et cumul annuel
|
||||||
- Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable
|
- Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable
|
||||||
- Motifs SVG (lignes, points, hachures) pour distinguer les catégories
|
- Motifs SVG (lignes, points, hachures) pour distinguer les catégories
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Filter, Search } from "lucide-react";
|
import { Filter, Search } from "lucide-react";
|
||||||
import type { ImportSource } from "../../shared/types";
|
import type { ImportSource } from "../../shared/types";
|
||||||
|
import type { CategoryTypeFilter } from "../../hooks/useReports";
|
||||||
|
|
||||||
interface ReportFilterPanelProps {
|
interface ReportFilterPanelProps {
|
||||||
categories: { name: string; color: string }[];
|
categories: { name: string; color: string }[];
|
||||||
|
|
@ -11,6 +12,8 @@ interface ReportFilterPanelProps {
|
||||||
sources: ImportSource[];
|
sources: ImportSource[];
|
||||||
selectedSourceId: number | null;
|
selectedSourceId: number | null;
|
||||||
onSourceChange: (id: number | null) => void;
|
onSourceChange: (id: number | null) => void;
|
||||||
|
categoryType?: CategoryTypeFilter;
|
||||||
|
onCategoryTypeChange?: (type: CategoryTypeFilter) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportFilterPanel({
|
export default function ReportFilterPanel({
|
||||||
|
|
@ -21,6 +24,8 @@ export default function ReportFilterPanel({
|
||||||
sources,
|
sources,
|
||||||
selectedSourceId,
|
selectedSourceId,
|
||||||
onSourceChange,
|
onSourceChange,
|
||||||
|
categoryType,
|
||||||
|
onCategoryTypeChange,
|
||||||
}: ReportFilterPanelProps) {
|
}: ReportFilterPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
@ -57,6 +62,28 @@ export default function ReportFilterPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Type filter */}
|
||||||
|
{onCategoryTypeChange && (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
|
<div className="px-3 py-2.5 text-sm font-medium text-[var(--foreground)] flex items-center gap-2">
|
||||||
|
<Filter size={14} className="text-[var(--muted-foreground)]" />
|
||||||
|
{t("categories.type")}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-[var(--border)] px-2 py-2">
|
||||||
|
<select
|
||||||
|
value={categoryType ?? ""}
|
||||||
|
onChange={(e) => onCategoryTypeChange((e.target.value || null) as CategoryTypeFilter)}
|
||||||
|
className="w-full px-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||||
|
>
|
||||||
|
<option value="">{t("reports.filters.allTypes")}</option>
|
||||||
|
<option value="expense">{t("categories.expense")}</option>
|
||||||
|
<option value="income">{t("categories.income")}</option>
|
||||||
|
<option value="transfer">{t("categories.transfer")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Category filter */}
|
{/* Category filter */}
|
||||||
{categories.length > 0 && <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
{categories.length > 0 && <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,15 @@ import { getExpensesByCategory } from "../services/dashboardService";
|
||||||
import { getBudgetVsActualData } from "../services/budgetService";
|
import { getBudgetVsActualData } from "../services/budgetService";
|
||||||
import { computeDateRange } from "../utils/dateRange";
|
import { computeDateRange } from "../utils/dateRange";
|
||||||
|
|
||||||
|
export type CategoryTypeFilter = "expense" | "income" | "transfer" | null;
|
||||||
|
|
||||||
interface ReportsState {
|
interface ReportsState {
|
||||||
tab: ReportTab;
|
tab: ReportTab;
|
||||||
period: DashboardPeriod;
|
period: DashboardPeriod;
|
||||||
customDateFrom: string;
|
customDateFrom: string;
|
||||||
customDateTo: string;
|
customDateTo: string;
|
||||||
sourceId: number | null;
|
sourceId: number | null;
|
||||||
|
categoryType: CategoryTypeFilter;
|
||||||
monthlyTrends: MonthlyTrendItem[];
|
monthlyTrends: MonthlyTrendItem[];
|
||||||
categorySpending: CategoryBreakdownItem[];
|
categorySpending: CategoryBreakdownItem[];
|
||||||
categoryOverTime: CategoryOverTimeData;
|
categoryOverTime: CategoryOverTimeData;
|
||||||
|
|
@ -45,7 +48,8 @@ type ReportsAction =
|
||||||
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
|
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
|
||||||
| { type: "SET_PIVOT_RESULT"; payload: PivotResult }
|
| { type: "SET_PIVOT_RESULT"; payload: PivotResult }
|
||||||
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
|
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
|
||||||
| { type: "SET_SOURCE_ID"; payload: number | null };
|
| { type: "SET_SOURCE_ID"; payload: number | null }
|
||||||
|
| { type: "SET_CATEGORY_TYPE"; payload: CategoryTypeFilter };
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
|
@ -57,6 +61,7 @@ const initialState: ReportsState = {
|
||||||
customDateFrom: monthStartStr,
|
customDateFrom: monthStartStr,
|
||||||
customDateTo: todayStr,
|
customDateTo: todayStr,
|
||||||
sourceId: null,
|
sourceId: null,
|
||||||
|
categoryType: null,
|
||||||
monthlyTrends: [],
|
monthlyTrends: [],
|
||||||
categorySpending: [],
|
categorySpending: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||||
|
|
@ -97,6 +102,8 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
||||||
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
||||||
case "SET_SOURCE_ID":
|
case "SET_SOURCE_ID":
|
||||||
return { ...state, sourceId: action.payload };
|
return { ...state, sourceId: action.payload };
|
||||||
|
case "SET_CATEGORY_TYPE":
|
||||||
|
return { ...state, categoryType: action.payload };
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +122,7 @@ export function useReports() {
|
||||||
customTo?: string,
|
customTo?: string,
|
||||||
pivotCfg?: PivotConfig,
|
pivotCfg?: PivotConfig,
|
||||||
srcId?: number | null,
|
srcId?: number | null,
|
||||||
|
catType?: CategoryTypeFilter,
|
||||||
) => {
|
) => {
|
||||||
const fetchId = ++fetchIdRef.current;
|
const fetchId = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
|
@ -138,7 +146,7 @@ export function useReports() {
|
||||||
}
|
}
|
||||||
case "overTime": {
|
case "overTime": {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||||
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined);
|
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined, catType ?? undefined);
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||||
break;
|
break;
|
||||||
|
|
@ -170,8 +178,8 @@ export function useReports() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId);
|
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType);
|
||||||
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, fetchData]);
|
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType, fetchData]);
|
||||||
|
|
||||||
const setTab = useCallback((tab: ReportTab) => {
|
const setTab = useCallback((tab: ReportTab) => {
|
||||||
dispatch({ type: "SET_TAB", payload: tab });
|
dispatch({ type: "SET_TAB", payload: tab });
|
||||||
|
|
@ -197,5 +205,9 @@ export function useReports() {
|
||||||
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId };
|
const setCategoryType = useCallback((catType: CategoryTypeFilter) => {
|
||||||
|
dispatch({ type: "SET_CATEGORY_TYPE", payload: catType });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,8 @@
|
||||||
"title": "Categories",
|
"title": "Categories",
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"none": "None"
|
"none": "None",
|
||||||
|
"allTypes": "All types"
|
||||||
},
|
},
|
||||||
"bva": {
|
"bva": {
|
||||||
"monthly": "Monthly",
|
"monthly": "Monthly",
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,8 @@
|
||||||
"title": "Catégories",
|
"title": "Catégories",
|
||||||
"search": "Rechercher...",
|
"search": "Rechercher...",
|
||||||
"all": "Toutes",
|
"all": "Toutes",
|
||||||
"none": "Aucune"
|
"none": "Aucune",
|
||||||
|
"allTypes": "Tous les types"
|
||||||
},
|
},
|
||||||
"bva": {
|
"bva": {
|
||||||
"monthly": "Mensuel",
|
"monthly": "Mensuel",
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual",
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId } = useReports();
|
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType } = useReports();
|
||||||
const [sources, setSources] = useState<ImportSource[]>([]);
|
const [sources, setSources] = useState<ImportSource[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -69,7 +69,7 @@ export default function ReportsPage() {
|
||||||
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
|
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
|
||||||
|
|
||||||
const hasCategories = ["byCategory", "overTime"].includes(state.tab) && filterCategories.length > 0;
|
const hasCategories = ["byCategory", "overTime"].includes(state.tab) && filterCategories.length > 0;
|
||||||
const showFilterPanel = hasCategories || (state.tab === "trends" && sources.length > 1);
|
const showFilterPanel = hasCategories || state.tab === "overTime" || (state.tab === "trends" && sources.length > 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
|
|
@ -236,6 +236,8 @@ export default function ReportsPage() {
|
||||||
sources={sources}
|
sources={sources}
|
||||||
selectedSourceId={state.sourceId}
|
selectedSourceId={state.sourceId}
|
||||||
onSourceChange={setSourceId}
|
onSourceChange={setSourceId}
|
||||||
|
categoryType={state.tab === "overTime" ? state.categoryType : undefined}
|
||||||
|
onCategoryTypeChange={state.tab === "overTime" ? setCategoryType : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -58,13 +58,20 @@ export async function getCategoryOverTime(
|
||||||
dateTo?: string,
|
dateTo?: string,
|
||||||
topN: number = 50,
|
topN: number = 50,
|
||||||
sourceId?: number,
|
sourceId?: number,
|
||||||
|
typeFilter?: "expense" | "income" | "transfer",
|
||||||
): Promise<CategoryOverTimeData> {
|
): Promise<CategoryOverTimeData> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
const whereClauses: string[] = ["t.amount < 0"];
|
const whereClauses: string[] = [];
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (typeFilter) {
|
||||||
|
whereClauses.push(`COALESCE(c.type, 'expense') = $${paramIndex}`);
|
||||||
|
params.push(typeFilter);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
if (dateFrom) {
|
if (dateFrom) {
|
||||||
whereClauses.push(`t.date >= $${paramIndex}`);
|
whereClauses.push(`t.date >= $${paramIndex}`);
|
||||||
params.push(dateFrom);
|
params.push(dateFrom);
|
||||||
|
|
@ -81,7 +88,7 @@ export async function getCategoryOverTime(
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||||
|
|
||||||
// Get top N categories by total spend
|
// Get top N categories by total spend
|
||||||
const topCategories = await db.select<CategoryBreakdownItem[]>(
|
const topCategories = await db.select<CategoryBreakdownItem[]>(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue