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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user