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
+18
View File
@@ -9,10 +9,16 @@ interface Condition {
value: string;
}
interface SplitEntry {
participant_id: number;
share_percent: number;
}
interface Actions {
set_category?: string;
add_tag_ids?: number[];
set_merchant?: string;
apply_split?: SplitEntry[];
}
interface TxFields {
@@ -108,6 +114,18 @@ export async function POST(req: NextRequest) {
);
}
}
if (actions.apply_split?.length) {
// Delete existing splits then insert new ones
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [tx.id]);
for (const s of actions.apply_split) {
await queryRaw(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[tx.id, s.participant_id, s.share_percent]
);
}
}
}
}
+3 -2
View File
@@ -232,7 +232,7 @@ function TransactionsContent() {
const [selected, setSelected] = useState<Set<number>>(new Set());
const [bulkCategory, setBulkCategory] = useState("");
const [bulkTagId, setBulkTagId] = useState("");
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string } | null>(null);
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | null>(null);
const [rulePrompt, setRulePrompt] = useState<{
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
field: "category" | "merchant";
@@ -543,7 +543,7 @@ function TransactionsContent() {
</td>
<td className="p-2">
<button
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, transactionIds: undefined })}
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, merchant: t.effective_merchant || undefined, transactionIds: undefined })}
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
title="Split this transaction"
>
@@ -564,6 +564,7 @@ function TransactionsContent() {
transactionIds={splitModal.transactionIds}
amount={splitModal.amount}
description={splitModal.description}
merchant={splitModal.merchant}
onClose={() => { setSplitModal(null); if (splitModal.transactionIds) setSelected(new Set()); }}
/>
)}
+35 -2
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 });
}
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}
+1 -1
View File
@@ -360,7 +360,7 @@ export interface RuleRow {
id: number;
name: string;
conditions: { field: string; operator: string; value: string }[];
actions: { set_category?: string; add_tag_ids?: number[]; set_merchant?: string };
actions: { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; apply_split?: { participant_id: number; share_percent: number }[] };
enabled: boolean;
priority: number;
created_at: string;