cfc2b2c974
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
539 lines
19 KiB
TypeScript
539 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { Loader2 } from "lucide-react";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { Controller, useForm, useWatch, type Resolver } from "react-hook-form";
|
||
import { toast } from "sonner";
|
||
import { MoneyInput } from "@/components/money-input";
|
||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
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 { getTransactionFormOptionsAction } from "@/features/transactions/actions";
|
||
import {
|
||
AccountSelect,
|
||
CategorySelect,
|
||
PayeeInput,
|
||
} from "@/features/transactions/components/transaction-form";
|
||
import type { TransactionFormOptions } from "@/features/transactions/service";
|
||
import { tr } from "@/i18n/tr";
|
||
import { todayIstanbul } from "@/shared/dates";
|
||
import { formatDate } from "@/shared/format";
|
||
import { formatMoneyInput } from "@/shared/money";
|
||
import {
|
||
describeRecurrence,
|
||
FREQUENCIES,
|
||
listOccurrences,
|
||
MONTHLY_MODES,
|
||
type Frequency,
|
||
type MonthlyMode,
|
||
} from "@/shared/recurrence";
|
||
import {
|
||
END_KINDS,
|
||
recurringRuleFormSchema,
|
||
type RecurringRuleForm,
|
||
} from "@/shared/schemas/recurring";
|
||
import { TRANSACTION_TYPES, type TransactionType } from "@/shared/transactions";
|
||
import { createRuleAction, updateRuleAction } from "../actions";
|
||
import type { RuleFormValues } from "../service";
|
||
|
||
const t = tr.recurring;
|
||
const tf = tr.transactions.fields;
|
||
|
||
export type RuleFormMode =
|
||
| { kind: "create" }
|
||
| {
|
||
kind: "edit";
|
||
rule: RuleFormValues;
|
||
scope: "all" | "following";
|
||
/** "Bu ve sonrakiler" için başlangıç tekrarı ve onun tarihi / kalan tekrar sayısı. */
|
||
fromTransactionId?: string;
|
||
fromDate?: string;
|
||
remainingCount?: number | null;
|
||
};
|
||
|
||
type FormValues = {
|
||
type: TransactionType;
|
||
amount: string;
|
||
isEstimate: boolean;
|
||
accountId: string;
|
||
toAccountId: string;
|
||
categoryId: string;
|
||
payee: string;
|
||
description: string;
|
||
tags: string;
|
||
startDate: string;
|
||
frequency: Frequency;
|
||
interval: string;
|
||
monthlyMode: MonthlyMode;
|
||
endKind: (typeof END_KINDS)[number];
|
||
count: string;
|
||
endDate: string;
|
||
};
|
||
|
||
function toValues(mode: RuleFormMode, options: TransactionFormOptions | null): FormValues {
|
||
if (mode.kind === "create") {
|
||
return {
|
||
type: "expense",
|
||
amount: "",
|
||
isEstimate: false,
|
||
accountId: options?.accounts[0]?.id ?? "",
|
||
toAccountId: "",
|
||
categoryId: "",
|
||
payee: "",
|
||
description: "",
|
||
tags: "",
|
||
startDate: todayIstanbul(),
|
||
frequency: "monthly",
|
||
interval: "1",
|
||
monthlyMode: "day",
|
||
endKind: "never",
|
||
count: "",
|
||
endDate: "",
|
||
};
|
||
}
|
||
const r = mode.rule;
|
||
const following = mode.scope === "following" && mode.fromDate;
|
||
const count = following ? (mode.remainingCount ?? null) : r.count;
|
||
return {
|
||
type: r.type,
|
||
amount: formatMoneyInput(r.amountMinor),
|
||
isEstimate: r.isEstimate,
|
||
accountId: r.accountId,
|
||
toAccountId: r.toAccountId ?? "",
|
||
categoryId: r.categoryId ?? "",
|
||
payee: r.payee,
|
||
description: r.description,
|
||
tags: r.tags.join(", "),
|
||
startDate: following ? mode.fromDate! : r.startDate,
|
||
frequency: r.frequency,
|
||
interval: String(r.interval),
|
||
monthlyMode: r.monthlyMode,
|
||
endKind: count ? "count" : r.endDate ? "date" : "never",
|
||
count: count ? String(count) : "",
|
||
endDate: r.endDate ?? "",
|
||
};
|
||
}
|
||
|
||
/** Düzenli işlem kuralı formu (oluştur / tümünü düzenle / bu ve sonrakileri düzenle). */
|
||
export function RuleFormDialog({
|
||
mode,
|
||
onOpenChange,
|
||
}: {
|
||
mode: RuleFormMode | null;
|
||
onOpenChange: (open: boolean) => void;
|
||
}) {
|
||
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
|
||
const [loadError, setLoadError] = useState<string | null>(null);
|
||
const form = useForm<FormValues, unknown, RecurringRuleForm>({
|
||
resolver: zodResolver(recurringRuleFormSchema) as unknown as Resolver<
|
||
FormValues,
|
||
unknown,
|
||
RecurringRuleForm
|
||
>,
|
||
defaultValues: toValues({ kind: "create" }, null),
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!mode) return;
|
||
let cancelled = false;
|
||
getTransactionFormOptionsAction().then((res) => {
|
||
if (cancelled) return;
|
||
if (!res.ok) return setLoadError(res.error);
|
||
setLoadError(null);
|
||
setOptions(res.data);
|
||
form.reset(toValues(mode, res.data));
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [mode, form]);
|
||
|
||
const w = useWatch({ control: form.control });
|
||
const type = w.type ?? "expense";
|
||
const categories = useMemo(() => {
|
||
const kind = type === "income" ? "income" : "expense";
|
||
return (options?.categories ?? []).filter((c) => c.kind === kind);
|
||
}, [options, type]);
|
||
|
||
// Önizleme: açıklama ve ilk 3 tarih (istemcide aynı saf fonksiyonla).
|
||
const preview = useMemo(() => {
|
||
const interval = Math.min(12, Math.max(1, Number(w.interval) || 1));
|
||
const r = {
|
||
frequency: w.frequency ?? "monthly",
|
||
interval,
|
||
monthlyMode: w.monthlyMode ?? "day",
|
||
startDate: w.startDate ?? todayIstanbul(),
|
||
endDate: w.endKind === "date" && w.endDate ? w.endDate : null,
|
||
count: w.endKind === "count" && Number(w.count) > 0 ? Number(w.count) : null,
|
||
} as const;
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(r.startDate)) return null;
|
||
return {
|
||
text: describeRecurrence(r),
|
||
dates: listOccurrences(r, "9999-12-31")
|
||
.slice(0, 3)
|
||
.map((o) => o.date),
|
||
};
|
||
}, [w]);
|
||
|
||
function changeType(next: TransactionType) {
|
||
form.setValue("type", next);
|
||
form.setValue("categoryId", "");
|
||
if (next !== "transfer") form.setValue("toAccountId", "");
|
||
form.clearErrors();
|
||
}
|
||
|
||
async function onSubmit() {
|
||
if (!mode) return;
|
||
const values = form.getValues();
|
||
const payload = {
|
||
...values,
|
||
toAccountId: values.type === "transfer" ? values.toAccountId : "",
|
||
categoryId: values.type === "transfer" ? "" : values.categoryId,
|
||
count: values.endKind === "count" ? values.count : null,
|
||
endDate: values.endKind === "date" ? values.endDate : null,
|
||
};
|
||
const result =
|
||
mode.kind === "edit"
|
||
? await updateRuleAction(mode.rule.id, payload, mode.scope, mode.fromTransactionId)
|
||
: await createRuleAction(payload);
|
||
if (result.ok) {
|
||
toast.success(mode.kind === "edit" ? t.toast.updated : t.toast.created);
|
||
onOpenChange(false);
|
||
return;
|
||
}
|
||
for (const [name, message] of Object.entries(result.fieldErrors ?? {})) {
|
||
if (message) form.setError(name as keyof FormValues, { message });
|
||
}
|
||
if (!result.fieldErrors) form.setError("root", { message: result.error });
|
||
}
|
||
|
||
const loading = Boolean(mode) && !options && !loadError;
|
||
const errors = form.formState.errors;
|
||
const title = mode?.kind === "edit" ? `${t.editTitle} · ${t.scopes[mode.scope]}` : t.createTitle;
|
||
|
||
return (
|
||
<ResponsiveDialog
|
||
open={Boolean(mode)}
|
||
onOpenChange={onOpenChange}
|
||
title={title}
|
||
description={mode?.kind === "edit" ? t.scopeHints[mode.scope] : undefined}
|
||
footer={
|
||
loadError ? null : (
|
||
<>
|
||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||
{tr.common.cancel}
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
form="rule-form"
|
||
disabled={form.formState.isSubmitting || loading}
|
||
>
|
||
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
|
||
{tr.common.save}
|
||
</Button>
|
||
</>
|
||
)
|
||
}
|
||
>
|
||
{loadError ? (
|
||
<p className="text-sm text-destructive">{loadError}</p>
|
||
) : loading || !options ? (
|
||
<div className="flex flex-col gap-4" role="status" aria-label={tr.common.loading}>
|
||
<Skeleton className="h-9 w-full" />
|
||
<Skeleton className="h-9 w-full" />
|
||
<Skeleton className="h-9 w-full" />
|
||
</div>
|
||
) : (
|
||
<form id="rule-form" onSubmit={form.handleSubmit(onSubmit)} noValidate>
|
||
<FieldGroup>
|
||
<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">
|
||
{tr.transactions.types[x]}
|
||
</TabsTrigger>
|
||
))}
|
||
</TabsList>
|
||
</Tabs>
|
||
|
||
<Controller
|
||
name="amount"
|
||
control={form.control}
|
||
render={({ field, fieldState }) => (
|
||
<Field data-invalid={fieldState.invalid}>
|
||
<FieldLabel htmlFor="rule-amount">{tf.amount}</FieldLabel>
|
||
<MoneyInput
|
||
id="rule-amount"
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
onBlur={field.onBlur}
|
||
aria-invalid={fieldState.invalid}
|
||
/>
|
||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||
</Field>
|
||
)}
|
||
/>
|
||
<Controller
|
||
name="isEstimate"
|
||
control={form.control}
|
||
render={({ field }) => (
|
||
<label className="-mt-2 flex items-center gap-2 text-sm">
|
||
<Checkbox
|
||
checked={field.value}
|
||
onCheckedChange={(v) => field.onChange(v === true)}
|
||
/>
|
||
{t.fields.estimate}
|
||
</label>
|
||
)}
|
||
/>
|
||
|
||
<Controller
|
||
name="accountId"
|
||
control={form.control}
|
||
render={({ field, fieldState }) => (
|
||
<Field data-invalid={fieldState.invalid}>
|
||
<FieldLabel htmlFor="rule-account">
|
||
{type === "transfer" ? tf.fromAccount : tf.account}
|
||
</FieldLabel>
|
||
<AccountSelect
|
||
id="rule-account"
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
accounts={options.accounts}
|
||
invalid={fieldState.invalid}
|
||
/>
|
||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||
</Field>
|
||
)}
|
||
/>
|
||
{type === "transfer" ? (
|
||
<Controller
|
||
name="toAccountId"
|
||
control={form.control}
|
||
render={({ field, fieldState }) => (
|
||
<Field data-invalid={fieldState.invalid}>
|
||
<FieldLabel htmlFor="rule-to">{tf.toAccount}</FieldLabel>
|
||
<AccountSelect
|
||
id="rule-to"
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
accounts={options.accounts}
|
||
exclude={w.accountId}
|
||
invalid={fieldState.invalid}
|
||
/>
|
||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||
</Field>
|
||
)}
|
||
/>
|
||
) : (
|
||
<Controller
|
||
name="categoryId"
|
||
control={form.control}
|
||
render={({ field, fieldState }) => (
|
||
<Field data-invalid={fieldState.invalid}>
|
||
<FieldLabel htmlFor="rule-category">{tf.category}</FieldLabel>
|
||
<CategorySelect
|
||
id="rule-category"
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
options={categories}
|
||
appearance={options.categoryAppearance}
|
||
invalid={fieldState.invalid}
|
||
/>
|
||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||
</Field>
|
||
)}
|
||
/>
|
||
)}
|
||
<Controller
|
||
name="payee"
|
||
control={form.control}
|
||
render={({ field }) => (
|
||
<Field>
|
||
<FieldLabel htmlFor="tx-payee">{tf.payee}</FieldLabel>
|
||
<PayeeInput value={field.value} onChange={field.onChange} onPick={() => {}} />
|
||
</Field>
|
||
)}
|
||
/>
|
||
<Controller
|
||
name="description"
|
||
control={form.control}
|
||
render={({ field }) => (
|
||
<Field>
|
||
<FieldLabel htmlFor="rule-description">{tf.description}</FieldLabel>
|
||
<Input {...field} id="rule-description" />
|
||
</Field>
|
||
)}
|
||
/>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Controller
|
||
name="startDate"
|
||
control={form.control}
|
||
render={({ field, fieldState }) => (
|
||
<Field data-invalid={fieldState.invalid}>
|
||
<FieldLabel htmlFor="rule-start">{t.fields.startDate}</FieldLabel>
|
||
<Input
|
||
{...field}
|
||
id="rule-start"
|
||
type="date"
|
||
aria-invalid={fieldState.invalid}
|
||
/>
|
||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||
</Field>
|
||
)}
|
||
/>
|
||
<Controller
|
||
name="frequency"
|
||
control={form.control}
|
||
render={({ field }) => (
|
||
<Field>
|
||
<FieldLabel htmlFor="rule-frequency">{t.fields.frequency}</FieldLabel>
|
||
<Select value={field.value} onValueChange={field.onChange}>
|
||
<SelectTrigger id="rule-frequency" className="w-full">
|
||
<SelectValue>{t.frequencies[field.value]}</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{FREQUENCIES.map((x) => (
|
||
<SelectItem key={x} value={x}>
|
||
{t.frequencies[x]}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</Field>
|
||
)}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Controller
|
||
name="interval"
|
||
control={form.control}
|
||
render={({ field, fieldState }) => (
|
||
<Field data-invalid={fieldState.invalid}>
|
||
<FieldLabel htmlFor="rule-interval">
|
||
{t.fields.interval} ({t.units[w.frequency ?? "monthly"]})
|
||
</FieldLabel>
|
||
<Input
|
||
{...field}
|
||
id="rule-interval"
|
||
type="number"
|
||
inputMode="numeric"
|
||
min={1}
|
||
max={12}
|
||
aria-invalid={fieldState.invalid}
|
||
/>
|
||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||
</Field>
|
||
)}
|
||
/>
|
||
{w.frequency === "monthly" ? (
|
||
<Controller
|
||
name="monthlyMode"
|
||
control={form.control}
|
||
render={({ field }) => (
|
||
<Field>
|
||
<FieldLabel htmlFor="rule-monthly">{t.fields.monthlyMode}</FieldLabel>
|
||
<Select value={field.value} onValueChange={field.onChange}>
|
||
<SelectTrigger id="rule-monthly" className="w-full">
|
||
<SelectValue>{t.monthlyModes[field.value]}</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{MONTHLY_MODES.map((x) => (
|
||
<SelectItem key={x} value={x}>
|
||
{t.monthlyModes[x]}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</Field>
|
||
)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Controller
|
||
name="endKind"
|
||
control={form.control}
|
||
render={({ field }) => (
|
||
<Field>
|
||
<FieldLabel htmlFor="rule-end">{t.fields.end}</FieldLabel>
|
||
<Select value={field.value} onValueChange={field.onChange}>
|
||
<SelectTrigger id="rule-end" className="w-full">
|
||
<SelectValue>{t.endKinds[field.value]}</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{END_KINDS.map((x) => (
|
||
<SelectItem key={x} value={x}>
|
||
{t.endKinds[x]}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</Field>
|
||
)}
|
||
/>
|
||
{w.endKind === "count" ? (
|
||
<Controller
|
||
name="count"
|
||
control={form.control}
|
||
render={({ field, fieldState }) => (
|
||
<Field data-invalid={fieldState.invalid}>
|
||
<FieldLabel htmlFor="rule-count">{t.fields.count}</FieldLabel>
|
||
<Input
|
||
{...field}
|
||
id="rule-count"
|
||
type="number"
|
||
inputMode="numeric"
|
||
min={1}
|
||
aria-invalid={fieldState.invalid}
|
||
/>
|
||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||
</Field>
|
||
)}
|
||
/>
|
||
) : w.endKind === "date" ? (
|
||
<Controller
|
||
name="endDate"
|
||
control={form.control}
|
||
render={({ field, fieldState }) => (
|
||
<Field data-invalid={fieldState.invalid}>
|
||
<FieldLabel htmlFor="rule-enddate">{t.fields.endDate}</FieldLabel>
|
||
<Input
|
||
{...field}
|
||
id="rule-enddate"
|
||
type="date"
|
||
aria-invalid={fieldState.invalid}
|
||
/>
|
||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||
</Field>
|
||
)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
{preview ? (
|
||
<FieldDescription>
|
||
{preview.text} · {t.fields.preview}:{" "}
|
||
{preview.dates.map((d) => formatDate(`${d}T12:00:00+03:00`)).join(", ")}
|
||
</FieldDescription>
|
||
) : null}
|
||
{errors.root ? <FieldError>{errors.root.message}</FieldError> : null}
|
||
</FieldGroup>
|
||
</form>
|
||
)}
|
||
</ResponsiveDialog>
|
||
);
|
||
}
|