feat(transactions): manual transaction support and multi-owner query infrastructure
- Add POST /api/transactions to create manual transactions (statement_id=NULL, owner_id set directly) - Queries switch from JOIN to LEFT JOIN statements so manual transactions are visible - COALESCE(t.owner_id, s.owner_id) throughout for owner resolution - Add "Manual" bank filter option in getTransactions - Search extended to include merchant_normalized override - Split data fetched via lateral subquery on every transaction row - getParticipantBalances rewritten as UNION for bidirectional net balances (credits/refunds negate, split from either side of the relationship) - getSharedTransactions: remove my_share_percent from SELECT (fixes GROUP BY error), WHERE rewritten as two distinct cases (owner with others split vs participant on others' txn) - getTransactions: OR EXISTS condition so split participants see shared transactions - add-transaction-modal component for creating manual transactions with splits - 0008_my_share_percent migration adds my_share_percent to transaction_overrides
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCreateTransaction, useParticipants } from "@/lib/hooks";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
|
||||
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||
|
||||
interface Prefill {
|
||||
date?: string;
|
||||
description?: string;
|
||||
amount?: number;
|
||||
transaction_type?: string;
|
||||
merchant_normalized?: string;
|
||||
category?: string;
|
||||
splits?: { participant_id: number; share_percent: number }[];
|
||||
}
|
||||
|
||||
export function AddTransactionModal({
|
||||
prefill,
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
prefill?: Prefill;
|
||||
title?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const createTransaction = useCreateTransaction();
|
||||
const { data: participants = [] } = useParticipants();
|
||||
|
||||
const [date, setDate] = useState(prefill?.date ?? new Date().toISOString().slice(0, 10));
|
||||
const [description, setDescription] = useState(prefill?.description ?? "");
|
||||
const [amount, setAmount] = useState(prefill?.amount != null ? String(prefill.amount) : "");
|
||||
const [type, setType] = useState(prefill?.transaction_type ?? "debit");
|
||||
const [merchant, setMerchant] = useState(prefill?.merchant_normalized ?? "");
|
||||
const [category, setCategory] = useState(prefill?.category ?? "");
|
||||
const [splits, setSplits] = useState<{ participant_id: number; share_percent: number }[]>(
|
||||
prefill?.splits ?? []
|
||||
);
|
||||
|
||||
function addSplit() {
|
||||
if (!participants.length) return;
|
||||
setSplits([...splits, { participant_id: participants[0].id, share_percent: 50 }]);
|
||||
}
|
||||
|
||||
function updateSplit(i: number, patch: Partial<{ participant_id: number; share_percent: number }>) {
|
||||
setSplits(splits.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
|
||||
}
|
||||
|
||||
function removeSplit(i: number) {
|
||||
setSplits(splits.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await createTransaction.mutateAsync({
|
||||
date,
|
||||
description,
|
||||
amount: parseFloat(amount),
|
||||
transaction_type: type,
|
||||
merchant_normalized: merchant || undefined,
|
||||
category: category || undefined,
|
||||
splits: splits.length ? splits : undefined,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
|
||||
const splitTotal = splits.reduce((s, e) => s + (e.share_percent || 0), 0);
|
||||
|
||||
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 p-6 w-full max-w-md shadow-2xl space-y-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="font-semibold text-sm text-zinc-300">{title ?? "Add Transaction"}</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} 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"
|
||||
required
|
||||
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"
|
||||
required
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
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
|
||||
required
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="e.g. Coles Wyndham Vale"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 (optional)</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>
|
||||
|
||||
{/* Splits */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs text-zinc-500">
|
||||
Splits (optional)
|
||||
{splits.length > 0 && (
|
||||
<span className={`ml-2 ${splitTotal === 100 ? "text-emerald-400" : "text-amber-400"}`}>
|
||||
{splitTotal}%
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{participants.length > 0 && (
|
||||
<button type="button" onClick={addSplit} className="text-xs text-indigo-400 hover:text-indigo-300">
|
||||
+ Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{splits.map((s, i) => (
|
||||
<div key={i} className="flex gap-2 mb-1.5 items-center">
|
||||
<select
|
||||
value={s.participant_id}
|
||||
onChange={(e) => updateSplit(i, { participant_id: Number(e.target.value) })}
|
||||
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{participants.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={s.share_percent}
|
||||
onChange={(e) => updateSplit(i, { share_percent: Number(e.target.value) })}
|
||||
className="w-16 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<span className="text-xs text-zinc-500">%</span>
|
||||
<button type="button" onClick={() => removeSplit(i)} className="text-zinc-500 hover:text-red-400 text-lg leading-none">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createTransaction.isPending}
|
||||
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{createTransaction.isPending ? "Saving..." : "Save Transaction"}
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user