feat(ui): dashboard ana grafiği ve rapor sayfaları

- dashboard: periyot seçici ve kümülatif/dönemsel görünüm, tablo; günlük plan kartı sadeleşti
- /raporlar özet ve kategori, trend, kişi/hesap, bütçe sayfaları; ay seçici ve CSV indirme

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 00:29:25 +03:00
parent bc2a8a3bde
commit 29d7b29cfa
19 changed files with 1488 additions and 108 deletions
+101
View File
@@ -0,0 +1,101 @@
import { ChartPie, ChevronRight, Target, TrendingUp, Users } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { monthLabel, formatPct } from "@/features/reports/components/report-frame";
import { getCategoryReport, getTrendReport } from "@/features/reports/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format";
const t = tr.reports;
export const metadata: Metadata = { title: t.title };
const REPORTS = [
{ href: "/raporlar/kategoriler", key: "categories", icon: ChartPie },
{ href: "/raporlar/trend", key: "trend", icon: TrendingUp },
{ href: "/raporlar/kisi-hesap", key: "people", icon: Users },
{ href: "/raporlar/butce", key: "budget", icon: Target },
] as const;
/** /raporlar — bu bütçe ayının özeti ve raporlar. */
export default async function ReportsPage() {
const { user, household } = await requireActiveHousehold("viewer");
const [trend, cats] = await Promise.all([
getTrendReport(user.id, household.id, 1),
getCategoryReport(user.id, household.id),
]);
if (!trend || !cats) notFound();
const m = trend.months[0]!;
const top = cats.breakdown.rows.filter((r) => r.amountMinor > 0).slice(0, 3);
const stats = [
{ label: t.hub.income, value: formatMoney(m.incomeMinor), tone: "text-income" },
{ label: t.hub.expense, value: formatMoney(m.expenseMinor), tone: "text-expense" },
{
label: t.hub.net,
value: `${m.netMinor < 0 ? "−" : ""}${formatMoney(Math.abs(m.netMinor))}`,
tone: m.netMinor < 0 ? "text-expense" : undefined,
},
{ label: t.hub.savings, value: formatPct(m.savingsPct) },
];
return (
<>
<PageHeader title={t.title} description={t.description} />
<section aria-labelledby="hub-month" className="rounded-xl border bg-card p-4">
<h2 id="hub-month" className="text-sm font-medium">
{t.hub.thisMonth}: <span className="capitalize">{monthLabel(m.month)}</span>{" "}
<span className="font-normal text-muted-foreground">({t.trend.ongoing})</span>
</h2>
<dl className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
{stats.map((s) => (
<div key={s.label}>
<dt className="text-xs text-muted-foreground">{s.label}</dt>
<dd className={cn("text-lg font-semibold tabular-nums", s.tone)}>{s.value}</dd>
</div>
))}
</dl>
{top.length ? (
<div className="mt-4">
<h3 className="text-xs text-muted-foreground">{t.hub.topCategories}</h3>
<ul className="mt-1 flex flex-col gap-1 text-sm">
{top.map((r) => (
<li key={r.id} className="flex justify-between gap-3">
<span className="truncate">{r.name}</span>
<span className="tabular-nums">
{formatMoney(r.amountMinor)}{" "}
<span className="text-muted-foreground">· {formatPct(r.sharePct)}</span>
</span>
</li>
))}
</ul>
</div>
) : null}
</section>
<ul className="grid gap-3 md:grid-cols-2">
{REPORTS.map((r) => {
const c = t.hub.cards[r.key];
const Icon = r.icon;
return (
<li key={r.href}>
<Link
href={r.href}
className="flex h-full items-start gap-3 rounded-xl border bg-card p-4 transition-colors hover:bg-accent/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<Icon className="size-4.5 text-muted-foreground" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block font-medium">{c.title}</span>
<span className="block text-sm text-muted-foreground">{c.description}</span>
</span>
<ChevronRight className="mt-2 size-4 text-muted-foreground" aria-hidden />
</Link>
</li>
);
})}
</ul>
</>
);
}