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 <noreply@anthropic.com>
This commit is contained in:
parent
c7baf85cbb
commit
142c240a00
8 changed files with 513 additions and 16 deletions
286
src/components/transactions/SplitAdjustmentModal.tsx
Normal file
286
src/components/transactions/SplitAdjustmentModal.tsx
Normal file
|
|
@ -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<SplitChild[]>;
|
||||
onSave: (
|
||||
parentId: number,
|
||||
entries: Array<{ category_id: number; amount: number; description: string }>
|
||||
) => Promise<void>;
|
||||
onDelete: (parentId: number) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SplitAdjustmentModal({
|
||||
transaction,
|
||||
categories,
|
||||
onLoadChildren,
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [entries, setEntries] = useState<SplitEntry[]>([
|
||||
{ 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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-lg border border-[var(--border)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{t("transactions.splitAdjustment")}</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)] truncate max-w-xs">
|
||||
{transaction.description}
|
||||
<span
|
||||
className={`ml-2 font-mono ${
|
||||
transaction.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||
}`}
|
||||
>
|
||||
{transaction.amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-4 py-3 space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{t("common.loading")}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Original category remainder row */}
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-[var(--muted)] text-sm">
|
||||
<span className="shrink-0 text-[var(--muted-foreground)]">
|
||||
{t("transactions.splitBase")}:
|
||||
</span>
|
||||
{transaction.category_color && (
|
||||
<span
|
||||
className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: transaction.category_color }}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{transaction.category_name}</span>
|
||||
<span className="ml-auto font-mono whitespace-nowrap">
|
||||
{isExpense ? "-" : ""}
|
||||
{remainder.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Split entry rows */}
|
||||
{entries.map((entry, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CategoryCombobox
|
||||
categories={categories}
|
||||
value={entry.category_id}
|
||||
onChange={(id) => updateEntry(index, "category_id", id)}
|
||||
placeholder={t("transactions.splitCategory")}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={entry.amount}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.description}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeEntry(index)}
|
||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--negative)] transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add row */}
|
||||
<button
|
||||
onClick={addEntry}
|
||||
className="flex items-center gap-1 text-sm text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("transactions.splitAddRow")}
|
||||
</button>
|
||||
|
||||
{/* Validation message */}
|
||||
{remainder < 0 && (
|
||||
<p className="text-xs text-[var(--negative)]">
|
||||
{t("transactions.splitTotal")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border)]">
|
||||
<div>
|
||||
{transaction.is_split && !confirmDelete && (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="text-sm text-[var(--negative)] hover:underline"
|
||||
>
|
||||
{t("transactions.splitRemove")}
|
||||
</button>
|
||||
)}
|
||||
{confirmDelete && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--negative)]">
|
||||
{t("transactions.splitDeleteConfirm")}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="px-2 py-1 text-xs rounded bg-[var(--negative)] text-white hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
className="px-2 py-1 text-xs rounded border border-[var(--border)] hover:bg-[var(--muted)]"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!isValid || saving}
|
||||
className="px-4 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
|
||||
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
|
||||
onDeleteSplit: (parentId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function SortIcon({
|
||||
|
|
@ -42,6 +47,9 @@ export default function TransactionTable({
|
|||
onCategoryChange,
|
||||
onNotesChange,
|
||||
onAddKeyword,
|
||||
onLoadSplitChildren,
|
||||
onSaveSplit,
|
||||
onDeleteSplit,
|
||||
}: TransactionTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
|
@ -49,6 +57,7 @@ export default function TransactionTable({
|
|||
const [keywordRowId, setKeywordRowId] = useState<number | null>(null);
|
||||
const [keywordText, setKeywordText] = useState("");
|
||||
const [keywordSaved, setKeywordSaved] = useState<number | null>(null);
|
||||
const [splitRow, setSplitRow] = useState<TransactionRow | null>(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 && (
|
||||
<button
|
||||
onClick={() => toggleKeyword(row)}
|
||||
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
|
||||
keywordSaved === row.id
|
||||
? "text-[var(--positive)]"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
title={keywordSaved === row.id ? t("transactions.keywordAdded") : t("transactions.addKeyword")}
|
||||
>
|
||||
<Tag size={14} />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={() => toggleKeyword(row)}
|
||||
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
|
||||
keywordSaved === row.id
|
||||
? "text-[var(--positive)]"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
title={keywordSaved === row.id ? t("transactions.keywordAdded") : t("transactions.addKeyword")}
|
||||
>
|
||||
<Tag size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSplitRow(row)}
|
||||
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
|
||||
row.is_split
|
||||
? "text-[var(--primary)]"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
title={t("transactions.splitAdjustment")}
|
||||
>
|
||||
<Split size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -245,6 +267,16 @@ export default function TransactionTable({
|
|||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{splitRow && (
|
||||
<SplitAdjustmentModal
|
||||
transaction={splitRow}
|
||||
categories={categories}
|
||||
onLoadChildren={onLoadSplitChildren}
|
||||
onSave={onSaveSplit}
|
||||
onDelete={onDeleteSplit}
|
||||
onClose={() => setSplitRow(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SplitChild[]> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
|
||||
|
|
@ -81,6 +81,9 @@ export default function TransactionsPage() {
|
|||
onCategoryChange={updateCategory}
|
||||
onNotesChange={saveNotes}
|
||||
onAddKeyword={addKeywordToCategory}
|
||||
onLoadSplitChildren={loadSplitChildren}
|
||||
onSaveSplit={saveSplit}
|
||||
onDeleteSplit={deleteSplit}
|
||||
/>
|
||||
|
||||
<TransactionPagination
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
TransactionPageResult,
|
||||
Category,
|
||||
ImportSource,
|
||||
SplitChild,
|
||||
} from "../shared/types";
|
||||
|
||||
export async function insertBatch(
|
||||
|
|
@ -138,8 +139,10 @@ export async function getTransactionPage(
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereSQL =
|
||||
whereClauses.length > 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<string, string> = {
|
||||
|
|
@ -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<number> {
|
|||
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function getSplitChildren(parentId: number): Promise<SplitChild[]> {
|
||||
const db = await getDb();
|
||||
return db.select<SplitChild[]>(
|
||||
`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<void> {
|
||||
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<Transaction[]>(
|
||||
`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<void> {
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue