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
This commit is contained in:
+109
-28
@@ -5,6 +5,7 @@ import {
|
|||||||
useSharedTransactions,
|
useSharedTransactions,
|
||||||
useParticipantBalances,
|
useParticipantBalances,
|
||||||
useSettleSplits,
|
useSettleSplits,
|
||||||
|
useCreateParticipant,
|
||||||
} from "@/lib/hooks";
|
} from "@/lib/hooks";
|
||||||
import type { SharedTransactionRow } from "@/lib/queries";
|
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" });
|
return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||||||
return `$${Number(n).toFixed(2)}`;
|
|
||||||
|
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() {
|
export default function SharedPage() {
|
||||||
@@ -21,6 +81,7 @@ export default function SharedPage() {
|
|||||||
const { data: balances = [], isLoading: balLoading } = useParticipantBalances();
|
const { data: balances = [], isLoading: balLoading } = useParticipantBalances();
|
||||||
const settle = useSettleSplits();
|
const settle = useSettleSplits();
|
||||||
const [settling, setSettling] = useState<number | null>(null);
|
const [settling, setSettling] = useState<number | null>(null);
|
||||||
|
const [addingParticipant, setAddingParticipant] = useState(false);
|
||||||
|
|
||||||
async function handleSettleParticipant(participantId: number) {
|
async function handleSettleParticipant(participantId: number) {
|
||||||
setSettling(participantId);
|
setSettling(participantId);
|
||||||
@@ -28,40 +89,60 @@ export default function SharedPage() {
|
|||||||
setSettling(null);
|
setSettling(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const others = balances.filter((b) => b.name !== "Me");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-xl font-semibold">Shared Expenses</h2>
|
<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 */}
|
{/* Balance summary */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{balLoading ? (
|
{balLoading ? (
|
||||||
<p className="text-zinc-500 text-sm col-span-3">Loading balances...</p>
|
<p className="text-zinc-500 text-sm col-span-3">Loading balances...</p>
|
||||||
) : (
|
) : (
|
||||||
others.map((b) => (
|
balances.map((b) => {
|
||||||
<div key={b.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
const theyOweMe = b.total_owed > 0;
|
||||||
<div className="flex items-start justify-between mb-3">
|
const net = Math.abs(b.total_owed);
|
||||||
<div>
|
return (
|
||||||
<p className="font-medium">{b.name}</p>
|
<div key={b.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
<p className="text-xs text-zinc-500">{b.unsettled_count} unsettled</p>
|
<div className="flex items-start justify-between mb-3">
|
||||||
</div>
|
<div>
|
||||||
<div className="text-right">
|
<p className="font-medium">{b.name}</p>
|
||||||
<p className="text-lg font-semibold text-amber-400">{formatAmount(b.total_owed)}</p>
|
<p className="text-xs text-zinc-500">
|
||||||
<p className="text-xs text-zinc-500">owes you</p>
|
{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>
|
</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>
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
@@ -100,8 +181,8 @@ export default function SharedPage() {
|
|||||||
<p className="font-medium truncate max-w-48">{tx.effective_merchant || tx.description}</p>
|
<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>
|
<p className="text-xs text-zinc-500 truncate max-w-48">{tx.description}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right font-medium tabular-nums">
|
<td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}>
|
||||||
{formatAmount(tx.amount)}
|
{formatAmount(tx.amount, tx.transaction_type)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user