Files
butcem/src/features/recurring/components/planned-view.tsx
T

509 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
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>
);
}