diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx index 527a00f..deaae40 100644 --- a/app/transactions/page.tsx +++ b/app/transactions/page.tsx @@ -27,12 +27,12 @@ export default function TransactionsPage() { const searchParams = useSearchParams(); const { data, isLoading, refresh, update } = useBankingData(); const [searchQuery, setSearchQuery] = useState(""); - const [selectedAccount, setSelectedAccount] = useState("all"); + const [selectedAccounts, setSelectedAccounts] = useState(["all"]); useEffect(() => { const accountId = searchParams.get("accountId"); if (accountId) { - setSelectedAccount(accountId); + setSelectedAccounts([accountId]); } }, [searchParams]); @@ -60,9 +60,9 @@ export default function TransactionsPage() { ); } - if (selectedAccount !== "all") { + if (!selectedAccounts.includes("all")) { transactions = transactions.filter( - (t) => t.accountId === selectedAccount + (t) => selectedAccounts.includes(t.accountId) ); } @@ -103,7 +103,7 @@ export default function TransactionsPage() { }, [ data, searchQuery, - selectedAccount, + selectedAccounts, selectedCategories, showReconciled, sortField, @@ -376,13 +376,14 @@ export default function TransactionsPage() { diff --git a/components/transactions/transaction-filters.tsx b/components/transactions/transaction-filters.tsx index 883a501..9744760 100644 --- a/components/transactions/transaction-filters.tsx +++ b/components/transactions/transaction-filters.tsx @@ -11,33 +11,36 @@ import { SelectValue, } from "@/components/ui/select"; import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox"; +import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox"; import { CategoryIcon } from "@/components/ui/category-icon"; -import { Search, X, Filter } from "lucide-react"; -import type { Account, Category } from "@/lib/types"; +import { Search, X, Filter, Wallet } from "lucide-react"; +import type { Account, Category, Folder } from "@/lib/types"; interface TransactionFiltersProps { searchQuery: string; onSearchChange: (query: string) => void; - selectedAccount: string; - onAccountChange: (account: string) => void; + selectedAccounts: string[]; + onAccountsChange: (accounts: string[]) => void; selectedCategories: string[]; onCategoriesChange: (categories: string[]) => void; showReconciled: string; onReconciledChange: (value: string) => void; accounts: Account[]; + folders: Folder[]; categories: Category[]; } export function TransactionFilters({ searchQuery, onSearchChange, - selectedAccount, - onAccountChange, + selectedAccounts, + onAccountsChange, selectedCategories, onCategoriesChange, showReconciled, onReconciledChange, accounts, + folders, categories, }: TransactionFiltersProps) { return ( @@ -56,19 +59,13 @@ export function TransactionFilters({ - + onSearchChange("")} - selectedAccount={selectedAccount} - onClearAccount={() => onAccountChange("all")} + selectedAccounts={selectedAccounts} + onRemoveAccount={(id) => { + const newAccounts = selectedAccounts.filter((a) => a !== id); + onAccountsChange(newAccounts.length > 0 ? newAccounts : ["all"]); + }} + onClearAccounts={() => onAccountsChange(["all"])} selectedCategories={selectedCategories} onRemoveCategory={(id) => { const newCategories = selectedCategories.filter((c) => c !== id); @@ -113,8 +114,9 @@ export function TransactionFilters({ function ActiveFilters({ searchQuery, onClearSearch, - selectedAccount, - onClearAccount, + selectedAccounts, + onRemoveAccount, + onClearAccounts, selectedCategories, onRemoveCategory, onClearCategories, @@ -125,8 +127,9 @@ function ActiveFilters({ }: { searchQuery: string; onClearSearch: () => void; - selectedAccount: string; - onClearAccount: () => void; + selectedAccounts: string[]; + onRemoveAccount: (id: string) => void; + onClearAccounts: () => void; selectedCategories: string[]; onRemoveCategory: (id: string) => void; onClearCategories: () => void; @@ -136,21 +139,21 @@ function ActiveFilters({ categories: Category[]; }) { const hasSearch = searchQuery.trim() !== ""; - const hasAccount = selectedAccount !== "all"; + const hasAccounts = !selectedAccounts.includes("all"); const hasCategories = !selectedCategories.includes("all"); const hasReconciled = showReconciled !== "all"; - const hasActiveFilters = hasSearch || hasAccount || hasCategories || hasReconciled; + const hasActiveFilters = hasSearch || hasAccounts || hasCategories || hasReconciled; if (!hasActiveFilters) return null; - const account = accounts.find((a) => a.id === selectedAccount); + const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id)); const selectedCats = categories.filter((c) => selectedCategories.includes(c.id)); const isUncategorized = selectedCategories.includes("uncategorized"); const clearAll = () => { onClearSearch(); - onClearAccount(); + onClearAccounts(); onClearCategories(); onClearReconciled(); }; @@ -168,14 +171,18 @@ function ActiveFilters({ )} - {hasAccount && account && ( - - Compte: {account.name} - - )} + ))} {isUncategorized && ( diff --git a/components/ui/account-filter-combobox.tsx b/components/ui/account-filter-combobox.tsx new file mode 100644 index 0000000..9b4dfe8 --- /dev/null +++ b/components/ui/account-filter-combobox.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { CategoryIcon } from "@/components/ui/category-icon"; +import { ChevronsUpDown, Check, Wallet, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { Account, Folder } from "@/lib/types"; + +interface AccountFilterComboboxProps { + accounts: Account[]; + folders: Folder[]; + value: string[]; // ["all"] | [accountId, accountId, ...] + onChange: (value: string[]) => void; + className?: string; +} + +export function AccountFilterCombobox({ + accounts, + folders, + value, + onChange, + className, +}: AccountFilterComboboxProps) { + const [open, setOpen] = useState(false); + + // Get root folders (folders without parent) - same as folders/page.tsx + const rootFolders = useMemo( + () => folders.filter((f) => f.parentId === null), + [folders] + ); + + // Get child folders for a given parent - same as FolderTreeItem + const getChildFolders = (parentId: string) => + folders.filter((f) => f.parentId === parentId); + + // Get accounts in a specific folder - same as FolderTreeItem + const getFolderAccounts = (folderId: string) => + accounts.filter((a) => a.folderId === folderId); + + // Get accounts without folder + const orphanAccounts = useMemo( + () => accounts.filter((a) => !a.folderId), + [accounts] + ); + + const selectedAccounts = accounts.filter((a) => value.includes(a.id)); + const isAll = value.includes("all") || value.length === 0; + + // Get all accounts in a folder and its descendants recursively + const getAllAccountsInFolder = (folderId: string): Account[] => { + const directAccounts = getFolderAccounts(folderId); + const childFoldersList = getChildFolders(folderId); + const childAccounts = childFoldersList.flatMap((cf) => + getAllAccountsInFolder(cf.id) + ); + return [...directAccounts, ...childAccounts]; + }; + + const handleSelect = (newValue: string) => { + if (newValue === "all") { + onChange(["all"]); + return; + } + + let newSelection: string[]; + + if (isAll) { + newSelection = [newValue]; + } else if (value.includes(newValue)) { + newSelection = value.filter((v) => v !== newValue); + if (newSelection.length === 0) { + newSelection = ["all"]; + } + } else { + newSelection = [...value, newValue]; + } + + onChange(newSelection); + }; + + const handleSelectFolder = (folderId: string) => { + const allFolderAccounts = getAllAccountsInFolder(folderId); + const allFolderAccountIds = allFolderAccounts.map((a) => a.id); + + if (allFolderAccountIds.length === 0) return; + + const allSelected = allFolderAccountIds.every((id) => value.includes(id)); + + if (allSelected) { + const newSelection = value.filter( + (v) => !allFolderAccountIds.includes(v) + ); + onChange(newSelection.length > 0 ? newSelection : ["all"]); + } else { + if (isAll) { + onChange(allFolderAccountIds); + } else { + const newSelection = [...new Set([...value, ...allFolderAccountIds])]; + onChange(newSelection); + } + } + }; + + const clearSelection = () => { + onChange(["all"]); + }; + + const isFolderSelected = (folderId: string) => { + const folderAccounts = getAllAccountsInFolder(folderId); + if (folderAccounts.length === 0) return false; + return folderAccounts.every((a) => value.includes(a.id)); + }; + + const isFolderPartiallySelected = (folderId: string) => { + const folderAccounts = getAllAccountsInFolder(folderId); + if (folderAccounts.length === 0) return false; + const selectedCount = folderAccounts.filter((a) => + value.includes(a.id) + ).length; + return selectedCount > 0 && selectedCount < folderAccounts.length; + }; + + // Recursive render function - mirrors FolderTreeItem logic + const renderFolder = (folder: Folder, depth: number, parentPath: string) => { + const folderAccounts = getFolderAccounts(folder.id); + const childFoldersList = getChildFolders(folder.id); + const currentPath = parentPath ? `${parentPath} ${folder.name}` : folder.name; + const paddingLeft = depth * 16 + 8; + + return ( +
+ {/* Folder row */} + handleSelectFolder(folder.id)} + style={{ paddingLeft: `${paddingLeft}px` }} + className="font-medium" + > + + {folder.name} +
+ {isFolderPartiallySelected(folder.id) && ( +
+ )} + +
+ + + {/* Accounts in this folder */} + {folderAccounts.map((account) => ( + handleSelect(account.id)} + style={{ paddingLeft: `${paddingLeft + 16}px` }} + > + + {account.name} + + + ))} + + {/* Child folders - recursive */} + {childFoldersList.map((childFolder) => + renderFolder(childFolder, depth + 1, currentPath) + )} +
+ ); + }; + + return ( + + + + + e.preventDefault()} + > + + + + Aucun compte trouvé. + + handleSelect("all")}> + + Tous les comptes + + + + + + {rootFolders.map((folder) => renderFolder(folder, 0, ""))} + + + {orphanAccounts.length > 0 && ( + + {orphanAccounts.map((account) => ( + handleSelect(account.id)} + > + + {account.name} + + + ))} + + )} + + + + + ); +}