29d7b29cfa
- 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>
210 lines
8.2 KiB
TypeScript
210 lines
8.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|