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:
2026-03-14 20:04:00 +11:00
parent 0985c38be8
commit fc22a61a43
6 changed files with 427 additions and 36 deletions
+215
View File
@@ -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>
);
}