From 142c240a002932a9d2110cfdd2580fa268ea1aab Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Mon, 16 Feb 2026 23:51:36 +0000 Subject: [PATCH] feat: add transaction split adjustments across multiple categories Allow users to split a transaction across multiple categories directly from the transactions table. Split children are hidden from the list and automatically included in dashboard, report, and budget aggregates. Co-Authored-By: Claude Opus 4.6 --- .../transactions/SplitAdjustmentModal.tsx | 286 ++++++++++++++++++ .../transactions/TransactionTable.tsx | 56 +++- src/hooks/useTransactions.ts | 55 ++++ src/i18n/locales/en.json | 10 + src/i18n/locales/fr.json | 10 + src/pages/TransactionsPage.tsx | 5 +- src/services/transactionService.ts | 97 +++++- src/shared/types/index.ts | 10 + 8 files changed, 513 insertions(+), 16 deletions(-) create mode 100644 src/components/transactions/SplitAdjustmentModal.tsx diff --git a/src/components/transactions/SplitAdjustmentModal.tsx b/src/components/transactions/SplitAdjustmentModal.tsx new file mode 100644 index 0000000..2bcd32c --- /dev/null +++ b/src/components/transactions/SplitAdjustmentModal.tsx @@ -0,0 +1,286 @@ +import { useState, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { X, Plus, Trash2 } from "lucide-react"; +import type { TransactionRow, Category, SplitChild } from "../../shared/types"; +import CategoryCombobox from "../shared/CategoryCombobox"; + +interface SplitEntry { + category_id: number | null; + amount: string; + description: string; +} + +interface Props { + transaction: TransactionRow; + categories: Category[]; + onLoadChildren: (parentId: number) => Promise; + onSave: ( + parentId: number, + entries: Array<{ category_id: number; amount: number; description: string }> + ) => Promise; + onDelete: (parentId: number) => Promise; + onClose: () => void; +} + +export default function SplitAdjustmentModal({ + transaction, + categories, + onLoadChildren, + onSave, + onDelete, + onClose, +}: Props) { + const { t } = useTranslation(); + const [entries, setEntries] = useState([ + { category_id: null, amount: "", description: "" }, + ]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + + const isExpense = transaction.amount < 0; + const absOriginal = Math.abs(transaction.amount); + + // Load existing children if this is already split + useEffect(() => { + if (!transaction.is_split) return; + setLoading(true); + onLoadChildren(transaction.id).then((children) => { + // Filter out the offset child (same category as parent) + const splitEntries = children.filter( + (c) => c.category_id !== transaction.category_id || Math.sign(c.amount) === Math.sign(transaction.amount) + ); + if (splitEntries.length > 0) { + setEntries( + splitEntries.map((c) => ({ + category_id: c.category_id, + amount: Math.abs(c.amount).toFixed(2), + description: c.description, + })) + ); + } + setLoading(false); + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const parsedAmounts = useMemo( + () => entries.map((e) => parseFloat(e.amount) || 0), + [entries] + ); + const splitTotal = useMemo( + () => parsedAmounts.reduce((s, a) => s + a, 0), + [parsedAmounts] + ); + const remainder = +(absOriginal - splitTotal).toFixed(2); + + const isValid = + entries.length > 0 && + entries.every((e) => e.category_id !== null && (parseFloat(e.amount) || 0) > 0) && + splitTotal > 0 && + remainder >= 0; + + const updateEntry = (index: number, field: keyof SplitEntry, value: string | number | null) => { + setEntries((prev) => + prev.map((e, i) => (i === index ? { ...e, [field]: value } : e)) + ); + }; + + const addEntry = () => { + setEntries((prev) => [...prev, { category_id: null, amount: "", description: "" }]); + }; + + const removeEntry = (index: number) => { + setEntries((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleSave = async () => { + if (!isValid) return; + setSaving(true); + try { + await onSave( + transaction.id, + entries.map((e) => ({ + category_id: e.category_id!, + amount: isExpense + ? -Math.abs(parseFloat(e.amount)) + : Math.abs(parseFloat(e.amount)), + description: e.description || transaction.description, + })) + ); + onClose(); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + setSaving(true); + try { + await onDelete(transaction.id); + onClose(); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+

{t("transactions.splitAdjustment")}

+

+ {transaction.description} + = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" + }`} + > + {transaction.amount.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + +

+
+ +
+ + {/* Body */} +
+ {loading ? ( +

{t("common.loading")}

+ ) : ( + <> + {/* Original category remainder row */} +
+ + {t("transactions.splitBase")}: + + {transaction.category_color && ( + + )} + {transaction.category_name} + + {isExpense ? "-" : ""} + {remainder.toFixed(2)} + +
+ + {/* Split entry rows */} + {entries.map((entry, index) => ( +
+
+ updateEntry(index, "category_id", id)} + placeholder={t("transactions.splitCategory")} + compact + /> +
+ updateEntry(index, "amount", e.target.value)} + placeholder={t("transactions.splitAmount")} + className="w-24 px-2 py-1.5 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] text-right font-mono focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" + /> + updateEntry(index, "description", e.target.value)} + placeholder={t("transactions.splitDescription")} + className="w-32 px-2 py-1.5 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" + /> + +
+ ))} + + {/* Add row */} + + + {/* Validation message */} + {remainder < 0 && ( +

+ {t("transactions.splitTotal")} +

+ )} + + )} +
+ + {/* Footer */} +
+
+ {transaction.is_split && !confirmDelete && ( + + )} + {confirmDelete && ( +
+ + {t("transactions.splitDeleteConfirm")} + + + +
+ )} +
+
+ + +
+
+
+
+ ); +} diff --git a/src/components/transactions/TransactionTable.tsx b/src/components/transactions/TransactionTable.tsx index bf2d0fb..deda01f 100644 --- a/src/components/transactions/TransactionTable.tsx +++ b/src/components/transactions/TransactionTable.tsx @@ -1,12 +1,14 @@ import { Fragment, useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronUp, ChevronDown, MessageSquare, Tag } from "lucide-react"; +import { ChevronUp, ChevronDown, MessageSquare, Tag, Split } from "lucide-react"; import type { TransactionRow, TransactionSort, Category, + SplitChild, } from "../../shared/types"; import CategoryCombobox from "../shared/CategoryCombobox"; +import SplitAdjustmentModal from "./SplitAdjustmentModal"; interface TransactionTableProps { rows: TransactionRow[]; @@ -16,6 +18,9 @@ interface TransactionTableProps { onCategoryChange: (txId: number, categoryId: number | null) => void; onNotesChange: (txId: number, notes: string) => void; onAddKeyword: (categoryId: number, keyword: string) => Promise; + onLoadSplitChildren: (parentId: number) => Promise; + onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise; + onDeleteSplit: (parentId: number) => Promise; } function SortIcon({ @@ -42,6 +47,9 @@ export default function TransactionTable({ onCategoryChange, onNotesChange, onAddKeyword, + onLoadSplitChildren, + onSaveSplit, + onDeleteSplit, }: TransactionTableProps) { const { t } = useTranslation(); const [expandedId, setExpandedId] = useState(null); @@ -49,6 +57,7 @@ export default function TransactionTable({ const [keywordRowId, setKeywordRowId] = useState(null); const [keywordText, setKeywordText] = useState(""); const [keywordSaved, setKeywordSaved] = useState(null); + const [splitRow, setSplitRow] = useState(null); const noCategoryExtra = useMemo( () => [{ value: "", label: t("transactions.table.noCategory") }], [t] @@ -155,17 +164,30 @@ export default function TransactionTable({ onExtraSelect={() => onCategoryChange(row.id, null)} /> {row.category_id !== null && ( - + <> + + + )} @@ -245,6 +267,16 @@ export default function TransactionTable({ ))} + {splitRow && ( + setSplitRow(null)} + /> + )} ); } diff --git a/src/hooks/useTransactions.ts b/src/hooks/useTransactions.ts index 56a2e61..dac699c 100644 --- a/src/hooks/useTransactions.ts +++ b/src/hooks/useTransactions.ts @@ -6,6 +6,7 @@ import type { TransactionPageResult, Category, ImportSource, + SplitChild, } from "../shared/types"; import { getTransactionPage, @@ -14,6 +15,9 @@ import { getAllCategories, getAllImportSources, autoCategorizeTransactions, + getSplitChildren, + saveSplitAdjustment, + deleteSplitAdjustment, } from "../services/transactionService"; import { createKeyword } from "../services/categoryService"; @@ -308,6 +312,54 @@ export function useTransactions() { [] ); + const loadSplitChildren = useCallback( + async (parentId: number): Promise => { + try { + return await getSplitChildren(parentId); + } catch (e) { + dispatch({ + type: "SET_ERROR", + payload: e instanceof Error ? e.message : String(e), + }); + return []; + } + }, + [] + ); + + const saveSplit = useCallback( + async ( + parentId: number, + entries: Array<{ category_id: number; amount: number; description: string }> + ) => { + try { + await saveSplitAdjustment(parentId, entries); + fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize); + } catch (e) { + dispatch({ + type: "SET_ERROR", + payload: e instanceof Error ? e.message : String(e), + }); + } + }, + [state.sort, state.page, state.pageSize, fetchData] + ); + + const deleteSplit = useCallback( + async (parentId: number) => { + try { + await deleteSplitAdjustment(parentId); + fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize); + } catch (e) { + dispatch({ + type: "SET_ERROR", + payload: e instanceof Error ? e.message : String(e), + }); + } + }, + [state.sort, state.page, state.pageSize, fetchData] + ); + return { state, setFilter, @@ -317,5 +369,8 @@ export function useTransactions() { saveNotes, autoCategorize, addKeywordToCategory, + loadSplitChildren, + saveSplit, + deleteSplit, }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e1286b7..8c91ef2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -216,6 +216,16 @@ "addKeyword": "Add keyword", "keywordAdded": "Keyword added", "keywordPlaceholder": "Keyword to match...", + "splitAdjustment": "Split adjustment", + "splitBase": "Base", + "splitAdjusted": "Adjusted", + "splitCategory": "Category", + "splitAmount": "Amount", + "splitDescription": "Description", + "splitAddRow": "Add split", + "splitRemove": "Remove split", + "splitTotal": "Total must equal original amount", + "splitDeleteConfirm": "Remove this split adjustment?", "help": { "title": "How to use Transactions", "tips": [ diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 9128084..86b9177 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -216,6 +216,16 @@ "addKeyword": "Ajouter un mot-clé", "keywordAdded": "Mot-clé ajouté", "keywordPlaceholder": "Mot-clé à rechercher...", + "splitAdjustment": "Répartition", + "splitBase": "Base", + "splitAdjusted": "Ajusté", + "splitCategory": "Catégorie", + "splitAmount": "Montant", + "splitDescription": "Description", + "splitAddRow": "Ajouter une répartition", + "splitRemove": "Supprimer la répartition", + "splitTotal": "Le total doit égaler le montant original", + "splitDeleteConfirm": "Supprimer cette répartition ?", "help": { "title": "Comment utiliser les Transactions", "tips": [ diff --git a/src/pages/TransactionsPage.tsx b/src/pages/TransactionsPage.tsx index f8405ad..604baf2 100644 --- a/src/pages/TransactionsPage.tsx +++ b/src/pages/TransactionsPage.tsx @@ -10,7 +10,7 @@ import TransactionPagination from "../components/transactions/TransactionPaginat export default function TransactionsPage() { const { t } = useTranslation(); - const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory } = + const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } = useTransactions(); const [resultMessage, setResultMessage] = useState(null); @@ -81,6 +81,9 @@ export default function TransactionsPage() { onCategoryChange={updateCategory} onNotesChange={saveNotes} onAddKeyword={addKeywordToCategory} + onLoadSplitChildren={loadSplitChildren} + onSaveSplit={saveSplit} + onDeleteSplit={deleteSplit} /> 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; + // Always exclude split children from the transaction list + whereClauses.push(`t.parent_transaction_id IS NULL`); + + const whereSQL = `WHERE ${whereClauses.join(" AND ")}`; // Map sort column to SQL const sortColumnMap: Record = { @@ -156,7 +159,8 @@ export async function getTransactionPage( const rowsSQL = ` SELECT t.id, t.date, t.description, t.amount, t.category_id, c.name AS category_name, c.color AS category_color, - s.name AS source_name, t.notes, t.is_manually_categorized + s.name AS source_name, t.notes, t.is_manually_categorized, + t.is_split FROM transactions t LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN import_sources s ON t.source_id = s.id @@ -267,3 +271,90 @@ export async function autoCategorizeTransactions(): Promise { return count; } + +export async function getSplitChildren(parentId: number): Promise { + const db = await getDb(); + return db.select( + `SELECT t.id, t.category_id, c.name AS category_name, c.color AS category_color, + t.amount, t.description + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + WHERE t.parent_transaction_id = $1 + ORDER BY t.id`, + [parentId] + ); +} + +export async function saveSplitAdjustment( + parentId: number, + entries: Array<{ category_id: number; amount: number; description: string }> +): Promise { + const db = await getDb(); + + // Delete any existing children + await db.execute( + `DELETE FROM transactions WHERE parent_transaction_id = $1`, + [parentId] + ); + + // Fetch parent transaction + const [parent] = await db.select( + `SELECT * FROM transactions WHERE id = $1`, + [parentId] + ); + if (!parent) throw new Error("Parent transaction not found"); + + // Insert each split child + let offsetTotal = 0; + for (const entry of entries) { + await db.execute( + `INSERT INTO transactions (date, description, amount, category_id, source_id, file_id, original_description, parent_transaction_id, is_split) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1)`, + [ + parent.date, + entry.description, + entry.amount, + entry.category_id, + parent.source_id ?? null, + parent.file_id ?? null, + parent.original_description ?? "", + parentId, + ] + ); + offsetTotal += entry.amount; + } + + // Insert offset child (cancels the redistributed portion from the original category) + await db.execute( + `INSERT INTO transactions (date, description, amount, category_id, source_id, file_id, original_description, parent_transaction_id, is_split) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1)`, + [ + parent.date, + parent.description, + -offsetTotal, + parent.category_id ?? null, + parent.source_id ?? null, + parent.file_id ?? null, + parent.original_description ?? "", + parentId, + ] + ); + + // Mark parent as split + await db.execute( + `UPDATE transactions SET is_split = 1, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, + [parentId] + ); +} + +export async function deleteSplitAdjustment(parentId: number): Promise { + const db = await getDb(); + await db.execute( + `DELETE FROM transactions WHERE parent_transaction_id = $1`, + [parentId] + ); + await db.execute( + `UPDATE transactions SET is_split = 0, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, + [parentId] + ); +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index ea043ae..12681ea 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -358,6 +358,16 @@ export interface TransactionRow { source_name: string | null; notes: string | null; is_manually_categorized: boolean; + is_split: boolean; +} + +export interface SplitChild { + id: number; + category_id: number | null; + category_name: string | null; + category_color: string | null; + amount: number; + description: string; } export interface TransactionFilters {