feat(reports/category): replace select with searchable combobox (#103)
Swap the native <select> in CategoryZoomHeader for the reusable CategoryCombobox. Enhances the combobox with ARIA compliance (combobox, listbox, option roles + aria-expanded, aria-controls, aria-activedescendant) and hierarchy indentation based on parent_id depth. Adds reports.category.searchPlaceholder in FR/EN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
49a4ef2171
commit
01869462f4
6 changed files with 85 additions and 30 deletions
|
|
@ -5,6 +5,9 @@
|
||||||
### Ajouté
|
### Ajouté
|
||||||
- **Rapport Cartes** : info-bulle d'aide sur le KPI taux d'épargne expliquant la formule — `(revenus − dépenses) ÷ revenus × 100`, calculée sur le mois de référence (#101)
|
- **Rapport Cartes** : info-bulle d'aide sur le KPI taux d'épargne expliquant la formule — `(revenus − dépenses) ÷ revenus × 100`, calculée sur le mois de référence (#101)
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- **Rapport Zoom catégorie** (`/reports/category`) : le sélecteur de catégorie est désormais un combobox saisissable et filtrable avec recherche insensible aux accents, navigation clavier (↑/↓/Entrée/Échap) et indentation hiérarchique, en remplacement du `<select>` natif (#103)
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
- **Rapport Cartes** : retrait du sélecteur de période non fonctionnel — le rapport Cartes est un instantané « mois X vs X-1 vs X-12 », seul le sélecteur de mois de référence est nécessaire (#101)
|
- **Rapport Cartes** : retrait du sélecteur de période non fonctionnel — le rapport Cartes est un instantané « mois X vs X-1 vs X-12 », seul le sélecteur de mois de référence est nécessaire (#101)
|
||||||
- **Rapport Cartes** : le KPI taux d'épargne affiche maintenant « — » au lieu de « 0 % » lorsque le mois de référence n'a aucun revenu (une division par zéro est indéfinie, pas zéro) (#101)
|
- **Rapport Cartes** : le KPI taux d'épargne affiche maintenant « — » au lieu de « 0 % » lorsque le mois de référence n'a aucun revenu (une division par zéro est indéfinie, pas zéro) (#101)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
### Added
|
### Added
|
||||||
- **Cartes report**: help tooltip on the savings-rate KPI explaining the formula — `(income − expenses) ÷ income × 100`, computed on the reference month (#101)
|
- **Cartes report**: help tooltip on the savings-rate KPI explaining the formula — `(income − expenses) ÷ income × 100`, computed on the reference month (#101)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Category zoom report** (`/reports/category`): the category picker is now a typeable, searchable combobox with accent-insensitive matching, keyboard navigation (↑/↓/Enter/Esc) and hierarchy indentation, replacing the native `<select>` (#103)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Cartes report**: removed the non-functional period selector — the Cartes report is a "month X vs X-1 vs X-12" snapshot, so only the reference-month picker is needed (#101)
|
- **Cartes report**: removed the non-functional period selector — the Cartes report is a "month X vs X-1 vs X-12" snapshot, so only the reference-month picker is needed (#101)
|
||||||
- **Cartes report**: savings-rate KPI now shows "—" instead of "0 %" when the reference month has no income (division by zero is undefined, not zero) (#101)
|
- **Cartes report**: savings-rate KPI now shows "—" instead of "0 %" when the reference month has no income (division by zero is undefined, not zero) (#101)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getAllCategoriesWithCounts } from "../../services/categoryService";
|
import { getAllCategoriesWithCounts } from "../../services/categoryService";
|
||||||
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||||
interface CategoryOption {
|
import type { Category } from "../../shared/types";
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
color: string | null;
|
|
||||||
parent_id: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CategoryZoomHeaderProps {
|
export interface CategoryZoomHeaderProps {
|
||||||
categoryId: number | null;
|
categoryId: number | null;
|
||||||
|
|
@ -23,13 +18,24 @@ export default function CategoryZoomHeader({
|
||||||
onIncludeSubcategoriesChange,
|
onIncludeSubcategoriesChange,
|
||||||
}: CategoryZoomHeaderProps) {
|
}: CategoryZoomHeaderProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [categories, setCategories] = useState<CategoryOption[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getAllCategoriesWithCounts()
|
getAllCategoriesWithCounts()
|
||||||
.then((rows) =>
|
.then((rows) =>
|
||||||
setCategories(
|
setCategories(
|
||||||
rows.map((r) => ({ id: r.id, name: r.name, color: r.color, parent_id: r.parent_id })),
|
rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
parent_id: r.parent_id ?? undefined,
|
||||||
|
color: r.color ?? undefined,
|
||||||
|
icon: r.icon ?? undefined,
|
||||||
|
type: r.type,
|
||||||
|
is_active: r.is_active,
|
||||||
|
is_inputable: r.is_inputable,
|
||||||
|
sort_order: r.sort_order,
|
||||||
|
created_at: "",
|
||||||
|
})),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.catch(() => setCategories([]));
|
.catch(() => setCategories([]));
|
||||||
|
|
@ -41,18 +47,13 @@ export default function CategoryZoomHeader({
|
||||||
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
|
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
|
||||||
{t("reports.category.selectCategory")}
|
{t("reports.category.selectCategory")}
|
||||||
</span>
|
</span>
|
||||||
<select
|
<CategoryCombobox
|
||||||
value={categoryId ?? ""}
|
categories={categories}
|
||||||
onChange={(e) => onCategoryChange(e.target.value ? Number(e.target.value) : null)}
|
value={categoryId}
|
||||||
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
|
onChange={onCategoryChange}
|
||||||
>
|
placeholder={t("reports.category.searchPlaceholder")}
|
||||||
<option value="">—</option>
|
ariaLabel={t("reports.category.selectCategory")}
|
||||||
{categories.map((cat) => (
|
/>
|
||||||
<option key={cat.id} value={cat.id}>
|
|
||||||
{cat.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="inline-flex items-center gap-2 text-sm">
|
<label className="inline-flex items-center gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react";
|
||||||
import type { Category } from "../../shared/types";
|
import type { Category } from "../../shared/types";
|
||||||
|
|
||||||
interface CategoryComboboxProps {
|
interface CategoryComboboxProps {
|
||||||
|
|
@ -7,6 +7,7 @@ interface CategoryComboboxProps {
|
||||||
onChange: (id: number | null) => void;
|
onChange: (id: number | null) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
/** Extra options shown before the category list (e.g. "All categories", "Uncategorized") */
|
/** Extra options shown before the category list (e.g. "All categories", "Uncategorized") */
|
||||||
extraOptions?: Array<{ value: string; label: string }>;
|
extraOptions?: Array<{ value: string; label: string }>;
|
||||||
/** Called when an extra option is selected */
|
/** Called when an extra option is selected */
|
||||||
|
|
@ -15,12 +16,40 @@ interface CategoryComboboxProps {
|
||||||
activeExtra?: string | null;
|
activeExtra?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip accents + lowercase for accent-insensitive matching
|
||||||
|
function normalize(s: string): string {
|
||||||
|
return s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute depth of each category based on parent_id chain
|
||||||
|
function computeDepths(categories: Category[]): Map<number, number> {
|
||||||
|
const byId = new Map<number, Category>();
|
||||||
|
for (const c of categories) byId.set(c.id, c);
|
||||||
|
const depths = new Map<number, number>();
|
||||||
|
function depthOf(id: number, seen: Set<number>): number {
|
||||||
|
if (depths.has(id)) return depths.get(id)!;
|
||||||
|
if (seen.has(id)) return 0;
|
||||||
|
seen.add(id);
|
||||||
|
const cat = byId.get(id);
|
||||||
|
if (!cat || cat.parent_id == null) {
|
||||||
|
depths.set(id, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const d = depthOf(cat.parent_id, seen) + 1;
|
||||||
|
depths.set(id, d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
for (const c of categories) depthOf(c.id, new Set());
|
||||||
|
return depths;
|
||||||
|
}
|
||||||
|
|
||||||
export default function CategoryCombobox({
|
export default function CategoryCombobox({
|
||||||
categories,
|
categories,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = "",
|
placeholder = "",
|
||||||
compact = false,
|
compact = false,
|
||||||
|
ariaLabel,
|
||||||
extraOptions,
|
extraOptions,
|
||||||
onExtraSelect,
|
onExtraSelect,
|
||||||
activeExtra,
|
activeExtra,
|
||||||
|
|
@ -31,19 +60,18 @@ export default function CategoryCombobox({
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const listRef = useRef<HTMLUListElement>(null);
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const baseId = useId();
|
||||||
|
const listboxId = `${baseId}-listbox`;
|
||||||
|
const optionId = (i: number) => `${baseId}-option-${i}`;
|
||||||
|
|
||||||
|
const depths = useMemo(() => computeDepths(categories), [categories]);
|
||||||
|
|
||||||
// Build display label
|
|
||||||
const selectedCategory = categories.find((c) => c.id === value);
|
const selectedCategory = categories.find((c) => c.id === value);
|
||||||
const displayLabel =
|
const displayLabel =
|
||||||
activeExtra != null
|
activeExtra != null
|
||||||
? extraOptions?.find((o) => o.value === activeExtra)?.label ?? ""
|
? extraOptions?.find((o) => o.value === activeExtra)?.label ?? ""
|
||||||
: selectedCategory?.name ?? "";
|
: selectedCategory?.name ?? "";
|
||||||
|
|
||||||
// Strip accents + lowercase for accent-insensitive matching
|
|
||||||
const normalize = (s: string) =>
|
|
||||||
s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
|
||||||
|
|
||||||
// Filter categories
|
|
||||||
const normalizedQuery = normalize(query);
|
const normalizedQuery = normalize(query);
|
||||||
const filtered = query
|
const filtered = query
|
||||||
? categories.filter((c) => normalize(c.name).includes(normalizedQuery))
|
? categories.filter((c) => normalize(c.name).includes(normalizedQuery))
|
||||||
|
|
@ -57,7 +85,6 @@ export default function CategoryCombobox({
|
||||||
|
|
||||||
const totalItems = filteredExtras.length + filtered.length;
|
const totalItems = filteredExtras.length + filtered.length;
|
||||||
|
|
||||||
// Scroll highlighted item into view
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && listRef.current) {
|
if (open && listRef.current) {
|
||||||
const el = listRef.current.children[highlightIndex] as HTMLElement | undefined;
|
const el = listRef.current.children[highlightIndex] as HTMLElement | undefined;
|
||||||
|
|
@ -65,7 +92,6 @@ export default function CategoryCombobox({
|
||||||
}
|
}
|
||||||
}, [highlightIndex, open]);
|
}, [highlightIndex, open]);
|
||||||
|
|
||||||
// Close on outside click
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
|
|
@ -128,11 +154,19 @@ export default function CategoryCombobox({
|
||||||
const py = compact ? "py-1" : "py-2";
|
const py = compact ? "py-1" : "py-2";
|
||||||
const px = compact ? "px-2" : "px-3";
|
const px = compact ? "px-2" : "px-3";
|
||||||
|
|
||||||
|
const activeId = open && totalItems > 0 ? optionId(highlightIndex) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} className="relative">
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={listboxId}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-activedescendant={activeId}
|
||||||
value={open ? query : displayLabel}
|
value={open ? query : displayLabel}
|
||||||
placeholder={placeholder || displayLabel}
|
placeholder={placeholder || displayLabel}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
@ -151,11 +185,16 @@ export default function CategoryCombobox({
|
||||||
{open && totalItems > 0 && (
|
{open && totalItems > 0 && (
|
||||||
<ul
|
<ul
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
|
id={listboxId}
|
||||||
|
role="listbox"
|
||||||
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg"
|
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg"
|
||||||
>
|
>
|
||||||
{filteredExtras.map((opt, i) => (
|
{filteredExtras.map((opt, i) => (
|
||||||
<li
|
<li
|
||||||
key={`extra-${opt.value}`}
|
key={`extra-${opt.value}`}
|
||||||
|
id={optionId(i)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === highlightIndex}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => selectItem(i)}
|
onClick={() => selectItem(i)}
|
||||||
onMouseEnter={() => setHighlightIndex(i)}
|
onMouseEnter={() => setHighlightIndex(i)}
|
||||||
|
|
@ -170,9 +209,14 @@ export default function CategoryCombobox({
|
||||||
))}
|
))}
|
||||||
{filtered.map((cat, i) => {
|
{filtered.map((cat, i) => {
|
||||||
const idx = filteredExtras.length + i;
|
const idx = filteredExtras.length + i;
|
||||||
|
const depth = depths.get(cat.id) ?? 0;
|
||||||
|
const indent = depth > 0 ? " ".repeat(depth) : "";
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
|
id={optionId(idx)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={idx === highlightIndex}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => selectItem(idx)}
|
onClick={() => selectItem(idx)}
|
||||||
onMouseEnter={() => setHighlightIndex(idx)}
|
onMouseEnter={() => setHighlightIndex(idx)}
|
||||||
|
|
@ -181,7 +225,9 @@ export default function CategoryCombobox({
|
||||||
? "bg-[var(--primary)] text-white"
|
? "bg-[var(--primary)] text-white"
|
||||||
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
|
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
}`}
|
}`}
|
||||||
|
style={depth > 0 ? { paddingLeft: `calc(${compact ? "0.5rem" : "0.75rem"} + ${depth * 1}rem)` } : undefined}
|
||||||
>
|
>
|
||||||
|
<span className="whitespace-pre">{indent}</span>
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -435,6 +435,7 @@
|
||||||
},
|
},
|
||||||
"category": {
|
"category": {
|
||||||
"selectCategory": "Select a category",
|
"selectCategory": "Select a category",
|
||||||
|
"searchPlaceholder": "Search a category…",
|
||||||
"includeSubcategories": "Include subcategories",
|
"includeSubcategories": "Include subcategories",
|
||||||
"directOnly": "Direct only",
|
"directOnly": "Direct only",
|
||||||
"breakdown": "Total",
|
"breakdown": "Total",
|
||||||
|
|
|
||||||
|
|
@ -435,6 +435,7 @@
|
||||||
},
|
},
|
||||||
"category": {
|
"category": {
|
||||||
"selectCategory": "Choisir une catégorie",
|
"selectCategory": "Choisir une catégorie",
|
||||||
|
"searchPlaceholder": "Rechercher une catégorie…",
|
||||||
"includeSubcategories": "Inclure les sous-catégories",
|
"includeSubcategories": "Inclure les sous-catégories",
|
||||||
"directOnly": "Directe seulement",
|
"directOnly": "Directe seulement",
|
||||||
"breakdown": "Total",
|
"breakdown": "Total",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue