"use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { ChevronDown, Loader2, Plus, Split, Trash2, Wand2 } from "lucide-react"; import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; import { Controller, useFieldArray, useForm, useWatch, type Resolver } from "react-hook-form"; import { toast } from "sonner"; import { IconBadge } from "@/components/appearance/swatch"; import { MoneyInput } from "@/components/money-input"; import { ResponsiveDialog } from "@/components/responsive-dialog"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { tr } from "@/i18n/tr"; import { cn } from "@/lib/utils"; import { todayIstanbul } from "@/shared/dates"; import { formatMoney } from "@/shared/format"; import { formatMoneyInput, parseMoney } from "@/shared/money"; import { allowedAttachmentKinds } from "@/shared/attachments"; import { AttachmentPicker } from "@/features/attachments/components/attachment-picker"; import type { AttachmentInfo } from "@/features/attachments/service"; import { transactionFormSchema, type TransactionForm } from "@/shared/schemas/transaction"; import { MAX_SPLITS, TRANSACTION_TYPES, type TransactionType } from "@/shared/transactions"; import { createTransactionAction, getTransactionFormOptionsAction, searchPayeesAction, updateTransactionAction, } from "../actions"; import type { PayeeSuggestion, TransactionDetail, TransactionFormOptions } from "../service"; const t = tr.transactions; const f = t.fields; const INSTALLMENT_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 15, 18, 24, 36]; export type TransactionFormMode = | { kind: "create"; type: TransactionType; /** Ön doldurma (ör. kart borcu ödemesi, KMH faiz kaydı). */ prefill?: { accountId?: string; toAccountId?: string; amountMinor?: number; categoryId?: string; date?: string; description?: string; }; } | { kind: "edit"; transaction: TransactionDetail } | { kind: "copy"; transaction: TransactionDetail }; type SplitValue = { categoryId: string; amount: string; note: string }; type FormValues = { type: TransactionType; amount: string; date: string; accountId: string; toAccountId: string; categoryId: string; splits: SplitValue[]; payee: string; description: string; tags: string; /** Önizleme bilgisiyle ekler; sunucuya yalnızca kimlikleri gider. */ attachments: AttachmentInfo[]; /** Taksit sayısı (yalnızca yeni kredi kartı gideri). */ installments: string; }; function toValues(mode: TransactionFormMode, options: TransactionFormOptions | null): FormValues { if (mode.kind === "create") { const p = mode.prefill ?? {}; return { type: mode.type, amount: p.amountMinor ? formatMoneyInput(p.amountMinor) : "", date: p.date ?? todayIstanbul(), accountId: p.accountId ?? options?.accounts[0]?.id ?? "", toAccountId: p.toAccountId ?? "", categoryId: p.categoryId ?? "", splits: [], payee: "", description: p.description ?? "", tags: "", attachments: [], installments: "1", }; } const x = mode.transaction; return { type: x.type, amount: formatMoneyInput(x.amountMinor), date: mode.kind === "copy" ? todayIstanbul() : x.date, accountId: x.account?.id ?? "", toAccountId: x.toAccount?.id ?? "", categoryId: x.categoryId ?? "", splits: x.splits.map((s) => ({ categoryId: s.categoryId, amount: formatMoneyInput(s.amountMinor), note: s.note, })), payee: x.payeeName, description: x.description, tags: x.tags.join(", "), // Kopyada ekler taşınmaz (fiş o işleme aittir). attachments: mode.kind === "edit" ? x.attachments : [], installments: "1", }; } export function AccountSelect({ id, value, onChange, accounts, invalid, exclude, }: { id: string; value: string; onChange: (v: string) => void; accounts: { id: string; name: string; icon?: TransactionFormOptions["accounts"][number]["icon"]; color?: TransactionFormOptions["accounts"][number]["color"]; }[]; invalid?: boolean; exclude?: string; }) { const current = accounts.find((a) => a.id === value); return ( {current?.name} {accounts .filter((a) => a.id !== exclude) .map((a) => ( {a.icon && a.color ? ( ) : null} {a.name} ))} ); } export function CategorySelect({ id, value, onChange, options, appearance, invalid, className, }: { id: string; value: string; onChange: (v: string) => void; options: TransactionFormOptions["categories"]; appearance: TransactionFormOptions["categoryAppearance"]; invalid?: boolean; className?: string; }) { const current = options.find((o) => o.id === value); return ( {current?.path} {options.map((o) => { const look = appearance[o.id]; return ( {look ? ( ) : null} {o.label} ); })} ); } /** Satıcı alanı: yazdıkça öneri; seçilince son kullanılan kategori/hesap önerilir. */ export function PayeeInput({ value, onChange, onPick, }: { value: string; onChange: (v: string) => void; onPick: (p: PayeeSuggestion) => void; }) { const [items, setItems] = useState([]); const [open, setOpen] = useState(false); const [active, setActive] = useState(-1); const timer = useRef | null>(null); function query(v: string) { if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(async () => { const res = await searchPayeesAction(v); setItems( res.filter( (p) => p.name.toLocaleLowerCase("tr-TR") !== v.trim().toLocaleLowerCase("tr-TR") || res.length > 1, ), ); setActive(-1); }, 150); } function pick(p: PayeeSuggestion) { onChange(p.name); onPick(p); setOpen(false); } return ( 0} aria-controls="tx-payee-list" aria-autocomplete="list" onChange={(e) => { onChange(e.target.value); setOpen(true); query(e.target.value); }} onFocus={() => { setOpen(true); query(value); }} onBlur={() => setTimeout(() => setOpen(false), 150)} onKeyDown={(e) => { if (!open || !items.length) return; if (e.key === "ArrowDown") { e.preventDefault(); setActive((i) => Math.min(i + 1, items.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)); } else if (e.key === "Enter" && active >= 0) { e.preventDefault(); pick(items[active]!); } else if (e.key === "Escape") setOpen(false); }} /> {open && items.length > 0 ? ( {items.map((p, i) => ( { e.preventDefault(); pick(p); }} className="cursor-pointer rounded-md px-2 py-1.5 text-sm hover:bg-accent aria-selected:bg-accent" > {p.name} ))} ) : null} ); } export function TransactionFormDialog({ mode, onOpenChange, }: { mode: TransactionFormMode | null; onOpenChange: (open: boolean) => void; }) { const [options, setOptions] = useState(null); const [loadError, setLoadError] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); const [suggested, setSuggested] = useState(false); const [uploadBusy, setUploadBusy] = useState(false); const form = useForm({ // FormValues, şemanın giriş şekliyle birebir (tutarlar string; zod kuruşa çevirir). resolver: zodResolver(transactionFormSchema) as unknown as Resolver< FormValues, unknown, TransactionForm >, defaultValues: toValues({ kind: "create", type: "expense" }, null), }); const splits = useFieldArray({ control: form.control, name: "splits" }); // Form her açıldığında seçenekleri sunucudan al (hesaplar/kategoriler güncel olsun). useEffect(() => { if (!mode) return; let cancelled = false; getTransactionFormOptionsAction().then((res) => { if (cancelled) return; if (!res.ok) { setLoadError(res.error); return; } setLoadError(null); setOptions(res.data); form.reset(toValues(mode, res.data)); }); return () => { cancelled = true; }; }, [mode, form]); const type = useWatch({ control: form.control, name: "type" }); const amount = useWatch({ control: form.control, name: "amount" }); const accountId = useWatch({ control: form.control, name: "accountId" }); const splitValues = useWatch({ control: form.control, name: "splits" }); const attachmentCount = useWatch({ control: form.control, name: "attachments" })?.length ?? 0; const splitMode = splits.fields.length > 0; // Düzenlemede arşivli/kapalı hesap veya kategori seçenek listesinde yoksa ekle (kaybolmasın). const accounts = useMemo(() => { const list = [...(options?.accounts ?? [])]; if (mode && mode.kind !== "create") { for (const a of [mode.transaction.account, mode.transaction.toAccount]) { if (a && !list.some((x) => x.id === a.id)) list.push({ ...a, type: "bank", icon: "wallet", color: "slate" }); } } return list; }, [options, mode]); const categories = useMemo(() => { const kind = type === "income" ? "income" : "expense"; const list = (options?.categories ?? []).filter((c) => c.kind === kind); if (mode && mode.kind !== "create") { const extra = [ ...(mode.transaction.category ? [{ id: mode.transaction.category.id, path: mode.transaction.category.path }] : []), ...mode.transaction.splits.map((s) => ({ id: s.categoryId, path: s.path })), ]; for (const c of extra) { if (!list.some((x) => x.id === c.id)) list.push({ id: c.id, label: c.path, path: c.path, depth: 0, kind }); } } return list; }, [options, type, mode]); const totalMinor = parseMoney(amount ?? "") ?? 0; const selectedAccountType = options?.accounts.find((a) => a.id === accountId)?.type; // Taksit: yeni (veya kopya) kredi kartı gideri, bölünmemiş. const showInstallments = mode?.kind !== "edit" && type === "expense" && selectedAccountType === "credit_card" && !splitMode; const splitSum = (splitValues ?? []).reduce((a, s) => a + (parseMoney(s.amount ?? "") ?? 0), 0); const remaining = totalMinor - splitSum; function handleOpenChange(open: boolean) { if (!open) { setAdvancedOpen(false); setSuggested(false); } onOpenChange(open); } function changeType(next: TransactionType) { form.setValue("type", next); // Kategoriler türe bağlıdır; tür değişince temizlenir. form.setValue("categoryId", ""); splits.replace([]); if (next !== "transfer") form.setValue("toAccountId", ""); form.clearErrors(); } function toggleSplit() { if (splitMode) { splits.replace([]); return; } const first = form.getValues("categoryId"); splits.replace([ { categoryId: first, amount: form.getValues("amount"), note: "" }, { categoryId: "", amount: "", note: "" }, ]); form.setValue("categoryId", ""); setAdvancedOpen(true); } function fillRemaining() { const last = splits.fields.length - 1; if (last < 0) return; const current = parseMoney(form.getValues(`splits.${last}.amount`) ?? "") ?? 0; const next = current + remaining; if (next > 0) form.setValue(`splits.${last}.amount`, formatMoneyInput(next), { shouldValidate: true }); } function onPayeePicked(p: PayeeSuggestion) { let changed = false; // Kullanıcı seçimini ezme: yalnızca boş alanları doldur. if (p.lastAccountId && !form.getValues("accountId")) { form.setValue("accountId", p.lastAccountId); changed = true; } else if (p.lastAccountId && form.formState.dirtyFields.accountId === undefined) { form.setValue("accountId", p.lastAccountId); changed = true; } if ( p.lastCategoryId && !splitMode && !form.getValues("categoryId") && p.lastType === form.getValues("type") ) { form.setValue("categoryId", p.lastCategoryId); changed = true; } setSuggested(changed); } async function onSubmit() { if (!mode) return; if (uploadBusy) { form.setError("root", { message: tr.attachments.waitForUploads }); return; } const { attachments, ...values } = form.getValues(); const payload = { ...values, installments: showInstallments ? values.installments : "1", attachmentIds: attachments.map((a) => a.id), toAccountId: values.type === "transfer" ? values.toAccountId : "", categoryId: values.type === "transfer" || values.splits.length ? "" : values.categoryId, splits: values.type === "transfer" ? [] : values.splits, }; const result = mode.kind === "edit" ? await updateTransactionAction(mode.transaction.id, payload) : await createTransactionAction(payload); if (result.ok) { toast.success(mode.kind === "edit" ? t.toast.updated : t.toast.created); handleOpenChange(false); return; } for (const [name, message] of Object.entries(result.fieldErrors ?? {})) { if (!message) continue; form.setError(name as keyof FormValues, { message }); if (name.startsWith("splits") || name === "tags") setAdvancedOpen(true); } if (!result.fieldErrors) form.setError("root", { message: result.error }); } const title = mode?.kind === "edit" ? t.editTitle : mode?.kind === "copy" ? t.copyTitle : t.createTitle[mode?.type ?? "expense"]; const loading = Boolean(mode) && !options && !loadError; const noAccounts = options && options.accounts.length === 0; const errors = form.formState.errors; return ( handleOpenChange(false)}> {tr.common.cancel} {form.formState.isSubmitting ? : null} {tr.common.save} > ) } > {loadError ? ( {loadError} ) : loading ? ( ) : noAccounts ? ( {t.noAccounts} handleOpenChange(false)}> {t.noAccountsLink} ) : ( {mode?.kind !== "edit" ? ( changeType(v as TransactionType)}> {TRANSACTION_TYPES.map((x) => ( {t.types[x]} ))} ) : null} ( {f.amount} {fieldState.invalid ? : null} )} /> ( {f.date} {fieldState.invalid ? : null} )} /> ( {type === "transfer" ? f.fromAccount : f.account} {fieldState.invalid ? : null} )} /> {showInstallments ? ( ( {f.installments} {field.value === "1" ? f.singlePayment : f.installmentCount(Number(field.value))} {INSTALLMENT_OPTIONS.map((n) => ( {n === 1 ? f.singlePayment : f.installmentCount(n)} ))} {Number(field.value) > 1 && totalMinor > 0 ? ( {f.installmentHint( Number(field.value), formatMoney(Math.ceil(totalMinor / Number(field.value))), )} ) : null} {fieldState.invalid ? : null} )} /> ) : null} {type === "transfer" ? ( ( {f.toAccount} {fieldState.invalid ? : null} )} /> ) : null} {type !== "transfer" && !splitMode ? ( ( {f.category} {fieldState.invalid ? : null} )} /> ) : null} ( {f.payee} {suggested ? {f.suggested} : null} )} /> ( {f.description} {fieldState.invalid ? : null} )} /> {f.advanced} {splitMode ? ( · {t.list.split(splits.fields.length)} ) : null} {attachmentCount ? ( · {tr.attachments.count(attachmentCount)} ) : null} ( {tr.attachments.title} )} /> ( {f.tags} {f.tagsHint} )} /> {type !== "transfer" ? ( {f.split} {f.splitHint} {splitMode ? tr.common.cancel : f.split} {splits.fields.map((row, i) => ( ( {fieldState.invalid ? ( ) : null} )} /> ( )} /> splits.remove(i)} disabled={splits.fields.length <= 2} aria-label={f.removeSplit} > ( )} /> ))} {splitMode ? ( splits.append({ categoryId: "", amount: "", note: "" })} disabled={splits.fields.length >= MAX_SPLITS} > {f.addSplit} {remaining === 0 ? f.remainingOk : `${f.remaining}: ${formatMoney(remaining)}`} {remaining !== 0 ? ( {f.fillRemaining} ) : null} ) : null} {errors.splits?.message || errors.splits?.root?.message ? ( {errors.splits.message ?? errors.splits.root?.message} ) : null} ) : null} {errors.root ? {errors.root.message} : null} )} ); }
{loadError}
{t.noAccounts}
{f.split}
{f.splitHint}