import { useEffect, useState } from "react"; import { Plus, X } from "lucide-react"; import { Dialog } from "@/components/ui/Dialog"; import { Button } from "@/components/ui/Button"; import { Input, Label, Select } from "@/components/ui/Input"; import { TagInput } from "@/components/transactions/TagInput"; import { useAccounts } from "@/hooks/useAccounts"; import { useCategories } from "@/hooks/useCategories"; import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions"; import { useTranslation } from "@/lib/i18n"; import { buildHierarchicalCategories, translateCategoryName } from "@/lib/categoryLabels"; import { formatCurrency } from "@/lib/format"; import type { Tag, Transaction, TransactionInput, TransactionSplitInput, TransactionType } from "@/types"; interface TransactionFormModalProps { open: boolean; onClose: () => void; transaction?: Transaction | null; } function todayIso() { return new Date().toISOString().slice(0, 10); } const EMPTY_FORM = { type: "expense" as TransactionType, account_id: "", category_id: "", transfer_account_id: "", amount: "", description: "", merchant: "", notes: "", date: todayIso(), }; interface SplitRowState { key: string; category_id: string; amount: string; note: string; } // crypto.randomUUID() only exists in secure contexts (HTTPS/localhost) — on plain // HTTP (e.g. accessing the app by LAN IP) it's undefined and throws a TypeError. // This key is only a local React list key, never sent to the backend, so a // Math.random()-based fallback is fine when the Web Crypto API isn't available. function generateRowKey(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } return `split-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } function emptySplitRow(): SplitRowState { return { key: generateRowKey(), category_id: "", amount: "", note: "" }; } // Cents, not floats — a plain Number sum of "0.10" + "0.20" style amounts can // drift from the transaction total by fractions of a cent, which would // falsely trip the "must add up exactly" check the backend also enforces. function toCents(value: string): number { const parsed = Number(value); return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0; } export function TransactionFormModal({ open, onClose, transaction }: TransactionFormModalProps) { const { t, language } = useTranslation(); const { data: accounts } = useAccounts(); const { data: categories } = useCategories(); const createTransaction = useCreateTransaction(); const updateTransaction = useUpdateTransaction(); const [form, setForm] = useState(EMPTY_FORM); const [tags, setTags] = useState([]); const [splitMode, setSplitMode] = useState(false); const [splitRows, setSplitRows] = useState([emptySplitRow(), emptySplitRow()]); const [error, setError] = useState(null); useEffect(() => { if (!open) return; if (transaction) { const hasSplits = transaction.splits.length > 0; // A split's categories always share one parent (see // routes/transactions.py's _build_splits) — derive that shared base // from whichever split's category is still live. If every split's // category was since deleted, there's nothing to derive from; the // base field is left blank and the user has to pick one again. const baseCategory = hasSplits ? transaction.splits.find((split) => split.category)?.category : null; setForm({ type: transaction.type, account_id: String(transaction.account_id), category_id: hasSplits ? baseCategory ? String(baseCategory.parent_id ?? baseCategory.id) : "" : transaction.category_id ? String(transaction.category_id) : "", transfer_account_id: transaction.transfer_account_id ? String(transaction.transfer_account_id) : "", amount: transaction.amount, description: transaction.description, merchant: transaction.merchant ?? "", notes: transaction.notes ?? "", date: transaction.date, }); setTags(transaction.tags); setSplitMode(hasSplits); setSplitRows( hasSplits ? transaction.splits.map((split) => ({ key: String(split.id), category_id: split.category_id ? String(split.category_id) : "", amount: split.amount, note: split.note ?? "", })) : [emptySplitRow(), emptySplitRow()] ); } else { setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" }); setTags([]); setSplitMode(false); setSplitRows([emptySplitRow(), emptySplitRow()]); } setError(null); }, [open, transaction, accounts]); const kindCategories = (categories ?? []).filter((category) => form.type === "income" ? category.kind === "income" : category.kind === "expense" ); // Subcategories are listed right under their parent (not scattered by // name) so the hierarchy set up on the Categories page reads the same way // here. const relevantCategories = buildHierarchicalCategories(kindCategories, language); const isSaving = createTransaction.isPending || updateTransaction.isPending; function updateSplitRow(key: string, patch: Partial) { setSplitRows((prev) => prev.map((row) => (row.key === key ? { ...row, ...patch } : row))); } function addSplitRow() { setSplitRows((prev) => [...prev, emptySplitRow()]); } function removeSplitRow(key: string) { setSplitRows((prev) => (prev.length <= 2 ? prev : prev.filter((row) => row.key !== key))); } const isSplitEditingNow = form.type !== "transfer" && splitMode; const splitAllocatedCents = splitRows.reduce((sum, row) => sum + toCents(row.amount), 0); const splitRemainingCents = toCents(form.amount) - splitAllocatedCents; // The category select becomes the split's "base" category while // splitting — restricted to top-level categories — and each split row can // only pick that base itself or one of its direct children (see // routes/transactions.py's _build_splits: a split's categories always // share one parent). const topLevelCategories = relevantCategories.filter((category) => !category.indented); const baseChildCategories = kindCategories.filter((category) => category.parent_id === Number(form.category_id)); const categorySelectOptions = splitMode ? topLevelCategories : relevantCategories; function toggleSplitMode() { setSplitMode((prev) => { const next = !prev; if (next) { const current = relevantCategories.find((category) => String(category.id) === form.category_id); if (current?.parent_id) { setForm((f) => ({ ...f, category_id: String(current.parent_id) })); } setSplitRows([emptySplitRow(), emptySplitRow()]); } return next; }); } function handleBaseCategoryChange(value: string) { setForm((prev) => ({ ...prev, category_id: value })); if (splitMode) setSplitRows([emptySplitRow(), emptySplitRow()]); } async function handleSubmit(event: React.FormEvent) { event.preventDefault(); setError(null); if (!form.account_id) { setError(t("transactions.form.errorSelectAccount")); return; } if (form.type === "transfer" && !form.transfer_account_id) { setError(t("transactions.form.errorSelectDestination")); return; } if (form.type === "transfer" && form.transfer_account_id === form.account_id) { setError(t("transactions.form.errorSameAccount")); return; } // Undefined -> leave the transaction's existing splits untouched on // update, and create a normal single-category transaction. [] -> clears // splits that used to be there (the user turned split mode off, or // switched the transaction to a transfer, which can't carry splits). let splits: TransactionSplitInput[] | undefined; if (isSplitEditingNow) { if (!form.category_id) { setError(t("transactions.form.errorSplitNoBaseCategory")); return; } const filledRows = splitRows.filter((row) => row.category_id || row.amount); if (filledRows.length < 2) { setError(t("transactions.form.errorSplitMinRows")); return; } if (filledRows.some((row) => !row.category_id || !row.amount || Number(row.amount) <= 0)) { setError(t("transactions.form.errorSplitIncomplete")); return; } const allocatedCents = filledRows.reduce((sum, row) => sum + toCents(row.amount), 0); if (allocatedCents !== toCents(form.amount)) { setError(t("transactions.form.errorSplitMismatch")); return; } splits = filledRows.map((row) => ({ category_id: Number(row.category_id), amount: row.amount, note: row.note || null, })); } else if (transaction && transaction.splits.length > 0) { splits = []; } const payload: TransactionInput = { type: form.type, account_id: Number(form.account_id), category_id: form.type === "transfer" || (splits && splits.length > 0) ? null : form.category_id ? Number(form.category_id) : null, transfer_account_id: form.type === "transfer" ? Number(form.transfer_account_id) : null, amount: form.amount, description: form.description, merchant: form.merchant || null, notes: form.notes || null, date: form.date, tag_ids: tags.map((tag) => tag.id), splits, }; try { if (transaction) { await updateTransaction.mutateAsync({ id: transaction.id, input: payload }); } else { await createTransaction.mutateAsync(payload); } onClose(); } catch { setError(t("transactions.form.saveError")); } } return (
setForm((prev) => ({ ...prev, amount: event.target.value }))} />
setForm((prev) => ({ ...prev, date: event.target.value }))} />
setForm((prev) => ({ ...prev, description: event.target.value }))} />
{form.type === "transfer" ? (
) : (
{splitMode && (
{!form.category_id ? (

{t("transactions.form.splitHint")}

) : baseChildCategories.length === 0 ? (

{t("transactions.form.splitNoChildren")}

) : (

{t("transactions.form.splitHint")}

)} {splitRows.map((row) => (
updateSplitRow(row.key, { amount: event.target.value })} />
updateSplitRow(row.key, { note: event.target.value })} />
))}

{splitRemainingCents > 0 ? t("transactions.form.splitRemainingLabel", { amount: formatCurrency(splitRemainingCents / 100), }) : splitRemainingCents < 0 ? t("transactions.form.splitOverAllocatedLabel", { amount: formatCurrency(Math.abs(splitRemainingCents) / 100), }) : t("transactions.form.splitFullyAllocatedLabel")}

)}
)}
setForm((prev) => ({ ...prev, merchant: event.target.value }))} />
{/* Matches the server's max_length on notes — without it an overlong note only fails on save, as an untranslated 422. */} setForm((prev) => ({ ...prev, notes: event.target.value }))} />
{error &&

{error}

}
); }