Remove hard-coded expense filter from Category Over Time report
The Category Over Time report previously only showed expenses (t.amount < 0). This removes that filter so all transaction types are shown by default, and adds a type filter (expense/income/transfer) in the right filter panel. Ref: simpl-resultat#41 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
99fdf4f9ea
commit
56b46f1dfa
9 changed files with 70 additions and 12 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [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]
|
||||
|
||||
### Modifié
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [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]
|
||||
|
||||
### 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)
|
||||
- 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
|
||||
- Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable
|
||||
- Motifs SVG (lignes, points, hachures) pour distinguer les catégories
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Filter, Search } from "lucide-react";
|
||||
import type { ImportSource } from "../../shared/types";
|
||||
import type { CategoryTypeFilter } from "../../hooks/useReports";
|
||||
|
||||
interface ReportFilterPanelProps {
|
||||
categories: { name: string; color: string }[];
|
||||
|
|
@ -11,6 +12,8 @@ interface ReportFilterPanelProps {
|
|||
sources: ImportSource[];
|
||||
selectedSourceId: number | null;
|
||||
onSourceChange: (id: number | null) => void;
|
||||
categoryType?: CategoryTypeFilter;
|
||||
onCategoryTypeChange?: (type: CategoryTypeFilter) => void;
|
||||
}
|
||||
|
||||
export default function ReportFilterPanel({
|
||||
|
|
@ -21,6 +24,8 @@ export default function ReportFilterPanel({
|
|||
sources,
|
||||
selectedSourceId,
|
||||
onSourceChange,
|
||||
categoryType,
|
||||
onCategoryTypeChange,
|
||||
}: ReportFilterPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
|
|
@ -57,6 +62,28 @@ export default function ReportFilterPanel({
|
|||
</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 */}
|
||||
{categories.length > 0 && <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -14,12 +14,15 @@ import { getExpensesByCategory } from "../services/dashboardService";
|
|||
import { getBudgetVsActualData } from "../services/budgetService";
|
||||
import { computeDateRange } from "../utils/dateRange";
|
||||
|
||||
export type CategoryTypeFilter = "expense" | "income" | "transfer" | null;
|
||||
|
||||
interface ReportsState {
|
||||
tab: ReportTab;
|
||||
period: DashboardPeriod;
|
||||
customDateFrom: string;
|
||||
customDateTo: string;
|
||||
sourceId: number | null;
|
||||
categoryType: CategoryTypeFilter;
|
||||
monthlyTrends: MonthlyTrendItem[];
|
||||
categorySpending: CategoryBreakdownItem[];
|
||||
categoryOverTime: CategoryOverTimeData;
|
||||
|
|
@ -45,7 +48,8 @@ type ReportsAction =
|
|||
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
|
||||
| { type: "SET_PIVOT_RESULT"; payload: PivotResult }
|
||||
| { 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 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,
|
||||
customDateTo: todayStr,
|
||||
sourceId: null,
|
||||
categoryType: null,
|
||||
monthlyTrends: [],
|
||||
categorySpending: [],
|
||||
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 };
|
||||
case "SET_SOURCE_ID":
|
||||
return { ...state, sourceId: action.payload };
|
||||
case "SET_CATEGORY_TYPE":
|
||||
return { ...state, categoryType: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
@ -115,6 +122,7 @@ export function useReports() {
|
|||
customTo?: string,
|
||||
pivotCfg?: PivotConfig,
|
||||
srcId?: number | null,
|
||||
catType?: CategoryTypeFilter,
|
||||
) => {
|
||||
const fetchId = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
|
|
@ -138,7 +146,7 @@ export function useReports() {
|
|||
}
|
||||
case "overTime": {
|
||||
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;
|
||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||
break;
|
||||
|
|
@ -170,8 +178,8 @@ export function useReports() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId);
|
||||
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, fetchData]);
|
||||
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, state.categoryType, fetchData]);
|
||||
|
||||
const setTab = useCallback((tab: ReportTab) => {
|
||||
dispatch({ type: "SET_TAB", payload: tab });
|
||||
|
|
@ -197,5 +205,9 @@ export function useReports() {
|
|||
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",
|
||||
"search": "Search...",
|
||||
"all": "All",
|
||||
"none": "None"
|
||||
"none": "None",
|
||||
"allTypes": "All types"
|
||||
},
|
||||
"bva": {
|
||||
"monthly": "Monthly",
|
||||
|
|
|
|||
|
|
@ -370,7 +370,8 @@
|
|||
"title": "Catégories",
|
||||
"search": "Rechercher...",
|
||||
"all": "Toutes",
|
||||
"none": "Aucune"
|
||||
"none": "Aucune",
|
||||
"allTypes": "Tous les types"
|
||||
},
|
||||
"bva": {
|
||||
"monthly": "Mensuel",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual",
|
|||
|
||||
export default function ReportsPage() {
|
||||
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[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -69,7 +69,7 @@ export default function ReportsPage() {
|
|||
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
|
||||
|
||||
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 (
|
||||
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||
|
|
@ -236,6 +236,8 @@ export default function ReportsPage() {
|
|||
sources={sources}
|
||||
selectedSourceId={state.sourceId}
|
||||
onSourceChange={setSourceId}
|
||||
categoryType={state.tab === "overTime" ? state.categoryType : undefined}
|
||||
onCategoryTypeChange={state.tab === "overTime" ? setCategoryType : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -58,13 +58,20 @@ export async function getCategoryOverTime(
|
|||
dateTo?: string,
|
||||
topN: number = 50,
|
||||
sourceId?: number,
|
||||
typeFilter?: "expense" | "income" | "transfer",
|
||||
): Promise<CategoryOverTimeData> {
|
||||
const db = await getDb();
|
||||
|
||||
const whereClauses: string[] = ["t.amount < 0"];
|
||||
const whereClauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (typeFilter) {
|
||||
whereClauses.push(`COALESCE(c.type, 'expense') = $${paramIndex}`);
|
||||
params.push(typeFilter);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
whereClauses.push(`t.date >= $${paramIndex}`);
|
||||
params.push(dateFrom);
|
||||
|
|
@ -81,7 +88,7 @@ export async function getCategoryOverTime(
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||
|
||||
// Get top N categories by total spend
|
||||
const topCategories = await db.select<CategoryBreakdownItem[]>(
|
||||
|
|
|
|||
Loading…
Reference in a new issue