From aeaca84cc7b53d9be91d5a9a0e440f550e042617 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sat, 14 Mar 2026 20:06:32 +1100 Subject: [PATCH] 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 --- src/app/api/transactions/[id]/route.ts | 39 ++- src/app/transactions/page.tsx | 86 +++++- src/components/edit-transaction-modal.tsx | 332 ++++++++++++++++++++++ 3 files changed, 448 insertions(+), 9 deletions(-) create mode 100644 src/components/edit-transaction-modal.tsx diff --git a/src/app/api/transactions/[id]/route.ts b/src/app/api/transactions/[id]/route.ts index 9e3e534..b6947bf 100644 --- a/src/app/api/transactions/[id]/route.ts +++ b/src/app/api/transactions/[id]/route.ts @@ -23,13 +23,44 @@ export async function PATCH( const transactionId = Number(id); const body = await req.json(); - const { category, merchant_normalized, notes, transaction_type } = body as { + const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date } = body as { category?: string; merchant_normalized?: string; notes?: string; transaction_type?: string; + my_share_percent?: number | null; + description?: string; + amount?: number; + transaction_date?: string; }; + if (my_share_percent !== undefined && my_share_percent !== null) { + if (typeof my_share_percent !== "number" || my_share_percent <= 0 || my_share_percent > 100) { + return NextResponse.json({ error: "my_share_percent must be between 1 and 100" }, { status: 400 }); + } + } + + // Direct field edits — only allowed for manual transactions (statement_id IS NULL) + const directFields = [description, amount, transaction_date].filter((v) => v !== undefined); + if (directFields.length > 0) { + const txRows = await queryRaw<{ statement_id: number | null }>( + `SELECT statement_id FROM transactions WHERE id = $1`, + [transactionId] + ); + if (!txRows[0]?.statement_id) { + const setClauses: string[] = []; + const params: unknown[] = []; + let idx = 1; + if (description !== undefined) { setClauses.push(`description = $${idx++}`); params.push(description); } + if (amount !== undefined) { setClauses.push(`amount = $${idx++}`); params.push(amount); } + if (transaction_date !== undefined) { setClauses.push(`transaction_date = $${idx++}`); params.push(transaction_date); } + if (setClauses.length) { + params.push(transactionId); + await queryRaw(`UPDATE transactions SET ${setClauses.join(", ")} WHERE id = $${idx}`, params); + } + } + } + // transaction_type is a direct correction on the transactions table if (transaction_type !== undefined) { if (!VALID_TYPES.includes(transaction_type)) { @@ -41,8 +72,8 @@ export async function PATCH( ); } - // category/merchant/notes go through the overrides table - const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined; + // category/merchant/notes/my_share_percent go through the overrides table + const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined; if (!hasOverride) { return NextResponse.json({ ok: true }); } @@ -51,6 +82,7 @@ export async function PATCH( if (category !== undefined) data.category_override = category; if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized; if (notes !== undefined) data.notes = notes; + if (my_share_percent !== undefined) data.my_share_percent = my_share_percent; const override = await prisma.transaction_overrides.upsert({ where: { transaction_id: transactionId }, @@ -60,6 +92,7 @@ export async function PATCH( category_override: category || null, merchant_normalized: merchant_normalized || null, notes: notes || null, + my_share_percent: my_share_percent != null ? String(my_share_percent) : null, }, }); diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx index e22104c..a37d9a2 100644 --- a/src/app/transactions/page.tsx +++ b/src/app/transactions/page.tsx @@ -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[0]["prefill"]; title?: string } | null>(null); + const [editModal, setEditModal] = useState(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 (
-

Transactions

+
+

Transactions

+ +
{/* Statement context banner */} {filters.statement_id && statementInfo && ( @@ -483,7 +496,12 @@ function TransactionsContent() { /> {formatDate(t.transaction_date)} - {t.description} + +

{t.description}

+ {t.notes && ( +

{t.notes}

+ )} +
- + +
+ {t.splits?.filter((s) => s.name !== "Me").map((s) => ( + + {s.name} {s.share_percent}% + + ))} + +
+ @@ -568,6 +627,21 @@ function TransactionsContent() { /> )} + {addModal && ( + setAddModal(null)} + /> + )} + + {editModal && ( + setEditModal(null)} + /> + )} + {rulePrompt && ( (initialTags); + const [showPicker, setShowPicker] = useState(false); + + const available = allTags.filter((t) => !tags.find((ct) => ct.id === t.id)); + + return ( +
+
+ {tags.map((tag) => ( + + {tag.name} + + + ))} + {available.length > 0 && ( + + )} +
+ {showPicker && ( +
+ {available.map((tag) => ( + + ))} +
+ )} +
+ ); +} + +export function EditTransactionModal({ + transaction, + onClose, +}: { + transaction: TransactionRow; + onClose: () => void; +}) { + const isManual = !transaction.statement_id; + const updateTxn = useUpdateTransaction(); + + // Editable override fields + const [merchant, setMerchant] = useState(transaction.merchant_override ?? transaction.merchant_normalized ?? ""); + const [category, setCategory] = useState(transaction.effective_category ?? ""); + const [type, setType] = useState(transaction.transaction_type); + const [notes, setNotes] = useState(transaction.notes ?? ""); + + // Manual-only direct fields + const [date, setDate] = useState(transaction.transaction_date?.slice(0, 10) ?? ""); + const [description, setDescription] = useState(transaction.description); + const [amount, setAmount] = useState(String(transaction.amount)); + + // Splits — live via hook so they refresh after SplitModal saves + const { data: liveSplits = [] } = useTransactionSplits(transaction.id); + + const [showSplitModal, setShowSplitModal] = useState(false); + const [error, setError] = useState(""); + + async function handleSave() { + setError(""); + try { + const patch: Parameters[0] = { id: transaction.id }; + + // Override fields (always) + if (merchant !== (transaction.merchant_override ?? transaction.merchant_normalized ?? "")) + patch.merchant_normalized = merchant; + if (category !== (transaction.effective_category ?? "")) + patch.category = category; + if (type !== transaction.transaction_type) + patch.transaction_type = type; + if (notes !== (transaction.notes ?? "")) + patch.notes = notes; + + // Direct fields (manual only) + if (isManual) { + if (date !== transaction.transaction_date?.slice(0, 10)) + patch.transaction_date = date; + if (description !== transaction.description) + patch.description = description; + if (parseFloat(amount) !== transaction.amount) + patch.amount = parseFloat(amount); + } + + await updateTxn.mutateAsync(patch); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save"); + } + } + + return ( + <> +
+
e.stopPropagation()} + > + {/* Header */} +
+

Edit Transaction

+

{transaction.bank_name}

+
+ +
+ + {/* Core fields — read-only for statement, editable for manual */} + {isManual ? ( +
+
+
+ + setDate(e.target.value)} + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" + /> +
+
+ + setAmount(e.target.value)} + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" + /> +
+
+
+ + setDescription(e.target.value)} + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" + /> +
+
+ ) : ( +
+

{transaction.description}

+

+ {formatAmount(transaction.amount, transaction.transaction_type)} +

+

+ {new Date(transaction.transaction_date).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" })} +

+
+ )} + + {/* Override fields */} +
+
+
+ + +
+
+ + +
+
+ +
+ + setMerchant(e.target.value)} + placeholder="Normalized merchant name" + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" + /> +
+ +
+ +