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:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user