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 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-11 17:15:10 +00:00
parent 3351601ff5
commit 6037c87846
5 changed files with 109 additions and 12 deletions

View file

@ -1,6 +1,6 @@
import { Fragment, useState, useMemo } from "react"; import { Fragment, useState, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronUp, ChevronDown, MessageSquare } from "lucide-react"; import { ChevronUp, ChevronDown, MessageSquare, Tag } from "lucide-react";
import type { import type {
TransactionRow, TransactionRow,
TransactionSort, TransactionSort,
@ -15,6 +15,7 @@ interface TransactionTableProps {
onSort: (column: TransactionSort["column"]) => void; onSort: (column: TransactionSort["column"]) => void;
onCategoryChange: (txId: number, categoryId: number | null) => void; onCategoryChange: (txId: number, categoryId: number | null) => void;
onNotesChange: (txId: number, notes: string) => void; onNotesChange: (txId: number, notes: string) => void;
onAddKeyword: (categoryId: number, keyword: string) => Promise<void>;
} }
function SortIcon({ function SortIcon({
@ -40,10 +41,14 @@ export default function TransactionTable({
onSort, onSort,
onCategoryChange, onCategoryChange,
onNotesChange, onNotesChange,
onAddKeyword,
}: TransactionTableProps) { }: TransactionTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
const [editingNotes, setEditingNotes] = useState(""); const [editingNotes, setEditingNotes] = useState("");
const [keywordRowId, setKeywordRowId] = useState<number | null>(null);
const [keywordText, setKeywordText] = useState("");
const [keywordSaved, setKeywordSaved] = useState<number | null>(null);
const noCategoryExtra = useMemo( const noCategoryExtra = useMemo(
() => [{ value: "", label: t("transactions.table.noCategory") }], () => [{ value: "", label: t("transactions.table.noCategory") }],
[t] [t]
@ -82,6 +87,23 @@ export default function TransactionTable({
setExpandedId(null); 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 ( return (
<div className="overflow-x-auto rounded-xl border border-[var(--border)]"> <div className="overflow-x-auto rounded-xl border border-[var(--border)]">
<table className="w-full text-sm"> <table className="w-full text-sm">
@ -121,16 +143,31 @@ export default function TransactionTable({
})} })}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<CategoryCombobox <div className="flex items-center gap-1">
categories={categories} <CategoryCombobox
value={row.category_id} categories={categories}
onChange={(id) => onCategoryChange(row.id, id)} value={row.category_id}
placeholder={t("transactions.table.noCategory")} onChange={(id) => onCategoryChange(row.id, id)}
compact placeholder={t("transactions.table.noCategory")}
extraOptions={noCategoryExtra} compact
activeExtra={row.category_id === null ? "" : null} extraOptions={noCategoryExtra}
onExtraSelect={() => onCategoryChange(row.id, null)} activeExtra={row.category_id === null ? "" : null}
/> onExtraSelect={() => onCategoryChange(row.id, null)}
/>
{row.category_id !== null && (
<button
onClick={() => toggleKeyword(row)}
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
keywordSaved === row.id
? "text-[var(--positive)]"
: "text-[var(--muted-foreground)]"
}`}
title={keywordSaved === row.id ? t("transactions.keywordAdded") : t("transactions.addKeyword")}
>
<Tag size={14} />
</button>
)}
</div>
</td> </td>
<td className="px-3 py-2 text-center"> <td className="px-3 py-2 text-center">
<button <button
@ -167,6 +204,43 @@ export default function TransactionTable({
</td> </td>
</tr> </tr>
)} )}
{keywordRowId === row.id && row.category_id !== null && (
<tr>
<td colSpan={5} className="px-3 py-2 bg-[var(--muted)]">
<div className="flex items-center gap-2">
<Tag size={14} className="text-[var(--muted-foreground)] shrink-0" />
<span className="text-xs px-2 py-0.5 rounded bg-[var(--border)] text-[var(--foreground)] shrink-0">
{row.category_name}
</span>
<input
type="text"
value={keywordText}
onChange={(e) => 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
/>
<button
onClick={() => handleKeywordSave(row)}
disabled={!keywordText.trim()}
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity disabled:opacity-50"
>
{t("common.save")}
</button>
<button
onClick={() => setKeywordRowId(null)}
className="px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--border)] transition-colors"
>
{t("common.cancel")}
</button>
</div>
</td>
</tr>
)}
</Fragment> </Fragment>
))} ))}
</tbody> </tbody>

View file

@ -15,6 +15,7 @@ import {
getAllImportSources, getAllImportSources,
autoCategorizeTransactions, autoCategorizeTransactions,
} from "../services/transactionService"; } from "../services/transactionService";
import { createKeyword } from "../services/categoryService";
interface TransactionsState { interface TransactionsState {
rows: TransactionRow[]; rows: TransactionRow[];
@ -293,6 +294,20 @@ export function useTransactions() {
} }
}, [state.sort, state.page, state.pageSize, fetchData]); }, [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 { return {
state, state,
setFilter, setFilter,
@ -301,5 +316,6 @@ export function useTransactions() {
updateCategory, updateCategory,
saveNotes, saveNotes,
autoCategorize, autoCategorize,
addKeywordToCategory,
}; };
} }

View file

@ -203,6 +203,9 @@
"autoCategorize": "Auto-categorize", "autoCategorize": "Auto-categorize",
"autoCategorizeResult": "{{count}} transaction(s) categorized", "autoCategorizeResult": "{{count}} transaction(s) categorized",
"autoCategorizeNone": "No new matches found", "autoCategorizeNone": "No new matches found",
"addKeyword": "Add keyword",
"keywordAdded": "Keyword added",
"keywordPlaceholder": "Keyword to match...",
"help": { "help": {
"title": "How to use Transactions", "title": "How to use Transactions",
"tips": [ "tips": [

View file

@ -203,6 +203,9 @@
"autoCategorize": "Auto-catégoriser", "autoCategorize": "Auto-catégoriser",
"autoCategorizeResult": "{{count}} transaction(s) catégorisée(s)", "autoCategorizeResult": "{{count}} transaction(s) catégorisée(s)",
"autoCategorizeNone": "Aucune correspondance trouvée", "autoCategorizeNone": "Aucune correspondance trouvée",
"addKeyword": "Ajouter un mot-clé",
"keywordAdded": "Mot-clé ajouté",
"keywordPlaceholder": "Mot-clé à rechercher...",
"help": { "help": {
"title": "Comment utiliser les Transactions", "title": "Comment utiliser les Transactions",
"tips": [ "tips": [

View file

@ -10,7 +10,7 @@ import TransactionPagination from "../components/transactions/TransactionPaginat
export default function TransactionsPage() { export default function TransactionsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize } = const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory } =
useTransactions(); useTransactions();
const [resultMessage, setResultMessage] = useState<string | null>(null); const [resultMessage, setResultMessage] = useState<string | null>(null);
@ -80,6 +80,7 @@ export default function TransactionsPage() {
onSort={setSort} onSort={setSort}
onCategoryChange={updateCategory} onCategoryChange={updateCategory}
onNotesChange={saveNotes} onNotesChange={saveNotes}
onAddKeyword={addKeywordToCategory}
/> />
<TransactionPagination <TransactionPagination