feat: implement optimistic updates for transaction handling and improve category selection in combobox components for enhanced user experience

This commit is contained in:
Julien Froidefond
2025-12-08 07:44:02 +01:00
parent 1263ac9c52
commit 2177ae7b4a
4 changed files with 80 additions and 84 deletions

View File

@@ -341,6 +341,20 @@ export default function TransactionsPage() {
const updatedTransaction = { ...transaction, categoryId }; const updatedTransaction = { ...transaction, categoryId };
// Optimistic update: update the cache immediately
queryClient.setQueryData<typeof transactionsData>(
["transactions", transactionParams],
(oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t,
),
};
},
);
try { try {
await fetch("/api/banking/transactions", { await fetch("/api/banking/transactions", {
method: "PUT", method: "PUT",
@@ -350,6 +364,8 @@ export default function TransactionsPage() {
invalidateTransactions(); invalidateTransactions();
} catch (error) { } catch (error) {
console.error("Failed to update transaction:", error); console.error("Failed to update transaction:", error);
// Revert optimistic update on error
invalidateTransactions();
} }
}; };
@@ -385,8 +401,23 @@ export default function TransactionsPage() {
selectedTransactions.has(t.id), selectedTransactions.has(t.id),
); );
const transactionIds = transactionsToUpdate.map((t) => t.id);
setSelectedTransactions(new Set()); setSelectedTransactions(new Set());
// Optimistic update: update the cache immediately
queryClient.setQueryData<typeof transactionsData>(
["transactions", transactionParams],
(oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
transactionIds.includes(t.id) ? { ...t, categoryId } : t,
),
};
},
);
try { try {
await Promise.all( await Promise.all(
transactionsToUpdate.map((t) => transactionsToUpdate.map((t) =>
@@ -400,6 +431,8 @@ export default function TransactionsPage() {
invalidateTransactions(); invalidateTransactions();
} catch (error) { } catch (error) {
console.error("Failed to update transactions:", error); console.error("Failed to update transactions:", error);
// Revert optimistic update on error
invalidateTransactions();
} }
}; };

View File

@@ -171,7 +171,7 @@ export function AccountFilterCombobox({
<div key={folder.id}> <div key={folder.id}>
{/* Folder row */} {/* Folder row */}
<CommandItem <CommandItem
value={`folder-${currentPath}`} value={`folder-${folder.id}`}
onSelect={() => handleSelectFolder(folder.id)} onSelect={() => handleSelectFolder(folder.id)}
style={{ paddingLeft: `${paddingLeft}px` }} style={{ paddingLeft: `${paddingLeft}px` }}
className="font-medium" className="font-medium"
@@ -182,12 +182,9 @@ export function AccountFilterCombobox({
{isFolderPartiallySelected(folder.id) && ( {isFolderPartiallySelected(folder.id) && (
<div className="h-3 w-3 rounded-sm bg-primary/50 mr-1" /> <div className="h-3 w-3 rounded-sm bg-primary/50 mr-1" />
)} )}
<Check {isFolderSelected(folder.id) && (
className={cn( <Check className="h-4 w-4" />
"h-4 w-4",
isFolderSelected(folder.id) ? "opacity-100" : "opacity-0",
)} )}
/>
</div> </div>
</CommandItem> </CommandItem>
@@ -198,7 +195,7 @@ export function AccountFilterCombobox({
return ( return (
<CommandItem <CommandItem
key={account.id} key={account.id}
value={`${currentPath} ${account.name}`} value={account.id}
onSelect={() => handleSelect(account.id)} onSelect={() => handleSelect(account.id)}
style={{ paddingLeft: `${paddingLeft + 16}px` }} style={{ paddingLeft: `${paddingLeft + 16}px` }}
className="min-w-0" className="min-w-0"
@@ -210,12 +207,9 @@ export function AccountFilterCombobox({
({formatCurrency(total)}) ({formatCurrency(total)})
</span> </span>
)} )}
<Check {value.includes(account.id) && (
className={cn( <Check className="ml-auto h-4 w-4 shrink-0" />
"ml-auto h-4 w-4 shrink-0",
value.includes(account.id) ? "opacity-100" : "opacity-0",
)} )}
/>
</CommandItem> </CommandItem>
); );
})} })}
@@ -292,7 +286,7 @@ export function AccountFilterCombobox({
align="start" align="start"
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<Command> <Command value={isAll ? "all" : value.join(",")}>
<CommandInput placeholder="Rechercher..." /> <CommandInput placeholder="Rechercher..." />
<CommandList className="max-h-[300px]"> <CommandList className="max-h-[300px]">
<CommandEmpty>Aucun compte trouvé.</CommandEmpty> <CommandEmpty>Aucun compte trouvé.</CommandEmpty>
@@ -312,12 +306,9 @@ export function AccountFilterCombobox({
) )
</span> </span>
)} )}
<Check {isAll && (
className={cn( <Check className="ml-auto h-4 w-4" />
"ml-auto h-4 w-4",
isAll ? "opacity-100" : "opacity-0",
)} )}
/>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
@@ -333,7 +324,7 @@ export function AccountFilterCombobox({
return ( return (
<CommandItem <CommandItem
key={account.id} key={account.id}
value={`sans-dossier ${account.name}`} value={account.id}
onSelect={() => handleSelect(account.id)} onSelect={() => handleSelect(account.id)}
className="min-w-0" className="min-w-0"
> >
@@ -346,14 +337,9 @@ export function AccountFilterCombobox({
({formatCurrency(total)}) ({formatCurrency(total)})
</span> </span>
)} )}
<Check {value.includes(account.id) && (
className={cn( <Check className="ml-auto h-4 w-4 shrink-0" />
"ml-auto h-4 w-4 shrink-0",
value.includes(account.id)
? "opacity-100"
: "opacity-0",
)} )}
/>
</CommandItem> </CommandItem>
); );
})} })}

View File

@@ -105,7 +105,7 @@ export function CategoryCombobox({
align={align} align={align}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<Command> <Command value={value || ""}>
<CommandInput placeholder="Rechercher une catégorie..." /> <CommandInput placeholder="Rechercher une catégorie..." />
<CommandList className="max-h-[250px]"> <CommandList className="max-h-[250px]">
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty> <CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
@@ -118,19 +118,16 @@ export function CategoryCombobox({
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Aucune catégorie Aucune catégorie
</span> </span>
<Check {value === null && (
className={cn( <Check className="ml-auto h-4 w-4" />
"ml-auto h-4 w-4",
value === null ? "opacity-100" : "opacity-0",
)} )}
/>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
<CommandGroup> <CommandGroup>
{parentCategories.map((parent) => ( {parentCategories.map((parent) => (
<div key={parent.id}> <div key={parent.id}>
<CommandItem <CommandItem
value={`${parent.name}`} value={parent.id}
onSelect={() => handleSelect(parent.id)} onSelect={() => handleSelect(parent.id)}
> >
<CategoryIcon <CategoryIcon
@@ -139,17 +136,14 @@ export function CategoryCombobox({
size={16} size={16}
/> />
<span className="font-medium">{parent.name}</span> <span className="font-medium">{parent.name}</span>
<Check {value === parent.id && (
className={cn( <Check className="ml-auto h-4 w-4" />
"ml-auto h-4 w-4",
value === parent.id ? "opacity-100" : "opacity-0",
)} )}
/>
</CommandItem> </CommandItem>
{childrenByParent[parent.id]?.map((child) => ( {childrenByParent[parent.id]?.map((child) => (
<CommandItem <CommandItem
key={child.id} key={child.id}
value={`${parent.name} ${child.name}`} value={child.id}
onSelect={() => handleSelect(child.id)} onSelect={() => handleSelect(child.id)}
className="pl-8" className="pl-8"
> >
@@ -159,12 +153,9 @@ export function CategoryCombobox({
size={16} size={16}
/> />
<span>{child.name}</span> <span>{child.name}</span>
<Check {value === child.id && (
className={cn( <Check className="ml-auto h-4 w-4" />
"ml-auto h-4 w-4",
value === child.id ? "opacity-100" : "opacity-0",
)} )}
/>
</CommandItem> </CommandItem>
))} ))}
</div> </div>
@@ -207,7 +198,7 @@ export function CategoryCombobox({
align={align} align={align}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<Command> <Command value={value || ""}>
<CommandInput placeholder="Rechercher une catégorie..." /> <CommandInput placeholder="Rechercher une catégorie..." />
<CommandList className="max-h-[250px]"> <CommandList className="max-h-[250px]">
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty> <CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
@@ -227,7 +218,7 @@ export function CategoryCombobox({
{parentCategories.map((parent) => ( {parentCategories.map((parent) => (
<div key={parent.id}> <div key={parent.id}>
<CommandItem <CommandItem
value={`${parent.name}`} value={parent.id}
onSelect={() => handleSelect(parent.id)} onSelect={() => handleSelect(parent.id)}
> >
<CategoryIcon <CategoryIcon
@@ -246,7 +237,7 @@ export function CategoryCombobox({
{childrenByParent[parent.id]?.map((child) => ( {childrenByParent[parent.id]?.map((child) => (
<CommandItem <CommandItem
key={child.id} key={child.id}
value={`${parent.name} ${child.name}`} value={child.id}
onSelect={() => handleSelect(child.id)} onSelect={() => handleSelect(child.id)}
className="pl-8" className="pl-8"
> >

View File

@@ -193,7 +193,7 @@ export function CategoryFilterCombobox({
align="start" align="start"
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<Command> <Command value={isAll ? "all" : isUncategorized ? "uncategorized" : value.join(",")}>
<CommandInput placeholder="Rechercher..." /> <CommandInput placeholder="Rechercher..." />
<CommandList className="max-h-[300px]"> <CommandList className="max-h-[300px]">
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty> <CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
@@ -212,15 +212,12 @@ export function CategoryFilterCombobox({
({filteredTransactions.length}) ({filteredTransactions.length})
</span> </span>
)} )}
<Check {isAll && (
className={cn( <Check className="ml-auto h-4 w-4 shrink-0" />
"ml-auto h-4 w-4 shrink-0",
isAll ? "opacity-100" : "opacity-0",
)} )}
/>
</CommandItem> </CommandItem>
<CommandItem <CommandItem
value="uncategorized non catégorisé" value="uncategorized"
onSelect={() => handleSelect("uncategorized")} onSelect={() => handleSelect("uncategorized")}
className="min-w-0" className="min-w-0"
> >
@@ -231,19 +228,16 @@ export function CategoryFilterCombobox({
({categoryCounts["uncategorized"]}) ({categoryCounts["uncategorized"]})
</span> </span>
)} )}
<Check {isUncategorized && (
className={cn( <Check className="ml-auto h-4 w-4 shrink-0" />
"ml-auto h-4 w-4 shrink-0",
isUncategorized ? "opacity-100" : "opacity-0",
)} )}
/>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
<CommandGroup heading="Catégories"> <CommandGroup heading="Catégories">
{parentCategories.map((parent) => ( {parentCategories.map((parent) => (
<div key={parent.id}> <div key={parent.id}>
<CommandItem <CommandItem
value={`${parent.name}`} value={parent.id}
onSelect={() => handleSelect(parent.id)} onSelect={() => handleSelect(parent.id)}
className="min-w-0" className="min-w-0"
> >
@@ -261,17 +255,14 @@ export function CategoryFilterCombobox({
({categoryCounts[parent.id]}) ({categoryCounts[parent.id]})
</span> </span>
)} )}
<Check {value.includes(parent.id) && (
className={cn( <Check className="ml-auto h-4 w-4 shrink-0" />
"ml-auto h-4 w-4 shrink-0",
value.includes(parent.id) ? "opacity-100" : "opacity-0",
)} )}
/>
</CommandItem> </CommandItem>
{childrenByParent[parent.id]?.map((child) => ( {childrenByParent[parent.id]?.map((child) => (
<CommandItem <CommandItem
key={child.id} key={child.id}
value={`${parent.name} ${child.name}`} value={child.id}
onSelect={() => handleSelect(child.id)} onSelect={() => handleSelect(child.id)}
className="pl-8 min-w-0" className="pl-8 min-w-0"
> >
@@ -289,14 +280,9 @@ export function CategoryFilterCombobox({
({categoryCounts[child.id]}) ({categoryCounts[child.id]})
</span> </span>
)} )}
<Check {value.includes(child.id) && (
className={cn( <Check className="ml-auto h-4 w-4 shrink-0" />
"ml-auto h-4 w-4 shrink-0",
value.includes(child.id)
? "opacity-100"
: "opacity-0",
)} )}
/>
</CommandItem> </CommandItem>
))} ))}
</div> </div>