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
@@ -0,0 +1,209 @@
"use client";
import Link from "next/link";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatDate, formatMoney } from "@/shared/format";
import { cumulative, OVERVIEW_PERIODS, type OverviewPeriod } from "@/shared/overview";
import type { OverviewData } from "../service";
const t = tr.dashboard.overview;
export type OverviewView = "cumulative" | "periodic";
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
const dayShort = new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" });
const weekdayShort = new Intl.DateTimeFormat("tr-TR", { weekday: "short", day: "numeric" });
const monthShort = new Intl.DateTimeFormat("tr-TR", { month: "short", year: "2-digit" });
const monthLong = new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" });
const config = {
plannedIncome: { label: t.series.plannedIncome, color: "var(--income)" },
budget: { label: t.series.budget, color: "var(--budget)" },
actual: { label: t.series.actual, color: "var(--expense)" },
} satisfies ChartConfig;
const SERIES = ["plannedIncome", "budget", "actual"] as const;
const asDate = (key: string) =>
new Date(key.length === 7 ? `${key}-15T12:00:00+03:00` : `${key}T12:00:00+03:00`);
function bucketLabel(key: string, period: OverviewPeriod) {
const d = asDate(key);
if (key.length === 7) return monthShort.format(d);
return period === "week" ? weekdayShort.format(d) : dayShort.format(d);
}
/** Bölüm 6.13 ana çizgi grafik: periyot seçici ve kümülatif / dönemsel görünüm (URL'de). */
export function OverviewChart({ data, view }: { data: OverviewData; view: OverviewView }) {
const points = view === "cumulative" ? cumulative(data.points) : data.points;
const chart = points.map((p) => ({
key: p.key,
label: bucketLabel(p.key, data.period),
plannedIncome: p.plannedIncome === null ? null : p.plannedIncome / 100,
budget: p.budget === null ? null : p.budget / 100,
actual: p.actual === null ? null : p.actual / 100,
}));
const hasGap = data.points.some((p) => p.budget === null);
const href = (period: OverviewPeriod, v: OverviewView) =>
`?periyot=${period}&gorunum=${v === "cumulative" ? "kumulatif" : "donemsel"}`;
const rangeText =
data.range.granularity === "month"
? `${monthLong.format(asDate(data.range.buckets[0]!))} – ${monthLong.format(asDate(data.range.buckets.at(-1)!))}`
: `${formatDate(asDate(data.range.from))} – ${formatDate(asDate(data.range.to))}`;
const pill = (active: boolean) =>
cn(
"rounded-md px-2.5 py-1 text-sm transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
active
? "bg-background font-medium shadow-sm"
: "text-muted-foreground hover:text-foreground",
);
return (
<Card size="sm">
<CardHeader className="flex flex-wrap items-start gap-3">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">{t.title}</CardTitle>
<CardDescription>{t.description(rangeText)}</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<nav aria-label={t.periodLabel} className="flex rounded-lg bg-muted p-0.5">
{OVERVIEW_PERIODS.map((p) => (
<Link
key={p}
href={href(p, view)}
scroll={false}
replace
aria-current={p === data.period ? "page" : undefined}
className={pill(p === data.period)}
>
{t.periods[p]}
</Link>
))}
</nav>
<nav aria-label={t.viewLabel} className="flex rounded-lg bg-muted p-0.5">
{(["cumulative", "periodic"] as const).map((v) => (
<Link
key={v}
href={href(data.period, v)}
scroll={false}
replace
aria-current={v === view ? "page" : undefined}
className={pill(v === view)}
>
{t.views[v]}
</Link>
))}
</nav>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<ChartContainer
config={config}
className="aspect-auto h-64 w-full"
aria-label={t.chartLabel}
role="img"
>
<LineChart data={chart} margin={{ left: 4, right: 12, top: 8 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={16}
/>
<YAxis
tickLine={false}
axisLine={false}
width={48}
tickFormatter={(v: number) => compact.format(v)}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const key = payload?.[0]?.payload?.key as string | undefined;
if (!key) return "";
return key.length === 7
? monthLong.format(asDate(key))
: formatDate(asDate(key));
}}
formatter={(value, name) => (
<div className="flex w-full items-center justify-between gap-3">
<span className="text-muted-foreground">
{config[name as keyof typeof config]?.label}
</span>
<span className="font-medium text-foreground tabular-nums">
{formatMoney(Math.round(Number(value) * 100))}
</span>
</div>
)}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{SERIES.map((k) => (
<Line
key={k}
dataKey={k}
type="linear"
stroke={`var(--color-${k})`}
strokeWidth={2}
strokeDasharray={k === "budget" ? "6 3" : undefined}
dot={
data.range.granularity === "month" || data.period === "week" ? { r: 3 } : false
}
activeDot={{ r: 5 }}
connectNulls={false}
isAnimationActive={false}
/>
))}
</LineChart>
</ChartContainer>
{hasGap ? <p className="text-xs text-muted-foreground">{t.noBudget}</p> : null}
<details className="text-sm">
<summary className="cursor-pointer text-xs text-muted-foreground">{t.table}</summary>
<div className="mt-2 max-h-80 overflow-y-auto">
<table className="w-full text-right tabular-nums">
<thead className="sticky top-0 bg-card text-xs text-muted-foreground">
<tr>
<th className="py-1 text-left font-normal">{t.bucket[data.range.granularity]}</th>
{SERIES.map((k) => (
<th key={k} className="py-1 font-normal">
{t.series[k]}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{points.map((p) => (
<tr key={p.key}>
<td className="py-1 text-left">
{p.key.length === 7
? monthLong.format(asDate(p.key))
: formatDate(asDate(p.key))}
</td>
{SERIES.map((k) => (
<td key={k} className="py-1">
{p[k] === null ? "—" : formatMoney(p[k])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</details>
</CardContent>
</Card>
);
}