// ===== ExpenseModal.jsx — add / edit a charge ===== function Dropdown({ trigger, children, width, align }) { const [open, setOpen] = useState(false); const [pos, setPos] = useState(null); const ref = useRef(null); useEffect(() => { const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, []); // Position the menu with fixed coords clamped to the viewport, so it never // gets cut off regardless of where the trigger sits (left/right, any width). useEffect(() => { if (!open) return; const place = () => { if (!ref.current) return; const r = ref.current.getBoundingClientRect(); const vw = document.documentElement.clientWidth; const w = Math.min(typeof width === "number" ? width : r.width, vw - 16); let left = align === "right" ? r.right - w : r.left; left = Math.max(8, Math.min(left, vw - w - 8)); setPos({ top: r.bottom + 6, left, width: w }); }; place(); window.addEventListener("resize", place); window.addEventListener("scroll", place, true); return () => { window.removeEventListener("resize", place); window.removeEventListener("scroll", place, true); }; }, [open, width, align]); return (
{trigger(open, () => setOpen((o) => !o))} {open && pos && (
{children(() => setOpen(false))}
)}
); } function PersonSelect({ value, people, onChange }) { const sel = people.find((p) => p.id === value); const selIdx = people.findIndex((p) => p.id === value); return ( ( )} > {(close) => people.map((p, i) => ( ))} ); } function CategorySelect({ value, onChange }) { const sel = catOf(value); return ( ( )} > {(close) => CATEGORIES.map((c) => ( ))} ); } function ExpenseModal({ people, initial, onSave, onClose }) { const idxOf = (id) => people.findIndex((p) => p.id === id); const [desc, setDesc] = useState(initial?.description || ""); const [amount, setAmount] = useState(initial?.amount != null ? String(initial.amount) : ""); const [category, setCategory] = useState(initial?.category || "food"); const [date, setDate] = useState(initial?.date || todayISO()); const [note, setNote] = useState(initial?.note || ""); const [multiPayer, setMultiPayer] = useState((initial?.payers?.length || 0) > 1); const [payers, setPayers] = useState( initial?.payers?.length ? initial.payers.map((p) => ({ ...p, amount: String(p.amount) })) : [{ personId: people[0]?.id, amount: "" }] ); const [participants, setParticipants] = useState( initial?.participants?.length ? initial.participants : people.map((p) => p.id) ); const [splitMode, setSplitMode] = useState(initial?.splitMode || "equal"); const [splits, setSplits] = useState(() => { const s = {}; people.forEach((p) => (s[p.id] = initial?.splits?.[p.id] != null ? String(initial.splits[p.id]) : "")); return s; }); const amtNum = Number(amount) || 0; useEffect(() => { if (!multiPayer) setPayers((ps) => [{ personId: ps[0]?.personId || people[0]?.id, amount: amount }]); }, [amount, multiPayer]); const txnDraft = { amount: amtNum, participants, splitMode, splits: Object.fromEntries(Object.entries(splits).map(([k, v]) => [k, Number(v) || 0])), payers: payers.map((p) => ({ personId: p.personId, amount: Number(p.amount) || 0 })), }; const sStatus = splitStatus(txnDraft); const pStatus = payerStatus(txnDraft); const sharePreview = computeShares(txnDraft, people); const togglePart = (id) => setParticipants((cur) => (cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id])); const setSplit = (id, v) => setSplits((s) => ({ ...s, [id]: v })); const switchMode = (m) => { setSplitMode(m); if (m === "shares") { setSplits((s) => { const next = { ...s }; participants.forEach((id) => { if (!(Number(next[id]) > 0)) next[id] = "1"; }); return next; }); } }; const bumpShare = (id, d) => setSplits((s) => ({ ...s, [id]: String(Math.max(0, (Number(s[id]) || 0) + d)) })); const fillEqualExact = () => { const total = toCents(amtNum), n = participants.length; if (!n) return; const base = Math.floor(total / n); let rem = total - base * n; const next = { ...splits }; participants.forEach((id, i) => (next[id] = fromCents(base + (i < rem ? 1 : 0)).toFixed(2))); setSplits(next); }; const fillEqualPercent = () => { const n = participants.length; if (!n) return; const each = Math.floor(10000 / n) / 100; const next = { ...splits }; let acc = 0; participants.forEach((id, i) => { if (i === n - 1) next[id] = String(Math.round((100 - acc) * 100) / 100); else { next[id] = String(each); acc += each; } }); setSplits(next); }; const addPayer = () => { const used = new Set(payers.map((p) => p.personId)); const next = people.find((p) => !used.has(p.id)); setPayers((ps) => [...ps, { personId: next?.id || people[0]?.id, amount: "" }]); }; const removePayer = (i) => setPayers((ps) => ps.filter((_, k) => k !== i)); const setPayer = (i, patch) => setPayers((ps) => ps.map((p, k) => (k === i ? { ...p, ...patch } : p))); const canSave = desc.trim() && amtNum > 0 && participants.length > 0 && pStatus.ok && sStatus.ok; const submit = () => { if (!canSave) return; onSave({ id: initial?.id || uid(), description: desc.trim(), amount: amtNum, category, date, note: note.trim(), payers: txnDraft.payers, participants, splitMode, splits: txnDraft.splits, createdAt: initial?.createdAt || Date.now(), }); }; useEffect(() => { const h = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, []); const totalShares = participants.reduce((s, id) => s + (Number(splits[id]) || 0), 0); const fieldLabel = (t) =>
{t}
; return (
{ if (e.target === e.currentTarget) onClose(); }}>

{initial ? "Edit charge" : "Add a charge"}


{fieldLabel("What was it for")} setDesc(e.target.value)} placeholder="Dinner at the beach shack" />
{fieldLabel("Amount")}
$ setAmount(e.target.value)} placeholder="0.00" style={{ paddingLeft: 28, textAlign: "right" }} min="0" step="0.01" />
{fieldLabel("Category")}
{fieldLabel("Date")} setDate(e.target.value)} style={{ colorScheme: "dark" }} />
{/* paid by */}
Paid by
{!multiPayer ? ( setPayer(0, { personId: id })} /> ) : (
{payers.map((p, i) => (
setPayer(i, { personId: id })} />
$ setPayer(i, { amount: e.target.value })} placeholder="0.00" style={{ paddingLeft: 26, textAlign: "right" }} min="0" step="0.01" />
))}
{pStatus.ok ? "Balanced" : `${money(pStatus.remaining)} ${pStatus.remaining > 0 ? "left" : "over"}`}
)}
{/* split between */}
Split between
{people.map((p, i) => { const on = participants.includes(p.id); return ( ); })}
{/* split method */} {participants.length > 0 && (
{splitMode === "exact" && } {splitMode === "percent" && } {splitMode === "shares" && }
{participants.map((id) => { const p = people.find((x) => x.id === id); const i = idxOf(id); const shareC = sharePreview[id] || 0; return (
{p?.name} {splitMode === "equal" && {money(fromCents(shareC))}} {splitMode === "exact" && (
$ setSplit(id, e.target.value)} placeholder="0.00" style={{ height: 38, paddingLeft: 24, paddingRight: 10, textAlign: "right" }} min="0" step="0.01" />
)} {splitMode === "percent" && (
{money(fromCents(shareC))}
% setSplit(id, e.target.value)} placeholder="0" style={{ height: 38, paddingRight: 24, paddingLeft: 10, textAlign: "right" }} min="0" step="0.1" />
)} {splitMode === "shares" && (
{money(fromCents(shareC))}
{Number(splits[id]) || 0}
)}
); })} {splitMode !== "equal" && ( <>
{splitMode === "shares" ? `Split into ${totalShares} share${totalShares === 1 ? "" : "s"}` : sStatus.ok ? "Adds up perfectly" : "Needs to total " + (splitMode === "percent" ? "100%" : money(sStatus.target))} {splitMode === "shares" ? (sStatus.ok ? "even by share" : "add a share") : sStatus.ok ? (splitMode === "percent" ? "100%" : money(sStatus.target)) : (splitMode === "percent" ? `${sStatus.remaining}% left` : `${money(sStatus.remaining)} left`)}
)}
)}
{fieldLabel("Note (optional)")}