"use client"; import { useState } from "react"; import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction, useCreateRule, useCurrentUser } from "@/lib/hooks"; interface Split { participant_id: number; share_percent: number; } interface Props { transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string; onClose: () => void; } export function SplitModal({ transactionId, transactionIds, amount, description, merchant, onClose }: Props) { const isBulk = !!transactionIds && transactionIds.length > 0; const singleId = transactionId ?? 0; const { data: participants } = useParticipants(); const { data: currentUser } = useCurrentUser(); const { data: existingSplits } = useTransactionSplits(isBulk ? 0 : singleId); const setSplits = useSetSplits(); const bulkAction = useBulkAction(); const createRule = useCreateRule(); const [splits, setSplitsState] = useState([]); const [error, setError] = useState(""); const [saveAsRule, setSaveAsRule] = useState(false); const [ruleSaved, setRuleSaved] = useState(false); // Initialise: bulk always defaults to 100% Me; single loads existing splits. // // Seeded during render behind a previous-value guard rather than in an // effect. Both queries resolve after the first paint, so as an effect this // rendered an empty split table and then replaced it — and, worse, it re-ran // on every identity change of `participants`/`existingSplits`, which would // discard edits in progress if either query refetched while the modal was // open. Keying on the inputs that actually define the seed fixes both. const seedKey = !participants || participants.length === 0 || !currentUser ? null : `${isBulk ? "bulk" : singleId}:${currentUser.id}:${ existingSplits ? JSON.stringify(existingSplits) : "none" }`; const [seededFrom, setSeededFrom] = useState(null); if (seedKey !== null && seedKey !== seededFrom) { setSeededFrom(seedKey); setSplitsState( !isBulk && existingSplits && existingSplits.length > 0 ? existingSplits.map((s: { participant_id: number; share_percent: number }) => ({ participant_id: s.participant_id, share_percent: Number(s.share_percent), })) : [{ participant_id: currentUser!.id, share_percent: 100 }] ); } const total = splits.reduce((sum, s) => sum + s.share_percent, 0); const toggleParticipant = (id: number) => { setSplitsState((prev) => { const exists = prev.find((s) => s.participant_id === id); if (exists) { return prev.filter((s) => s.participant_id !== id); } // Add with equal split const count = prev.length + 1; const equal = Math.floor(100 / count); const remainder = 100 - equal * count; return [ ...prev.map((s, i) => ({ ...s, share_percent: equal + (i === 0 ? remainder : 0) })), { participant_id: id, share_percent: equal }, ]; }); }; const updateShare = (id: number, value: number) => { setSplitsState((prev) => prev.map((s) => (s.participant_id === id ? { ...s, share_percent: value } : s)) ); }; const splitEvenly = () => { if (splits.length === 0) return; const each = Math.floor(100 / splits.length); const remainder = 100 - each * splits.length; setSplitsState((prev) => prev.map((s, i) => ({ ...s, share_percent: each + (i === 0 ? remainder : 0) })) ); }; const isPending = isBulk ? bulkAction.isPending : setSplits.isPending; const handleSave = async () => { setError(""); if (Math.abs(total - 100) > 0.01) { setError(`Shares must sum to 100% (currently ${total.toFixed(1)}%)`); return; } try { if (isBulk) { await bulkAction.mutateAsync({ action: "split", ids: transactionIds!, splits }); } else { await setSplits.mutateAsync({ transactionId: singleId, splits }); } if (saveAsRule && !isBulk) { const matchValue = merchant || (description.split(" ")[0] ?? description); await createRule.mutateAsync({ name: `Split: ${merchant || description}`, conditions: [{ field: "merchant_normalized", operator: "contains", value: matchValue }], actions: { apply_split: splits }, enabled: true, priority: 0, }); setRuleSaved(true); setTimeout(onClose, 1200); } else { onClose(); } } catch (e) { setError(e instanceof Error ? e.message : "Failed to save splits"); } }; return (
e.stopPropagation()} >

{isBulk ? `Split ${transactionIds!.length} Transactions` : "Split Transaction"}

{description}

{!isBulk && amount !== undefined && (

${Number(amount).toFixed(2)}

)} {/* Participant toggles */}
{participants?.map((p) => { const split = splits.find((s) => s.participant_id === p.id); const active = !!split; return (
{p.name} {active && (
updateShare(p.id, Number(e.target.value))} className="w-24 accent-blue-600" /> {split.share_percent}% {!isBulk && amount !== undefined && ( ${((amount * split.share_percent) / 100).toFixed(2)} )}
)}
); })}
{/* Total indicator */}
0.01 ? "text-red-400" : "text-green-400"}`}> Total: {total.toFixed(1)}%
{error &&

{error}

} {ruleSaved && (

Rule saved — future matching transactions will be split the same way.

)} {!isBulk && ( )}
); }