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 { 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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue