feat(ui): planlı işlemler sayfası, kural formu ve vadesi geçmiş uyarısı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { Suspense } from "react";
|
|||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||||
|
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -59,6 +60,9 @@ export default async function DashboardPage() {
|
|||||||
title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)}
|
title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)}
|
||||||
description={`${household.name} · ${tr.dashboard.welcomeDescription}`}
|
description={`${household.name} · ${tr.dashboard.welcomeDescription}`}
|
||||||
/>
|
/>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<OverdueBanner userId={user.id} householdId={household.id} />
|
||||||
|
</Suspense>
|
||||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||||
<SummaryCards />
|
<SummaryCards />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader title={tr.recurring.title} description={tr.recurring.description} />
|
||||||
|
<PlannedView data={data} canWrite={hasHouseholdRole(household.role, "member")} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ export type NavItem = {
|
|||||||
|
|
||||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** 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;
|
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: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6.2 },
|
||||||
{ href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 },
|
{ href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 },
|
||||||
{ href: "/kategoriler", label: tr.nav.categories, icon: Tags, 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: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 },
|
||||||
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 },
|
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 },
|
||||||
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
|
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="flex flex-wrap items-center gap-3 rounded-xl border border-expense/30 bg-expense/10 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<TriangleAlert className="size-4 shrink-0 text-expense" aria-hidden />
|
||||||
|
<span className="flex-1">{tr.recurring.overdueBanner(n)}</span>
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link href="/planli">{tr.recurring.overdueLink}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Pending>(null);
|
||||||
|
const [ruleForm, setRuleForm] = useState<RuleFormMode | null>(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 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>{empty}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="flex items-center gap-3 p-3">
|
||||||
|
<RowIcon item={item} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="truncate font-medium">{rowTitle(item)}</span>
|
||||||
|
{item.seriesPosition ? (
|
||||||
|
<Badge variant="outline" className="hidden sm:inline-flex">
|
||||||
|
{item.seriesPosition}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{item.isEstimate ? (
|
||||||
|
<Badge variant="secondary" className="hidden sm:inline-flex">
|
||||||
|
{t.estimate}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{/* Mobilde rozetler alt satırda (başlık daralmasın). */}
|
||||||
|
<span className="sm:hidden">
|
||||||
|
{item.seriesPosition ? `${item.seriesPosition} · ` : ""}
|
||||||
|
{item.isEstimate ? `${t.estimate} · ` : ""}
|
||||||
|
</span>
|
||||||
|
{dateLabel(item.date)} · {rowSubtitle(item)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Amount type={item.type} amountMinor={item.amountMinor} />
|
||||||
|
{canWrite ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPending({ kind: "confirm", item })}
|
||||||
|
>
|
||||||
|
<Check />
|
||||||
|
<span className="hidden sm:inline">{t.actions.confirm}</span>
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" aria-label={t.actions.more}>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={() => setPending({ kind: "postpone", item })}>
|
||||||
|
<CalendarClock />
|
||||||
|
{t.actions.postpone}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => skip(item)}>
|
||||||
|
<SkipForward />
|
||||||
|
{t.actions.skip}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
item.recurringRuleId
|
||||||
|
? setPending({ kind: "scope", item })
|
||||||
|
: void editScope(item, "one")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
{t.actions.edit}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{item.recurringRuleId ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() =>
|
||||||
|
setPending({ kind: "delete", ruleId: item.recurringRuleId!, item })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
{t.actions.delete}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{canWrite ? (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => setRuleForm({ kind: "create" })}>
|
||||||
|
<Plus />
|
||||||
|
{t.newRule}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overdue">
|
||||||
|
{t.tabs.overdue}
|
||||||
|
{data.overdue.length ? (
|
||||||
|
<Badge variant="destructive" className="ml-1">
|
||||||
|
{data.overdue.length}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="upcoming">{t.tabs.upcoming}</TabsTrigger>
|
||||||
|
<TabsTrigger value="rules">{t.tabs.rules}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="overdue" className="mt-3">
|
||||||
|
{itemList(data.overdue, t.empty.overdue)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="upcoming" className="mt-3">
|
||||||
|
{itemList(data.upcoming, t.empty.upcoming)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="rules" className="mt-3">
|
||||||
|
{data.rules.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>{t.empty.rules}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{data.rules.map((rule) => (
|
||||||
|
<li key={rule.id} className="flex items-center gap-3 p-3">
|
||||||
|
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||||
|
<Repeat className="size-4.5" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="truncate font-medium">{rule.title}</span>
|
||||||
|
{rule.isEstimate ? <Badge variant="secondary">{t.estimate}</Badge> : null}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{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}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Amount type={rule.type} amountMinor={rule.amountMinor} />
|
||||||
|
{canWrite ? (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" aria-label={t.actions.more}>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={() => void editRule(rule)}>
|
||||||
|
<Pencil />
|
||||||
|
{t.actions.edit}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() => setPending({ kind: "delete", ruleId: rule.id })}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
{t.actions.delete}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
item={pending?.kind === "confirm" ? pending.item : null}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
/>
|
||||||
|
<PostponeDialog
|
||||||
|
item={pending?.kind === "postpone" ? pending.item : null}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
/>
|
||||||
|
<ScopeDialog
|
||||||
|
item={pending?.kind === "scope" ? pending.item : null}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
onPick={(item, scope) => void editScope(item, scope)}
|
||||||
|
/>
|
||||||
|
<DeleteDialog
|
||||||
|
target={pending?.kind === "delete" ? pending : null}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
/>
|
||||||
|
<RuleFormDialog mode={ruleForm} onOpenChange={(o) => !o && setRuleForm(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmDialog({ item, onClose }: { item: PlannedItem | null; onClose: () => void }) {
|
||||||
|
return item ? <ConfirmForm key={item.id} item={item} onClose={onClose} /> : 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<Record<string, string | undefined>>({});
|
||||||
|
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 (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={`${t.confirmTitle}: ${rowTitle(item)}`}
|
||||||
|
description={t.confirmDescription}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="animate-spin" /> : <Check />}
|
||||||
|
{t.actions.confirm}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={Boolean(errors.amount)}>
|
||||||
|
<FieldLabel htmlFor="confirm-amount">{tr.transactions.fields.amount}</FieldLabel>
|
||||||
|
<MoneyInput id="confirm-amount" value={amount} onChange={setAmount} autoFocus />
|
||||||
|
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.date)}>
|
||||||
|
<FieldLabel htmlFor="confirm-date">{tr.transactions.fields.date}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="confirm-date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
{item.isEstimate ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.estimate}: {formatMoney(item.amountMinor)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostponeDialog({ item, onClose }: { item: PlannedItem | null; onClose: () => void }) {
|
||||||
|
return item ? <PostponeForm key={item.id} item={item} onClose={onClose} /> : 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<string | null>(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 (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={`${t.postponeTitle}: ${rowTitle(item)}`}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="animate-spin" /> : <CalendarClock />}
|
||||||
|
{t.actions.postpone}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Field data-invalid={Boolean(error)}>
|
||||||
|
<FieldLabel htmlFor="postpone-date">{t.postponeDate}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="postpone-date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
min={addDays(item.date, 1)}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error ? <FieldError>{error}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeDialog({
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
item: PlannedItem | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onPick: (item: PlannedItem, scope: "one" | "following" | "all") => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={Boolean(item)}
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={t.scopeTitle}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{(["one", "following", "all"] as const).map((scope) => (
|
||||||
|
<button
|
||||||
|
key={scope}
|
||||||
|
type="button"
|
||||||
|
onClick={() => item && onPick(item, scope)}
|
||||||
|
className="rounded-xl border p-3 text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<span className="block font-medium">{t.scopes[scope]}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{t.scopeHints[scope]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<AlertDialog open={Boolean(target)} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t.deleteDescription}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
{target?.item ? (
|
||||||
|
<Button variant="outline" disabled={busy} onClick={() => run("following")}>
|
||||||
|
{t.deleteScopes.following}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button variant="destructive" disabled={busy} onClick={() => run("all")}>
|
||||||
|
{t.deleteScopes.all}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -101,7 +101,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountSelect({
|
export function AccountSelect({
|
||||||
id,
|
id,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -148,7 +148,7 @@ function AccountSelect({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategorySelect({
|
export function CategorySelect({
|
||||||
id,
|
id,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -199,7 +199,7 @@ function CategorySelect({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Satıcı alanı: yazdıkça öneri; seçilince son kullanılan kategori/hesap önerilir. */
|
/** Satıcı alanı: yazdıkça öneri; seçilince son kullanılan kategori/hesap önerilir. */
|
||||||
function PayeeInput({
|
export function PayeeInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onPick,
|
onPick,
|
||||||
|
|||||||
@@ -352,6 +352,84 @@ export const tr = {
|
|||||||
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
|
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
|
||||||
noAccountsLink: "Hesaplara git",
|
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: {
|
attachments: {
|
||||||
title: "Ekler",
|
title: "Ekler",
|
||||||
add: "Ek ekle",
|
add: "Ek ekle",
|
||||||
|
|||||||
Reference in New Issue
Block a user