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 transactionId = Number(id);
|
||||||
const body = await req.json();
|
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;
|
category?: string;
|
||||||
merchant_normalized?: string;
|
merchant_normalized?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
transaction_type?: 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
|
// transaction_type is a direct correction on the transactions table
|
||||||
if (transaction_type !== undefined) {
|
if (transaction_type !== undefined) {
|
||||||
if (!VALID_TYPES.includes(transaction_type)) {
|
if (!VALID_TYPES.includes(transaction_type)) {
|
||||||
@@ -41,8 +72,8 @@ export async function PATCH(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// category/merchant/notes go through the overrides table
|
// category/merchant/notes/my_share_percent go through the overrides table
|
||||||
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined;
|
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined;
|
||||||
if (!hasOverride) {
|
if (!hasOverride) {
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -51,6 +82,7 @@ export async function PATCH(
|
|||||||
if (category !== undefined) data.category_override = category;
|
if (category !== undefined) data.category_override = category;
|
||||||
if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized;
|
if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized;
|
||||||
if (notes !== undefined) data.notes = notes;
|
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({
|
const override = await prisma.transaction_overrides.upsert({
|
||||||
where: { transaction_id: transactionId },
|
where: { transaction_id: transactionId },
|
||||||
@@ -60,6 +92,7 @@ export async function PATCH(
|
|||||||
category_override: category || null,
|
category_override: category || null,
|
||||||
merchant_normalized: merchant_normalized || null,
|
merchant_normalized: merchant_normalized || null,
|
||||||
notes: notes || 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 { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
import { SplitModal } from "@/components/split-modal";
|
import { SplitModal } from "@/components/split-modal";
|
||||||
import { TagPicker } from "@/components/tag-picker";
|
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) {
|
function formatDate(d: string) {
|
||||||
return new Date(d).toLocaleDateString("en-AU", {
|
return new Date(d).toLocaleDateString("en-AU", {
|
||||||
@@ -232,6 +235,8 @@ function TransactionsContent() {
|
|||||||
const [bulkCategory, setBulkCategory] = useState("");
|
const [bulkCategory, setBulkCategory] = useState("");
|
||||||
const [bulkTagId, setBulkTagId] = useState("");
|
const [bulkTagId, setBulkTagId] = useState("");
|
||||||
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | null>(null);
|
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<{
|
const [rulePrompt, setRulePrompt] = useState<{
|
||||||
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
|
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
|
||||||
field: "category" | "merchant";
|
field: "category" | "merchant";
|
||||||
@@ -283,7 +288,15 @@ function TransactionsContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 */}
|
{/* Statement context banner */}
|
||||||
{filters.statement_id && statementInfo && (
|
{filters.statement_id && statementInfo && (
|
||||||
@@ -483,7 +496,12 @@ function TransactionsContent() {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 whitespace-nowrap">{formatDate(t.transaction_date)}</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]">
|
<td className="p-2 max-w-[150px]">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
@@ -540,13 +558,54 @@ function TransactionsContent() {
|
|||||||
<TagPicker transactionId={t.id} currentTags={t.tags ?? []} />
|
<TagPicker transactionId={t.id} currentTags={t.tags ?? []} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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
|
<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"
|
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>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 && (
|
{rulePrompt && (
|
||||||
<SaveAsRulePrompt
|
<SaveAsRulePrompt
|
||||||
tx={rulePrompt.tx}
|
tx={rulePrompt.tx}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user