feat: add auto-categorize button and fix keyword word-boundary matching
Replace substring matching (.includes) with \b word-boundary regex so keywords like "Pay" no longer match "Payment". Add an auto-categorize button on the transactions page that re-runs keyword matching on uncategorized transactions and displays the result count. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
84eca47afd
commit
ca531262f7
6 changed files with 98 additions and 7 deletions
|
|
@ -13,6 +13,7 @@ import {
|
|||
updateTransactionNotes,
|
||||
getAllCategories,
|
||||
getAllImportSources,
|
||||
autoCategorizeTransactions,
|
||||
} from "../services/transactionService";
|
||||
|
||||
interface TransactionsState {
|
||||
|
|
@ -28,6 +29,7 @@ interface TransactionsState {
|
|||
categories: Category[];
|
||||
sources: ImportSource[];
|
||||
isLoading: boolean;
|
||||
isAutoCategorizing: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +43,8 @@ type TransactionsAction =
|
|||
| { type: "SET_CATEGORIES"; payload: Category[] }
|
||||
| { type: "SET_SOURCES"; payload: ImportSource[] }
|
||||
| { type: "UPDATE_ROW_CATEGORY"; payload: { txId: number; categoryId: number | null; categoryName: string | null; categoryColor: string | null } }
|
||||
| { type: "UPDATE_ROW_NOTES"; payload: { txId: number; notes: string } };
|
||||
| { type: "UPDATE_ROW_NOTES"; payload: { txId: number; notes: string } }
|
||||
| { type: "SET_AUTO_CATEGORIZING"; payload: boolean };
|
||||
|
||||
const initialFilters: TransactionFilters = {
|
||||
search: "",
|
||||
|
|
@ -65,6 +68,7 @@ const initialState: TransactionsState = {
|
|||
categories: [],
|
||||
sources: [],
|
||||
isLoading: false,
|
||||
isAutoCategorizing: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
|
|
@ -120,6 +124,8 @@ function reducer(state: TransactionsState, action: TransactionsAction): Transact
|
|||
r.id === action.payload.txId ? { ...r, notes: action.payload.notes } : r
|
||||
),
|
||||
};
|
||||
case "SET_AUTO_CATEGORIZING":
|
||||
return { ...state, isAutoCategorizing: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
@ -268,6 +274,25 @@ export function useTransactions() {
|
|||
[state.sort, state.page, state.pageSize, fetchData]
|
||||
);
|
||||
|
||||
const autoCategorize = useCallback(async () => {
|
||||
dispatch({ type: "SET_AUTO_CATEGORIZING", payload: true });
|
||||
try {
|
||||
const count = await autoCategorizeTransactions();
|
||||
if (count > 0) {
|
||||
fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize);
|
||||
}
|
||||
return count;
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return 0;
|
||||
} finally {
|
||||
dispatch({ type: "SET_AUTO_CATEGORIZING", payload: false });
|
||||
}
|
||||
}, [state.sort, state.page, state.pageSize, fetchData]);
|
||||
|
||||
return {
|
||||
state,
|
||||
setFilter,
|
||||
|
|
@ -275,5 +300,6 @@ export function useTransactions() {
|
|||
setPage,
|
||||
updateCategory,
|
||||
saveNotes,
|
||||
autoCategorize,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,7 +165,10 @@
|
|||
},
|
||||
"notes": {
|
||||
"placeholder": "Add a note..."
|
||||
}
|
||||
},
|
||||
"autoCategorize": "Auto-categorize",
|
||||
"autoCategorizeResult": "{{count}} transaction(s) categorized",
|
||||
"autoCategorizeNone": "No new matches found"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categories",
|
||||
|
|
|
|||
|
|
@ -165,7 +165,10 @@
|
|||
},
|
||||
"notes": {
|
||||
"placeholder": "Ajouter une note..."
|
||||
}
|
||||
},
|
||||
"autoCategorize": "Auto-catégoriser",
|
||||
"autoCategorizeResult": "{{count}} transaction(s) catégorisée(s)",
|
||||
"autoCategorizeNone": "Aucune correspondance trouvée"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Catégories",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wand2 } from "lucide-react";
|
||||
import { useTransactions } from "../hooks/useTransactions";
|
||||
import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
|
||||
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
|
||||
|
|
@ -7,12 +9,41 @@ import TransactionPagination from "../components/transactions/TransactionPaginat
|
|||
|
||||
export default function TransactionsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { state, setFilter, setSort, setPage, updateCategory, saveNotes } =
|
||||
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize } =
|
||||
useTransactions();
|
||||
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
||||
|
||||
const handleAutoCategorize = async () => {
|
||||
setResultMessage(null);
|
||||
const count = await autoCategorize();
|
||||
if (count > 0) {
|
||||
setResultMessage(t("transactions.autoCategorizeResult", { count }));
|
||||
} else {
|
||||
setResultMessage(t("transactions.autoCategorizeNone"));
|
||||
}
|
||||
setTimeout(() => setResultMessage(null), 4000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">{t("transactions.title")}</h1>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<h1 className="text-2xl font-bold">{t("transactions.title")}</h1>
|
||||
<button
|
||||
onClick={handleAutoCategorize}
|
||||
disabled={state.isAutoCategorizing}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
<Wand2 size={16} />
|
||||
{state.isAutoCategorizing
|
||||
? t("common.loading")
|
||||
: t("transactions.autoCategorize")}
|
||||
</button>
|
||||
{resultMessage && (
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
{resultMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TransactionFilterBar
|
||||
filters={state.filters}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ export async function categorizeDescription(
|
|||
|
||||
for (const kw of keywords) {
|
||||
const normalizedKeyword = normalizeDescription(kw.keyword);
|
||||
if (normalized.includes(normalizedKeyword)) {
|
||||
const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (new RegExp(`\\b${escaped}\\b`).test(normalized)) {
|
||||
return {
|
||||
category_id: kw.category_id,
|
||||
supplier_id: kw.supplier_id ?? null,
|
||||
|
|
@ -64,7 +65,8 @@ export async function categorizeBatch(
|
|||
const normalized = normalizeDescription(desc);
|
||||
for (const kw of keywords) {
|
||||
const normalizedKeyword = normalizeDescription(kw.keyword);
|
||||
if (normalized.includes(normalizedKeyword)) {
|
||||
const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (new RegExp(`\\b${escaped}\\b`).test(normalized)) {
|
||||
return {
|
||||
category_id: kw.category_id,
|
||||
supplier_id: kw.supplier_id ?? null,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getDb } from "./db";
|
||||
import { categorizeBatch } from "./categorizationService";
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionRow,
|
||||
|
|
@ -233,3 +234,28 @@ export async function getAllImportSources(): Promise<ImportSource[]> {
|
|||
`SELECT * FROM import_sources ORDER BY name`
|
||||
);
|
||||
}
|
||||
|
||||
export async function autoCategorizeTransactions(): Promise<number> {
|
||||
const db = await getDb();
|
||||
const uncategorized = await db.select<Array<{ id: number; description: string }>>(
|
||||
`SELECT id, description FROM transactions WHERE category_id IS NULL AND is_manually_categorized = 0`
|
||||
);
|
||||
|
||||
if (uncategorized.length === 0) return 0;
|
||||
|
||||
const results = await categorizeBatch(uncategorized.map((tx) => tx.description));
|
||||
|
||||
let count = 0;
|
||||
for (let i = 0; i < uncategorized.length; i++) {
|
||||
const result = results[i];
|
||||
if (result.category_id !== null) {
|
||||
await db.execute(
|
||||
`UPDATE transactions SET category_id = $1, supplier_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`,
|
||||
[result.category_id, result.supplier_id, uncategorized[i].id]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue