feat(transactions): Payment button to record existing transaction as debt payment

This commit is contained in:
2026-03-14 21:20:25 +11:00
parent 281f0d3782
commit 084b8764e3
+154 -1
View File
@@ -2,7 +2,7 @@
import { useState, useCallback, useRef, useEffect, Suspense } from "react"; import { useState, useCallback, useRef, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule } from "@/lib/hooks"; import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser } from "@/lib/hooks";
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";
@@ -206,6 +206,142 @@ function InlineEdit({
); );
} }
// ── Mark as Payment modal ─────────────────────────────────────────────────────
function MarkAsPaymentModal({
transaction,
onClose,
}: {
transaction: TransactionRow;
onClose: () => void;
}) {
const { data: participants = [] } = useParticipants();
const { data: me } = useCurrentUser();
const record = useRecordPayment();
const others = participants.filter((p) => p.name !== "Me");
const [participantId, setParticipantId] = useState<number | "">(others[0]?.id ?? "");
// For credits/refunds the default direction is "they paid me"
const [direction, setDirection] = useState<"received" | "sent">(
SPEND_TYPES.has(transaction.transaction_type) ? "sent" : "received"
);
const [amount, setAmount] = useState(transaction.amount.toFixed(2));
const [date, setDate] = useState(transaction.transaction_date.slice(0, 10));
const [notes, setNotes] = useState("");
const [error, setError] = useState("");
const selectedParticipant = others.find((p) => p.id === participantId);
async function handleSave() {
setError("");
if (!participantId || !me) { setError("Select a participant"); return; }
const amt = parseFloat(amount);
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
try {
await record.mutateAsync({
from_participant_id: direction === "received" ? participantId : me.id,
to_participant_id: direction === "received" ? me.id : participantId,
amount: amt,
payment_date: date,
notes: notes || undefined,
linked_transaction_id: transaction.id,
});
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to record");
}
}
return (
<div className="fixed inset-0 z-50 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-sm mx-4 shadow-2xl p-6 space-y-4"
onClick={(e) => e.stopPropagation()}
>
<div>
<h3 className="font-semibold text-sm text-zinc-300">Record as Debt Payment</h3>
<p className="text-xs text-zinc-500 mt-0.5 truncate">{transaction.description}</p>
</div>
{/* Participant */}
<div>
<label className="block text-xs text-zinc-500 mb-1">Participant</label>
<select
value={participantId}
onChange={(e) => setParticipantId(Number(e.target.value))}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{others.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* Direction */}
<div className="flex rounded-lg overflow-hidden border border-zinc-700 text-sm">
<button
type="button"
onClick={() => setDirection("received")}
className={`flex-1 py-1.5 transition-colors ${direction === "received" ? "bg-emerald-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
>
{selectedParticipant?.name ?? "They"} paid me
</button>
<button
type="button"
onClick={() => setDirection("sent")}
className={`flex-1 py-1.5 transition-colors ${direction === "sent" ? "bg-blue-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
>
I paid {selectedParticipant?.name ?? "them"}
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
<div className="relative">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">$</span>
<input
type="number" step="0.01" min="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 pl-6"
/>
</div>
</div>
<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>
<div>
<label className="block text-xs text-zinc-500 mb-1">Notes (optional)</label>
<input
value={notes} onChange={(e) => setNotes(e.target.value)}
placeholder="e.g. bank transfer reference"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={onClose}
className="flex-1 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={record.isPending}
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
{record.isPending ? "Saving…" : "Record Payment"}
</button>
</div>
</div>
</div>
);
}
// ── Query bar parser ────────────────────────────────────────────────────────── // ── Query bar parser ──────────────────────────────────────────────────────────
interface QueryToken { key: string; label: string } interface QueryToken { key: string; label: string }
interface ParsedQuery { text: string; amountMin?: number; amountMax?: number; tokens: QueryToken[] } interface ParsedQuery { text: string; amountMin?: number; amountMax?: number; tokens: QueryToken[] }
@@ -365,6 +501,7 @@ function TransactionsContent() {
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 [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
const [editModal, setEditModal] = useState<TransactionRow | null>(null); const [editModal, setEditModal] = useState<TransactionRow | null>(null);
const [paymentModal, setPaymentModal] = 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";
@@ -738,6 +875,15 @@ function TransactionsContent() {
> >
Edit Edit
</button> </button>
{!SPEND_TYPES.has(t.transaction_type) && (
<button
onClick={() => setPaymentModal(t)}
className="text-xs text-emerald-600 hover:text-emerald-400 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
title="Record as debt payment"
>
Payment
</button>
)}
<button <button
onClick={() => setAddModal({ onClick={() => setAddModal({
title: "Duplicate Transaction", title: "Duplicate Transaction",
@@ -790,6 +936,13 @@ function TransactionsContent() {
/> />
)} )}
{paymentModal && (
<MarkAsPaymentModal
transaction={paymentModal}
onClose={() => setPaymentModal(null)}
/>
)}
{rulePrompt && ( {rulePrompt && (
<SaveAsRulePrompt <SaveAsRulePrompt
tx={rulePrompt.tx} tx={rulePrompt.tx}