feat(ui): değer grafiği, net varlık kartı, dashboard kartları ve ödeme diyaloğunda varlık bağı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 00:19:05 +03:00
parent 9a60085d14
commit b022c1c3b0
10 changed files with 825 additions and 36 deletions
@@ -54,7 +54,14 @@ type Pending =
| null;
/** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */
export function AssetDetailView({ data }: { data: AssetDetail }) {
export function AssetDetailView({
data,
report,
}: {
data: AssetDetail;
/** Dönemsel rapor (sunucuda hazırlanır). */
report?: React.ReactNode;
}) {
const router = useRouter();
const [pending, setPending] = useState<Pending>(null);
const close = () => setPending(null);
@@ -171,6 +178,8 @@ export function AssetDetailView({ data }: { data: AssetDetail }) {
</CardContent>
</Card>
{report}
<section className="flex flex-col gap-2" aria-labelledby="movements-title">
<h2 id="movements-title" className="text-base font-medium">
{t.history}
@@ -0,0 +1,204 @@
"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>
);
}