diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx
index c69c6a3..7ea3637 100644
--- a/src/app/(app)/dashboard/page.tsx
+++ b/src/app/(app)/dashboard/page.tsx
@@ -3,6 +3,7 @@ import { Suspense } from "react";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
+import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { cn } from "@/lib/utils";
@@ -59,6 +60,9 @@ export default async function DashboardPage() {
title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)}
description={`${household.name} · ${tr.dashboard.welcomeDescription}`}
/>
+
+
+
}>
diff --git a/src/app/(app)/planli/page.tsx b/src/app/(app)/planli/page.tsx
new file mode 100644
index 0000000..223b7d7
--- /dev/null
+++ b/src/app/(app)/planli/page.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { PageHeader } from "@/components/page-header";
+import { hasHouseholdRole } from "@/features/households/roles";
+import { PlannedView } from "@/features/recurring/components/planned-view";
+import { getPlannedOverview } from "@/features/recurring/service";
+import { tr } from "@/i18n/tr";
+import { requireActiveHousehold } from "@/lib/dal";
+
+export const metadata: Metadata = { title: tr.recurring.title };
+
+export default async function PlannedPage() {
+ const { user, household } = await requireActiveHousehold("viewer");
+ const data = await getPlannedOverview(user.id, household.id);
+ if (!data) notFound();
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts
index 25e362a..c333d29 100644
--- a/src/components/layout/nav-config.ts
+++ b/src/components/layout/nav-config.ts
@@ -27,7 +27,7 @@ export type NavItem = {
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
-export const CURRENT_PHASE = 6.3;
+export const CURRENT_PHASE = 7.1;
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
@@ -36,7 +36,7 @@ export const MAIN_NAV: NavItem[] = [
{ href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6.2 },
{ href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 },
{ href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 },
- { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7 },
+ { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 },
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 },
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 },
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
diff --git a/src/features/recurring/components/overdue-banner.tsx b/src/features/recurring/components/overdue-banner.tsx
new file mode 100644
index 0000000..ba10dbd
--- /dev/null
+++ b/src/features/recurring/components/overdue-banner.tsx
@@ -0,0 +1,32 @@
+import { TriangleAlert } from "lucide-react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { tr } from "@/i18n/tr";
+import { countOverdue, ensureHorizon } from "../service";
+import { toObjectId } from "@/lib/object-id";
+
+/** Dashboard: vadesi geçmiş onaylanmamış planlı işlemler uyarısı (Bölüm 6.7). */
+export async function OverdueBanner({
+ userId,
+ householdId,
+}: {
+ userId: string;
+ householdId: string;
+}) {
+ const hid = toObjectId(householdId);
+ if (hid) await ensureHorizon(hid);
+ const n = await countOverdue(userId, householdId);
+ if (!n) return null;
+ return (
+
+
+ {tr.recurring.overdueBanner(n)}
+
+
+ );
+}
diff --git a/src/features/recurring/components/planned-view.tsx b/src/features/recurring/components/planned-view.tsx
new file mode 100644
index 0000000..adc0326
--- /dev/null
+++ b/src/features/recurring/components/planned-view.tsx
@@ -0,0 +1,508 @@
+"use client";
+
+import {
+ CalendarClock,
+ Check,
+ EllipsisVertical,
+ Loader2,
+ Pencil,
+ Plus,
+ SkipForward,
+ Trash2,
+ Repeat,
+} from "lucide-react";
+import { useState, useTransition } from "react";
+import { toast } from "sonner";
+import { MoneyInput } from "@/components/money-input";
+import { ResponsiveDialog } from "@/components/responsive-dialog";
+import { useQuickAdd } from "@/components/layout/quick-add";
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardDescription, CardHeader } from "@/components/ui/card";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { getTransactionAction } from "@/features/transactions/actions";
+import {
+ Amount,
+ RowIcon,
+ rowSubtitle,
+ rowTitle,
+} from "@/features/transactions/components/transaction-parts";
+import { tr } from "@/i18n/tr";
+import { todayIstanbul } from "@/shared/dates";
+import { formatDate, formatMoney } from "@/shared/format";
+import { formatMoneyInput } from "@/shared/money";
+import { addDays } from "@/shared/recurrence";
+import {
+ confirmOccurrenceAction,
+ deleteRuleAction,
+ getRuleFormAction,
+ postponeOccurrenceAction,
+ skipOccurrenceAction,
+} from "../actions";
+import type { PlannedItem, PlannedOverview, RuleView } from "../service";
+import { RuleFormDialog, type RuleFormMode } from "./rule-form";
+
+const t = tr.recurring;
+const dateLabel = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
+
+type Pending =
+ | { kind: "confirm"; item: PlannedItem }
+ | { kind: "postpone"; item: PlannedItem }
+ | { kind: "scope"; item: PlannedItem }
+ | { kind: "delete"; ruleId: string; item?: PlannedItem }
+ | null;
+
+export function PlannedView({ data, canWrite }: { data: PlannedOverview; canWrite: boolean }) {
+ const [tab, setTab] = useState(data.overdue.length ? "overdue" : "upcoming");
+ const [pending, setPending] = useState(null);
+ const [ruleForm, setRuleForm] = useState(null);
+ const [, startTransition] = useTransition();
+ const { openTransactionForm } = useQuickAdd();
+
+ function skip(item: PlannedItem) {
+ startTransition(async () => {
+ const r = await skipOccurrenceAction(item.id);
+ if (r.ok) toast.success(t.toast.skipped);
+ else toast.error(r.error);
+ });
+ }
+
+ async function editScope(item: PlannedItem, scope: "one" | "following" | "all") {
+ setPending(null);
+ if (scope === "one" || !item.recurringRuleId) {
+ const d = await getTransactionAction(item.id);
+ if (d.ok) openTransactionForm({ kind: "edit", transaction: d.data });
+ else toast.error(d.error);
+ return;
+ }
+ const rule = await getRuleFormAction(item.recurringRuleId);
+ if (!rule.ok) return void toast.error(rule.error);
+ const [pos, total] = (item.seriesPosition ?? "").split("/").map(Number);
+ setRuleForm({
+ kind: "edit",
+ rule: rule.data,
+ scope,
+ fromTransactionId: scope === "following" ? item.id : undefined,
+ fromDate: scope === "following" ? item.date : undefined,
+ remainingCount: pos && total ? total - pos + 1 : null,
+ });
+ }
+
+ async function editRule(rule: RuleView) {
+ const r = await getRuleFormAction(rule.id);
+ if (r.ok) setRuleForm({ kind: "edit", rule: r.data, scope: "all" });
+ else toast.error(r.error);
+ }
+
+ const itemList = (items: PlannedItem[], empty: string) =>
+ items.length === 0 ? (
+
+
+ {empty}
+
+
+ ) : (
+
+ {items.map((item) => (
+ -
+
+
+
+ {rowTitle(item)}
+ {item.seriesPosition ? (
+
+ {item.seriesPosition}
+
+ ) : null}
+ {item.isEstimate ? (
+
+ {t.estimate}
+
+ ) : null}
+
+
+ {/* Mobilde rozetler alt satırda (başlık daralmasın). */}
+
+ {item.seriesPosition ? `${item.seriesPosition} · ` : ""}
+ {item.isEstimate ? `${t.estimate} · ` : ""}
+
+ {dateLabel(item.date)} · {rowSubtitle(item)}
+
+
+
+ {canWrite ? (
+
+
+
+
+
+
+
+ setPending({ kind: "postpone", item })}>
+
+ {t.actions.postpone}
+
+ skip(item)}>
+
+ {t.actions.skip}
+
+
+ item.recurringRuleId
+ ? setPending({ kind: "scope", item })
+ : void editScope(item, "one")
+ }
+ >
+
+ {t.actions.edit}
+
+ {item.recurringRuleId ? (
+ <>
+
+
+ setPending({ kind: "delete", ruleId: item.recurringRuleId!, item })
+ }
+ >
+
+ {t.actions.delete}
+
+ >
+ ) : null}
+
+
+
+ ) : null}
+
+ ))}
+
+ );
+
+ return (
+
+ {canWrite ? (
+
+
+
+ ) : null}
+
+
+
+ {t.tabs.overdue}
+ {data.overdue.length ? (
+
+ {data.overdue.length}
+
+ ) : null}
+
+ {t.tabs.upcoming}
+ {t.tabs.rules}
+
+
+ {itemList(data.overdue, t.empty.overdue)}
+
+
+ {itemList(data.upcoming, t.empty.upcoming)}
+
+
+ {data.rules.length === 0 ? (
+
+
+ {t.empty.rules}
+
+
+ ) : (
+
+ {data.rules.map((rule) => (
+ -
+
+
+
+
+
+ {rule.title}
+ {rule.isEstimate ? {t.estimate} : null}
+
+
+ {rule.recurrence} ·{" "}
+ {rule.toAccount ? `${rule.account} → ${rule.toAccount}` : rule.account}
+ {rule.nextDate ? ` · ${t.next(dateLabel(rule.nextDate))}` : ""}
+ {rule.remaining !== null
+ ? ` · ${t.remaining(rule.remaining)}`
+ : ` · ${t.endless}`}
+
+
+
+ {canWrite ? (
+
+
+
+
+
+ void editRule(rule)}>
+
+ {t.actions.edit}
+
+ setPending({ kind: "delete", ruleId: rule.id })}
+ >
+
+ {t.actions.delete}
+
+
+
+ ) : null}
+
+ ))}
+
+ )}
+
+
+
+
setPending(null)}
+ />
+ setPending(null)}
+ />
+ setPending(null)}
+ onPick={(item, scope) => void editScope(item, scope)}
+ />
+ setPending(null)}
+ />
+ !o && setRuleForm(null)} />
+
+ );
+}
+
+function ConfirmDialog({ item, onClose }: { item: PlannedItem | null; onClose: () => void }) {
+ return item ? : null;
+}
+
+function ConfirmForm({ item, onClose }: { item: PlannedItem; onClose: () => void }) {
+ const today = todayIstanbul();
+ const [amount, setAmount] = useState(formatMoneyInput(item.amountMinor));
+ // Gelecekteki kaydı erken onaylarken bugünün tarihi; geçmiştekini kendi tarihiyle.
+ const [date, setDate] = useState(item.date > today ? today : item.date);
+ const [errors, setErrors] = useState>({});
+ const [busy, setBusy] = useState(false);
+
+ async function submit() {
+ setBusy(true);
+ const r = await confirmOccurrenceAction(item.id, { amount, date });
+ setBusy(false);
+ if (r.ok) {
+ toast.success(t.toast.confirmed);
+ onClose();
+ } else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
+ }
+
+ return (
+ !o && onClose()}
+ title={`${t.confirmTitle}: ${rowTitle(item)}`}
+ description={t.confirmDescription}
+ footer={
+ <>
+
+
+ >
+ }
+ >
+
+
+ {tr.transactions.fields.amount}
+
+ {errors.amount ? {errors.amount} : null}
+
+
+ {tr.transactions.fields.date}
+ setDate(e.target.value)}
+ />
+ {errors.date ? {errors.date} : null}
+
+ {item.isEstimate ? (
+
+ {t.estimate}: {formatMoney(item.amountMinor)}
+
+ ) : null}
+ {errors.root ? {errors.root} : null}
+
+
+ );
+}
+
+function PostponeDialog({ item, onClose }: { item: PlannedItem | null; onClose: () => void }) {
+ return item ? : null;
+}
+
+function PostponeForm({ item, onClose }: { item: PlannedItem; onClose: () => void }) {
+ const base = item.date > todayIstanbul() ? item.date : todayIstanbul();
+ const [date, setDate] = useState(addDays(base, 7));
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ async function submit() {
+ setBusy(true);
+ const r = await postponeOccurrenceAction(item.id, date);
+ setBusy(false);
+ if (r.ok) {
+ toast.success(t.toast.postponed);
+ onClose();
+ } else setError(r.fieldErrors?.date ?? r.error);
+ }
+
+ return (
+ !o && onClose()}
+ title={`${t.postponeTitle}: ${rowTitle(item)}`}
+ footer={
+ <>
+
+
+ >
+ }
+ >
+
+ {t.postponeDate}
+ setDate(e.target.value)}
+ />
+ {error ? {error} : null}
+
+
+ );
+}
+
+function ScopeDialog({
+ item,
+ onClose,
+ onPick,
+}: {
+ item: PlannedItem | null;
+ onClose: () => void;
+ onPick: (item: PlannedItem, scope: "one" | "following" | "all") => void;
+}) {
+ return (
+ !o && onClose()}
+ title={t.scopeTitle}
+ >
+
+ {(["one", "following", "all"] as const).map((scope) => (
+
+ ))}
+
+
+ );
+}
+
+function DeleteDialog({
+ target,
+ onClose,
+}: {
+ target: { ruleId: string; item?: PlannedItem } | null;
+ onClose: () => void;
+}) {
+ const [busy, setBusy] = useState(false);
+ async function run(scope: "all" | "following") {
+ if (!target) return;
+ setBusy(true);
+ const r = await deleteRuleAction(target.ruleId, scope, target.item?.id);
+ setBusy(false);
+ if (r.ok) {
+ toast.success(t.toast.deleted);
+ onClose();
+ } else toast.error(r.error);
+ }
+ return (
+ !o && onClose()}>
+
+
+ {t.deleteTitle}
+ {t.deleteDescription}
+
+
+ {tr.common.cancel}
+ {target?.item ? (
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/src/features/recurring/components/rule-form.tsx b/src/features/recurring/components/rule-form.tsx
new file mode 100644
index 0000000..bdf5987
--- /dev/null
+++ b/src/features/recurring/components/rule-form.tsx
@@ -0,0 +1,538 @@
+"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(null);
+ const [loadError, setLoadError] = useState(null);
+ const form = useForm({
+ 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 (
+
+
+
+ >
+ )
+ }
+ >
+ {loadError ? (
+ {loadError}
+ ) : loading || !options ? (
+
+
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/features/transactions/components/transaction-form.tsx b/src/features/transactions/components/transaction-form.tsx
index 15849c1..72ed56c 100644
--- a/src/features/transactions/components/transaction-form.tsx
+++ b/src/features/transactions/components/transaction-form.tsx
@@ -101,7 +101,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
};
}
-function AccountSelect({
+export function AccountSelect({
id,
value,
onChange,
@@ -148,7 +148,7 @@ function AccountSelect({
);
}
-function CategorySelect({
+export function CategorySelect({
id,
value,
onChange,
@@ -199,7 +199,7 @@ function CategorySelect({
}
/** Satıcı alanı: yazdıkça öneri; seçilince son kullanılan kategori/hesap önerilir. */
-function PayeeInput({
+export function PayeeInput({
value,
onChange,
onPick,
diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts
index 2178555..b937c78 100644
--- a/src/i18n/tr.ts
+++ b/src/i18n/tr.ts
@@ -352,6 +352,84 @@ export const tr = {
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
noAccountsLink: "Hesaplara git",
},
+ recurring: {
+ title: "Planlı işlemler",
+ description:
+ "Düzenli ve planlı gelir/giderler. Planlı kayıtlar bakiyeyi etkilemez; onaylanınca gerçekleşir.",
+ newRule: "Yeni düzenli işlem",
+ createTitle: "Yeni düzenli işlem",
+ editTitle: "Düzenli işlemi düzenle",
+ tabs: { upcoming: "Yaklaşan", overdue: "Vadesi geçmiş", rules: "Kurallar" },
+ fields: {
+ estimate: "Tutar tahmini (ör. fatura); onaylarken düzeltilir",
+ startDate: "İlk tarih",
+ frequency: "Sıklık",
+ interval: "Kaç birimde bir",
+ monthlyMode: "Ayın hangi günü",
+ end: "Bitiş",
+ count: "Tekrar sayısı",
+ endDate: "Bitiş tarihi",
+ preview: "İlk tarihler",
+ },
+ frequencies: { daily: "Günlük", weekly: "Haftalık", monthly: "Aylık", yearly: "Yıllık" },
+ units: { daily: "gün", weekly: "hafta", monthly: "ay", yearly: "yıl" },
+ monthlyModes: { day: "İlk tarihin günü", last: "Ayın son günü" },
+ endKinds: { never: "Bitiş yok", count: "Tekrar sayısı", date: "Bitiş tarihi" },
+ actions: {
+ confirm: "Onayla",
+ skip: "Atla",
+ postpone: "Ertele",
+ edit: "Düzenle",
+ delete: "Sil",
+ more: "Diğer işlemler",
+ },
+ confirmTitle: "İşlemi onayla",
+ confirmDescription: "Gerçekleşen tutarı ve tarihi kontrol edin; onaylanınca bakiyeye yansır.",
+ postponeTitle: "Ertele",
+ postponeDate: "Yeni tarih",
+ scopeTitle: "Hangi kayıtlar değişsin?",
+ scopes: { one: "Yalnızca bu", following: "Bu ve sonrakiler", all: "Tümü" },
+ scopeHints: {
+ one: "Yalnızca bu planlı kayıt değişir; seri aynı kalır.",
+ following: "Seri bu tarihten itibaren yeni değerlerle devam eder.",
+ all: "Onaylanmamış tüm planlı kayıtlar yeni değerlerle yeniden oluşturulur. Gerçekleşmiş kayıtlar değişmez.",
+ },
+ deleteTitle: "Düzenli işlem silinsin mi?",
+ deleteDescription:
+ "Gerçekleşmiş kayıtlar korunur; yalnızca onaylanmamış planlı kayıtlar silinir.",
+ deleteScopes: { following: "Bu ve sonrakileri sil", all: "Tüm seriyi sil" },
+ estimate: "Tahmini",
+ next: (date: string) => `Sıradaki: ${date}`,
+ remaining: (n: number) => `${n} tekrar kaldı`,
+ endless: "Süresiz",
+ series: (pos: string) => `${pos}`,
+ empty: {
+ upcoming: "Önümüzdeki 60 günde planlı işlem yok.",
+ overdue: "Vadesi geçmiş planlı işlem yok.",
+ rules: "Henüz düzenli işlem yok. Kira, faturalar ve abonelikler için ekleyin.",
+ },
+ overdueBanner: (n: number) =>
+ `${n} planlı işlemin tarihi geçti; onaylayın, erteleyin veya atlayın.`,
+ overdueLink: "Planlı işlemlere git",
+ upcomingTitle: "Yaklaşan planlı işlemler",
+ toast: {
+ created: "Düzenli işlem kaydedildi.",
+ updated: "Düzenli işlem güncellendi.",
+ deleted: "Düzenli işlem silindi.",
+ confirmed: "İşlem onaylandı.",
+ skipped: "Bu tekrar atlandı.",
+ postponed: "İşlem ertelendi.",
+ },
+ validation: {
+ interval: "1 ile 12 arasında olmalı",
+ count: "1 ile 600 arasında bir sayı girin",
+ endBeforeStart: "Bitiş tarihi ilk tarihten önce olamaz",
+ },
+ errors: {
+ splitAmount: "Bölünmüş planlı işlemin tutarı değiştirilemez.",
+ postponeDate: "Yeni tarih mevcut tarihten sonra olmalı.",
+ },
+ },
attachments: {
title: "Ekler",
add: "Ek ekle",