feat(transactions): Payment button to record existing transaction as debt payment
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, Suspense } from "react";
|
||||
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 { SplitModal } from "@/components/split-modal";
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
interface QueryToken { key: string; label: string }
|
||||
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 [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
|
||||
const [editModal, setEditModal] = useState<TransactionRow | null>(null);
|
||||
const [paymentModal, setPaymentModal] = useState<TransactionRow | null>(null);
|
||||
const [rulePrompt, setRulePrompt] = useState<{
|
||||
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
|
||||
field: "category" | "merchant";
|
||||
@@ -738,6 +875,15 @@ function TransactionsContent() {
|
||||
>
|
||||
Edit
|
||||
</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
|
||||
onClick={() => setAddModal({
|
||||
title: "Duplicate Transaction",
|
||||
@@ -790,6 +936,13 @@ function TransactionsContent() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{paymentModal && (
|
||||
<MarkAsPaymentModal
|
||||
transaction={paymentModal}
|
||||
onClose={() => setPaymentModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rulePrompt && (
|
||||
<SaveAsRulePrompt
|
||||
tx={rulePrompt.tx}
|
||||
|
||||
Reference in New Issue
Block a user