Files
fintrack/components/ui/account-filter-combobox.tsx

339 lines
11 KiB
TypeScript

"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, Transaction } from "@/lib/types";
interface AccountFilterComboboxProps {
accounts: Account[];
folders: Folder[];
value: string[]; // ["all"] | [accountId, accountId, ...]
onChange: (value: string[]) => void;
className?: string;
filteredTransactions?: Transaction[]; // Transactions filtered by other filters (categories, period, etc.)
}
export function AccountFilterCombobox({
accounts,
folders,
value,
onChange,
className,
filteredTransactions,
}: AccountFilterComboboxProps) {
const [open, setOpen] = useState(false);
// Calculate total amount per account based on filtered transactions
const accountTotals = useMemo(() => {
if (!filteredTransactions) return {};
const totals: Record<string, number> = {};
filteredTransactions.forEach((t) => {
totals[t.accountId] = (totals[t.accountId] || 0) + t.amount;
});
return totals;
}, [filteredTransactions]);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
// 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 (
<div key={folder.id}>
{/* Folder row */}
<CommandItem
value={`folder-${currentPath}`}
onSelect={() => handleSelectFolder(folder.id)}
style={{ paddingLeft: `${paddingLeft}px` }}
className="font-medium"
>
<CategoryIcon icon={folder.icon} color={folder.color} size={16} />
<span>{folder.name}</span>
<div className="ml-auto flex items-center">
{isFolderPartiallySelected(folder.id) && (
<div className="h-3 w-3 rounded-sm bg-primary/50 mr-1" />
)}
<Check
className={cn(
"h-4 w-4",
isFolderSelected(folder.id) ? "opacity-100" : "opacity-0"
)}
/>
</div>
</CommandItem>
{/* Accounts in this folder */}
{folderAccounts.map((account) => {
const total = accountTotals[account.id];
return (
<CommandItem
key={account.id}
value={`${currentPath} ${account.name}`}
onSelect={() => handleSelect(account.id)}
style={{ paddingLeft: `${paddingLeft + 16}px` }}
>
<Wallet className="h-4 w-4 text-muted-foreground" />
<span>{account.name}</span>
{total !== undefined && (
<span className="text-xs text-muted-foreground ml-1">
({formatCurrency(total)})
</span>
)}
<Check
className={cn(
"ml-auto h-4 w-4",
value.includes(account.id) ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
);
})}
{/* Child folders - recursive */}
{childFoldersList.map((childFolder) =>
renderFolder(childFolder, depth + 1, currentPath)
)}
</div>
);
};
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("justify-between", className)}
>
<div className="flex items-center gap-2 min-w-0">
{selectedAccounts.length === 1 ? (
<>
<Wallet className="h-4 w-4 text-muted-foreground" />
<span className="truncate">{selectedAccounts[0].name}</span>
</>
) : selectedAccounts.length > 1 ? (
<>
<Wallet className="h-4 w-4 text-muted-foreground" />
<span className="truncate">
{selectedAccounts.length} comptes
</span>
</>
) : (
<>
<Wallet className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Tous les comptes</span>
</>
)}
</div>
<div className="flex items-center gap-1">
{!isAll && (
<div
role="button"
className="h-4 w-4 rounded-sm hover:bg-muted flex items-center justify-center"
onClick={(e) => {
e.stopPropagation();
clearSelection();
}}
>
<X className="h-3 w-3" />
</div>
)}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[300px] p-0"
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command>
<CommandInput placeholder="Rechercher..." />
<CommandList className="max-h-[300px]">
<CommandEmpty>Aucun compte trouvé.</CommandEmpty>
<CommandGroup>
<CommandItem value="all" onSelect={() => handleSelect("all")}>
<Wallet className="h-4 w-4 text-muted-foreground" />
<span>Tous les comptes</span>
{filteredTransactions && (
<span className="text-xs text-muted-foreground ml-1">
({formatCurrency(
filteredTransactions.reduce((sum, t) => sum + t.amount, 0)
)})
</span>
)}
<Check
className={cn(
"ml-auto h-4 w-4",
isAll ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
</CommandGroup>
<CommandGroup>
{rootFolders.map((folder) => renderFolder(folder, 0, ""))}
</CommandGroup>
{orphanAccounts.length > 0 && (
<CommandGroup heading="Sans dossier">
{orphanAccounts.map((account) => {
const total = accountTotals[account.id];
return (
<CommandItem
key={account.id}
value={`sans-dossier ${account.name}`}
onSelect={() => handleSelect(account.id)}
>
<Wallet className="h-4 w-4 text-muted-foreground" />
<span>{account.name}</span>
{total !== undefined && (
<span className="text-xs text-muted-foreground ml-1">
({formatCurrency(total)})
</span>
)}
<Check
className={cn(
"ml-auto h-4 w-4",
value.includes(account.id) ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}