Files
butcem/src/features/recurring/components/rule-form.tsx
T

539 lines
19 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 { 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>
);
}