Files
butcem/src/features/transactions/components/transaction-form.tsx
T

919 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 (
<Select value={value || undefined} onValueChange={onChange}>
<SelectTrigger id={id} className="w-full" aria-invalid={invalid}>
<SelectValue placeholder={f.selectAccount}>{current?.name}</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
{accounts
.filter((a) => a.id !== exclude)
.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.icon && a.color ? (
<IconBadge
icon={a.icon}
color={a.color}
size="sm"
className="size-5 [&_svg]:size-3"
/>
) : null}
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
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 (
<Select value={value || undefined} onValueChange={onChange}>
<SelectTrigger id={id} className={cn("w-full", className)} aria-invalid={invalid}>
<SelectValue placeholder={f.selectCategory}>{current?.path}</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-80">
{options.map((o) => {
const look = appearance[o.id];
return (
<SelectItem key={o.id} value={o.id}>
<span
className="flex items-center gap-2"
style={{ paddingInlineStart: `${o.depth * 1}rem` }}
>
{look ? (
<IconBadge
icon={look.icon}
color={look.color}
size="sm"
className="size-5 [&_svg]:size-3"
/>
) : null}
<span className={o.depth === 0 ? "font-medium" : undefined}>{o.label}</span>
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
/** 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<PayeeSuggestion[]>([]);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const timer = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="relative">
<Input
id="tx-payee"
value={value}
placeholder={f.payeePlaceholder}
autoComplete="off"
role="combobox"
aria-expanded={open && items.length > 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 ? (
<ul
id="tx-payee-list"
role="listbox"
className="absolute z-50 mt-1 max-h-56 w-full overflow-y-auto rounded-lg border bg-popover p-1 text-popover-foreground shadow-md"
>
{items.map((p, i) => (
<li
key={p.name}
role="option"
aria-selected={i === active}
onMouseDown={(e) => {
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}
</li>
))}
</ul>
) : null}
</div>
);
}
export function TransactionFormDialog({
mode,
onOpenChange,
}: {
mode: TransactionFormMode | null;
onOpenChange: (open: boolean) => void;
}) {
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [suggested, setSuggested] = useState(false);
const [uploadBusy, setUploadBusy] = useState(false);
const form = useForm<FormValues, unknown, TransactionForm>({
// 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 (
<ResponsiveDialog
open={Boolean(mode)}
onOpenChange={handleOpenChange}
title={title}
footer={
noAccounts || loadError ? null : (
<>
<Button variant="outline" type="button" onClick={() => handleOpenChange(false)}>
{tr.common.cancel}
</Button>
<Button
type="submit"
form="transaction-form"
disabled={form.formState.isSubmitting || loading || uploadBusy}
>
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
{tr.common.save}
</Button>
</>
)
}
>
{loadError ? (
<p className="text-sm text-destructive">{loadError}</p>
) : loading ? (
<div className="flex flex-col gap-4" role="status" aria-label={tr.common.loading}>
<Skeleton className="h-9 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : noAccounts ? (
<div className="flex flex-col items-start gap-3 text-sm">
<p className="text-muted-foreground">{t.noAccounts}</p>
<Button asChild size="sm" onClick={() => handleOpenChange(false)}>
<Link href="/hesaplar">{t.noAccountsLink}</Link>
</Button>
</div>
) : (
<form id="transaction-form" onSubmit={form.handleSubmit(onSubmit)} noValidate>
<FieldGroup>
{mode?.kind !== "edit" ? (
<Tabs value={type} onValueChange={(v) => changeType(v as TransactionType)}>
<TabsList className="w-full">
{TRANSACTION_TYPES.map((x) => (
<TabsTrigger key={x} value={x} className="flex-1">
{t.types[x]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
<div className="grid gap-4 sm:grid-cols-[1fr_auto]">
<Controller
name="amount"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-amount">{f.amount}</FieldLabel>
<MoneyInput
id="tx-amount"
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="0,00"
className="h-11 text-lg"
autoFocus={mode?.kind === "create"}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="date"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-date">{f.date}</FieldLabel>
<Input
{...field}
id="tx-date"
type="date"
className="h-11"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</div>
<div className={cn("grid gap-4", type === "transfer" && "sm:grid-cols-2")}>
<Controller
name="accountId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-account">
{type === "transfer" ? f.fromAccount : f.account}
</FieldLabel>
<AccountSelect
id="tx-account"
value={field.value}
onChange={field.onChange}
accounts={accounts}
invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
{showInstallments ? (
<Controller
name="installments"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-installments">{f.installments}</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="tx-installments" className="w-full">
<SelectValue>
{field.value === "1"
? f.singlePayment
: f.installmentCount(Number(field.value))}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
{INSTALLMENT_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n === 1 ? f.singlePayment : f.installmentCount(n)}
</SelectItem>
))}
</SelectContent>
</Select>
{Number(field.value) > 1 && totalMinor > 0 ? (
<FieldDescription>
{f.installmentHint(
Number(field.value),
formatMoney(Math.ceil(totalMinor / Number(field.value))),
)}
</FieldDescription>
) : null}
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
) : null}
{type === "transfer" ? (
<Controller
name="toAccountId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-to-account">{f.toAccount}</FieldLabel>
<AccountSelect
id="tx-to-account"
value={field.value}
onChange={field.onChange}
accounts={accounts}
exclude={accountId}
invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
) : null}
</div>
{type !== "transfer" && !splitMode ? (
<Controller
name="categoryId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-category">{f.category}</FieldLabel>
<div className="flex gap-2">
<CategorySelect
id="tx-category"
value={field.value}
onChange={field.onChange}
options={categories}
appearance={options?.categoryAppearance ?? {}}
invalid={fieldState.invalid}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={toggleSplit}
title={f.split}
aria-label={f.split}
>
<Split />
</Button>
</div>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
) : null}
<Controller
name="payee"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="tx-payee">{f.payee}</FieldLabel>
<PayeeInput
value={field.value}
onChange={field.onChange}
onPick={onPayeePicked}
/>
{suggested ? <FieldDescription>{f.suggested}</FieldDescription> : null}
</Field>
)}
/>
<Controller
name="description"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-description">{f.description}</FieldLabel>
<Input {...field} id="tx-description" aria-invalid={fieldState.invalid} />
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" className="-ml-2 w-fit">
<ChevronDown
className={cn("transition-transform", advancedOpen && "rotate-180")}
/>
{f.advanced}
{splitMode ? (
<span className="text-xs text-muted-foreground">
· {t.list.split(splits.fields.length)}
</span>
) : null}
{attachmentCount ? (
<span className="text-xs text-muted-foreground">
· {tr.attachments.count(attachmentCount)}
</span>
) : null}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<FieldGroup>
<Controller
name="attachments"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel>{tr.attachments.title}</FieldLabel>
<AttachmentPicker
value={field.value}
onChange={field.onChange}
allowedKinds={allowedAttachmentKinds(type)}
onBusyChange={setUploadBusy}
/>
</Field>
)}
/>
<Controller
name="tags"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-tags">{f.tags}</FieldLabel>
<Input {...field} id="tx-tags" aria-describedby="tx-tags-hint" />
<FieldDescription id="tx-tags-hint">{f.tagsHint}</FieldDescription>
</Field>
)}
/>
{type !== "transfer" ? (
<div className="flex flex-col gap-3 rounded-xl border p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-medium">{f.split}</p>
<p className="text-xs text-muted-foreground">{f.splitHint}</p>
</div>
<Button
type="button"
variant={splitMode ? "outline" : "secondary"}
size="sm"
onClick={toggleSplit}
>
<Split />
{splitMode ? tr.common.cancel : f.split}
</Button>
</div>
{splits.fields.map((row, i) => (
<div
key={row.id}
className="grid grid-cols-[1fr_8rem_auto] items-start gap-2"
>
<Controller
name={`splits.${i}.categoryId`}
control={form.control}
render={({ field, fieldState }) => (
<div>
<CategorySelect
id={`tx-split-${i}-category`}
value={field.value}
onChange={field.onChange}
options={categories}
appearance={options?.categoryAppearance ?? {}}
invalid={fieldState.invalid}
/>
{fieldState.invalid ? (
<FieldError errors={[fieldState.error]} />
) : null}
</div>
)}
/>
<Controller
name={`splits.${i}.amount`}
control={form.control}
render={({ field, fieldState }) => (
<MoneyInput
id={`tx-split-${i}-amount`}
aria-label={`${f.amount} ${i + 1}`}
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="0,00"
aria-invalid={fieldState.invalid}
/>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => splits.remove(i)}
disabled={splits.fields.length <= 2}
aria-label={f.removeSplit}
>
<Trash2 />
</Button>
<Controller
name={`splits.${i}.note`}
control={form.control}
render={({ field }) => (
<Input
{...field}
placeholder={f.splitNote}
aria-label={`${f.splitNote} ${i + 1}`}
className="col-span-2 h-8 text-sm"
/>
)}
/>
</div>
))}
{splitMode ? (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => splits.append({ categoryId: "", amount: "", note: "" })}
disabled={splits.fields.length >= MAX_SPLITS}
>
<Plus />
{f.addSplit}
</Button>
<span
role="status"
className={cn(
"ml-auto text-sm tabular-nums",
remaining === 0 ? "text-income" : "text-expense",
)}
>
{remaining === 0
? f.remainingOk
: `${f.remaining}: ${formatMoney(remaining)}`}
</span>
{remaining !== 0 ? (
<Button type="button" variant="ghost" size="sm" onClick={fillRemaining}>
<Wand2 />
{f.fillRemaining}
</Button>
) : null}
</div>
) : null}
{errors.splits?.message || errors.splits?.root?.message ? (
<FieldError>
{errors.splits.message ?? errors.splits.root?.message}
</FieldError>
) : null}
</div>
) : null}
</FieldGroup>
</CollapsibleContent>
</Collapsible>
{errors.root ? <FieldError>{errors.root.message}</FieldError> : null}
</FieldGroup>
</form>
)}
</ResponsiveDialog>
);
}