Files
butcem/src/features/transactions/components/transactions-view.tsx
T

426 lines
14 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 { ChevronLeft, ChevronRight, Download, Filter, Paperclip, Plus, X } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { useQuickAdd } from "@/components/layout/quick-add";
import { MoneyInput } from "@/components/money-input";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format";
import type { TransactionFilters } from "@/shared/schemas/transaction";
import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions";
import type { TransactionFormOptions, TransactionListItem, TransactionPage } from "../service";
import {
Amount,
dayLabel,
RowIcon,
rowSubtitle,
rowTitle,
signed,
useTransactionDetail,
} from "./transaction-parts";
const t = tr.transactions;
const ALL = "__all__";
// --- Filtreler ---
type Options = TransactionFormOptions & { members: { id: string; name: string }[] };
function countActive(f: TransactionFilters) {
return (
[
"from",
"to",
"type",
"hesap",
"kategori",
"kisi",
"durum",
"min",
"max",
"etiket",
"q",
] as const
).filter((k) => f[k] !== undefined && f[k] !== "").length;
}
function FiltersDialog({
open,
onOpenChange,
filters,
raw,
options,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
filters: TransactionFilters;
raw: Record<string, string>;
options: Options;
}) {
const router = useRouter();
const pathname = usePathname();
const [v, setV] = useState<Record<string, string>>(raw);
const set = (k: string, value: string) => setV((prev) => ({ ...prev, [k]: value }));
const f = t.filters;
function apply(next: Record<string, string>) {
const params = new URLSearchParams();
for (const [k, value] of Object.entries(next)) if (value && k !== "sayfa") params.set(k, value);
router.push(params.size ? `${pathname}?${params}` : pathname);
onOpenChange(false);
}
const select = (key: string, label: string, items: { value: string; label: string }[]) => (
<Field>
<FieldLabel htmlFor={`flt-${key}`}>{label}</FieldLabel>
<Select value={v[key] || ALL} onValueChange={(x) => set(key, x === ALL ? "" : x)}>
<SelectTrigger id={`flt-${key}`} className="w-full">
<SelectValue>{items.find((i) => i.value === v[key])?.label ?? f.all}</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value={ALL}>{f.all}</SelectItem>
{items.map((i) => (
<SelectItem key={i.value} value={i.value}>
{i.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
return (
<ResponsiveDialog
open={open}
onOpenChange={(o) => {
if (o) setV(raw);
onOpenChange(o);
}}
title={f.title}
footer={
<>
<Button variant="outline" onClick={() => apply({})} disabled={!countActive(filters)}>
{f.clear}
</Button>
<Button onClick={() => apply(v)}>{f.apply}</Button>
</>
}
>
<form
onSubmit={(e) => {
e.preventDefault();
apply(v);
}}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="flt-q">{f.search}</FieldLabel>
<Input
id="flt-q"
value={v.q ?? ""}
onChange={(e) => set("q", e.target.value)}
placeholder={f.searchPlaceholder}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="flt-from">{f.from}</FieldLabel>
<Input
id="flt-from"
type="date"
value={v.from ?? ""}
onChange={(e) => set("from", e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="flt-to">{f.to}</FieldLabel>
<Input
id="flt-to"
type="date"
value={v.to ?? ""}
onChange={(e) => set("to", e.target.value)}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
{select(
"type",
f.type,
TRANSACTION_TYPES.map((x) => ({ value: x, label: t.types[x] })),
)}
{select(
"durum",
f.status,
TRANSACTION_STATUSES.map((x) => ({ value: x, label: t.statuses[x] })),
)}
</div>
{select(
"hesap",
f.account,
options.accounts.map((a) => ({ value: a.id, label: a.name })),
)}
{select(
"kategori",
f.category,
options.categories.map((c) => ({
value: c.id,
label: `${c.kind === "income" ? "↑" : "↓"} ${c.path}`,
})),
)}
{select(
"kisi",
f.person,
options.members.map((m) => ({ value: m.id, label: m.name })),
)}
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="flt-min">{f.min}</FieldLabel>
<MoneyInput id="flt-min" value={v.min ?? ""} onChange={(x) => set("min", x)} />
</Field>
<Field>
<FieldLabel htmlFor="flt-max">{f.max}</FieldLabel>
<MoneyInput id="flt-max" value={v.max ?? ""} onChange={(x) => set("max", x)} />
</Field>
</div>
<Field>
<FieldLabel htmlFor="flt-tag">{f.tag}</FieldLabel>
<Input
id="flt-tag"
value={v.etiket ?? ""}
onChange={(e) => set("etiket", e.target.value)}
/>
</Field>
<button type="submit" hidden />
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
// --- Ayrıntı ---
// --- Ana görünüm ---
export function TransactionsView({
data,
filters,
raw,
options,
canWrite,
}: {
data: TransactionPage;
filters: TransactionFilters;
raw: Record<string, string>;
options: Options;
canWrite: boolean;
}) {
const pathname = usePathname();
const { openQuickAdd } = useQuickAdd();
const [filtersOpen, setFiltersOpen] = useState(false);
const { openDetail, element: detailElement } = useTransactionDetail(canWrite);
const active = countActive(filters);
const pageHref = (page: number) => {
const params = new URLSearchParams(raw);
if (page > 1) params.set("sayfa", String(page));
else params.delete("sayfa");
return params.size ? `${pathname}?${params}` : pathname;
};
const exportHref = () => {
const params = new URLSearchParams(raw);
params.delete("sayfa");
return `/islemler/disa-aktar${params.size ? `?${params}` : ""}`;
};
// Sayfadaki işlemleri güne göre grupla (liste zaten tarihe göre sıralı).
const groups: { date: string; items: TransactionListItem[] }[] = [];
for (const item of data.items) {
const last = groups.at(-1);
if (last?.date === item.date) last.items.push(item);
else groups.push({ date: item.date, items: [item] });
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={() => setFiltersOpen(true)}>
<Filter />
{t.filters.open}
{active ? <Badge className="ml-1">{t.filters.active(active)}</Badge> : null}
</Button>
{active ? (
<Button variant="ghost" asChild>
<Link href={pathname}>
<X />
{t.filters.clear}
</Link>
</Button>
) : null}
<Button variant="ghost" asChild className="ml-auto">
{/* Dosya indirme: istemci gezinmesi değil, tam istek. */}
<a href={exportHref()} download>
<Download />
<span className="hidden sm:inline">{t.export}</span>
</a>
</Button>
{canWrite ? (
<Button onClick={() => openQuickAdd()}>
<Plus />
{t.new}
</Button>
) : null}
</div>
<div className="grid grid-cols-3 gap-3">
{(
[
["income", data.sums.incomeMinor, "text-income"],
["expense", data.sums.expenseMinor, "text-expense"],
["net", data.sums.incomeMinor - data.sums.expenseMinor, ""],
] as const
).map(([key, value, tone]) => (
<Card key={key} size="sm">
<CardHeader>
<CardDescription>{t.list.sums[key]}</CardDescription>
<CardTitle
className={cn(
"text-base tabular-nums sm:text-lg",
tone,
key === "net" && value < 0 && "text-expense",
)}
>
{formatMoney(value)}
</CardTitle>
</CardHeader>
</Card>
))}
</div>
{data.items.length === 0 ? (
<p className="rounded-xl border border-dashed p-8 text-center text-muted-foreground">
{active ? t.list.empty : t.list.emptyAll}
</p>
) : (
groups.map((g) => {
const net = g.items.reduce(
(a, i) => a + (i.status === "posted" ? signed(i.type, i.amountMinor) : 0),
0,
);
return (
<section key={g.date} aria-label={dayLabel(g.date)} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between px-1 text-xs font-medium text-muted-foreground">
<h2 className="first-letter:uppercase">{dayLabel(g.date)}</h2>
<span
className={cn(
"tabular-nums",
net > 0 && "text-income",
net < 0 && "text-expense",
)}
title={t.list.dayTotal}
>
{net === 0 ? "" : `${net > 0 ? "+" : "−"}${formatMoney(Math.abs(net))}`}
</span>
</div>
<ul className="divide-y rounded-xl border bg-card">
{g.items.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => openDetail(item.id)}
className="flex w-full items-center gap-3 p-3 text-left outline-none hover:bg-muted/50 focus-visible:bg-muted"
>
<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.status !== "posted" ? (
<Badge variant="outline">{t.statuses[item.status]}</Badge>
) : null}
{item.attachmentCount ? (
<Paperclip className="size-3.5 shrink-0 text-muted-foreground" />
) : null}
</span>
<span className="block truncate text-xs text-muted-foreground">
{rowSubtitle(item)}
</span>
</span>
<Amount
type={item.type}
amountMinor={item.amountMinor}
className={cn(item.status !== "posted" && "opacity-60")}
/>
</button>
</li>
))}
</ul>
</section>
);
})
)}
{data.pageCount > 1 ? (
<nav
aria-label={tr.common.page(data.page, data.pageCount)}
className="flex items-center justify-between text-sm text-muted-foreground"
>
<span>{t.list.total(data.total)}</span>
<div className="flex items-center gap-2">
<span className="tabular-nums">{tr.common.page(data.page, data.pageCount)}</span>
<Button
variant="outline"
size="icon-sm"
asChild
disabled={data.page <= 1}
aria-label={tr.common.previous}
>
<Link
href={pageHref(data.page - 1)}
aria-disabled={data.page <= 1}
className={cn(data.page <= 1 && "pointer-events-none opacity-50")}
>
<ChevronLeft />
</Link>
</Button>
<Button variant="outline" size="icon-sm" asChild aria-label={tr.common.next}>
<Link
href={pageHref(data.page + 1)}
aria-disabled={data.page >= data.pageCount}
className={cn(data.page >= data.pageCount && "pointer-events-none opacity-50")}
>
<ChevronRight />
</Link>
</Button>
</div>
</nav>
) : data.total ? (
<p className="text-sm text-muted-foreground">{t.list.total(data.total)}</p>
) : null}
<FiltersDialog
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
raw={raw}
options={options}
/>
{detailElement}
</div>
);
}