// ===== panels.jsx — Breakdown, Settle, PersonDetail, Export, TripMenu ===== // ---------- Spending breakdown (donut) ---------- function Breakdown({ txns, people }) { const cats = useMemo(() => categoryTotals(txns), [txns]); const total = txns.reduce((s, t) => s + (Number(t.amount) || 0), 0); return (
Spending
{txns.length === 0 ? (
Log charges to see where the money goes.
) : ( <>
{cats.map((c) => (
{c.label} {money(c.value)} {pctOf(c.value, total)}%
))}

{txns.length} charges {people.length} people {people.length > 0 && {money(total / people.length)} / person}
)}
); } // ---------- Settle up (with payment tracking) ---------- function SettlePanel({ people, txns, payments, canEdit, onAddPayment, onRemovePayment, onOpenPerson, onExport }) { const balances = useMemo(() => computeBalances(people, txns, payments), [people, txns, payments]); const transfers = useMemo(() => settle(balances), [balances]); const sorted = [...people].map((p, i) => ({ ...p, idx: i, bal: fromCents(balances[p.id] || 0) })).sort((a, b) => b.bal - a.bal); const anyActivity = txns.length > 0; const remaining = transfers.reduce((s, t) => s + t.amt, 0); const settledTotal = payments.reduce((s, p) => s + p.amt, 0); const grand = remaining + settledTotal; const pct = grand > 0 ? (settledTotal / grand) * 100 : (anyActivity ? 100 : 0); const maxAbs = Math.max(1, ...sorted.map((p) => Math.abs(p.bal))); const [showPaid, setShowPaid] = useState(false); return (
{/* net balances */}
Net balances
{anyActivity && tap for detail}
{!anyActivity ? (
Add charges to see who's up and who's down.
) : (
{sorted.map((p) => { const settled = Math.abs(p.bal) < 0.005; const col = settled ? "var(--muted)" : p.bal > 0 ? "var(--pos)" : "var(--neg)"; const w = (Math.abs(p.bal) / maxAbs) * 100; return ( ); })}
is owed owes
)}
{/* settle up */}
Settle up {transfers.length > 0 && {transfers.length} left}
{!anyActivity ? (
The fewest-payments plan to zero everyone out will appear here.
) : ( <> {grand > 0 && (
{money(settledTotal)} settled {money(remaining)} to go
)} {transfers.length === 0 ? (
All square — everyone's paid up.
) : (
{transfers.map((t, i) => { const from = people.find((p) => p.id === t.from), to = people.find((p) => p.id === t.to); const fi = people.findIndex((p) => p.id === t.from), ti = people.findIndex((p) => p.id === t.to); return (
{from?.name} {to?.name}
{money(t.amt)} {canEdit && ( )}
); })}
)} {payments.length > 0 && (
{showPaid && (
{payments.map((pm) => { const from = people.find((p) => p.id === pm.from), to = people.find((p) => p.id === pm.to); return (
{from?.name} paid {to?.name} {money(pm.amt)} {canEdit && }
); })}
)}
)} )}
); } // ---------- Per-person detail ---------- function PersonDetail({ personId, people, txns, payments, onClose }) { const idx = people.findIndex((p) => p.id === personId); const person = people[idx]; const balances = useMemo(() => computeBalances(people, txns, payments), [people, txns, payments]); const transfers = useMemo(() => settle(balances), [balances]); const roll = useMemo(() => personRollup(personId, people, txns, payments), [personId, people, txns, payments]); const bal = fromCents(balances[personId] || 0); const settled = Math.abs(bal) < 0.005; const col = settled ? "var(--muted)" : bal > 0 ? "var(--pos)" : "var(--neg)"; const mine = transfers.filter((t) => t.from === personId || t.to === personId); useEffect(() => { const h = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, []); return (
{ if (e.target === e.currentTarget) onClose(); }}>

{person?.name}

{settled ? "All settled up" : bal > 0 ? `is owed ${money(bal)}` : `owes ${money(-bal)}`}

{/* stat trio */}
{[["Paid out", money(roll.paid), "var(--text)"], ["Their share", money(roll.owed), "var(--text)"], ["Net", settled ? "—" : money(bal, { sign: true }), col]].map(([k, v, c], i) => (
{k}
{v}
))}
{/* share of total spend */} {(() => { const total = txns.reduce((s, t) => s + (Number(t.amount) || 0), 0); const pct = pctOf(roll.owed, total); return (
Covers {money(roll.owed)} of the {money(total)} trip — {pct}% of total spend.
); })()} {/* to settle */} {mine.length > 0 && (
To settle
{mine.map((t, i) => { const isPayer = t.from === personId; const other = people.find((p) => p.id === (isPayer ? t.to : t.from)); const oi = people.findIndex((p) => p.id === other.id); return (
{isPayer ? <>Pay {other?.name} : <>{other?.name} pays you} {money(t.amt)}
); })}
)} {/* charges */}
In these charges ({roll.inCharges.length})
{roll.inCharges.length === 0 ? (
Not part of any charge yet.
) : (
{roll.inCharges.map(({ txn, share, paid }) => (
{txn.description}
{fmtDate(txn.date)} {paid > 0 && paid {money(paid)}}
{money(share)}
their share
))}
)}
); } // ---------- Charge detail (read-only, tap a charge) ---------- function ChargeDetail({ txn, people, onClose }) { useEffect(() => { const h = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, []); const SPLIT_LABEL = { equal: "Split equally", exact: "Custom amounts", percent: "By percentage", shares: "By shares" }; const total = toCents(txn.amount); const shares = computeShares(txn, people); const parts = txn.participants.filter((id) => people.some((p) => p.id === id)); const cat = catOf(txn.category); const idxOf = (id) => people.findIndex((p) => p.id === id); const multi = txn.payers.length > 1; return (
{ if (e.target === e.currentTarget) onClose(); }}>

{txn.description}

{fmtDate(txn.date)} · {cat.label}

{money(txn.amount)}
{SPLIT_LABEL[txn.splitMode] || "Split equally"} · {parts.length} {parts.length === 1 ? "person" : "people"}
{txn.note && (
{txn.note}
)} {/* paid by */}
Paid by
{txn.payers.map((pay, i) => { const p = people.find((x) => x.id === pay.personId); return (
{p?.name || "—"} {money(multi ? pay.amount : txn.amount)}
); })}
{/* split breakdown */}
Split between
{parts.length === 0 ? (
Nobody is on this charge.
) : parts.map((id) => { const p = people.find((x) => x.id === id); const c = shares[id] || 0; const pct = pctOf(c, total); return (
{p?.name} {pct}% {money(fromCents(c))}
); })}
); } // ---------- Export summary ---------- function ExportModal({ trip, onClose }) { const text = useMemo(() => buildSummary(trip), [trip]); const [copied, setCopied] = useState(false); const copy = async () => { try { await navigator.clipboard.writeText(text); } catch (e) { const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); } catch (e2) {} document.body.removeChild(ta); } setCopied(true); setTimeout(() => setCopied(false), 1800); }; const download = () => { const blob = new Blob([text], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = (trip.name || "trip").replace(/[^\w]+/g, "-").toLowerCase() + "-summary.txt"; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); }; useEffect(() => { const h = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, []); return (
{ if (e.target === e.currentTarget) onClose(); }}>

Trip summary


{text}

); } // ---------- Trip switcher menu ---------- function TripMenu({ trips, activeId, onSwitch, onNew, onJoin, onDelete, onLeave, admin, adminEnabled, onAdmin, onExitAdmin }) { return ( ( )} > {(close) => ( <> {trips.map((t) => { const total = t.txns.reduce((s, x) => s + (Number(x.amount) || 0), 0); return (
{t.owner ? ( ) : ( )}
); })}
{admin ? ( ) : adminEnabled ? ( ) : null} )}
); } Object.assign(window, { Breakdown, SettlePanel, PersonDetail, ChargeDetail, ExportModal, TripMenu });