feat: add icon support to category creation and editing, enhance transaction rule creation with new dialog and filters

This commit is contained in:
Julien Froidefond
2025-11-29 17:42:11 +01:00
parent 0ce50d1477
commit 0fb3222ba2
8 changed files with 820 additions and 38 deletions

View File

@@ -29,6 +29,7 @@ export default function CategoriesPage() {
const [formData, setFormData] = useState({
name: "",
color: "#22c55e",
icon: "tag",
keywords: [] as string[],
parentId: null as string | null,
});
@@ -132,7 +133,7 @@ export default function CategoriesPage() {
const handleNewCategory = (parentId: string | null = null) => {
setEditingCategory(null);
setFormData({ name: "", color: "#22c55e", keywords: [], parentId });
setFormData({ name: "", color: "#22c55e", icon: "tag", keywords: [], parentId });
setIsDialogOpen(true);
};
@@ -141,6 +142,7 @@ export default function CategoriesPage() {
setFormData({
name: category.name,
color: category.color,
icon: category.icon,
keywords: [...category.keywords],
parentId: category.parentId,
});
@@ -154,6 +156,7 @@ export default function CategoriesPage() {
...editingCategory,
name: formData.name,
color: formData.color,
icon: formData.icon,
keywords: formData.keywords,
parentId: formData.parentId,
});
@@ -161,8 +164,8 @@ export default function CategoriesPage() {
await addCategory({
name: formData.name,
color: formData.color,
icon: formData.icon,
keywords: formData.keywords,
icon: "tag",
parentId: formData.parentId,
});
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import {
@@ -8,10 +8,17 @@ import {
TransactionBulkActions,
TransactionTable,
} from "@/components/transactions";
import { RuleCreateDialog } from "@/components/rules";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import { useBankingData } from "@/lib/hooks";
import { updateCategory, updateTransaction } from "@/lib/store-db";
import { Button } from "@/components/ui/button";
import { Upload } from "lucide-react";
import type { Transaction } from "@/lib/types";
import {
normalizeDescription,
suggestKeyword,
} from "@/components/rules/constants";
type SortField = "date" | "amount" | "description";
type SortOrder = "asc" | "desc";
@@ -36,6 +43,8 @@ export default function TransactionsPage() {
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
new Set()
);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(null);
const filteredTransactions = useMemo(() => {
if (!data) return [];
@@ -101,6 +110,76 @@ export default function TransactionsPage() {
sortOrder,
]);
const handleCreateRule = useCallback((transaction: Transaction) => {
setRuleTransaction(transaction);
setRuleDialogOpen(true);
}, []);
// Create a virtual group for the rule dialog based on selected transaction
const ruleGroup = useMemo(() => {
if (!ruleTransaction || !data) return null;
// Find similar transactions (same normalized description)
const normalizedDesc = normalizeDescription(ruleTransaction.description);
const similarTransactions = data.transactions.filter(
(t) => normalizeDescription(t.description) === normalizedDesc
);
return {
key: normalizedDesc,
displayName: ruleTransaction.description,
transactions: similarTransactions,
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
suggestedKeyword: suggestKeyword(similarTransactions.map((t) => t.description)),
};
}, [ruleTransaction, data]);
const handleSaveRule = useCallback(
async (ruleData: {
keyword: string;
categoryId: string;
applyToExisting: boolean;
transactionIds: string[];
}) => {
if (!data) return;
// 1. Add keyword to category
const category = data.categories.find((c) => c.id === ruleData.categoryId);
if (!category) {
throw new Error("Category not found");
}
// Check if keyword already exists
const keywordExists = category.keywords.some(
(k) => k.toLowerCase() === ruleData.keyword.toLowerCase()
);
if (!keywordExists) {
await updateCategory({
...category,
keywords: [...category.keywords, ruleData.keyword],
});
}
// 2. Apply to existing transactions if requested
if (ruleData.applyToExisting) {
const transactions = data.transactions.filter((t) =>
ruleData.transactionIds.includes(t.id)
);
await Promise.all(
transactions.map((t) =>
updateTransaction({ ...t, categoryId: ruleData.categoryId })
)
);
}
refresh();
setRuleDialogOpen(false);
},
[data, refresh]
);
if (isLoading || !data) {
return <LoadingState />;
}
@@ -327,9 +406,18 @@ export default function TransactionsPage() {
onToggleReconciled={toggleReconciled}
onMarkReconciled={markReconciled}
onSetCategory={setCategory}
onCreateRule={handleCreateRule}
formatCurrency={formatCurrency}
formatDate={formatDate}
/>
<RuleCreateDialog
open={ruleDialogOpen}
onOpenChange={setRuleDialogOpen}
group={ruleGroup}
categories={data.categories}
onSave={handleSaveRule}
/>
</PageLayout>
);
}