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;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SplitEntry {
|
||||||
|
participant_id: number;
|
||||||
|
share_percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Actions {
|
interface Actions {
|
||||||
set_category?: string;
|
set_category?: string;
|
||||||
add_tag_ids?: number[];
|
add_tag_ids?: number[];
|
||||||
set_merchant?: string;
|
set_merchant?: string;
|
||||||
|
apply_split?: SplitEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TxFields {
|
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 [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [bulkCategory, setBulkCategory] = useState("");
|
const [bulkCategory, setBulkCategory] = useState("");
|
||||||
const [bulkTagId, setBulkTagId] = 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<{
|
const [rulePrompt, setRulePrompt] = useState<{
|
||||||
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
|
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
|
||||||
field: "category" | "merchant";
|
field: "category" | "merchant";
|
||||||
@@ -543,7 +543,7 @@ function TransactionsContent() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<button
|
<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"
|
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"
|
title="Split this transaction"
|
||||||
>
|
>
|
||||||
@@ -564,6 +564,7 @@ function TransactionsContent() {
|
|||||||
transactionIds={splitModal.transactionIds}
|
transactionIds={splitModal.transactionIds}
|
||||||
amount={splitModal.amount}
|
amount={splitModal.amount}
|
||||||
description={splitModal.description}
|
description={splitModal.description}
|
||||||
|
merchant={splitModal.merchant}
|
||||||
onClose={() => { setSplitModal(null); if (splitModal.transactionIds) setSelected(new Set()); }}
|
onClose={() => { setSplitModal(null); if (splitModal.transactionIds) setSelected(new Set()); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction } from "@/lib/hooks";
|
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction, useCreateRule } from "@/lib/hooks";
|
||||||
|
|
||||||
interface Split {
|
interface Split {
|
||||||
participant_id: number;
|
participant_id: number;
|
||||||
@@ -13,10 +13,11 @@ interface Props {
|
|||||||
transactionIds?: number[];
|
transactionIds?: number[];
|
||||||
amount?: number;
|
amount?: number;
|
||||||
description: string;
|
description: string;
|
||||||
|
merchant?: string;
|
||||||
onClose: () => void;
|
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 isBulk = !!transactionIds && transactionIds.length > 0;
|
||||||
const singleId = transactionId ?? 0;
|
const singleId = transactionId ?? 0;
|
||||||
|
|
||||||
@@ -24,9 +25,12 @@ export function SplitModal({ transactionId, transactionIds, amount, description,
|
|||||||
const { data: existingSplits } = useTransactionSplits(isBulk ? 0 : singleId);
|
const { data: existingSplits } = useTransactionSplits(isBulk ? 0 : singleId);
|
||||||
const setSplits = useSetSplits();
|
const setSplits = useSetSplits();
|
||||||
const bulkAction = useBulkAction();
|
const bulkAction = useBulkAction();
|
||||||
|
const createRule = useCreateRule();
|
||||||
|
|
||||||
const [splits, setSplitsState] = useState<Split[]>([]);
|
const [splits, setSplitsState] = useState<Split[]>([]);
|
||||||
const [error, setError] = 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
|
// Initialise: bulk always defaults to 100% Me; single loads existing splits
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -94,7 +98,20 @@ export function SplitModal({ transactionId, transactionIds, amount, description,
|
|||||||
} else {
|
} else {
|
||||||
await setSplits.mutateAsync({ transactionId: singleId, splits });
|
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();
|
onClose();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to save splits");
|
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>}
|
{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">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|||||||
+1
-1
@@ -360,7 +360,7 @@ export interface RuleRow {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
conditions: { field: string; operator: string; value: 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;
|
enabled: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user