feat(edit-transaction): edit modal with notes, inline tags, and split management

- New EditTransactionModal with scrollable body (sticky header/footer)
- Statement transactions: read-only core fields; manual transactions: editable date/amount/description
- Override fields for all: merchant, category, type, notes (textarea)
- InlineTags sub-component: add/remove tags without dropdown clipping issues
- Live split display via useTransactionSplits, opens SplitModal for editing
- PATCH /api/transactions/:id extended for description/amount/transaction_date (manual only)
- Transactions page: edit button per row, notes shown below description in italic
This commit is contained in:
2026-03-14 20:06:32 +11:00
parent 278e57354c
commit aeaca84cc7
3 changed files with 448 additions and 9 deletions
+80 -6
View File
@@ -6,6 +6,9 @@ import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags
import { CATEGORIES, formatCategory } from "@/lib/categories";
import { SplitModal } from "@/components/split-modal";
import { TagPicker } from "@/components/tag-picker";
import { AddTransactionModal } from "@/components/add-transaction-modal";
import { EditTransactionModal } from "@/components/edit-transaction-modal";
import type { TransactionRow } from "@/lib/queries";
function formatDate(d: string) {
return new Date(d).toLocaleDateString("en-AU", {
@@ -232,6 +235,8 @@ function TransactionsContent() {
const [bulkCategory, setBulkCategory] = useState("");
const [bulkTagId, setBulkTagId] = useState("");
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | null>(null);
const [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
const [editModal, setEditModal] = useState<TransactionRow | null>(null);
const [rulePrompt, setRulePrompt] = useState<{
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
field: "category" | "merchant";
@@ -283,7 +288,15 @@ function TransactionsContent() {
return (
<div>
<h2 className="text-xl font-semibold mb-4">Transactions</h2>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Transactions</h2>
<button
onClick={() => setAddModal({})}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
>
+ Add Transaction
</button>
</div>
{/* Statement context banner */}
{filters.statement_id && statementInfo && (
@@ -483,7 +496,12 @@ function TransactionsContent() {
/>
</td>
<td className="p-2 whitespace-nowrap">{formatDate(t.transaction_date)}</td>
<td className="p-2 max-w-xs truncate" title={t.description}>{t.description}</td>
<td className="p-2 max-w-xs">
<p className="truncate" title={t.description}>{t.description}</p>
{t.notes && (
<p className="truncate text-xs text-zinc-500 italic mt-0.5" title={t.notes}>{t.notes}</p>
)}
</td>
<td className="p-2 max-w-[150px]">
<div className="relative">
<InlineEdit
@@ -540,13 +558,54 @@ function TransactionsContent() {
<TagPicker transactionId={t.id} currentTags={t.tags ?? []} />
</div>
</td>
<td className="p-2">
<td className="p-2 whitespace-nowrap">
<div className="flex items-center gap-1 flex-wrap">
{t.splits?.filter((s) => s.name !== "Me").map((s) => (
<span
key={s.participant_id}
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${
s.settled ? "bg-zinc-800 text-zinc-500" : "bg-amber-900/40 text-amber-300"
}`}
title={`${s.name}: ${s.share_percent}%${s.settled ? " (settled)" : ""}`}
>
{s.name} {s.share_percent}%
</span>
))}
<button
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, merchant: t.effective_merchant || undefined, transactionIds: undefined })}
className={`text-xs px-2 py-0.5 rounded transition-colors ${
t.splits?.some((s) => s.name !== "Me")
? "text-amber-400 hover:text-amber-200 hover:bg-zinc-800"
: "text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800"
}`}
title="Split this transaction"
>
Split
</button>
</div>
<button
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, merchant: t.effective_merchant || undefined, transactionIds: undefined })}
onClick={() => setEditModal(t)}
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
title="Split this transaction"
title="Edit this transaction"
>
Split
Edit
</button>
<button
onClick={() => setAddModal({
title: "Duplicate Transaction",
prefill: {
date: new Date().toISOString().slice(0, 10),
description: t.description,
amount: t.amount,
transaction_type: t.transaction_type,
merchant_normalized: t.effective_merchant || undefined,
category: t.effective_category || undefined,
},
})}
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
title="Duplicate this transaction"
>
Dupe
</button>
</td>
</tr>
@@ -568,6 +627,21 @@ function TransactionsContent() {
/>
)}
{addModal && (
<AddTransactionModal
prefill={addModal.prefill}
title={addModal.title}
onClose={() => setAddModal(null)}
/>
)}
{editModal && (
<EditTransactionModal
transaction={editModal}
onClose={() => setEditModal(null)}
/>
)}
{rulePrompt && (
<SaveAsRulePrompt
tx={rulePrompt.tx}