Files
finance-app/src/app/shared/page.tsx
T
siddharthd 859043f5a5 feat(shared): bidirectional split balance, credit direction, and multi-user view
- Rewrite participant balance to UNION both directions (they owe me + I owe them)
- Credits/refunds subtract from owed amount for correct net balance
- Allow secondary users to see transactions split with them
- Add participant balance cards with colour-coded owe direction
- Add inline AddParticipantForm with name + optional email
2026-03-14 20:06:13 +11:00

229 lines
9.2 KiB
TypeScript

"use client";
import { useState } from "react";
import {
useSharedTransactions,
useParticipantBalances,
useSettleSplits,
useCreateParticipant,
} from "@/lib/hooks";
import type { SharedTransactionRow } from "@/lib/queries";
function formatDate(d: string) {
return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
}
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
function formatAmount(n: number, type?: string) {
const formatted = `$${Number(n).toFixed(2)}`;
return type && !SPEND_TYPES.has(type) ? `+${formatted}` : formatted;
}
function AddParticipantForm({ onDone }: { onDone: () => void }) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [error, setError] = useState("");
const create = useCreateParticipant();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!name.trim()) { setError("Name is required"); return; }
try {
await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined });
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create");
}
}
return (
<form onSubmit={handleSubmit} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4 space-y-3">
<p className="text-sm font-medium">Add Participant</p>
<div className="flex gap-2">
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
/>
<input
type="email"
placeholder="Email (optional)"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
/>
<button
type="submit"
disabled={create.isPending}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{create.isPending ? "Adding..." : "Add"}
</button>
<button
type="button"
onClick={onDone}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-lg text-sm"
>
Cancel
</button>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
</form>
);
}
export default function SharedPage() {
const { data: transactions = [], isLoading: txLoading } = useSharedTransactions();
const { data: balances = [], isLoading: balLoading } = useParticipantBalances();
const settle = useSettleSplits();
const [settling, setSettling] = useState<number | null>(null);
const [addingParticipant, setAddingParticipant] = useState(false);
async function handleSettleParticipant(participantId: number) {
setSettling(participantId);
await settle.mutateAsync({ participant_id: participantId });
setSettling(null);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Shared Expenses</h2>
{!addingParticipant && (
<button
onClick={() => setAddingParticipant(true)}
className="text-sm px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
>
+ Add Participant
</button>
)}
</div>
{addingParticipant && (
<AddParticipantForm onDone={() => setAddingParticipant(false)} />
)}
{/* Balance summary */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{balLoading ? (
<p className="text-zinc-500 text-sm col-span-3">Loading balances...</p>
) : (
balances.map((b) => {
const theyOweMe = b.total_owed > 0;
const net = Math.abs(b.total_owed);
return (
<div key={b.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<div className="flex items-start justify-between mb-3">
<div>
<p className="font-medium">{b.name}</p>
<p className="text-xs text-zinc-500">
{theyOweMe ? `${b.unsettled_count} unsettled` : "you owe"}
</p>
</div>
<div className="text-right">
<p className={`text-lg font-semibold ${theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
${net.toFixed(2)}
</p>
<p className="text-xs text-zinc-500">{theyOweMe ? "owes you" : "you owe"}</p>
</div>
</div>
{theyOweMe && b.unsettled_count > 0 && (
<button
onClick={() => handleSettleParticipant(b.id)}
disabled={settling === b.id}
className="w-full py-1.5 text-xs font-medium bg-emerald-700 hover:bg-emerald-600 text-white rounded-lg disabled:opacity-50"
>
{settling === b.id ? "Settling..." : "Mark All Settled"}
</button>
)}
</div>
);
})
)}
</div>
{/* Transaction list */}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-zinc-800">
<h3 className="text-sm font-medium">Split Transactions</h3>
</div>
{txLoading ? (
<p className="text-zinc-500 text-sm px-4 py-6">Loading...</p>
) : transactions.length === 0 ? (
<p className="text-zinc-500 text-sm px-4 py-6">
No split transactions yet. Use the Split button on any transaction.
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Date</th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Description</th>
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Amount</th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th>
<th className="px-4 py-2 text-xs text-zinc-500 font-medium">Action</th>
</tr>
</thead>
<tbody>
{(transactions as SharedTransactionRow[]).map((tx) => {
const splits = Array.isArray(tx.splits) ? tx.splits : [];
const unsettled = splits.filter((s) => !s.settled && s.name !== "Me");
return (
<tr key={tx.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
{formatDate(tx.transaction_date)}
</td>
<td className="px-4 py-3">
<p className="font-medium truncate max-w-48">{tx.effective_merchant || tx.description}</p>
<p className="text-xs text-zinc-500 truncate max-w-48">{tx.description}</p>
</td>
<td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}>
{formatAmount(tx.amount, tx.transaction_type)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{splits.map((s) => (
<span
key={s.participant_id}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${
s.settled
? "bg-zinc-800 text-zinc-500"
: "bg-amber-900/40 text-amber-300"
}`}
>
{s.name} {s.share_percent}%
{s.settled && <span className="text-emerald-500"></span>}
</span>
))}
</div>
</td>
<td className="px-4 py-3 text-center">
{unsettled.length > 0 && (
<button
onClick={() =>
settle.mutateAsync({
split_ids: unsettled.map((s) => (s as unknown as { split_id: number }).split_id),
})
}
disabled={settle.isPending}
className="text-xs px-2 py-1 bg-emerald-800 hover:bg-emerald-700 text-white rounded disabled:opacity-50"
>
Settle
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
}