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:
parent
3351601ff5
commit
6037c87846
5 changed files with 109 additions and 12 deletions
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
function SortIcon({
|
||||
|
|
@ -40,10 +41,14 @@ export default function TransactionTable({
|
|||
onSort,
|
||||
onCategoryChange,
|
||||
onNotesChange,
|
||||
onAddKeyword,
|
||||
}: TransactionTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
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(
|
||||
() => [{ 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 (
|
||||
<div className="overflow-x-auto rounded-xl border border-[var(--border)]">
|
||||
<table className="w-full text-sm">
|
||||
|
|
@ -121,6 +143,7 @@ export default function TransactionTable({
|
|||
})}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<CategoryCombobox
|
||||
categories={categories}
|
||||
value={row.category_id}
|
||||
|
|
@ -131,6 +154,20 @@ export default function TransactionTable({
|
|||
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 className="px-3 py-2 text-center">
|
||||
<button
|
||||
|
|
@ -167,6 +204,43 @@ export default function TransactionTable({
|
|||
</td>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
|
||||
|
|
@ -80,6 +80,7 @@ export default function TransactionsPage() {
|
|||
onSort={setSort}
|
||||
onCategoryChange={updateCategory}
|
||||
onNotesChange={saveNotes}
|
||||
onAddKeyword={addKeywordToCategory}
|
||||
/>
|
||||
|
||||
<TransactionPagination
|
||||
|
|
|
|||
Loading…
Reference in a new issue