From cfc2b2c974d7362f29aad20ec7b077bb8c7f6b63 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 10:55:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20planl=C4=B1=20i=C5=9Flemler=20sayfa?= =?UTF-8?q?s=C4=B1,=20kural=20formu=20ve=20vadesi=20ge=C3=A7mi=C5=9F=20uya?= =?UTF-8?q?r=C4=B1s=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/dashboard/page.tsx | 4 + src/app/(app)/planli/page.tsx | 22 + src/components/layout/nav-config.ts | 4 +- .../recurring/components/overdue-banner.tsx | 32 ++ .../recurring/components/planned-view.tsx | 508 +++++++++++++++++ .../recurring/components/rule-form.tsx | 538 ++++++++++++++++++ .../components/transaction-form.tsx | 6 +- src/i18n/tr.ts | 78 +++ 8 files changed, 1187 insertions(+), 5 deletions(-) create mode 100644 src/app/(app)/planli/page.tsx create mode 100644 src/features/recurring/components/overdue-banner.tsx create mode 100644 src/features/recurring/components/planned-view.tsx create mode 100644 src/features/recurring/components/rule-form.tsx 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 ? ( +
+ + + +
+ ) : ( +
+ + changeType(v as TransactionType)}> + + {TRANSACTION_TYPES.map((x) => ( + + {tr.transactions.types[x]} + + ))} + + + + ( + + {tf.amount} + + {fieldState.invalid ? : null} + + )} + /> + ( + + )} + /> + + ( + + + {type === "transfer" ? tf.fromAccount : tf.account} + + + {fieldState.invalid ? : null} + + )} + /> + {type === "transfer" ? ( + ( + + {tf.toAccount} + + {fieldState.invalid ? : null} + + )} + /> + ) : ( + ( + + {tf.category} + + {fieldState.invalid ? : null} + + )} + /> + )} + ( + + {tf.payee} + {}} /> + + )} + /> + ( + + {tf.description} + + + )} + /> + +
+ ( + + {t.fields.startDate} + + {fieldState.invalid ? : null} + + )} + /> + ( + + {t.fields.frequency} + + + )} + /> +
+
+ ( + + + {t.fields.interval} ({t.units[w.frequency ?? "monthly"]}) + + + {fieldState.invalid ? : null} + + )} + /> + {w.frequency === "monthly" ? ( + ( + + {t.fields.monthlyMode} + + + )} + /> + ) : null} +
+
+ ( + + {t.fields.end} + + + )} + /> + {w.endKind === "count" ? ( + ( + + {t.fields.count} + + {fieldState.invalid ? : null} + + )} + /> + ) : w.endKind === "date" ? ( + ( + + {t.fields.endDate} + + {fieldState.invalid ? : null} + + )} + /> + ) : null} +
+ {preview ? ( + + {preview.text} · {t.fields.preview}:{" "} + {preview.dates.map((d) => formatDate(`${d}T12:00:00+03:00`)).join(", ")} + + ) : null} + {errors.root ? {errors.root.message} : null} +
+
+ )} +
+ ); +} 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",