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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user