feat(splits): save split as rule from split modal

- Checkbox in split modal: 'Also save as rule for <merchant>'
- Creates a rule with apply_split action storing the participant shares
- Rules engine now handles apply_split: deletes existing splits and re-applies
- Bulk split mode hides the checkbox (rule wouldn't make sense for ad-hoc bulk)
This commit is contained in:
2026-03-11 12:40:03 +11:00
parent a8743ba7df
commit af4c64bba7
4 changed files with 58 additions and 6 deletions
+36 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction } from "@/lib/hooks";
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction, useCreateRule } from "@/lib/hooks";
interface Split {
participant_id: number;
@@ -13,10 +13,11 @@ interface Props {
transactionIds?: number[];
amount?: number;
description: string;
merchant?: string;
onClose: () => void;
}
export function SplitModal({ transactionId, transactionIds, amount, description, onClose }: Props) {
export function SplitModal({ transactionId, transactionIds, amount, description, merchant, onClose }: Props) {
const isBulk = !!transactionIds && transactionIds.length > 0;
const singleId = transactionId ?? 0;
@@ -24,9 +25,12 @@ export function SplitModal({ transactionId, transactionIds, amount, description,
const { data: existingSplits } = useTransactionSplits(isBulk ? 0 : singleId);
const setSplits = useSetSplits();
const bulkAction = useBulkAction();
const createRule = useCreateRule();
const [splits, setSplitsState] = useState<Split[]>([]);
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
useEffect(() => {
@@ -94,7 +98,20 @@ export function SplitModal({ transactionId, transactionIds, amount, description,
} else {
await setSplits.mutateAsync({ transactionId: singleId, splits });
}
onClose();
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");
}
@@ -174,6 +191,22 @@ export function SplitModal({ transactionId, transactionIds, amount, description,
{error && <p className="text-red-400 text-sm mb-3">{error}</p>}
{ruleSaved && (
<p className="text-green-400 text-sm mb-3">Rule saved — future matching transactions will be split the same way.</p>
)}
{!isBulk && (
<label className="flex items-center gap-2 text-sm text-zinc-400 mb-3 cursor-pointer select-none">
<input
type="checkbox"
checked={saveAsRule}
onChange={(e) => setSaveAsRule(e.target.checked)}
className="accent-blue-500"
/>
Also save as rule for <span className="text-zinc-200 font-medium">{merchant || description.split(" ")[0]}</span>
</label>
)}
<div className="flex gap-3">
<button
onClick={onClose}