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,196 @@
"use client";
import {
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
ReferenceLine,
XAxis,
YAxis,
} from "recharts";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format";
import type { TrendReport } from "../service";
import { monthLabel, monthShort, formatPct } from "./report-frame";
const t = tr.reports.trend;
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
const barConfig = {
income: { label: t.income, color: "var(--income)" },
expense: { label: t.expense, color: "var(--expense)" },
} satisfies ChartConfig;
const rateConfig = { rate: { label: t.savings, color: "var(--budget)" } } satisfies ChartConfig;
export function TrendView({ data }: { data: TrendReport }) {
const chart = data.months.map((m) => ({
month: m.month,
label: monthShort(m.month),
income: m.incomeMinor / 100,
expense: m.expenseMinor / 100,
// Devam eden ayın oranı (maaş henüz yatmamış olabilir) ölçeği bozmasın: grafikte yok.
rate: m.month === data.currentMonth ? null : m.savingsPct,
}));
const moneyTip = (
<ChartTooltipContent
labelFormatter={(_, payload) => {
const m = payload?.[0]?.payload?.month as string | undefined;
return m ? monthLabel(m) : "";
}}
formatter={(value, name) => (
<div className="flex w-full items-center justify-between gap-3">
<span className="text-muted-foreground">
{barConfig[name as keyof typeof barConfig]?.label}
</span>
<span className="font-medium text-foreground tabular-nums">
{formatMoney(Math.round(Number(value) * 100))}
</span>
</div>
)}
/>
);
return (
<div className="flex flex-col gap-4">
<div className="rounded-xl border bg-card p-4">
<ChartContainer
config={barConfig}
className="aspect-auto h-64 w-full"
role="img"
aria-label={t.chartLabel}
>
<BarChart data={chart} margin={{ left: 4, right: 8, top: 8 }} barGap={2}>
<CartesianGrid vertical={false} />
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis
tickLine={false}
axisLine={false}
width={48}
tickFormatter={(v: number) => compact.format(v)}
/>
<ChartTooltip content={moneyTip} />
<ChartLegend content={<ChartLegendContent />} />
<Bar
dataKey="income"
fill="var(--color-income)"
radius={[4, 4, 0, 0]}
isAnimationActive={false}
/>
<Bar
dataKey="expense"
fill="var(--color-expense)"
radius={[4, 4, 0, 0]}
isAnimationActive={false}
/>
</BarChart>
</ChartContainer>
</div>
<div className="rounded-xl border bg-card p-4">
<h2 className="text-sm font-medium">{t.rateTitle}</h2>
<p className="mb-2 text-xs text-muted-foreground">
{t.rateHint} {t.partialHint}
</p>
<ChartContainer
config={rateConfig}
className="aspect-auto h-40 w-full"
role="img"
aria-label={t.rateChartLabel}
>
<LineChart data={chart} margin={{ left: 4, right: 8, top: 8 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis
tickLine={false}
axisLine={false}
width={40}
tickFormatter={(v: number) => `%${v}`}
/>
<ReferenceLine y={0} stroke="var(--border)" />
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const m = payload?.[0]?.payload?.month as string | undefined;
return m ? monthLabel(m) : "";
}}
formatter={(value) => (
<span className="font-medium text-foreground tabular-nums">
{formatPct(Number(value))}
</span>
)}
/>
}
/>
<Line
dataKey="rate"
stroke="var(--color-rate)"
strokeWidth={2}
dot={{ r: 3 }}
connectNulls={false}
isAnimationActive={false}
/>
</LineChart>
</ChartContainer>
</div>
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[32rem] text-sm tabular-nums">
<thead className="border-b text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-normal">{t.month}</th>
<th className="px-3 py-2 text-right font-normal">{t.income}</th>
<th className="px-3 py-2 text-right font-normal">{t.expense}</th>
<th className="px-3 py-2 text-right font-normal">{t.net}</th>
<th className="px-3 py-2 text-right font-normal">{t.savings}</th>
</tr>
</thead>
<tbody className="divide-y">
{[...data.months].reverse().map((m) => (
<tr key={m.month} className={cn(m.month === data.currentMonth && "bg-muted/40")}>
<td className="px-3 py-1.5">
<span className="capitalize">{monthLabel(m.month)}</span>
{m.month === data.currentMonth ? (
<span className="ml-1 text-xs text-muted-foreground">({t.ongoing})</span>
) : null}
</td>
<td className="px-3 py-1.5 text-right">{formatMoney(m.incomeMinor)}</td>
<td className="px-3 py-1.5 text-right">{formatMoney(m.expenseMinor)}</td>
<td className={cn("px-3 py-1.5 text-right", m.netMinor < 0 && "text-expense")}>
{m.netMinor < 0 ? "−" : ""}
{formatMoney(Math.abs(m.netMinor))}
</td>
<td className="px-3 py-1.5 text-right">{formatPct(m.savingsPct)}</td>
</tr>
))}
</tbody>
<tfoot className="border-t font-medium">
<tr>
<td className="px-3 py-2">{t.total}</td>
<td className="px-3 py-2 text-right">{formatMoney(data.totals.incomeMinor)}</td>
<td className="px-3 py-2 text-right">{formatMoney(data.totals.expenseMinor)}</td>
<td
className={cn("px-3 py-2 text-right", data.totals.netMinor < 0 && "text-expense")}
>
{data.totals.netMinor < 0 ? "−" : ""}
{formatMoney(Math.abs(data.totals.netMinor))}
</td>
<td className="px-3 py-2 text-right">{formatPct(data.totals.savingsPct)}</td>
</tr>
</tfoot>
</table>
</div>
</div>
);
}