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
+332
View File
@@ -0,0 +1,332 @@
"use client";
import { useState } from "react";
import {
useUpdateTransaction,
useTags,
useAddTransactionTag,
useRemoveTransactionTag,
useTransactionSplits,
} from "@/lib/hooks";
import { SplitModal } from "./split-modal";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import type { TransactionRow, TagRow } from "@/lib/queries";
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
function formatAmount(amount: number, type: string) {
const formatted = `$${Number(amount).toFixed(2)}`;
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
}
function InlineTags({ transactionId, initialTags }: { transactionId: number; initialTags: TagRow[] }) {
const { data: allTags = [] } = useTags();
const addTag = useAddTransactionTag();
const removeTag = useRemoveTransactionTag();
const [tags, setTags] = useState<TagRow[]>(initialTags);
const [showPicker, setShowPicker] = useState(false);
const available = allTags.filter((t) => !tags.find((ct) => ct.id === t.id));
return (
<div>
<div className="flex flex-wrap gap-1 items-center">
{tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium text-white"
style={{ backgroundColor: tag.color + "99" }}
>
{tag.name}
<button
type="button"
onClick={() => {
removeTag.mutate({ transactionId, tagId: tag.id });
setTags((prev) => prev.filter((t) => t.id !== tag.id));
}}
className="ml-0.5 text-white/60 hover:text-white leading-none"
>
×
</button>
</span>
))}
{available.length > 0 && (
<button
type="button"
onClick={() => setShowPicker((v) => !v)}
className="text-xs text-zinc-500 hover:text-zinc-300 px-1.5 py-0.5 rounded hover:bg-zinc-800"
>
+ Add tag
</button>
)}
</div>
{showPicker && (
<div className="mt-1.5 flex flex-wrap gap-1">
{available.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => {
addTag.mutate({ transactionId, tagId: tag.id });
setTags((prev) => [...prev, tag]);
setShowPicker(false);
}}
className="px-2 py-0.5 rounded text-xs font-medium text-white hover:brightness-125"
style={{ backgroundColor: tag.color + "66" }}
>
{tag.name}
</button>
))}
</div>
)}
</div>
);
}
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<typeof updateTxn.mutateAsync>[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 (
<>
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-lg mx-4 shadow-2xl flex flex-col max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 pt-5 pb-4 border-b border-zinc-800">
<h3 className="font-semibold text-sm text-zinc-300">Edit Transaction</h3>
<p className="text-xs text-zinc-500 mt-0.5">{transaction.bank_name}</p>
</div>
<div className="overflow-y-auto flex-1 px-6 py-4 space-y-5">
{/* Core fields — read-only for statement, editable for manual */}
{isManual ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Date</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
<input
type="number"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Description</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
</div>
) : (
<div className="bg-zinc-800/50 rounded-lg px-3 py-2.5 space-y-1">
<p className="text-sm font-medium">{transaction.description}</p>
<p className={`text-sm font-mono ${SPEND_TYPES.has(transaction.transaction_type) ? "text-red-400" : "text-green-400"}`}>
{formatAmount(transaction.amount, transaction.transaction_type)}
</p>
<p className="text-xs text-zinc-500">
{new Date(transaction.transaction_date).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" })}
</p>
</div>
)}
{/* Override fields */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Type</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{TRANSACTION_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
<option value=""> none </option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{formatCategory(c)}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Merchant</label>
<input
value={merchant}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
placeholder="Additional context about this transaction…"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm resize-none"
/>
</div>
</div>
{/* Tags */}
<div>
<p className="text-xs text-zinc-500 mb-1.5">Tags</p>
<InlineTags transactionId={transaction.id} initialTags={transaction.tags ?? []} />
</div>
{/* Splits */}
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-xs text-zinc-500">Splits</p>
<button
type="button"
onClick={() => setShowSplitModal(true)}
className="text-xs text-blue-400 hover:text-blue-300"
>
{liveSplits.length > 0 ? "Edit splits" : "Add split"}
</button>
</div>
{liveSplits.length > 0 ? (
<div className="flex flex-wrap gap-1">
{liveSplits.map((s: { participant_id: number; name: string; share_percent: number; settled: boolean }) => (
<span
key={s.participant_id}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${
s.settled ? "bg-zinc-800 text-zinc-500" : "bg-amber-900/40 text-amber-300"
}`}
>
{s.name} {s.share_percent}%
{s.settled && <span className="text-emerald-500"></span>}
</span>
))}
</div>
) : (
<p className="text-xs text-zinc-600 italic">No splits</p>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-zinc-800 flex gap-2">
{error && <p className="text-red-400 text-xs flex-1 self-center">{error}</p>}
<div className="flex gap-2 ml-auto">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={updateTxn.isPending}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{updateTxn.isPending ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
</div>
{showSplitModal && (
<SplitModal
transactionId={transaction.id}
amount={transaction.amount}
description={transaction.description}
merchant={transaction.effective_merchant || undefined}
onClose={() => setShowSplitModal(false)}
/>
)}
</>
);
}