// ===== 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"}
{/* paid by */}
Paid by
{!multiPayer ? (
setPayer(0, { personId: id })} />
) : (
{payers.map((p, i) => (
))}
{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" && (
)}
{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)")}
{participants.length} {participants.length === 1 ? "person" : "people"} · {money(amtNum)}
);
}
Object.assign(window, { ExpenseModal, PersonSelect, CategorySelect, Dropdown });