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
+36 -3
View File
@@ -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,
},
});