feat: implement optimistic updates for transaction handling and improve category selection in combobox components for enhanced user experience
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user