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:
@@ -3,6 +3,8 @@ import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { OverviewCard } from "@/features/dashboard/components/overview-card";
|
||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||
import { DailyPlanCard } from "@/features/budgets/components/daily-plan-card";
|
||||
import { BudgetWarningBanner } from "@/features/budgets/components/warning-banner";
|
||||
@@ -84,7 +86,7 @@ async function SummaryCards({ userId, householdId }: { userId: string; household
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
export default async function DashboardPage({ searchParams }: PageProps<"/dashboard">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
return (
|
||||
<>
|
||||
@@ -104,6 +106,9 @@ export default async function DashboardPage() {
|
||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||
<SummaryCards userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<Skeleton className="h-96 rounded-xl" />}>
|
||||
<OverviewCard userId={user.id} householdId={household.id} searchParams={searchParams} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<DailyPlanCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getBudgetReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
|
||||
/** GET /raporlar/butce/csv?ay=YYYY-MM — kategori bazında limit ve gerçekleşen. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getBudgetReport(c.userId, c.householdId, c.month);
|
||||
if (!r) return forbidden();
|
||||
const csv = toCsv(
|
||||
tr.reports.csvHeaders.budget,
|
||||
r.lines.map((l) => [
|
||||
l.path,
|
||||
l.limitMinor === null ? "" : csvMoney(l.limitMinor),
|
||||
csvMoney(l.actualMinor),
|
||||
l.remainingMinor === null ? "" : csvMoney(l.remainingMinor),
|
||||
csvPct(l.ratioBp === null ? null : Math.round(l.ratioBp / 10) / 10),
|
||||
]),
|
||||
);
|
||||
return csvResponse(csv, tr.reports.csvFiles.budget(r.month));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BudgetReportView } from "@/features/reports/components/budget-report-view";
|
||||
import {
|
||||
CsvButton,
|
||||
ToolsEnd,
|
||||
MonthNav,
|
||||
ReportFrame,
|
||||
monthLabel,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getBudgetReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
const t = tr.reports.budget;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/butce?ay=YYYY-MM — bütçe ve gerçekleşen. */
|
||||
export default async function BudgetReportPage({ searchParams }: PageProps<"/raporlar/butce">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||
const data = await getBudgetReport(user.id, household.id, ay.success ? ay.data : undefined);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description(monthLabel(data.month))}
|
||||
tools={
|
||||
<>
|
||||
<MonthNav month={data.month} currentMonth={data.currentMonth} path="/raporlar/butce" />
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/butce/csv?ay=${data.month}`} />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<BudgetReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getCategoryReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
|
||||
/** GET /raporlar/kategoriler/csv?ay=YYYY-MM — üst kategori ve alt kategori satırları. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getCategoryReport(c.userId, c.householdId, c.month);
|
||||
if (!r) return forbidden();
|
||||
const rows = r.breakdown.rows.flatMap((top) => [
|
||||
top,
|
||||
...top.children.map((k) => ({ ...k, parent: top.name })),
|
||||
]);
|
||||
const csv = toCsv(
|
||||
tr.reports.csvHeaders.categories,
|
||||
rows.map((x) => [
|
||||
"parent" in x ? x.parent : x.name,
|
||||
"parent" in x ? x.name : "",
|
||||
csvMoney(x.amountMinor),
|
||||
csvPct(x.sharePct),
|
||||
csvMoney(x.prevMinor),
|
||||
csvMoney(x.changeMinor),
|
||||
csvPct(x.changePct),
|
||||
]),
|
||||
);
|
||||
return csvResponse(csv, tr.reports.csvFiles.categories(r.month));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CategoriesView } from "@/features/reports/components/categories-view";
|
||||
import {
|
||||
CsvButton,
|
||||
ToolsEnd,
|
||||
MonthNav,
|
||||
ReportFrame,
|
||||
monthLabel,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getCategoryReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
const t = tr.reports.categories;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/kategoriler?ay=YYYY-MM — kategori dağılımı. */
|
||||
export default async function CategoryReportPage({
|
||||
searchParams,
|
||||
}: PageProps<"/raporlar/kategoriler">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||
const data = await getCategoryReport(user.id, household.id, ay.success ? ay.data : undefined);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description(monthLabel(data.month))}
|
||||
tools={
|
||||
<>
|
||||
<MonthNav
|
||||
month={data.month}
|
||||
currentMonth={data.currentMonth}
|
||||
path="/raporlar/kategoriler"
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/kategoriler/csv?ay=${data.month}`} />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.current ? <p className="text-xs text-muted-foreground">{t.partialMonth}</p> : null}
|
||||
{data.includesHidden ? (
|
||||
<p className="text-xs text-muted-foreground">{tr.reports.includesHidden}</p>
|
||||
) : null}
|
||||
<CategoriesView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getPeopleAccountsReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
|
||||
/** GET /raporlar/kisi-hesap/csv?ay=YYYY-MM&tablo=kisi|hesap */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getPeopleAccountsReport(c.userId, c.householdId, c.month);
|
||||
if (!r) return forbidden();
|
||||
const h = tr.reports.csvHeaders;
|
||||
if (c.params.get("tablo") === "hesap")
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.accounts,
|
||||
r.accounts.map((a) => [
|
||||
a.name,
|
||||
csvMoney(a.incomeMinor),
|
||||
csvMoney(a.expenseMinor),
|
||||
csvMoney(a.transferInMinor),
|
||||
csvMoney(a.transferOutMinor),
|
||||
csvMoney(a.netMinor),
|
||||
a.count,
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.accounts(r.month),
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.people,
|
||||
r.people.map((p) => [p.name, csvMoney(p.expenseMinor), p.count, csvPct(p.sharePct)]),
|
||||
),
|
||||
tr.reports.csvFiles.people(r.month),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PeopleView } from "@/features/reports/components/people-view";
|
||||
import {
|
||||
CsvButton,
|
||||
ToolsEnd,
|
||||
MonthNav,
|
||||
ReportFrame,
|
||||
monthLabel,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getPeopleAccountsReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
const t = tr.reports.people;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/kisi-hesap?ay=YYYY-MM — kişi bazlı harcama ve hesap bazlı hareket. */
|
||||
export default async function PeopleReportPage({
|
||||
searchParams,
|
||||
}: PageProps<"/raporlar/kisi-hesap">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||
const data = await getPeopleAccountsReport(
|
||||
user.id,
|
||||
household.id,
|
||||
ay.success ? ay.data : undefined,
|
||||
);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description(monthLabel(data.month))}
|
||||
tools={
|
||||
<>
|
||||
<MonthNav
|
||||
month={data.month}
|
||||
currentMonth={data.currentMonth}
|
||||
path="/raporlar/kisi-hesap"
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton
|
||||
href={`/raporlar/kisi-hesap/csv?ay=${data.month}&tablo=kisi`}
|
||||
label={t.csvPeople}
|
||||
/>
|
||||
<CsvButton
|
||||
href={`/raporlar/kisi-hesap/csv?ay=${data.month}&tablo=hesap`}
|
||||
label={t.csvAccounts}
|
||||
/>
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.includesHidden ? (
|
||||
<p className="text-xs text-muted-foreground">{tr.reports.includesHidden}</p>
|
||||
) : null}
|
||||
<PeopleView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { ChartPie, ChevronRight, Target, TrendingUp, Users } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { monthLabel, formatPct } from "@/features/reports/components/report-frame";
|
||||
import { getCategoryReport, getTrendReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
|
||||
const t = tr.reports;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
const REPORTS = [
|
||||
{ href: "/raporlar/kategoriler", key: "categories", icon: ChartPie },
|
||||
{ href: "/raporlar/trend", key: "trend", icon: TrendingUp },
|
||||
{ href: "/raporlar/kisi-hesap", key: "people", icon: Users },
|
||||
{ href: "/raporlar/butce", key: "budget", icon: Target },
|
||||
] as const;
|
||||
|
||||
/** /raporlar — bu bütçe ayının özeti ve raporlar. */
|
||||
export default async function ReportsPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [trend, cats] = await Promise.all([
|
||||
getTrendReport(user.id, household.id, 1),
|
||||
getCategoryReport(user.id, household.id),
|
||||
]);
|
||||
if (!trend || !cats) notFound();
|
||||
const m = trend.months[0]!;
|
||||
const top = cats.breakdown.rows.filter((r) => r.amountMinor > 0).slice(0, 3);
|
||||
const stats = [
|
||||
{ label: t.hub.income, value: formatMoney(m.incomeMinor), tone: "text-income" },
|
||||
{ label: t.hub.expense, value: formatMoney(m.expenseMinor), tone: "text-expense" },
|
||||
{
|
||||
label: t.hub.net,
|
||||
value: `${m.netMinor < 0 ? "−" : ""}${formatMoney(Math.abs(m.netMinor))}`,
|
||||
tone: m.netMinor < 0 ? "text-expense" : undefined,
|
||||
},
|
||||
{ label: t.hub.savings, value: formatPct(m.savingsPct) },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.title} description={t.description} />
|
||||
<section aria-labelledby="hub-month" className="rounded-xl border bg-card p-4">
|
||||
<h2 id="hub-month" className="text-sm font-medium">
|
||||
{t.hub.thisMonth}: <span className="capitalize">{monthLabel(m.month)}</span>{" "}
|
||||
<span className="font-normal text-muted-foreground">({t.trend.ongoing})</span>
|
||||
</h2>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label}>
|
||||
<dt className="text-xs text-muted-foreground">{s.label}</dt>
|
||||
<dd className={cn("text-lg font-semibold tabular-nums", s.tone)}>{s.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{top.length ? (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs text-muted-foreground">{t.hub.topCategories}</h3>
|
||||
<ul className="mt-1 flex flex-col gap-1 text-sm">
|
||||
{top.map((r) => (
|
||||
<li key={r.id} className="flex justify-between gap-3">
|
||||
<span className="truncate">{r.name}</span>
|
||||
<span className="tabular-nums">
|
||||
{formatMoney(r.amountMinor)}{" "}
|
||||
<span className="text-muted-foreground">· {formatPct(r.sharePct)}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
{REPORTS.map((r) => {
|
||||
const c = t.hub.cards[r.key];
|
||||
const Icon = r.icon;
|
||||
return (
|
||||
<li key={r.href}>
|
||||
<Link
|
||||
href={r.href}
|
||||
className="flex h-full items-start gap-3 rounded-xl border bg-card p-4 transition-colors hover:bg-accent/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Icon className="size-4.5 text-muted-foreground" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium">{c.title}</span>
|
||||
<span className="block text-sm text-muted-foreground">{c.description}</span>
|
||||
</span>
|
||||
<ChevronRight className="mt-2 size-4 text-muted-foreground" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getTrendReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
|
||||
/** GET /raporlar/trend/csv — son 12 bütçe ayı. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getTrendReport(c.userId, c.householdId);
|
||||
if (!r) return forbidden();
|
||||
const csv = toCsv(
|
||||
tr.reports.csvHeaders.trend,
|
||||
r.months.map((m) => [
|
||||
m.month,
|
||||
csvMoney(m.incomeMinor),
|
||||
csvMoney(m.expenseMinor),
|
||||
csvMoney(m.netMinor),
|
||||
csvPct(m.savingsPct),
|
||||
]),
|
||||
);
|
||||
return csvResponse(csv, tr.reports.csvFiles.trend(todayIstanbul()));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CsvButton, ToolsEnd, ReportFrame } from "@/features/reports/components/report-frame";
|
||||
import { TrendView } from "@/features/reports/components/trend-view";
|
||||
import { getTrendReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
const t = tr.reports.trend;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/trend — son 12 bütçe ayı gelir, gider, net ve tasarruf oranı. */
|
||||
export default async function TrendReportPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await getTrendReport(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<ToolsEnd>
|
||||
<CsvButton href="/raporlar/trend/csv" />
|
||||
</ToolsEnd>
|
||||
}
|
||||
>
|
||||
<TrendView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export type NavItem = {
|
||||
|
||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||
export const CURRENT_PHASE = 13.2;
|
||||
export const CURRENT_PHASE = 14.1;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
@@ -44,7 +44,7 @@ export const MAIN_NAV: NavItem[] = [
|
||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
|
||||
{ href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 },
|
||||
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13.1 },
|
||||
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
|
||||
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14.1 },
|
||||
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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";
|
||||
@@ -19,24 +10,13 @@ import type { DailyPlanView as Data } from "../plan";
|
||||
|
||||
const t = tr.budget.daily;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
const dayFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: "Europe/Istanbul",
|
||||
});
|
||||
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Istanbul",
|
||||
});
|
||||
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||
const signed = (minor: number) => `${minor < 0 ? "−" : ""}${formatMoney(Math.abs(minor))}`;
|
||||
|
||||
const config = {
|
||||
planned: { label: t.cumPlanned, color: "var(--budget)" },
|
||||
actual: { label: t.cumActual, color: "var(--expense)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
@@ -57,7 +37,7 @@ function Stat({
|
||||
);
|
||||
}
|
||||
|
||||
/** Dashboard: ayın günlük planı, kümülatif grafik ve gelirle karşılaştırma. */
|
||||
/** Dashboard: ayın günlük planı ve gelirle karşılaştırma (grafik ana grafikte: Aylık). */
|
||||
export function DailyPlanView({ data }: { data: Data }) {
|
||||
const p = data.plan;
|
||||
if (!data.hasBudget) {
|
||||
@@ -75,14 +55,6 @@ export function DailyPlanView({ data }: { data: Data }) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const chart = p.days.map((day, i) => ({
|
||||
date: day.date,
|
||||
label: dayFmt.format(new Date(`${day.date}T12:00:00+03:00`)),
|
||||
planned: day.cumPlannedMinor / 100,
|
||||
// Gerçekleşen yalnızca bugüne kadar çizilir.
|
||||
actual: p.todayIndex === -1 || i <= p.todayIndex ? day.cumActualMinor / 100 : null,
|
||||
}));
|
||||
const income = data.income.expectedMinor / 100;
|
||||
const nextDiff =
|
||||
data.next.budgetMinor === null ? null : data.next.expectedIncomeMinor - data.next.budgetMinor;
|
||||
|
||||
@@ -116,82 +88,6 @@ export function DailyPlanView({ data }: { data: Data }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="aspect-auto h-56 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={24}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickFormatter={(v: number) => compact.format(v)}
|
||||
/>
|
||||
{income > 0 ? (
|
||||
<ReferenceLine
|
||||
y={income}
|
||||
stroke="var(--income)"
|
||||
strokeDasharray="4 4"
|
||||
label={{
|
||||
value: `${t.income} ${compact.format(income)}`,
|
||||
position: "insideTopLeft",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<ChartTooltip
|
||||
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="planned"
|
||||
type="linear"
|
||||
stroke="var(--color-planned)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="actual"
|
||||
type="linear"
|
||||
stroke="var(--color-actual)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.modes[data.mode]} {p.fixedMinor ? t.fixedNote(formatMoney(p.fixedMinor)) : ""}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { OVERVIEW_PERIODS, type OverviewPeriod } from "@/shared/overview";
|
||||
import { getOverview } from "../service";
|
||||
import { OverviewChart } from "./overview-chart";
|
||||
|
||||
type Search = Record<string, string | string[] | undefined>;
|
||||
|
||||
const periodOf = (x: unknown): OverviewPeriod =>
|
||||
OVERVIEW_PERIODS.includes(x as OverviewPeriod) ? (x as OverviewPeriod) : "month";
|
||||
|
||||
/** Dashboard ana grafiği (sunucu): ?periyot=week|month|half|year&gorunum=kumulatif|donemsel. */
|
||||
export async function OverviewCard({
|
||||
userId,
|
||||
householdId,
|
||||
searchParams,
|
||||
}: {
|
||||
userId: string;
|
||||
householdId: string;
|
||||
searchParams: Promise<Search>;
|
||||
}) {
|
||||
const q = await searchParams;
|
||||
const data = await getOverview(userId, householdId, periodOf(q.periyot));
|
||||
if (!data) return null;
|
||||
return <OverviewChart data={data} view={q.gorunum === "donemsel" ? "periodic" : "cumulative"} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { Bar, BarChart, CartesianGrid, 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 { BudgetReport } from "../service";
|
||||
import { monthLabel, monthShort } from "./report-frame";
|
||||
|
||||
const t = tr.reports.budget;
|
||||
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||
const ratio = (bp: number | null) => (bp === null ? "—" : `%${Math.round(bp / 100)}`);
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
ok: "bg-income",
|
||||
warning: "bg-amber-500",
|
||||
over: "bg-expense",
|
||||
none: "bg-muted-foreground",
|
||||
};
|
||||
|
||||
const config = {
|
||||
limit: { label: t.limit, color: "var(--budget)" },
|
||||
actual: { label: t.actual, color: "var(--expense)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function BudgetReportView({ data }: { data: BudgetReport }) {
|
||||
const chart = data.history.map((h) => ({
|
||||
month: h.month,
|
||||
label: monthShort(h.month),
|
||||
limit: h.limitMinor === null ? null : h.limitMinor / 100,
|
||||
actual: h.actualMinor / 100,
|
||||
}));
|
||||
const { totals, hasBudget } = data.budget;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{!hasBudget ? <p className="text-sm text-muted-foreground">{t.noBudget}</p> : null}
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[34rem] 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.category}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.limit}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.actual}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.remaining}</th>
|
||||
<th className="w-40 px-3 py-2 text-left font-normal">{t.ratio}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.lines.map((l) => (
|
||||
<tr key={l.categoryId} className={cn(l.depth === 0 && "font-medium")}>
|
||||
<td className="py-1.5 pr-3" style={{ paddingLeft: `${0.75 + l.depth * 1.25}rem` }}>
|
||||
{l.name}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{l.limitMinor === null ? (
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{l.covered ? t.covered : "—"}
|
||||
</span>
|
||||
) : (
|
||||
formatMoney(l.limitMinor)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatMoney(l.actualMinor)}</td>
|
||||
<td
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-right",
|
||||
(l.remainingMinor ?? 0) < 0 && "text-expense",
|
||||
)}
|
||||
>
|
||||
{l.remainingMinor === null
|
||||
? "—"
|
||||
: `${l.remainingMinor < 0 ? "−" : ""}${formatMoney(Math.abs(l.remainingMinor))}`}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{l.ratioBp === null ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted"
|
||||
aria-hidden
|
||||
>
|
||||
<span
|
||||
className={cn("block h-full rounded-full", STATUS_TONE[l.status])}
|
||||
style={{ width: `${Math.min(100, l.ratioBp / 100)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-12 text-right text-xs">{ratio(l.ratioBp)}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t font-medium">
|
||||
<tr>
|
||||
<td className="px-3 py-2">{tr.reports.categories.total}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{hasBudget ? formatMoney(totals.limitMinor) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{formatMoney(totals.actualMinor)}</td>
|
||||
<td
|
||||
className={cn("px-3 py-2 text-right", totals.remainingMinor < 0 && "text-expense")}
|
||||
>
|
||||
{hasBudget
|
||||
? `${totals.remainingMinor < 0 ? "−" : ""}${formatMoney(Math.abs(totals.remainingMinor))}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-left text-xs">{ratio(totals.ratioBp)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border bg-card p-4" aria-labelledby="bh-h">
|
||||
<h2 id="bh-h" className="mb-2 text-sm font-medium">
|
||||
{t.historyTitle}
|
||||
</h2>
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="aspect-auto h-56 w-full"
|
||||
role="img"
|
||||
aria-label={t.historyChartLabel}
|
||||
>
|
||||
<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={
|
||||
<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">
|
||||
{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 />} />
|
||||
<Bar
|
||||
dataKey="limit"
|
||||
fill="var(--color-limit)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="actual"
|
||||
fill="var(--color-actual)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
<table className="mt-3 w-full text-sm tabular-nums">
|
||||
<thead className="text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-1 text-left font-normal">{t.month}</th>
|
||||
<th className="py-1 text-right font-normal">{t.limit}</th>
|
||||
<th className="py-1 text-right font-normal">{t.actual}</th>
|
||||
<th className="py-1 text-right font-normal">{t.ratio}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.history.map((h) => (
|
||||
<tr key={h.month}>
|
||||
<td className="py-1 capitalize">{monthLabel(h.month)}</td>
|
||||
<td className="py-1 text-right">
|
||||
{h.limitMinor === null ? t.noLimit : formatMoney(h.limitMinor)}
|
||||
</td>
|
||||
<td className="py-1 text-right">{formatMoney(h.actualMinor)}</td>
|
||||
<td className="py-1 text-right">{ratio(h.ratioBp)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { Cell, Pie, PieChart } from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { IconBadge } from "@/components/appearance/swatch";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import type { CategoryReport } from "../service";
|
||||
import { Change, formatPct } from "./report-frame";
|
||||
|
||||
const t = tr.reports.categories;
|
||||
/** Halkada en fazla bu kadar dilim; kalanlar "Diğer". */
|
||||
const MAX_SLICES = 7;
|
||||
|
||||
export function CategoriesView({ data }: { data: CategoryReport }) {
|
||||
const { breakdown, appearance } = data;
|
||||
const positive = breakdown.rows.filter((r) => r.amountMinor > 0);
|
||||
const head = positive.slice(0, MAX_SLICES);
|
||||
const rest = positive.slice(MAX_SLICES);
|
||||
const slices = [
|
||||
...head.map((r) => ({
|
||||
key: r.id,
|
||||
name: r.name,
|
||||
value: r.amountMinor / 100,
|
||||
share: r.sharePct,
|
||||
color: `var(--swatch-${appearance[r.id]?.color ?? "slate"})`,
|
||||
})),
|
||||
...(rest.length
|
||||
? [
|
||||
{
|
||||
key: "__other__",
|
||||
name: t.other(rest.length),
|
||||
value: rest.reduce((a, r) => a + r.amountMinor, 0) / 100,
|
||||
share: Math.round(rest.reduce((a, r) => a + r.sharePct, 0) * 10) / 10,
|
||||
color: "var(--muted-foreground)",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const config = Object.fromEntries(
|
||||
slices.map((s) => [s.key, { label: s.name, color: s.color }]),
|
||||
) satisfies ChartConfig;
|
||||
|
||||
if (breakdown.totalMinor === 0 && breakdown.rows.length === 0)
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{tr.reports.noData}
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:items-start">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="relative">
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="mx-auto aspect-square w-full max-w-64"
|
||||
role="img"
|
||||
aria-label={t.chartLabel}
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
nameKey="key"
|
||||
formatter={(value, _name, item) => (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{item.payload.name}</span>
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{formatMoney(Math.round(Number(value) * 100))} ·{" "}
|
||||
{formatPct(item.payload.share)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={slices}
|
||||
dataKey="value"
|
||||
nameKey="key"
|
||||
innerRadius="62%"
|
||||
outerRadius="100%"
|
||||
stroke="var(--card)"
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{slices.map((s) => (
|
||||
<Cell key={s.key} fill={s.color} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">{t.total}</span>
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{formatMoney(breakdown.totalMinor)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="mt-4 flex flex-col gap-1.5 text-sm">
|
||||
{slices.map((s) => (
|
||||
<li key={s.key} className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-2.5 shrink-0 rounded-sm"
|
||||
style={{ background: s.color }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{s.name}</span>
|
||||
<span className="text-muted-foreground tabular-nums">{formatPct(s.share)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[34rem] text-sm">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-normal">{t.category}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.amount}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.share}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.previous}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.change}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{breakdown.rows.map((r) => {
|
||||
const a = appearance[r.id];
|
||||
return [
|
||||
<tr key={r.id} className="border-t bg-muted/20 font-medium">
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-2">
|
||||
{a ? <IconBadge icon={a.icon} color={a.color} size="sm" /> : null}
|
||||
{r.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{formatMoney(r.amountMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatPct(r.sharePct)}</td>
|
||||
<td className="px-3 py-2 text-right text-muted-foreground tabular-nums">
|
||||
{formatMoney(r.prevMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Change minor={r.changeMinor} pct={r.changePct} format={formatMoney} />
|
||||
</td>
|
||||
</tr>,
|
||||
...(r.children.length && r.ownMinor
|
||||
? [
|
||||
<tr key={`${r.id}-own`} className="text-muted-foreground">
|
||||
<td className="py-1.5 pr-3 pl-10 italic">{t.own}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums">
|
||||
{formatMoney(r.ownMinor)}
|
||||
</td>
|
||||
<td colSpan={3} />
|
||||
</tr>,
|
||||
]
|
||||
: []),
|
||||
...r.children.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td className={cn("py-1.5 pr-3", c.depth > 1 ? "pl-14" : "pl-10")}>{c.name}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums">
|
||||
{formatMoney(c.amountMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right text-muted-foreground tabular-nums">
|
||||
{formatPct(c.sharePct)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right text-muted-foreground tabular-nums">
|
||||
{formatMoney(c.prevMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
<Change minor={c.changeMinor} pct={c.changePct} format={formatMoney} />
|
||||
</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 tabular-nums">
|
||||
{formatMoney(breakdown.totalMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">%100</td>
|
||||
<td className="px-3 py-2 text-right text-muted-foreground tabular-nums">
|
||||
{formatMoney(breakdown.prevTotalMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Change
|
||||
minor={breakdown.totalMinor - breakdown.prevTotalMinor}
|
||||
pct={
|
||||
breakdown.prevTotalMinor
|
||||
? Math.round(
|
||||
((breakdown.totalMinor - breakdown.prevTotalMinor) * 1000) /
|
||||
breakdown.prevTotalMinor,
|
||||
) / 10
|
||||
: null
|
||||
}
|
||||
format={formatMoney}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import type { PeopleAccountsReport } from "../service";
|
||||
import { formatPct } from "./report-frame";
|
||||
|
||||
const t = tr.reports.people;
|
||||
const signed = (n: number) => `${n > 0 ? "+" : n < 0 ? "−" : ""}${formatMoney(Math.abs(n))}`;
|
||||
|
||||
/** Kişi bazlı harcama (pay çubuğu + tablo) ve hesap bazlı hareket tablosu. */
|
||||
export function PeopleView({ data }: { data: PeopleAccountsReport }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<section aria-labelledby="people-h" className="flex flex-col gap-2">
|
||||
<div>
|
||||
<h2 id="people-h" className="font-medium">
|
||||
{t.peopleTitle}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t.peopleHint}</p>
|
||||
</div>
|
||||
{data.people.length ? (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{data.people.map((p) => (
|
||||
<li key={p.userId} className="flex flex-col gap-1.5 p-3 text-sm">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{p.name}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{p.count} {t.count.toLocaleLowerCase("tr-TR")}
|
||||
</span>
|
||||
<span className="w-24 text-right tabular-nums">
|
||||
{formatMoney(p.expenseMinor)}
|
||||
</span>
|
||||
<span className="w-14 text-right text-muted-foreground tabular-nums">
|
||||
{formatPct(p.sharePct)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
|
||||
<div
|
||||
className="h-full rounded-full bg-expense"
|
||||
style={{ width: `${p.sharePct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex justify-between p-3 text-sm font-medium">
|
||||
<span>{tr.reports.categories.total}</span>
|
||||
<span className="tabular-nums">{formatMoney(data.peopleTotalMinor)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{tr.reports.noData}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="acc-h" className="flex flex-col gap-2">
|
||||
<div>
|
||||
<h2 id="acc-h" className="font-medium">
|
||||
{t.accountsTitle}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t.accountsHint}</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[40rem] 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.account}</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.transferIn}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.transferOut}</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.count}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.accounts.map((a) => (
|
||||
<tr key={a.id} className={cn(a.count === 0 && "text-muted-foreground")}>
|
||||
<td className="px-3 py-1.5">
|
||||
<span className="flex items-center gap-2">
|
||||
{a.name}
|
||||
{a.archived ? <Badge variant="secondary">{t.archived}</Badge> : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatMoney(a.incomeMinor)}</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatMoney(a.expenseMinor)}</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatMoney(a.transferInMinor)}</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatMoney(a.transferOutMinor)}</td>
|
||||
<td
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-right font-medium",
|
||||
a.netMinor < 0 ? "text-expense" : a.netMinor > 0 ? "text-income" : "",
|
||||
)}
|
||||
>
|
||||
{signed(a.netMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{a.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Download } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { shiftMonth } from "@/shared/budget";
|
||||
|
||||
const t = tr.reports;
|
||||
|
||||
const monthFmt = new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" });
|
||||
export const monthLabel = (month: string) =>
|
||||
monthFmt.format(new Date(`${month}-15T12:00:00+03:00`));
|
||||
const monthShortFmt = new Intl.DateTimeFormat("tr-TR", { month: "short", year: "2-digit" });
|
||||
export const monthShort = (month: string) =>
|
||||
monthShortFmt.format(new Date(`${month}-15T12:00:00+03:00`));
|
||||
|
||||
/** 14,3 → "%14,3"; −20 → "−%20"; null → "—". */
|
||||
export function formatPct(n: number | null): string {
|
||||
if (n === null) return "—";
|
||||
const abs = Math.abs(n).toLocaleString("tr-TR", { maximumFractionDigits: 1 });
|
||||
return n < 0 ? `−%${abs}` : `%${abs}`;
|
||||
}
|
||||
|
||||
/** Önceki / sonraki bütçe ayı (?ay=). Gelecek aya gidilmez. */
|
||||
export function MonthNav({
|
||||
month,
|
||||
currentMonth,
|
||||
path,
|
||||
}: {
|
||||
month: string;
|
||||
currentMonth: string;
|
||||
path: string;
|
||||
}) {
|
||||
const next = shiftMonth(month, 1);
|
||||
return (
|
||||
<nav aria-label={t.currentMonth} className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon-sm" asChild>
|
||||
<Link href={`${path}?ay=${shiftMonth(month, -1)}`} aria-label={t.prevMonth}>
|
||||
<ChevronLeft />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="min-w-36 text-center text-sm font-medium capitalize">
|
||||
{monthLabel(month)}
|
||||
</span>
|
||||
{next <= currentMonth ? (
|
||||
<Button variant="outline" size="icon-sm" asChild>
|
||||
<Link href={`${path}?ay=${next}`} aria-label={t.nextMonth}>
|
||||
<ChevronRight />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="icon-sm" disabled aria-label={t.nextMonth}>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
)}
|
||||
{month !== currentMonth ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={path}>{t.currentMonth}</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rapor sayfası çerçevesi: geri bağlantısı, başlık, araçlar (ay seçici, CSV). */
|
||||
export function ReportFrame({
|
||||
title,
|
||||
description,
|
||||
tools,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
tools?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/raporlar">
|
||||
<ArrowLeft />
|
||||
{t.back}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader title={title} description={description} />
|
||||
{tools ? <div className="flex flex-wrap items-center gap-2">{tools}</div> : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CsvButton({ href, label }: { href: string; label?: string }) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={href} download>
|
||||
<Download />
|
||||
{label ?? t.csv}
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Araç çubuğunun sağ tarafı (CSV düğmeleri). */
|
||||
export function ToolsEnd({ children }: { children: React.ReactNode }) {
|
||||
return <div className="ml-auto flex flex-wrap gap-2">{children}</div>;
|
||||
}
|
||||
|
||||
/** İşaretli tutar ve yüzde değişim; renk tek başına anlam taşımaz (işaret metinde). */
|
||||
export function Change({
|
||||
minor,
|
||||
pct,
|
||||
format,
|
||||
}: {
|
||||
minor: number;
|
||||
pct: number | null;
|
||||
format: (n: number) => string;
|
||||
}) {
|
||||
if (minor === 0) return <span className="text-muted-foreground tabular-nums">0</span>;
|
||||
const up = minor > 0;
|
||||
return (
|
||||
<span className={`tabular-nums ${up ? "text-expense" : "text-income"}`}>
|
||||
{up ? "+" : "−"}
|
||||
{format(Math.abs(minor))}
|
||||
{pct !== null ? ` (${up ? "+" : "−"}%${Math.abs(pct).toLocaleString("tr-TR")})` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user