From 859043f5a5aa80125a370dd64bd49fa44705e387 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sat, 14 Mar 2026 20:06:13 +1100 Subject: [PATCH] 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 --- src/app/shared/page.tsx | 137 ++++++++++++++++++++++++++++++++-------- 1 file changed, 109 insertions(+), 28 deletions(-) diff --git a/src/app/shared/page.tsx b/src/app/shared/page.tsx index 3850bbc..6f64758 100644 --- a/src/app/shared/page.tsx +++ b/src/app/shared/page.tsx @@ -5,6 +5,7 @@ import { useSharedTransactions, useParticipantBalances, useSettleSplits, + useCreateParticipant, } from "@/lib/hooks"; import type { SharedTransactionRow } from "@/lib/queries"; @@ -12,8 +13,67 @@ function formatDate(d: string) { return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" }); } -function formatAmount(n: number) { - return `$${Number(n).toFixed(2)}`; +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 ( +
+

Add Participant

+
+ 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" + /> + 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" + /> + + +
+ {error &&

{error}

} +
+ ); } export default function SharedPage() { @@ -21,6 +81,7 @@ export default function SharedPage() { const { data: balances = [], isLoading: balLoading } = useParticipantBalances(); const settle = useSettleSplits(); const [settling, setSettling] = useState(null); + const [addingParticipant, setAddingParticipant] = useState(false); async function handleSettleParticipant(participantId: number) { setSettling(participantId); @@ -28,40 +89,60 @@ export default function SharedPage() { setSettling(null); } - const others = balances.filter((b) => b.name !== "Me"); - return (
-

Shared Expenses

+
+

Shared Expenses

+ {!addingParticipant && ( + + )} +
+ + {addingParticipant && ( + setAddingParticipant(false)} /> + )} {/* Balance summary */}
{balLoading ? (

Loading balances...

) : ( - others.map((b) => ( -
-
-
-

{b.name}

-

{b.unsettled_count} unsettled

-
-
-

{formatAmount(b.total_owed)}

-

owes you

+ balances.map((b) => { + const theyOweMe = b.total_owed > 0; + const net = Math.abs(b.total_owed); + return ( +
+
+
+

{b.name}

+

+ {theyOweMe ? `${b.unsettled_count} unsettled` : "you owe"} +

+
+
+

+ ${net.toFixed(2)} +

+

{theyOweMe ? "owes you" : "you owe"}

+
+ {theyOweMe && b.unsettled_count > 0 && ( + + )}
- {b.unsettled_count > 0 && ( - - )} -
- )) + ); + }) )}
@@ -100,8 +181,8 @@ export default function SharedPage() {

{tx.effective_merchant || tx.description}

{tx.description}

- - {formatAmount(tx.amount)} + + {formatAmount(tx.amount, tx.transaction_type)}