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 { 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}
|
||||||
|
|||||||
Reference in New Issue
Block a user