feat(finance): implement Shared Expenses page
Show split transactions with per-participant balance cards and settle buttons.
This commit is contained in:
+142
-3
@@ -1,8 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useSharedTransactions,
|
||||
useParticipantBalances,
|
||||
useSettleSplits,
|
||||
} 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" });
|
||||
}
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return `$${Number(n).toFixed(2)}`;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
async function handleSettleParticipant(participantId: number) {
|
||||
setSettling(participantId);
|
||||
await settle.mutateAsync({ participant_id: participantId });
|
||||
setSettling(null);
|
||||
}
|
||||
|
||||
const others = balances.filter((b) => b.name !== "Me");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Shared Expenses</h2>
|
||||
<p className="text-zinc-500">Coming soon - track shared expenses and splits.</p>
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold">Shared Expenses</h2>
|
||||
|
||||
{/* 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>
|
||||
) : (
|
||||
others.map((b) => (
|
||||
<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">{b.unsettled_count} unsettled</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold text-amber-400">{formatAmount(b.total_owed)}</p>
|
||||
<p className="text-xs text-zinc-500">owes you</p>
|
||||
</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>
|
||||
|
||||
{/* 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">
|
||||
{formatAmount(tx.amount)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user