"use client"; import { useState, useRef, useEffect } from "react"; import { useSharedTransactions, useParticipantBalances, useParticipants, useCreateParticipant, useRecordPayment, usePaymentHistory, useDeletePayment, useCurrentUser, useTags, useTrips, type SplitPayment, } from "@/lib/hooks"; import type { SharedTransactionRow } from "@/lib/queries"; import { EditTransactionModal } from "@/components/edit-transaction-modal"; import { formatCategory } from "@/lib/categories"; import { CATEGORY_COLORS } from "@/lib/category-colors"; 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, currency?: string) { // A bare "$" on a non-AUD row was the visible half of the problem: the row // read as dollars while the participant balances converted to AUD, so the // two disagreed on screen with nothing to explain why. const value = Number(n).toFixed(2); const formatted = !currency || currency === "AUD" ? `$${value}` : `${currency} ${value}`; return type && !SPEND_TYPES.has(type) ? `+${formatted}` : formatted; } // ── Tag multi-select ────────────────────────────────────────────────────────── function TagFilter({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) { const { data: tags = [] } = useTags(); const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { function handler(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); } document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); const toggle = (id: string) => { let next: string[]; if (value.includes(id)) { next = value.filter((x) => x !== id); } else if (id === "untagged") { next = ["untagged"]; } else { next = [...value.filter((x) => x !== "untagged"), id]; } onChange(next); }; const label = value.length === 0 ? "All Tags" : value.includes("untagged") ? "No tags" : value.length === 1 ? (tags.find((t) => String(t.id) === value[0])?.name ?? "1 tag") : `${value.length} tags`; return (
{open && (
{tags.map((t) => ( ))}
)}
); } // ── Add Participant ─────────────────────────────────────────────────────────── 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}

}
); } // ── Record Payment modal ────────────────────────────────────────────────────── function RecordPaymentModal({ participant, currentUserId, currentBalance, onClose, }: { participant: { id: number; name: string }; currentUserId: number; currentBalance: number; // positive = they owe me, negative = I owe them onClose: () => void; }) { const record = useRecordPayment(); const { data: trips = [] } = useTrips(); const theyOweMe = currentBalance > 0; // Default direction matches the debt direction const [amount, setAmount] = useState(Math.abs(currentBalance).toFixed(2)); const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); const [notes, setNotes] = useState(""); // direction: "received" = they paid me, "sent" = I paid them const [direction, setDirection] = useState<"received" | "sent">(theyOweMe ? "received" : "sent"); // Which tab this settles. "" = the ongoing household tab (trip_id NULL). const [tripId, setTripId] = useState(""); const [error, setError] = useState(""); async function handleSave() { setError(""); const amt = parseFloat(amount); if (!amt || amt <= 0) { setError("Enter a valid amount"); return; } try { await record.mutateAsync({ from_participant_id: direction === "received" ? participant.id : currentUserId, to_participant_id: direction === "received" ? currentUserId : participant.id, amount: amt, payment_date: date, notes: notes || undefined, trip_id: tripId ? Number(tripId) : null, }); onClose(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to record payment"); } } return (
e.stopPropagation()}>

Record Payment

{/* Direction toggle */}
$ setAmount(e.target.value)} className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm pl-6" />
setDate(e.target.value)} className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" />
{/* Scope. Until now every payment recorded here landed on the household tab, because the API dropped trip_id — so a $11k Europe settlement silently reduced the ongoing household balance instead. */}

Covering more than one tab? Record it once per tab — the parts add back up to the transfer.

setNotes(e.target.value)} placeholder="e.g. Bank transfer, cash" className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" />
{error &&

{error}

}
); } // ── Payment history inline ──────────────────────────────────────────────────── function PaymentHistory({ participantId, currentUserId }: { participantId: number; currentUserId: number }) { const { data: payments = [], isLoading } = usePaymentHistory(participantId); const deletePayment = useDeletePayment(); if (isLoading) return

Loading payments…

; if (payments.length === 0) return

No payments recorded

; return (

Payment history

{payments.map((p: SplitPayment) => { const theyPaidMe = p.to_participant_id === currentUserId; return (
{theyPaidMe ? "+" : "-"}${Number(p.amount).toFixed(2)} {formatDate(p.payment_date)} {/* Scope, so a grouped transfer stops looking like a duplicate: two rows of the same amount and date differ only by which tab they settle, and that was invisible until the API returned trip_id. */} {p.trip_name ?? "Household"} {p.notes && {p.notes}}
); })}
); } // ── Main page ───────────────────────────────────────────────────────────────── type SortCol = "transaction_date" | "created_at" | "amount"; export default function SharedPage() { const [tagIds, setTagIds] = useState([]); const [participantId, setParticipantId] = useState(undefined); const [sortCol, setSortCol] = useState("transaction_date"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); const [search, setSearch] = useState(""); const realTagIds = tagIds.filter((id) => id !== "untagged"); const { data: participants = [] } = useParticipants(); const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds, participantId); // Filtered client-side, like the sort above and unlike the transactions page. // This endpoint returns every split row in one go (1,267 today) with no // pagination, so there is nothing for a server round-trip to narrow — and a // server search would have to be added to a query the balance cards share. // // Deliberately does NOT match participant names: the participant dropdown // already does that properly, and typing "sonu" matching every row she is // split on would make the box look broken. The payer IS matched, because // nothing else on the page filters by who paid. const transactions = [...rawTransactions] .filter((tx) => { const q = search.trim().toLowerCase(); if (!q) return true; return [ tx.description, tx.effective_merchant, tx.notes, tx.effective_category ? formatCategory(tx.effective_category) : null, tx.owner_name, ].some((f) => f?.toLowerCase().includes(q)); }) .sort((a, b) => { const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime(); const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime(); return sortDir === "desc" ? bv - av : av - bv; }); function toggleSort(col: SortCol) { if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc")); else { setSortCol(col); setSortDir("desc"); } } function SortIcon({ col }: { col: SortCol }) { if (sortCol !== col) return ; return {sortDir === "desc" ? "↓" : "↑"}; } const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds); const { data: allTags = [] } = useTags(); const { data: me } = useCurrentUser(); // Names the tag scope when one is active. Non-empty means the cards below are // split totals rather than payable balances. const tagScopeLabel = realTagIds.length === 0 ? null : realTagIds.length === 1 ? (allTags.find((t) => String(t.id) === realTagIds[0])?.name ?? "this tag") : `${realTagIds.length} tags`; const [addingParticipant, setAddingParticipant] = useState(false); const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null); const [showHistory, setShowHistory] = useState(null); const [editModal, setEditModal] = useState(null); return (

Shared Expenses

setSearch(e.target.value)} placeholder="Search description, merchant, category, payer…" aria-label="Search split transactions" className="w-64 bg-zinc-800 border border-zinc-700 rounded-lg pl-8 pr-2 py-1.5 text-sm placeholder:text-zinc-600 focus:outline-none focus:border-zinc-500" />
{!addingParticipant && ( )}
{addingParticipant && setAddingParticipant(false)} />} {/* Balance cards */} {realTagIds.length === 0 && tagIds.includes("untagged") ? null : realTagIds.length > 0 && (

Showing split totals for selected tag — payments excluded (payments settle overall debt, not per-tag)

)}
{balLoading ? (

Loading balances...

) : balances.length === 0 ? (

No participants yet.

) : ( balances.map((b) => { const theyOweMe = b.total_owed > 0; const net = Math.abs(b.total_owed); const settled = net < 0.005; return (

{b.name}

{/* With a tag filter on, payments are deliberately not subtracted — so this is a split total, not a payable balance, and must not claim to be one. */} {tagScopeLabel ? `split total in ${tagScopeLabel}` : settled ? "all square" : theyOweMe ? "owes you" : "you owe"}

${net.toFixed(2)}

{b.unconverted_count > 0 && (

approx · {b.unconverted_count} unconverted

)}
{/* Settling against a tag-scoped total would record a payment for a figure that never was the debt. */} {!tagScopeLabel && ( )}
{showHistory === b.id && me && ( )}
); }) )}
{/* Transaction list */}

Split Transactions

{search.trim() && !txLoading && ( {transactions.length} of {rawTransactions.length} match “{search.trim()}” )}
{txLoading ? (

Loading...

) : transactions.length === 0 ? ( // "None yet" is wrong when a search simply matched nothing, and it reads // as though the splits were lost. search.trim() ? (

Nothing matches “{search.trim()}”.{" "}

) : (

No split transactions yet. Use the Split button on any transaction.

) ) : ( {/* min-w raised from 520px with the Category and Paid-by columns: the wrapper scrolls horizontally, so a too-small minimum crushes cells rather than letting them scroll. */} {/* Paid by sits next to Splits deliberately: together they are the two halves of the question this page exists to answer — whose money went out, and whose share it was. */} {(transactions as SharedTransactionRow[]).map((tx) => { const splits = Array.isArray(tx.splits) ? tx.splits : []; return ( {/* Category is the effective one — the override wins over the extracted value, the same COALESCE every other view uses, so a correction made elsewhere shows up here too. */} {/* Whose money actually left. This is the effective owner — COALESCE(t.owner_id, s.owner_id) — so it is the account the spend came out of, which is what every balance on this page is computed from. "Me" matches the split chips rather than printing your own name twice in one row. */} ); })}
toggleSort("transaction_date")} > Date toggleSort("created_at")} > Imported Description Category toggleSort("amount")} > Amount Paid by Splits
{formatDate(tx.transaction_date)} {formatDate(tx.created_at)}

{tx.effective_merchant || tx.description}

{tx.effective_merchant && (

{tx.description}

)} {tx.notes && (

{tx.notes}

)}
{tx.effective_category ? ( {formatCategory(tx.effective_category)} ) : ( uncategorised )} {formatAmount(tx.amount, tx.transaction_type, tx.currency)} {tx.currency !== "AUD" && ( // Splits settle on the AUD figure, so show it next to the // native one rather than leaving the two to differ silently. {tx.amount_unconverted ? "AUD value unknown" : `≈ ${formatAmount(Number(tx.amount_aud), tx.transaction_type, "AUD")} AUD`} )} {tx.owner_id === me?.id ? "Me" : tx.owner_name}
{splits.map((s) => ( {s.participant_id === me?.id ? "Me" : s.name} {s.share_percent}% ))}
)}
{/* Payment modal */} {paymentModal && me && ( setPaymentModal(null)} /> )} {editModal && ( setEditModal(null)} /> )}
); }