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:
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user