From 6037c8784694ca837389f750326b44c2f2b38004 Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Wed, 11 Feb 2026 17:15:10 +0000 Subject: [PATCH] feat: add inline keyword creation from transaction rows Tag icon appears next to the category combobox when a category is assigned. Clicking it expands an inline row pre-filled with the transaction description, allowing users to add keyword rules without leaving the Transactions page. Co-Authored-By: Claude Opus 4.6 --- .../transactions/TransactionTable.tsx | 96 ++++++++++++++++--- src/hooks/useTransactions.ts | 16 ++++ src/i18n/locales/en.json | 3 + src/i18n/locales/fr.json | 3 + src/pages/TransactionsPage.tsx | 3 +- 5 files changed, 109 insertions(+), 12 deletions(-) diff --git a/src/components/transactions/TransactionTable.tsx b/src/components/transactions/TransactionTable.tsx index bf2da1c..bf2d0fb 100644 --- a/src/components/transactions/TransactionTable.tsx +++ b/src/components/transactions/TransactionTable.tsx @@ -1,6 +1,6 @@ import { Fragment, useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronUp, ChevronDown, MessageSquare } from "lucide-react"; +import { ChevronUp, ChevronDown, MessageSquare, Tag } from "lucide-react"; import type { TransactionRow, TransactionSort, @@ -15,6 +15,7 @@ interface TransactionTableProps { onSort: (column: TransactionSort["column"]) => void; onCategoryChange: (txId: number, categoryId: number | null) => void; onNotesChange: (txId: number, notes: string) => void; + onAddKeyword: (categoryId: number, keyword: string) => Promise; } function SortIcon({ @@ -40,10 +41,14 @@ export default function TransactionTable({ onSort, onCategoryChange, onNotesChange, + onAddKeyword, }: TransactionTableProps) { const { t } = useTranslation(); const [expandedId, setExpandedId] = useState(null); const [editingNotes, setEditingNotes] = useState(""); + const [keywordRowId, setKeywordRowId] = useState(null); + const [keywordText, setKeywordText] = useState(""); + const [keywordSaved, setKeywordSaved] = useState(null); const noCategoryExtra = useMemo( () => [{ value: "", label: t("transactions.table.noCategory") }], [t] @@ -82,6 +87,23 @@ export default function TransactionTable({ setExpandedId(null); }; + const toggleKeyword = (row: TransactionRow) => { + if (keywordRowId === row.id) { + setKeywordRowId(null); + } else { + setKeywordRowId(row.id); + setKeywordText(row.description); + } + }; + + const handleKeywordSave = async (row: TransactionRow) => { + if (!row.category_id || !keywordText.trim()) return; + await onAddKeyword(row.category_id, keywordText); + setKeywordRowId(null); + setKeywordSaved(row.id); + setTimeout(() => setKeywordSaved(null), 2000); + }; + return (
@@ -121,16 +143,31 @@ export default function TransactionTable({ })} )} + {keywordRowId === row.id && row.category_id !== null && ( + + + + )} ))} diff --git a/src/hooks/useTransactions.ts b/src/hooks/useTransactions.ts index a6989dd..5d7ad3d 100644 --- a/src/hooks/useTransactions.ts +++ b/src/hooks/useTransactions.ts @@ -15,6 +15,7 @@ import { getAllImportSources, autoCategorizeTransactions, } from "../services/transactionService"; +import { createKeyword } from "../services/categoryService"; interface TransactionsState { rows: TransactionRow[]; @@ -293,6 +294,20 @@ export function useTransactions() { } }, [state.sort, state.page, state.pageSize, fetchData]); + const addKeywordToCategory = useCallback( + async (categoryId: number, keyword: string) => { + try { + await createKeyword(categoryId, keyword.trim(), 0); + } catch (e) { + dispatch({ + type: "SET_ERROR", + payload: e instanceof Error ? e.message : String(e), + }); + } + }, + [] + ); + return { state, setFilter, @@ -301,5 +316,6 @@ export function useTransactions() { updateCategory, saveNotes, autoCategorize, + addKeywordToCategory, }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 172163b..4294a64 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -203,6 +203,9 @@ "autoCategorize": "Auto-categorize", "autoCategorizeResult": "{{count}} transaction(s) categorized", "autoCategorizeNone": "No new matches found", + "addKeyword": "Add keyword", + "keywordAdded": "Keyword added", + "keywordPlaceholder": "Keyword to match...", "help": { "title": "How to use Transactions", "tips": [ diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 69481be..442fc1c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -203,6 +203,9 @@ "autoCategorize": "Auto-catégoriser", "autoCategorizeResult": "{{count}} transaction(s) catégorisée(s)", "autoCategorizeNone": "Aucune correspondance trouvée", + "addKeyword": "Ajouter un mot-clé", + "keywordAdded": "Mot-clé ajouté", + "keywordPlaceholder": "Mot-clé à rechercher...", "help": { "title": "Comment utiliser les Transactions", "tips": [ diff --git a/src/pages/TransactionsPage.tsx b/src/pages/TransactionsPage.tsx index b4d46d3..f8405ad 100644 --- a/src/pages/TransactionsPage.tsx +++ b/src/pages/TransactionsPage.tsx @@ -10,7 +10,7 @@ import TransactionPagination from "../components/transactions/TransactionPaginat export default function TransactionsPage() { const { t } = useTranslation(); - const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize } = + const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory } = useTransactions(); const [resultMessage, setResultMessage] = useState(null); @@ -80,6 +80,7 @@ export default function TransactionsPage() { onSort={setSort} onCategoryChange={updateCategory} onNotesChange={saveNotes} + onAddKeyword={addKeywordToCategory} />
- onCategoryChange(row.id, id)} - placeholder={t("transactions.table.noCategory")} - compact - extraOptions={noCategoryExtra} - activeExtra={row.category_id === null ? "" : null} - onExtraSelect={() => onCategoryChange(row.id, null)} - /> +
+ onCategoryChange(row.id, id)} + placeholder={t("transactions.table.noCategory")} + compact + extraOptions={noCategoryExtra} + activeExtra={row.category_id === null ? "" : null} + onExtraSelect={() => onCategoryChange(row.id, null)} + /> + {row.category_id !== null && ( + + )} +
+
+ + + {row.category_name} + + setKeywordText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleKeywordSave(row); + if (e.key === "Escape") setKeywordRowId(null); + }} + placeholder={t("transactions.keywordPlaceholder")} + className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" + autoFocus + /> + + +
+