Files
finance-app/src/components/split-modal.tsx
T

196 lines
6.8 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction } from "@/lib/hooks";
interface Split {
participant_id: number;
share_percent: number;
}
interface Props {
transactionId?: number;
transactionIds?: number[];
amount?: number;
description: string;
onClose: () => void;
}
export function SplitModal({ transactionId, transactionIds, amount, description, onClose }: Props) {
const isBulk = !!transactionIds && transactionIds.length > 0;
const singleId = transactionId ?? 0;
const { data: participants } = useParticipants();
const { data: existingSplits } = useTransactionSplits(isBulk ? 0 : singleId);
const setSplits = useSetSplits();
const bulkAction = useBulkAction();
const [splits, setSplitsState] = useState<Split[]>([]);
const [error, setError] = useState("");
// Initialise: bulk always defaults to 100% Me; single loads existing splits
useEffect(() => {
if (!participants || participants.length === 0) return;
const me = participants.find((p) => p.name === "Me");
if (isBulk) {
if (me) setSplitsState([{ participant_id: me.id, share_percent: 100 }]);
} else if (existingSplits && existingSplits.length > 0) {
setSplitsState(
existingSplits.map((s: { participant_id: number; share_percent: number }) => ({
participant_id: s.participant_id,
share_percent: Number(s.share_percent),
}))
);
} else if (me) {
setSplitsState([{ participant_id: me.id, share_percent: 100 }]);
}
}, [existingSplits, participants, isBulk]);
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 });
}
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save splits");
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md mx-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold mb-1">
{isBulk ? `Split ${transactionIds!.length} Transactions` : "Split Transaction"}
</h3>
<p className="text-sm text-zinc-400 mb-4 truncate">{description}</p>
{!isBulk && amount !== undefined && (
<p className="text-2xl font-mono font-semibold mb-6">
${Number(amount).toFixed(2)}
</p>
)}
{/* Participant toggles */}
<div className="space-y-3 mb-4">
{participants?.map((p) => {
const split = splits.find((s) => s.participant_id === p.id);
const active = !!split;
return (
<div key={p.id} className="flex items-center gap-3">
<button
onClick={() => toggleParticipant(p.id)}
className={`w-8 h-8 rounded-full text-sm font-medium flex-shrink-0 transition-colors ${
active
? "bg-blue-600 text-white"
: "bg-zinc-800 text-zinc-500 hover:bg-zinc-700"
}`}
>
{p.name.charAt(0).toUpperCase()}
</button>
<span className="flex-1 text-sm">{p.name}</span>
{active && (
<div className="flex items-center gap-2">
<input
type="range"
min={1}
max={99}
value={split.share_percent}
onChange={(e) => updateShare(p.id, Number(e.target.value))}
className="w-24 accent-blue-600"
/>
<span className="w-12 text-right text-sm font-mono">
{split.share_percent}%
</span>
{!isBulk && amount !== undefined && (
<span className="w-20 text-right text-sm text-zinc-400 font-mono">
${((amount * split.share_percent) / 100).toFixed(2)}
</span>
)}
</div>
)}
</div>
);
})}
</div>
{/* Total indicator */}
<div className="flex items-center justify-between mb-4 text-sm">
<span className={`font-mono ${Math.abs(total - 100) > 0.01 ? "text-red-400" : "text-green-400"}`}>
Total: {total.toFixed(1)}%
</span>
<button
onClick={splitEvenly}
className="text-blue-400 hover:text-blue-300 text-xs"
>
Split evenly
</button>
</div>
{error && <p className="text-red-400 text-sm mb-3">{error}</p>}
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isPending || Math.abs(total - 100) > 0.01}
className="flex-1 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
>
{isPending ? "Saving..." : "Save splits"}
</button>
</div>
</div>
</div>
);
}