b022c1c3b0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
7.6 KiB
TypeScript
205 lines
7.6 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 type { PeriodReport } from "@/shared/portfolio";
|
||
import type { ReportRange } from "../service";
|
||
import { ProfitLoss, Stat } from "./assets-view";
|
||
|
||
const t = tr.assets.report;
|
||
const RANGES: ReportRange[] = ["3", "6", "12", "tum"];
|
||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||
const tickFmt = new Intl.DateTimeFormat("tr-TR", {
|
||
day: "numeric",
|
||
month: "short",
|
||
timeZone: "Europe/Istanbul",
|
||
});
|
||
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||
|
||
const config = {
|
||
value: { label: t.value, color: "var(--budget)" },
|
||
cost: { label: t.cost, color: "var(--muted-foreground)" },
|
||
} satisfies ChartConfig;
|
||
|
||
/** Dönem seçici + özet + aylık değer/maliyet grafiği + tablo görünümü. */
|
||
export function PeriodReportView({
|
||
report,
|
||
range,
|
||
basePath,
|
||
}: {
|
||
report: PeriodReport & { excluded: number };
|
||
range: ReportRange;
|
||
basePath: string;
|
||
}) {
|
||
const data = report.points.map((p) => ({
|
||
date: p.date,
|
||
label: tickFmt.format(new Date(`${p.date}T12:00:00+03:00`)),
|
||
// Kuruş → TL (eksen ve ipucu TL gösterir).
|
||
value: p.valueMinor === null ? null : p.valueMinor / 100,
|
||
cost: p.costMinor / 100,
|
||
}));
|
||
const hasData = report.points.some((p) => p.costMinor > 0 || (p.valueMinor ?? 0) > 0);
|
||
|
||
return (
|
||
<Card size="sm">
|
||
<CardHeader>
|
||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||
<div>
|
||
<CardTitle className="text-base">{t.title}</CardTitle>
|
||
<CardDescription>{t.period(d(report.from), d(report.to))}</CardDescription>
|
||
</div>
|
||
<nav aria-label={t.rangeLabel} className="flex rounded-lg bg-muted p-0.5 text-sm">
|
||
{RANGES.map((r) => (
|
||
<Link
|
||
key={r}
|
||
href={`${basePath}?donem=${r}`}
|
||
scroll={false}
|
||
aria-current={r === range ? "page" : undefined}
|
||
className={cn(
|
||
"rounded-md px-2.5 py-1 text-muted-foreground transition-colors",
|
||
r === range && "bg-background font-medium text-foreground shadow-sm",
|
||
)}
|
||
>
|
||
{t.ranges[r]}
|
||
</Link>
|
||
))}
|
||
</nav>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-4">
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||
<Stat label={t.startValue}>
|
||
{report.startValueMinor !== null ? formatMoney(report.startValueMinor) : t.noPrice}
|
||
</Stat>
|
||
<Stat label={t.invested}>{formatMoney(report.investedMinor)}</Stat>
|
||
<Stat label={t.endValue}>
|
||
{report.endValueMinor !== null ? formatMoney(report.endValueMinor) : t.noPrice}
|
||
</Stat>
|
||
<Stat label={t.profit}>
|
||
<ProfitLoss minor={report.profitMinor} bp={report.profitBp} />
|
||
</Stat>
|
||
{report.proceedsMinor ? (
|
||
<Stat label={t.proceeds}>{formatMoney(report.proceedsMinor)}</Stat>
|
||
) : null}
|
||
{report.giftInMinor ? (
|
||
<Stat label={t.giftIn}>{formatMoney(report.giftInMinor)}</Stat>
|
||
) : null}
|
||
{report.giftOutMinor ? (
|
||
<Stat label={t.giftOut}>{formatMoney(report.giftOutMinor)}</Stat>
|
||
) : null}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">{t.formula}</p>
|
||
{report.excluded ? (
|
||
<p className="text-xs text-muted-foreground">{t.excluded(report.excluded)}</p>
|
||
) : null}
|
||
|
||
{hasData ? (
|
||
<>
|
||
<ChartContainer
|
||
config={config}
|
||
className="aspect-auto h-56 w-full sm:h-64"
|
||
aria-label={t.chartLabel}
|
||
role="img"
|
||
>
|
||
<LineChart data={data} 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
|
||
cursor
|
||
content={
|
||
<ChartTooltipContent
|
||
labelFormatter={(_, payload) => {
|
||
const date = payload?.[0]?.payload?.date as string | undefined;
|
||
return date ? d(date) : "";
|
||
}}
|
||
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 />} />
|
||
<Line
|
||
dataKey="cost"
|
||
type="stepAfter"
|
||
stroke="var(--color-cost)"
|
||
strokeWidth={2}
|
||
strokeDasharray="4 4"
|
||
dot={false}
|
||
isAnimationActive={false}
|
||
/>
|
||
<Line
|
||
dataKey="value"
|
||
type="linear"
|
||
stroke="var(--color-value)"
|
||
strokeWidth={2}
|
||
dot={false}
|
||
activeDot={{ r: 4 }}
|
||
connectNulls={false}
|
||
isAnimationActive={false}
|
||
/>
|
||
</LineChart>
|
||
</ChartContainer>
|
||
<details className="text-sm">
|
||
<summary className="cursor-pointer text-xs text-muted-foreground">{t.table}</summary>
|
||
<table className="mt-2 w-full text-right tabular-nums">
|
||
<thead className="text-xs text-muted-foreground">
|
||
<tr>
|
||
<th className="py-1 text-left font-normal">{t.date}</th>
|
||
<th className="py-1 font-normal">{t.value}</th>
|
||
<th className="py-1 font-normal">{t.cost}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y">
|
||
{report.points.map((p) => (
|
||
<tr key={p.date}>
|
||
<td className="py-1 text-left">{d(p.date)}</td>
|
||
<td className="py-1">
|
||
{p.valueMinor !== null ? formatMoney(p.valueMinor) : t.noPrice}
|
||
</td>
|
||
<td className="py-1">{formatMoney(p.costMinor)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</details>
|
||
</>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">{t.empty}</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|