feat: Faz 14a - dashboard ana grafiği ve finans raporları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,22 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
|||||||
|
|
||||||
## [Yayınlanmamış]
|
## [Yayınlanmamış]
|
||||||
|
|
||||||
|
### Eklendi — Faz 14a: dashboard ana grafiği ve finans raporları
|
||||||
|
|
||||||
|
- Dashboard ana çizgi grafiği: Planlanan gelir (yeşil), Planlanan gider / bütçe (mavi, kesikli), Gerçekleşen gider (kırmızı). Periyotlar: Haftalık (bu hafta, günlük), Aylık (bütçe ayı, günlük), Altı aylık ve Yıllık (bütçe ayları). Kümülatif / dönemsel görünüm; seçim URL'de (`?periyot=…&gorunum=…`). Bütçesi olmayan dönemde bütçe çizgisi boş, gelecek günlerde gerçekleşen çizilmez; grafiğin altında tablo görünümü.
|
||||||
|
- Raporlar (`/raporlar`): bu bütçe ayının özeti (gelir, gider, net, tasarruf oranı, en çok harcanan kategoriler) ve rapor sayfaları; her raporda ay seçici ve CSV dışa aktarma (Türkçe Excel uyumlu).
|
||||||
|
- Kategori dağılımı: halka grafik ve tablo; üst kategori toplamları ve alt kategori kırılımı, toplam gidere oran (%14,3 gibi), önceki aya göre tutar ve yüzde değişim. Dilim renkleri kategorinin kendi rengi; 7'den fazla kategori "Diğer"de toplanır.
|
||||||
|
- Aylık trend: son 12 bütçe ayı gelir ve gider sütunları, ayrı grafikte tasarruf oranı (devam eden ay grafikte gösterilmez), net ve 12 ay toplamı.
|
||||||
|
- Kişi ve hesap bazlı: gideri kaydeden üyeye göre harcama ve pay; hesap başına gelir, gider, gelen/giden transfer ve net değişim. Başka üyelerin "yalnızca ben" hesapları hesap tablosunda hiç görünmez.
|
||||||
|
- Bütçe ve gerçekleşen: kategori bazında limit, gerçekleşen, kalan ve kullanım; son 6 ayın limit/gerçekleşen karşılaştırması.
|
||||||
|
- Kurallar bütçeyle aynı: bütçe ayı, gerçekleşmiş işlemler, bölünmüş işlemler kalem bazında, taksit modu ve gizli hesap giderleri hane ayarına göre.
|
||||||
|
- Testler: periyot aralıkları ve kümülatif dönüşüm, kategori payı/değişim/tasarruf oranı ve alt kategori kırılımı (birim); rapor toplamlarının bütçeyle tutarlılığı, gizli hesap görünürlüğü, dashboard periyotları (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 14a
|
||||||
|
|
||||||
|
- Dashboard'daki günlük plan kartı istatistik ve tabloya indi; kümülatif ay grafiği ana grafiğin "Aylık" görünümüne taşındı.
|
||||||
|
- Bütçe, gelir planı ve günlük plan hesapları bağlam alan yardımcılara ayrıldı (raporlarda birden çok ay için yeniden kullanılır).
|
||||||
|
|
||||||
## [0.13.0] - 2026-11-06
|
## [0.13.0] - 2026-11-06
|
||||||
|
|
||||||
Faz 13: alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu ve alışveriş sonrası giriş (13a ve 13b alt fazları).
|
Faz 13: alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu ve alışveriş sonrası giriş (13a ve 13b alt fazları).
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import Link from "next/link";
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||||
import { DailyPlanCard } from "@/features/budgets/components/daily-plan-card";
|
import { DailyPlanCard } from "@/features/budgets/components/daily-plan-card";
|
||||||
import { BudgetWarningBanner } from "@/features/budgets/components/warning-banner";
|
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");
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -104,6 +106,9 @@ export default async function DashboardPage() {
|
|||||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||||
<SummaryCards userId={user.id} householdId={household.id} />
|
<SummaryCards userId={user.id} householdId={household.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<Suspense fallback={<Skeleton className="h-96 rounded-xl" />}>
|
||||||
|
<OverviewCard userId={user.id} householdId={household.id} searchParams={searchParams} />
|
||||||
|
</Suspense>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DailyPlanCard userId={user.id} householdId={household.id} />
|
<DailyPlanCard userId={user.id} householdId={household.id} />
|
||||||
</Suspense>
|
</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. */
|
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** 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;
|
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: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
|
||||||
{ href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 },
|
{ href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 },
|
||||||
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13.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 },
|
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { tr } from "@/i18n/tr";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatDate, formatMoney } from "@/shared/format";
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
@@ -19,24 +10,13 @@ import type { DailyPlanView as Data } from "../plan";
|
|||||||
|
|
||||||
const t = tr.budget.daily;
|
const t = tr.budget.daily;
|
||||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
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", {
|
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
timeZone: "Europe/Istanbul",
|
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 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({
|
function Stat({
|
||||||
label,
|
label,
|
||||||
value,
|
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 }) {
|
export function DailyPlanView({ data }: { data: Data }) {
|
||||||
const p = data.plan;
|
const p = data.plan;
|
||||||
if (!data.hasBudget) {
|
if (!data.hasBudget) {
|
||||||
@@ -75,14 +55,6 @@ export function DailyPlanView({ data }: { data: Data }) {
|
|||||||
</Card>
|
</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 =
|
const nextDiff =
|
||||||
data.next.budgetMinor === null ? null : data.next.expectedIncomeMinor - data.next.budgetMinor;
|
data.next.budgetMinor === null ? null : data.next.expectedIncomeMinor - data.next.budgetMinor;
|
||||||
|
|
||||||
@@ -116,82 +88,6 @@ export function DailyPlanView({ data }: { data: Data }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t.modes[data.mode]} {p.fixedMinor ? t.fixedNote(formatMoney(p.fixedMinor)) : ""}
|
{t.modes[data.mode]} {p.fixedMinor ? t.fixedNote(formatMoney(p.fixedMinor)) : ""}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export type IncomePlan = {
|
|||||||
manual: { id: string; name: string; amountMinor: number }[];
|
manual: { id: string; name: string; amountMinor: number }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
async function incomePlan(ctx: Ctx, s: Settings, month: string): Promise<IncomePlan> {
|
export async function incomePlan(ctx: Ctx, s: Settings, month: string): Promise<IncomePlan> {
|
||||||
const { from, to } = budgetPeriod(month, s.startDay);
|
const { from, to } = budgetPeriod(month, s.startDay);
|
||||||
const [docs, budget] = await Promise.all([
|
const [docs, budget] = await Promise.all([
|
||||||
TransactionModel.find(baseMatch(ctx, s, "income", from, to))
|
TransactionModel.find(baseMatch(ctx, s, "income", from, to))
|
||||||
@@ -145,7 +145,7 @@ export async function saveIncomeItems(
|
|||||||
// --- Günlük plan ---
|
// --- Günlük plan ---
|
||||||
|
|
||||||
/** Güne göre gider: sabit (düzenli/taksit; gerçekleşmiş + planlı) ve gerçekleşen (tümü). */
|
/** Güne göre gider: sabit (düzenli/taksit; gerçekleşmiş + planlı) ve gerçekleşen (tümü). */
|
||||||
async function expensesByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
export async function expensesByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||||||
const pipeline: PipelineStage[] = [
|
const pipeline: PipelineStage[] = [
|
||||||
{ $match: baseMatch(ctx, s, "expense", from, to) },
|
{ $match: baseMatch(ctx, s, "expense", from, to) },
|
||||||
{
|
{
|
||||||
@@ -182,6 +182,20 @@ async function expensesByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
|||||||
return { fixed, actual, variable };
|
return { fixed, actual, variable };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Güne göre beklenen gelir (gerçekleşmiş + planlı gelir işlemleri). */
|
||||||
|
export async function incomeByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||||||
|
const rows = await TransactionModel.aggregate<{ _id: string; sum: number }>([
|
||||||
|
{ $match: baseMatch(ctx, s, "income", from, to) },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: { $dateToString: { date: "$date", format: "%Y-%m-%d", timezone: "Europe/Istanbul" } },
|
||||||
|
sum: { $sum: "$amountMinor" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return new Map(rows.map((r) => [r._id, r.sum]));
|
||||||
|
}
|
||||||
|
|
||||||
export type DailyPlanView = {
|
export type DailyPlanView = {
|
||||||
month: string;
|
month: string;
|
||||||
period: { from: string; to: string };
|
period: { from: string; to: string };
|
||||||
@@ -199,6 +213,40 @@ export type DailyPlanView = {
|
|||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bir bütçe ayının günlük planı: sabit giderler kendi günlerine, değişken bütçe hane ayarına göre
|
||||||
|
* (eşit ya da önceki ayın örüntüsü) dağıtılır.
|
||||||
|
*/
|
||||||
|
export async function monthDailyPlan(
|
||||||
|
ctx: Ctx,
|
||||||
|
s: Settings,
|
||||||
|
month: string,
|
||||||
|
budgetMinor: number,
|
||||||
|
today = todayIstanbul(),
|
||||||
|
): Promise<DailyPlan> {
|
||||||
|
const period = budgetPeriod(month, s.startDay);
|
||||||
|
const byDay = await expensesByDay(ctx, s, period.from, period.to);
|
||||||
|
let weights: number[] | undefined;
|
||||||
|
if (s.dailyMode === "previous_month_pattern") {
|
||||||
|
const prev = budgetPeriod(shiftMonth(month, -1), s.startDay);
|
||||||
|
const prevDays = await expensesByDay(ctx, s, prev.from, prev.to);
|
||||||
|
const len = daysBetween(prev.from, prev.to);
|
||||||
|
weights = patternWeights(
|
||||||
|
Array.from({ length: len }, (_, i) => prevDays.variable.get(addDays(prev.from, i)) ?? 0),
|
||||||
|
daysBetween(period.from, period.to),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return dailyPlan({
|
||||||
|
from: period.from,
|
||||||
|
to: period.to,
|
||||||
|
today,
|
||||||
|
budgetMinor,
|
||||||
|
fixedByDay: byDay.fixed,
|
||||||
|
actualByDay: byDay.actual,
|
||||||
|
weights,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Dashboard günlük planı: içinde bulunulan bütçe ayı. */
|
/** Dashboard günlük planı: içinde bulunulan bütçe ayı. */
|
||||||
export async function getDailyPlan(
|
export async function getDailyPlan(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -212,34 +260,14 @@ export async function getDailyPlan(
|
|||||||
const period = budgetPeriod(month, s.startDay);
|
const period = budgetPeriod(month, s.startDay);
|
||||||
const nextMonth = shiftMonth(month, 1);
|
const nextMonth = shiftMonth(month, 1);
|
||||||
const mode = s.dailyMode;
|
const mode = s.dailyMode;
|
||||||
const [budget, byDay, income, nextIncome, nextBudget] = await Promise.all([
|
const [budget, income, nextIncome, nextBudget] = await Promise.all([
|
||||||
getBudget(userId, householdId, month),
|
getBudget(userId, householdId, month),
|
||||||
expensesByDay(ctx, s, period.from, period.to),
|
|
||||||
incomePlan(ctx, s, month),
|
incomePlan(ctx, s, month),
|
||||||
incomePlan(ctx, s, nextMonth),
|
incomePlan(ctx, s, nextMonth),
|
||||||
getBudget(userId, householdId, nextMonth),
|
getBudget(userId, householdId, nextMonth),
|
||||||
]);
|
]);
|
||||||
if (!budget) return null;
|
if (!budget) return null;
|
||||||
|
const plan = await monthDailyPlan(ctx, s, month, budget.totals.limitMinor, today);
|
||||||
let weights: number[] | undefined;
|
|
||||||
if (mode === "previous_month_pattern") {
|
|
||||||
const prev = budgetPeriod(shiftMonth(month, -1), s.startDay);
|
|
||||||
const prevDays = await expensesByDay(ctx, s, prev.from, prev.to);
|
|
||||||
const len = daysBetween(prev.from, prev.to);
|
|
||||||
weights = patternWeights(
|
|
||||||
Array.from({ length: len }, (_, i) => prevDays.variable.get(addDays(prev.from, i)) ?? 0),
|
|
||||||
daysBetween(period.from, period.to),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const plan = dailyPlan({
|
|
||||||
from: period.from,
|
|
||||||
to: period.to,
|
|
||||||
today,
|
|
||||||
budgetMinor: budget.totals.limitMinor,
|
|
||||||
fixedByDay: byDay.fixed,
|
|
||||||
actualByDay: byDay.actual,
|
|
||||||
weights,
|
|
||||||
});
|
|
||||||
const forecastExpense = budget.totals.forecastMinor;
|
const forecastExpense = budget.totals.forecastMinor;
|
||||||
return {
|
return {
|
||||||
month,
|
month,
|
||||||
|
|||||||
@@ -191,7 +191,11 @@ export async function getBudget(
|
|||||||
): Promise<BudgetView | null> {
|
): Promise<BudgetView | null> {
|
||||||
const ctx = await loadContext(userId, householdId, "viewer");
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
if (!ctx) return null;
|
if (!ctx) return null;
|
||||||
const s = await settingsOf(ctx.m.householdId);
|
return budgetFor(ctx, await settingsOf(ctx.m.householdId), month);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bağlam ve ayarlar hazırken bütçe (raporlarda birden çok ay için yeniden yüklemeden). */
|
||||||
|
export async function budgetFor(ctx: Ctx, s: Settings, month?: string): Promise<BudgetView> {
|
||||||
const today = todayIstanbul();
|
const today = todayIstanbul();
|
||||||
const currentMonth = periodOfDate(today, s.startDay);
|
const currentMonth = periodOfDate(today, s.startDay);
|
||||||
const m = month ?? currentMonth;
|
const m = month ?? currentMonth;
|
||||||
|
|||||||
@@ -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,83 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { budgetFor, settingsOf } from "@/features/budgets/service";
|
||||||
|
import { incomeByDay, incomePlan, monthDailyPlan } from "@/features/budgets/plan";
|
||||||
|
import { loadContext } from "@/features/transactions/service";
|
||||||
|
import { budgetPeriod, periodOfDate } from "@/shared/budget";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import {
|
||||||
|
overviewRange,
|
||||||
|
type OverviewPeriod,
|
||||||
|
type OverviewPoint,
|
||||||
|
type OverviewRange,
|
||||||
|
} from "@/shared/overview";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard ana grafiği (Bölüm 6.13). Bütçe ve gelir planı motorlarıyla aynı kurallar: bütçe ayı,
|
||||||
|
* taksit modu ve gizli hesap ayarı.
|
||||||
|
*
|
||||||
|
* Aylık noktalar: beklenen gelir (gerçekleşen + planlı + elle), bütçe toplamı (bütçe yoksa boş),
|
||||||
|
* gerçekleşen gider. Günlük noktalar: o günün gelirleri (elle eklenen beklenen gelir dönemin ilk
|
||||||
|
* gününe), günlük plan payı, o günün gerçekleşen gideri; gelecek günlerde gerçekleşen boş.
|
||||||
|
*/
|
||||||
|
export type OverviewData = {
|
||||||
|
period: OverviewPeriod;
|
||||||
|
range: OverviewRange;
|
||||||
|
points: OverviewPoint[];
|
||||||
|
today: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getOverview(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
period: OverviewPeriod,
|
||||||
|
): Promise<OverviewData | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const s = await settingsOf(ctx.m.householdId);
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const range = overviewRange(period, today, s.startDay);
|
||||||
|
|
||||||
|
if (range.granularity === "month") {
|
||||||
|
const points = await Promise.all(
|
||||||
|
range.buckets.map(async (month) => {
|
||||||
|
const [budget, income] = await Promise.all([
|
||||||
|
budgetFor(ctx, s, month),
|
||||||
|
incomePlan(ctx, s, month),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
key: month,
|
||||||
|
plannedIncome: income.expectedMinor,
|
||||||
|
budget: budget.hasBudget ? budget.totals.limitMinor : null,
|
||||||
|
actual: budget.totals.actualMinor,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { period, range, points, today };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Günlük: aralığın dokunduğu bütçe ayları (hafta iki aya yayılabilir).
|
||||||
|
const months = [...new Set([range.from, range.to].map((d) => periodOfDate(d, s.startDay)))];
|
||||||
|
const planned = new Map<string, number | null>();
|
||||||
|
const actual = new Map<string, number>();
|
||||||
|
const income = new Map<string, number>();
|
||||||
|
for (const month of months) {
|
||||||
|
const [budget, plan] = await Promise.all([budgetFor(ctx, s, month), incomePlan(ctx, s, month)]);
|
||||||
|
const dp = await monthDailyPlan(ctx, s, month, budget.totals.limitMinor, today);
|
||||||
|
for (const d of dp.days) {
|
||||||
|
planned.set(d.date, budget.hasBudget ? d.plannedMinor : null);
|
||||||
|
actual.set(d.date, d.actualMinor);
|
||||||
|
}
|
||||||
|
const first = budgetPeriod(month, s.startDay).from;
|
||||||
|
if (plan.manualMinor) income.set(first, (income.get(first) ?? 0) + plan.manualMinor);
|
||||||
|
}
|
||||||
|
for (const [d, v] of await incomeByDay(ctx, s, range.from, range.to))
|
||||||
|
income.set(d, (income.get(d) ?? 0) + v);
|
||||||
|
|
||||||
|
const points = range.buckets.map((d) => ({
|
||||||
|
key: d,
|
||||||
|
plannedIncome: income.get(d) ?? 0,
|
||||||
|
budget: planned.get(d) ?? null,
|
||||||
|
actual: d <= today ? (actual.get(d) ?? 0) : null,
|
||||||
|
}));
|
||||||
|
return { period, range, points, today };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { getHouseholdContext } from "@/lib/dal";
|
||||||
|
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||||
|
|
||||||
|
/** CSV rotaları için ortak: oturum + aktif hane, ?ay= doğrulaması ve yanıt üretimi. */
|
||||||
|
export async function csvContext(request: Request) {
|
||||||
|
const ctx = await getHouseholdContext();
|
||||||
|
if (!ctx?.household) return null;
|
||||||
|
const params = new URL(request.url).searchParams;
|
||||||
|
const ay = budgetMonthSchema.safeParse(params.get("ay"));
|
||||||
|
return {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
householdId: ctx.household.id,
|
||||||
|
month: ay.success ? ay.data : undefined,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const unauthorized = () => new Response(tr.common.unauthorized, { status: 401 });
|
||||||
|
export const forbidden = () => new Response(tr.common.unauthorized, { status: 403 });
|
||||||
|
|
||||||
|
export function csvResponse(csv: string, filename: string) {
|
||||||
|
return new Response(csv, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/csv; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 14,3 → "14,3" (CSV ondalık virgül). */
|
||||||
|
export const csvPct = (n: number | null) => (n === null ? "" : String(n).replace(".", ","));
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { getBudget } from "@/features/budgets/service";
|
||||||
|
import { getOverview } from "@/features/dashboard/service";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
getBudgetReport,
|
||||||
|
getCategoryReport,
|
||||||
|
getPeopleAccountsReport,
|
||||||
|
getTrendReport,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
describe("kategori dağılımı", () => {
|
||||||
|
it("toplam bütçenin gerçekleşeniyle aynı; oranlar ~%100; başka hane göremez", async () => {
|
||||||
|
const r = (await getCategoryReport(ayse, cakir))!;
|
||||||
|
const budget = (await getBudget(ayse, cakir))!;
|
||||||
|
expect(r.breakdown.totalMinor).toBe(budget.totals.actualMinor);
|
||||||
|
expect(r.breakdown.totalMinor).toBeGreaterThan(0);
|
||||||
|
const sum = r.breakdown.rows.reduce((a, x) => a + x.amountMinor, 0);
|
||||||
|
expect(sum).toBe(r.breakdown.totalMinor);
|
||||||
|
const pct = r.breakdown.rows.reduce((a, x) => a + x.sharePct, 0);
|
||||||
|
expect(Math.abs(pct - 100)).toBeLessThan(1);
|
||||||
|
// Alt kategori kırılımı: çocukların ve doğrudan tutarın toplamı üst satıra eşit.
|
||||||
|
for (const row of r.breakdown.rows) {
|
||||||
|
const kids = row.children.reduce((a, c) => a + c.amountMinor, 0);
|
||||||
|
expect(kids + row.ownMinor).toBe(row.amountMinor);
|
||||||
|
}
|
||||||
|
expect(r.appearance[r.breakdown.rows[0]!.id]).toBeDefined();
|
||||||
|
expect(await getCategoryReport(fatma, cakir)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("önceki ay seçilebilir", async () => {
|
||||||
|
const cur = (await getCategoryReport(ayse, cakir))!;
|
||||||
|
const prev = (await getCategoryReport(ayse, cakir, cur.previous))!;
|
||||||
|
expect(prev.current).toBe(false);
|
||||||
|
expect(prev.breakdown.totalMinor).toBe(cur.breakdown.prevTotalMinor);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("aylık trend", () => {
|
||||||
|
it("12 ay; içinde bulunulan ayın gideri bütçeyle aynı; tasarruf oranı tutarlı", async () => {
|
||||||
|
const r = (await getTrendReport(ayse, cakir))!;
|
||||||
|
expect(r.months).toHaveLength(12);
|
||||||
|
const last = r.months.at(-1)!;
|
||||||
|
expect(last.month).toBe(r.currentMonth);
|
||||||
|
expect(last.expenseMinor).toBe((await getBudget(ayse, cakir))!.totals.actualMinor);
|
||||||
|
for (const m of r.months) {
|
||||||
|
expect(m.netMinor).toBe(m.incomeMinor - m.expenseMinor);
|
||||||
|
if (m.incomeMinor === 0) expect(m.savingsPct).toBeNull();
|
||||||
|
}
|
||||||
|
expect(r.months.filter((m) => m.incomeMinor > 0).length).toBeGreaterThan(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("kişi ve hesap bazlı", () => {
|
||||||
|
it("kişi toplamları ve oranları; gizli hesap yalnızca sahibine görünür", async () => {
|
||||||
|
const own = (await getPeopleAccountsReport(ayse, cakir))!;
|
||||||
|
expect(own.people.reduce((a, p) => a + p.expenseMinor, 0)).toBe(own.peopleTotalMinor);
|
||||||
|
expect(own.people.every((p) => p.name !== "—")).toBe(true);
|
||||||
|
expect(own.accounts.map((a) => a.name)).toContain("Ayşe — Pluxee");
|
||||||
|
for (const a of own.accounts)
|
||||||
|
expect(a.netMinor).toBe(
|
||||||
|
a.incomeMinor + a.transferInMinor - a.expenseMinor - a.transferOutMinor,
|
||||||
|
);
|
||||||
|
const other = (await getPeopleAccountsReport(zeynep, cakir))!;
|
||||||
|
expect(other.accounts.map((a) => a.name)).not.toContain("Ayşe — Pluxee");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bütçe ve gerçekleşen", () => {
|
||||||
|
it("son 6 ay; son satır seçilen ay", async () => {
|
||||||
|
const r = (await getBudgetReport(ayse, cakir))!;
|
||||||
|
expect(r.history).toHaveLength(6);
|
||||||
|
expect(r.history.at(-1)!.month).toBe(r.month);
|
||||||
|
expect(r.history.at(-1)!.actualMinor).toBe(r.budget.totals.actualMinor);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dashboard ana grafik", () => {
|
||||||
|
it("periyotlar: hafta 7 gün, ay günleri, 6 ve 12 ay; gelecek günlerde gerçekleşen boş", async () => {
|
||||||
|
const week = (await getOverview(ayse, cakir, "week"))!;
|
||||||
|
expect(week.points).toHaveLength(7);
|
||||||
|
expect(week.points.filter((p) => p.key > week.today).every((p) => p.actual === null)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const month = (await getOverview(ayse, cakir, "month"))!;
|
||||||
|
const budget = (await getBudget(ayse, cakir))!;
|
||||||
|
const actual = month.points.reduce((a, p) => a + (p.actual ?? 0), 0);
|
||||||
|
expect(actual).toBe(budget.totals.actualMinor);
|
||||||
|
if (budget.hasBudget) {
|
||||||
|
const planned = month.points.reduce((a, p) => a + (p.budget ?? 0), 0);
|
||||||
|
expect(planned).toBe(budget.totals.limitMinor);
|
||||||
|
}
|
||||||
|
const half = (await getOverview(ayse, cakir, "half"))!;
|
||||||
|
expect(half.points).toHaveLength(6);
|
||||||
|
expect(half.points.at(-1)!.actual).toBe(budget.totals.actualMinor);
|
||||||
|
expect((await getOverview(ayse, cakir, "year"))!.points).toHaveLength(12);
|
||||||
|
expect(await getOverview(fatma, cakir, "month")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import { incomePlan } from "@/features/budgets/plan";
|
||||||
|
import {
|
||||||
|
budgetFor,
|
||||||
|
expenseAmounts,
|
||||||
|
settingsOf,
|
||||||
|
type BudgetLineView,
|
||||||
|
type BudgetView,
|
||||||
|
type Settings,
|
||||||
|
} from "@/features/budgets/service";
|
||||||
|
import { loadContext, userNames, type Ctx } from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { AccountType } from "@/shared/accounts";
|
||||||
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
|
import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import { categoryBreakdown, savingsRate, sharePct, type CategoryBreakdown } from "@/shared/reports";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raporlar (Bölüm 6.14). Okuma: viewer. "Ay" bütçe ayıdır (hane ayarı); gider kuralları bütçeyle
|
||||||
|
* aynı: gerçekleşmiş işlemler, bölünmüş işlemler kalem bazında, taksit modu ve başkasının
|
||||||
|
* "yalnızca ben" hesabındaki giderler hane ayarına göre (yalnızca tutar). Başkasının gizli
|
||||||
|
* hesabı hesap raporunda hiç görünmez.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
const ORPHAN = "__none__";
|
||||||
|
|
||||||
|
type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string };
|
||||||
|
|
||||||
|
async function base(userId: string, householdId: string): Promise<Base | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const s = await settingsOf(ctx.m.householdId);
|
||||||
|
const today = todayIstanbul();
|
||||||
|
return { ctx, s, today, currentMonth: periodOfDate(today, s.startDay) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kategori bazında gerçekleşen gider (kendi tutarı); kategorisiz/bilinmeyen tek satırda. */
|
||||||
|
async function actualByCategory(b: Base, month: string) {
|
||||||
|
const { from, to } = budgetPeriod(month, b.s.startDay);
|
||||||
|
const amounts = await expenseAmounts(b.ctx, b.s, from, to);
|
||||||
|
const out = new Map<string, number>();
|
||||||
|
for (const [id, a] of amounts) {
|
||||||
|
const v = a.fixedMinor + a.variableMinor;
|
||||||
|
if (!v) continue;
|
||||||
|
const known = id && b.ctx.categoryMap.get(id)?.kind === "expense";
|
||||||
|
const key = known ? id : ORPHAN;
|
||||||
|
out.set(key, (out.get(key) ?? 0) + v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MonthInfo = {
|
||||||
|
month: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
current: boolean;
|
||||||
|
currentMonth: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthInfo = (b: Base, month: string): MonthInfo => ({
|
||||||
|
month,
|
||||||
|
...budgetPeriod(month, b.s.startDay),
|
||||||
|
current: month === b.currentMonth,
|
||||||
|
currentMonth: b.currentMonth,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Kategori dağılımı ---
|
||||||
|
|
||||||
|
export type CategoryReport = MonthInfo & {
|
||||||
|
previous: string;
|
||||||
|
breakdown: CategoryBreakdown;
|
||||||
|
appearance: Record<string, { icon: IconKey; color: Swatch; path: string }>;
|
||||||
|
includesHidden: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCategoryReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
month?: string,
|
||||||
|
): Promise<CategoryReport | null> {
|
||||||
|
const b = await base(userId, householdId);
|
||||||
|
if (!b) return null;
|
||||||
|
const m = month ?? b.currentMonth;
|
||||||
|
const prev = shiftMonth(m, -1);
|
||||||
|
const [current, previous] = await Promise.all([
|
||||||
|
actualByCategory(b, m),
|
||||||
|
actualByCategory(b, prev),
|
||||||
|
]);
|
||||||
|
const cats = b.ctx.flatCategories
|
||||||
|
.filter((c) => c.kind === "expense")
|
||||||
|
.map((c) => ({ id: c.id, parentId: c.parentId, name: c.name, order: c.order }));
|
||||||
|
cats.push({ id: ORPHAN, parentId: null, name: tr.reports.uncategorized, order: 1e9 });
|
||||||
|
const appearance: CategoryReport["appearance"] = {
|
||||||
|
[ORPHAN]: { icon: "tag", color: "slate", path: tr.reports.uncategorized },
|
||||||
|
};
|
||||||
|
for (const c of b.ctx.categoryMap.values())
|
||||||
|
if (c.kind === "expense")
|
||||||
|
appearance[String(c.id)] = { icon: c.icon, color: c.color, path: c.path };
|
||||||
|
return {
|
||||||
|
...monthInfo(b, m),
|
||||||
|
previous: prev,
|
||||||
|
breakdown: categoryBreakdown(cats, current, previous),
|
||||||
|
appearance,
|
||||||
|
includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Aylık trend ---
|
||||||
|
|
||||||
|
export type TrendRow = {
|
||||||
|
month: string;
|
||||||
|
incomeMinor: number;
|
||||||
|
expenseMinor: number;
|
||||||
|
netMinor: number;
|
||||||
|
savingsPct: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrendReport = {
|
||||||
|
months: TrendRow[];
|
||||||
|
totals: {
|
||||||
|
incomeMinor: number;
|
||||||
|
expenseMinor: number;
|
||||||
|
netMinor: number;
|
||||||
|
savingsPct: number | null;
|
||||||
|
};
|
||||||
|
currentMonth: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Son `count` bütçe ayı (içinde bulunulan dahil): gerçekleşen gelir, gider, net, tasarruf oranı. */
|
||||||
|
export async function getTrendReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
count = 12,
|
||||||
|
): Promise<TrendReport | null> {
|
||||||
|
const b = await base(userId, householdId);
|
||||||
|
if (!b) return null;
|
||||||
|
const months = Array.from({ length: count }, (_, i) => shiftMonth(b.currentMonth, i - count + 1));
|
||||||
|
const rows = await Promise.all(
|
||||||
|
months.map(async (month) => {
|
||||||
|
const [income, expense] = await Promise.all([
|
||||||
|
incomePlan(b.ctx, b.s, month),
|
||||||
|
actualByCategory(b, month),
|
||||||
|
]);
|
||||||
|
const incomeMinor = income.actualMinor;
|
||||||
|
const expenseMinor = [...expense.values()].reduce((a, v) => a + v, 0);
|
||||||
|
return {
|
||||||
|
month,
|
||||||
|
incomeMinor,
|
||||||
|
expenseMinor,
|
||||||
|
netMinor: incomeMinor - expenseMinor,
|
||||||
|
savingsPct: savingsRate(incomeMinor, expenseMinor),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const incomeMinor = rows.reduce((a, r) => a + r.incomeMinor, 0);
|
||||||
|
const expenseMinor = rows.reduce((a, r) => a + r.expenseMinor, 0);
|
||||||
|
return {
|
||||||
|
months: rows,
|
||||||
|
totals: {
|
||||||
|
incomeMinor,
|
||||||
|
expenseMinor,
|
||||||
|
netMinor: incomeMinor - expenseMinor,
|
||||||
|
savingsPct: savingsRate(incomeMinor, expenseMinor),
|
||||||
|
},
|
||||||
|
currentMonth: b.currentMonth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kişi ve hesap bazlı ---
|
||||||
|
|
||||||
|
export type PersonRow = {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
expenseMinor: number;
|
||||||
|
count: number;
|
||||||
|
sharePct: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: AccountType;
|
||||||
|
archived: boolean;
|
||||||
|
incomeMinor: number;
|
||||||
|
expenseMinor: number;
|
||||||
|
transferInMinor: number;
|
||||||
|
transferOutMinor: number;
|
||||||
|
netMinor: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeopleAccountsReport = MonthInfo & {
|
||||||
|
people: PersonRow[];
|
||||||
|
peopleTotalMinor: number;
|
||||||
|
accounts: AccountRow[];
|
||||||
|
includesHidden: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TxLean = {
|
||||||
|
type: "income" | "expense" | "transfer";
|
||||||
|
amountMinor: number;
|
||||||
|
accountId: Types.ObjectId | null;
|
||||||
|
toAccountId: Types.ObjectId | null;
|
||||||
|
createdBy: Types.ObjectId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getPeopleAccountsReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
month?: string,
|
||||||
|
): Promise<PeopleAccountsReport | null> {
|
||||||
|
const b = await base(userId, householdId);
|
||||||
|
if (!b) return null;
|
||||||
|
const m = month ?? b.currentMonth;
|
||||||
|
const info = monthInfo(b, m);
|
||||||
|
const docs = await TransactionModel.find({
|
||||||
|
householdId: b.ctx.m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
status: "posted",
|
||||||
|
date: {
|
||||||
|
$gte: istanbulDayStart(info.from)!,
|
||||||
|
$lt: new Date(istanbulDayStart(info.to)!.getTime() + DAY_MS),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.select({ type: 1, amountMinor: 1, accountId: 1, toAccountId: 1, createdBy: 1 })
|
||||||
|
.lean<TxLean[]>();
|
||||||
|
const hidden = new Set(b.ctx.hiddenAccountIds.map(String));
|
||||||
|
|
||||||
|
// Kişi: kaydı giren üye; gizli hesap giderleri hane ayarına göre (yalnızca tutar).
|
||||||
|
const people = new Map<string, { expense: number; count: number }>();
|
||||||
|
for (const d of docs) {
|
||||||
|
if (d.type !== "expense") continue;
|
||||||
|
if (d.accountId && hidden.has(String(d.accountId)) && !b.s.includePrivate) continue;
|
||||||
|
const key = String(d.createdBy);
|
||||||
|
const p = people.get(key) ?? { expense: 0, count: 0 };
|
||||||
|
p.expense += d.amountMinor;
|
||||||
|
p.count++;
|
||||||
|
people.set(key, p);
|
||||||
|
}
|
||||||
|
const names = await userNames([...people.keys()].map((id) => new Types.ObjectId(id)));
|
||||||
|
const peopleTotal = [...people.values()].reduce((a, p) => a + p.expense, 0);
|
||||||
|
|
||||||
|
// Hesap: başkasının gizli hesabı hiç görünmez.
|
||||||
|
const acc = new Map<string, Omit<AccountRow, "id" | "name" | "type" | "archived" | "netMinor">>();
|
||||||
|
const bump = (
|
||||||
|
id: Types.ObjectId | null,
|
||||||
|
k: "incomeMinor" | "expenseMinor" | "transferInMinor" | "transferOutMinor",
|
||||||
|
v: number,
|
||||||
|
) => {
|
||||||
|
if (!id || hidden.has(String(id))) return;
|
||||||
|
const key = String(id);
|
||||||
|
const row = acc.get(key) ?? {
|
||||||
|
incomeMinor: 0,
|
||||||
|
expenseMinor: 0,
|
||||||
|
transferInMinor: 0,
|
||||||
|
transferOutMinor: 0,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
row[k] += v;
|
||||||
|
row.count++;
|
||||||
|
acc.set(key, row);
|
||||||
|
};
|
||||||
|
for (const d of docs) {
|
||||||
|
if (d.type === "income") bump(d.accountId, "incomeMinor", d.amountMinor);
|
||||||
|
else if (d.type === "expense") bump(d.accountId, "expenseMinor", d.amountMinor);
|
||||||
|
else {
|
||||||
|
bump(d.accountId, "transferOutMinor", d.amountMinor);
|
||||||
|
bump(d.toAccountId, "transferInMinor", d.amountMinor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const accounts: AccountRow[] = [];
|
||||||
|
for (const a of b.ctx.accountMap.values()) {
|
||||||
|
if (a.hidden) continue;
|
||||||
|
const row = acc.get(String(a.id));
|
||||||
|
if (!row && a.archived) continue;
|
||||||
|
const r = row ?? {
|
||||||
|
incomeMinor: 0,
|
||||||
|
expenseMinor: 0,
|
||||||
|
transferInMinor: 0,
|
||||||
|
transferOutMinor: 0,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
accounts.push({
|
||||||
|
id: String(a.id),
|
||||||
|
name: a.name,
|
||||||
|
type: a.type,
|
||||||
|
archived: a.archived,
|
||||||
|
...r,
|
||||||
|
netMinor: r.incomeMinor + r.transferInMinor - r.expenseMinor - r.transferOutMinor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
accounts.sort((x, y) => y.count - x.count || x.name.localeCompare(y.name, "tr"));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...info,
|
||||||
|
people: [...people.entries()]
|
||||||
|
.map(([id, p]) => ({
|
||||||
|
userId: id,
|
||||||
|
name: names.get(id) ?? "—",
|
||||||
|
expenseMinor: p.expense,
|
||||||
|
count: p.count,
|
||||||
|
sharePct: sharePct(p.expense, peopleTotal),
|
||||||
|
}))
|
||||||
|
.sort((x, y) => y.expenseMinor - x.expenseMinor),
|
||||||
|
peopleTotalMinor: peopleTotal,
|
||||||
|
accounts,
|
||||||
|
includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bütçe vs. gerçekleşen ---
|
||||||
|
|
||||||
|
export type BudgetHistoryRow = {
|
||||||
|
month: string;
|
||||||
|
limitMinor: number | null;
|
||||||
|
actualMinor: number;
|
||||||
|
ratioBp: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BudgetReport = MonthInfo & {
|
||||||
|
budget: BudgetView;
|
||||||
|
lines: BudgetLineView[];
|
||||||
|
history: BudgetHistoryRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Seçilen ayın kategori bazında bütçe/gerçekleşen tablosu ve son 6 ayın toplamları. */
|
||||||
|
export async function getBudgetReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
month?: string,
|
||||||
|
): Promise<BudgetReport | null> {
|
||||||
|
const b = await base(userId, householdId);
|
||||||
|
if (!b) return null;
|
||||||
|
const m = month ?? b.currentMonth;
|
||||||
|
const months = Array.from({ length: 6 }, (_, i) => shiftMonth(m, i - 5));
|
||||||
|
const budgets = await Promise.all(months.map((x) => budgetFor(b.ctx, b.s, x)));
|
||||||
|
const budget = budgets.at(-1)!;
|
||||||
|
return {
|
||||||
|
...monthInfo(b, m),
|
||||||
|
budget,
|
||||||
|
lines: budget.lines,
|
||||||
|
history: budgets.map((x) => ({
|
||||||
|
month: x.month,
|
||||||
|
limitMinor: x.hasBudget ? x.totals.limitMinor : null,
|
||||||
|
actualMinor: x.totals.actualMinor,
|
||||||
|
ratioBp: x.hasBudget ? x.totals.ratioBp : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
+155
@@ -1862,6 +1862,144 @@ export const tr = {
|
|||||||
identifierRequired: "Kullanıcı adı veya e-posta girin",
|
identifierRequired: "Kullanıcı adı veya e-posta girin",
|
||||||
passwordRequired: "Şifre girin",
|
passwordRequired: "Şifre girin",
|
||||||
},
|
},
|
||||||
|
reports: {
|
||||||
|
title: "Raporlar",
|
||||||
|
description:
|
||||||
|
"Bütçe ayına göre. Gizli hesap giderleri hane ayarına göre (yalnızca tutar) dahil edilir.",
|
||||||
|
uncategorized: "Kategorisiz",
|
||||||
|
back: "Raporlar",
|
||||||
|
csv: "CSV indir",
|
||||||
|
prevMonth: "Önceki ay",
|
||||||
|
nextMonth: "Sonraki ay",
|
||||||
|
currentMonth: "Bu ay",
|
||||||
|
includesHidden: 'Başka üyelerin "yalnızca ben" hesaplarındaki giderler tutar olarak dahil.',
|
||||||
|
noData: "Bu dönemde kayıt yok.",
|
||||||
|
hub: {
|
||||||
|
thisMonth: "Bu bütçe ayı",
|
||||||
|
income: "Gelir",
|
||||||
|
expense: "Gider",
|
||||||
|
net: "Net",
|
||||||
|
savings: "Tasarruf oranı",
|
||||||
|
topCategories: "En çok harcanan kategoriler",
|
||||||
|
open: "Raporu aç",
|
||||||
|
cards: {
|
||||||
|
categories: {
|
||||||
|
title: "Kategori dağılımı",
|
||||||
|
description:
|
||||||
|
"Kategorilerin toplam gidere oranı, alt kategori kırılımı, önceki aya göre değişim.",
|
||||||
|
},
|
||||||
|
trend: {
|
||||||
|
title: "Aylık trend",
|
||||||
|
description: "Son 12 ay gelir, gider, net ve tasarruf oranı.",
|
||||||
|
},
|
||||||
|
people: {
|
||||||
|
title: "Kişi ve hesap bazlı",
|
||||||
|
description: "Üyelerin harcaması ve hesapların aylık hareketi.",
|
||||||
|
},
|
||||||
|
budget: {
|
||||||
|
title: "Bütçe ve gerçekleşen",
|
||||||
|
description: "Kategori bazında limit ve harcama, son 6 ayın karşılaştırması.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
title: "Kategori dağılımı",
|
||||||
|
description: (month: string) =>
|
||||||
|
`${month} bütçe ayı; toplam gidere oran ve önceki aya göre değişim.`,
|
||||||
|
chartLabel: "Kategorilerin toplam gider içindeki payı, halka grafik",
|
||||||
|
total: "Toplam gider",
|
||||||
|
category: "Kategori",
|
||||||
|
amount: "Tutar",
|
||||||
|
share: "Oran",
|
||||||
|
previous: "Önceki ay",
|
||||||
|
change: "Değişim",
|
||||||
|
own: "Doğrudan üst kategoriye",
|
||||||
|
partialMonth: "Ay devam ediyor; değişim önceki ayın tamamıyla karşılaştırılır.",
|
||||||
|
newCategory: "yeni",
|
||||||
|
other: (n: number) => `Diğer (${n} kategori)`,
|
||||||
|
},
|
||||||
|
trend: {
|
||||||
|
title: "Aylık trend",
|
||||||
|
description: "Son 12 bütçe ayı: gerçekleşen gelir ve gider, net ve tasarruf oranı.",
|
||||||
|
chartLabel: "Son 12 ay gelir ve gider sütun grafiği",
|
||||||
|
rateChartLabel: "Son 12 ay tasarruf oranı",
|
||||||
|
month: "Ay",
|
||||||
|
income: "Gelir",
|
||||||
|
expense: "Gider",
|
||||||
|
net: "Net",
|
||||||
|
savings: "Tasarruf oranı",
|
||||||
|
total: "12 ay toplam",
|
||||||
|
rateTitle: "Tasarruf oranı",
|
||||||
|
rateHint: "(gelir − gider) / gelir. Gelir yoksa hesaplanmaz.",
|
||||||
|
partialHint: "Devam eden ay grafikte gösterilmez.",
|
||||||
|
ongoing: "devam ediyor",
|
||||||
|
},
|
||||||
|
people: {
|
||||||
|
title: "Kişi ve hesap bazlı",
|
||||||
|
description: (month: string) => `${month} bütçe ayı; yalnızca gerçekleşmiş işlemler.`,
|
||||||
|
peopleTitle: "Kişi bazlı harcama",
|
||||||
|
peopleHint: "Gideri kaydeden üyeye göre.",
|
||||||
|
person: "Kişi",
|
||||||
|
expense: "Gider",
|
||||||
|
count: "İşlem",
|
||||||
|
share: "Oran",
|
||||||
|
csvPeople: "Kişiler CSV",
|
||||||
|
csvAccounts: "Hesaplar CSV",
|
||||||
|
accountsTitle: "Hesap bazlı hareket",
|
||||||
|
accountsHint: 'Transferler dahil; başka üyelerin "yalnızca ben" hesapları gösterilmez.',
|
||||||
|
account: "Hesap",
|
||||||
|
income: "Gelir",
|
||||||
|
transferIn: "Gelen transfer",
|
||||||
|
transferOut: "Giden transfer",
|
||||||
|
net: "Net değişim",
|
||||||
|
archived: "arşivli",
|
||||||
|
},
|
||||||
|
budget: {
|
||||||
|
title: "Bütçe ve gerçekleşen",
|
||||||
|
description: (month: string) => `${month} bütçe ayı; kategori bazında limit ve gerçekleşen.`,
|
||||||
|
noBudget: "Bu ay için bütçe yok.",
|
||||||
|
category: "Kategori",
|
||||||
|
limit: "Limit",
|
||||||
|
actual: "Gerçekleşen",
|
||||||
|
remaining: "Kalan",
|
||||||
|
ratio: "Kullanım",
|
||||||
|
historyTitle: "Son 6 ay",
|
||||||
|
historyChartLabel: "Son 6 ay bütçe ve gerçekleşen sütun grafiği",
|
||||||
|
month: "Ay",
|
||||||
|
noLimit: "Bütçe yok",
|
||||||
|
covered: "üst kategoride",
|
||||||
|
},
|
||||||
|
csvFiles: {
|
||||||
|
categories: (month: string) => `butcem-kategori-dagilimi-${month}.csv`,
|
||||||
|
trend: (date: string) => `butcem-aylik-trend-${date}.csv`,
|
||||||
|
people: (month: string) => `butcem-kisi-bazli-${month}.csv`,
|
||||||
|
accounts: (month: string) => `butcem-hesap-bazli-${month}.csv`,
|
||||||
|
budget: (month: string) => `butcem-butce-gerceklesen-${month}.csv`,
|
||||||
|
},
|
||||||
|
csvHeaders: {
|
||||||
|
categories: [
|
||||||
|
"Üst kategori",
|
||||||
|
"Kategori",
|
||||||
|
"Tutar",
|
||||||
|
"Toplam gidere oran (%)",
|
||||||
|
"Önceki ay",
|
||||||
|
"Değişim",
|
||||||
|
"Değişim (%)",
|
||||||
|
],
|
||||||
|
trend: ["Ay", "Gelir", "Gider", "Net", "Tasarruf oranı (%)"],
|
||||||
|
people: ["Kişi", "Gider", "İşlem sayısı", "Oran (%)"],
|
||||||
|
accounts: [
|
||||||
|
"Hesap",
|
||||||
|
"Gelir",
|
||||||
|
"Gider",
|
||||||
|
"Gelen transfer",
|
||||||
|
"Giden transfer",
|
||||||
|
"Net değişim",
|
||||||
|
"İşlem sayısı",
|
||||||
|
],
|
||||||
|
budget: ["Kategori", "Limit", "Gerçekleşen", "Kalan", "Kullanım (%)"],
|
||||||
|
},
|
||||||
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
title: "Ana sayfa",
|
title: "Ana sayfa",
|
||||||
welcome: (name: string) => `Hoş geldin, ${name}`,
|
welcome: (name: string) => `Hoş geldin, ${name}`,
|
||||||
@@ -1875,6 +2013,23 @@ export const tr = {
|
|||||||
netWorth: "Net varlık",
|
netWorth: "Net varlık",
|
||||||
},
|
},
|
||||||
noData: "Henüz veri yok",
|
noData: "Henüz veri yok",
|
||||||
|
overview: {
|
||||||
|
title: "Gelir, bütçe ve gider",
|
||||||
|
description: (range: string) => `${range}. Bütçe ayına göre.`,
|
||||||
|
periods: { week: "Haftalık", month: "Aylık", half: "Altı aylık", year: "Yıllık" },
|
||||||
|
views: { cumulative: "Kümülatif", periodic: "Dönemsel" },
|
||||||
|
periodLabel: "Periyot",
|
||||||
|
viewLabel: "Görünüm",
|
||||||
|
series: {
|
||||||
|
plannedIncome: "Planlanan gelir",
|
||||||
|
budget: "Planlanan gider (bütçe)",
|
||||||
|
actual: "Gerçekleşen gider",
|
||||||
|
},
|
||||||
|
chartLabel: "Planlanan gelir, planlanan gider ve gerçekleşen gider çizgi grafiği",
|
||||||
|
noBudget: "Bütçesi olmayan dönemde planlanan gider çizgisi boş kalır.",
|
||||||
|
table: "Tablo olarak göster",
|
||||||
|
bucket: { day: "Gün", month: "Ay" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
adminUsers: {
|
adminUsers: {
|
||||||
title: "Kullanıcılar",
|
title: "Kullanıcılar",
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { cumulative, overviewRange, weekday } from "./overview";
|
||||||
|
|
||||||
|
describe("overviewRange", () => {
|
||||||
|
it("haftalık: Pazartesi–Pazar, bugünü içerir", () => {
|
||||||
|
// 2026-11-07 Cumartesi.
|
||||||
|
expect(weekday("2026-11-07")).toBe(5);
|
||||||
|
const r = overviewRange("week", "2026-11-07");
|
||||||
|
expect(r).toMatchObject({ granularity: "day", from: "2026-11-02", to: "2026-11-08" });
|
||||||
|
expect(r.buckets).toHaveLength(7);
|
||||||
|
// Ay sınırını aşan hafta.
|
||||||
|
expect(overviewRange("week", "2026-12-01").from).toBe("2026-11-30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aylık: bütçe ayının günleri (başlangıç günü 15)", () => {
|
||||||
|
const r = overviewRange("month", "2026-11-07", 15);
|
||||||
|
expect(r.from).toBe("2026-10-15");
|
||||||
|
expect(r.to).toBe("2026-11-14");
|
||||||
|
expect(r.buckets).toHaveLength(31);
|
||||||
|
expect(overviewRange("month", "2026-11-07").buckets).toHaveLength(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("altı aylık ve yıllık: bu ay dahil son 6 / 12 bütçe ayı", () => {
|
||||||
|
const half = overviewRange("half", "2026-11-07");
|
||||||
|
expect(half).toMatchObject({ granularity: "month", from: "2026-06-01", to: "2026-11-30" });
|
||||||
|
expect(half.buckets).toEqual([
|
||||||
|
"2026-06",
|
||||||
|
"2026-07",
|
||||||
|
"2026-08",
|
||||||
|
"2026-09",
|
||||||
|
"2026-10",
|
||||||
|
"2026-11",
|
||||||
|
]);
|
||||||
|
const year = overviewRange("year", "2026-01-20", 15);
|
||||||
|
expect(year.buckets[0]).toBe("2025-02");
|
||||||
|
expect(year.buckets.at(-1)).toBe("2026-01");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cumulative", () => {
|
||||||
|
it("seriler ayrı birikir; boş nokta boş kalır, toplamı bozmaz", () => {
|
||||||
|
const out = cumulative([
|
||||||
|
{ key: "a", plannedIncome: 100, budget: 50, actual: 10 },
|
||||||
|
{ key: "b", plannedIncome: 0, budget: null, actual: 20 },
|
||||||
|
{ key: "c", plannedIncome: 50, budget: 30, actual: null },
|
||||||
|
]);
|
||||||
|
expect(out.map((p) => [p.plannedIncome, p.budget, p.actual])).toEqual([
|
||||||
|
[100, 50, 10],
|
||||||
|
[100, null, 30],
|
||||||
|
[150, 80, null],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { budgetPeriod, daysBetween, periodOfDate, shiftMonth } from "./budget";
|
||||||
|
import { addDays } from "./recurrence";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard ana grafiği (Bölüm 6.13): Planlanan Gelir, Planlanan Gider (Bütçe), Gerçekleşen Gider.
|
||||||
|
* Saf fonksiyonlar: periyot aralığı ve kümülatif dönüşüm.
|
||||||
|
*
|
||||||
|
* Periyotlar: Haftalık (bu hafta, Pazartesi–Pazar, günlük), Aylık (içinde bulunulan bütçe ayı,
|
||||||
|
* günlük), Altı Aylık ve Yıllık (bu bütçe ayı dahil son 6 / 12 bütçe ayı, aylık).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const OVERVIEW_PERIODS = ["week", "month", "half", "year"] as const;
|
||||||
|
export type OverviewPeriod = (typeof OVERVIEW_PERIODS)[number];
|
||||||
|
|
||||||
|
export type OverviewRange = {
|
||||||
|
granularity: "day" | "month";
|
||||||
|
/** Günlükte "YYYY-MM-DD", aylıkta bütçe ayı "YYYY-MM". */
|
||||||
|
buckets: string[];
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "YYYY-MM-DD" → haftanın günü (0 = Pazartesi … 6 = Pazar). */
|
||||||
|
export function weekday(ymd: string): number {
|
||||||
|
const d = new Date(`${ymd}T12:00:00Z`).getUTCDay();
|
||||||
|
return (d + 6) % 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overviewRange(period: OverviewPeriod, today: string, startDay = 1): OverviewRange {
|
||||||
|
if (period === "week") {
|
||||||
|
const from = addDays(today, -weekday(today));
|
||||||
|
const buckets = Array.from({ length: 7 }, (_, i) => addDays(from, i));
|
||||||
|
return { granularity: "day", buckets, from, to: buckets[6]! };
|
||||||
|
}
|
||||||
|
const current = periodOfDate(today, startDay);
|
||||||
|
if (period === "month") {
|
||||||
|
const { from, to } = budgetPeriod(current, startDay);
|
||||||
|
const buckets = Array.from({ length: daysBetween(from, to) }, (_, i) => addDays(from, i));
|
||||||
|
return { granularity: "day", buckets, from, to };
|
||||||
|
}
|
||||||
|
const n = period === "half" ? 6 : 12;
|
||||||
|
const buckets = Array.from({ length: n }, (_, i) => shiftMonth(current, i - n + 1));
|
||||||
|
return {
|
||||||
|
granularity: "month",
|
||||||
|
buckets,
|
||||||
|
from: budgetPeriod(buckets[0]!, startDay).from,
|
||||||
|
to: budgetPeriod(buckets.at(-1)!, startDay).to,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OverviewPoint = {
|
||||||
|
key: string;
|
||||||
|
plannedIncome: number | null;
|
||||||
|
budget: number | null;
|
||||||
|
actual: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SERIES = ["plannedIncome", "budget", "actual"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kümülatif görünüm: her seri kendi içinde birikir. Boş (null) nokta boş kalır ama birikime 0
|
||||||
|
* katar; böylece bütçesi olmayan ay ya da gelecek günler çizgiyi koparır, toplamı bozmaz.
|
||||||
|
*/
|
||||||
|
export function cumulative(points: readonly OverviewPoint[]): OverviewPoint[] {
|
||||||
|
const run = { plannedIncome: 0, budget: 0, actual: 0 };
|
||||||
|
return points.map((p) => {
|
||||||
|
const out: OverviewPoint = { ...p };
|
||||||
|
for (const k of SERIES) {
|
||||||
|
const v = p[k];
|
||||||
|
if (v === null) continue;
|
||||||
|
run[k] += v;
|
||||||
|
out[k] = run[k];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { categoryBreakdown, changePct, savingsRate, sharePct } from "./reports";
|
||||||
|
|
||||||
|
describe("oranlar", () => {
|
||||||
|
it("toplam gidere oran bir ondalıklı (35.000 içinde 5.000 → %14,3)", () => {
|
||||||
|
expect(sharePct(5_000_00, 35_000_00)).toBe(14.3);
|
||||||
|
expect(sharePct(1, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
it("önceki aya göre değişim; önceki 0 ise null", () => {
|
||||||
|
expect(changePct(120, 100)).toBe(20);
|
||||||
|
expect(changePct(90, 120)).toBe(-25);
|
||||||
|
expect(changePct(50, 0)).toBeNull();
|
||||||
|
});
|
||||||
|
it("tasarruf oranı; gelir yoksa null, gider gelirden fazlaysa negatif", () => {
|
||||||
|
expect(savingsRate(50_000_00, 35_000_00)).toBe(30);
|
||||||
|
expect(savingsRate(10_000_00, 12_000_00)).toBe(-20);
|
||||||
|
expect(savingsRate(0, 100)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("categoryBreakdown", () => {
|
||||||
|
const cats = [
|
||||||
|
{ id: "fatura", parentId: null, name: "Faturalar", order: 1 },
|
||||||
|
{ id: "elektrik", parentId: "fatura", name: "Elektrik", order: 1 },
|
||||||
|
{ id: "su", parentId: "fatura", name: "Su", order: 2 },
|
||||||
|
{ id: "market", parentId: null, name: "Market", order: 2 },
|
||||||
|
{ id: "gida", parentId: "market", name: "Gıda", order: 1 },
|
||||||
|
{ id: "ulasim", parentId: null, name: "Ulaşım", order: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("üst kategori alt ağacı toplar; oranlar toplam gidere göre; boşlar gizli", () => {
|
||||||
|
const current = new Map([
|
||||||
|
["elektrik", 3_000_00],
|
||||||
|
["su", 2_000_00],
|
||||||
|
["gida", 25_000_00],
|
||||||
|
["market", 5_000_00],
|
||||||
|
]);
|
||||||
|
const previous = new Map([
|
||||||
|
["elektrik", 2_500_00],
|
||||||
|
["gida", 20_000_00],
|
||||||
|
["ulasim", 1_000_00],
|
||||||
|
]);
|
||||||
|
const b = categoryBreakdown(cats, current, previous);
|
||||||
|
expect(b.totalMinor).toBe(35_000_00);
|
||||||
|
expect(b.prevTotalMinor).toBe(23_500_00);
|
||||||
|
expect(b.rows.map((r) => [r.id, r.amountMinor, r.sharePct])).toEqual([
|
||||||
|
["market", 30_000_00, 85.7],
|
||||||
|
["fatura", 5_000_00, 14.3],
|
||||||
|
// Bu ay harcama yok ama geçen ay vardı: değişimi göstermek için kalır.
|
||||||
|
["ulasim", 0, 0],
|
||||||
|
]);
|
||||||
|
const fatura = b.rows.find((r) => r.id === "fatura")!;
|
||||||
|
expect(fatura.children.map((c) => [c.id, c.amountMinor, c.changePct])).toEqual([
|
||||||
|
["elektrik", 3_000_00, 20],
|
||||||
|
["su", 2_000_00, null],
|
||||||
|
]);
|
||||||
|
const market = b.rows.find((r) => r.id === "market")!;
|
||||||
|
expect(market.ownMinor).toBe(5_000_00);
|
||||||
|
expect(market.changePct).toBe(50);
|
||||||
|
expect(b.rows.find((r) => r.id === "ulasim")!.changeMinor).toBe(-1_000_00);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Raporlar (Bölüm 6.14). Saf hesaplar: kategori dağılımı (üst kategori toplamları ve alt kategori
|
||||||
|
* kırılımı), toplam gidere oran, önceki aya göre değişim, tasarruf oranı. Tutarlar kuruş; oranlar
|
||||||
|
* bir ondalıklı yüzde (14,3).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Payın toplamdaki yüzdesi, bir ondalık (toplam 0 ise 0). */
|
||||||
|
export function sharePct(part: number, total: number): number {
|
||||||
|
if (total <= 0) return 0;
|
||||||
|
return Math.round((part * 1000) / total) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Önceki döneme göre yüzde değişim; önceki 0 ise hesaplanamaz (null). */
|
||||||
|
export function changePct(current: number, previous: number): number | null {
|
||||||
|
if (previous === 0) return null;
|
||||||
|
return Math.round(((current - previous) * 1000) / previous) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tasarruf oranı = (gelir − gider) / gelir; gelir yoksa null. */
|
||||||
|
export function savingsRate(incomeMinor: number, expenseMinor: number): number | null {
|
||||||
|
if (incomeMinor <= 0) return null;
|
||||||
|
return sharePct(incomeMinor - expenseMinor, incomeMinor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportCategory = {
|
||||||
|
id: string;
|
||||||
|
parentId: string | null;
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CategoryShare = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
depth: number;
|
||||||
|
amountMinor: number;
|
||||||
|
sharePct: number;
|
||||||
|
prevMinor: number;
|
||||||
|
changeMinor: number;
|
||||||
|
changePct: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopCategoryShare = CategoryShare & {
|
||||||
|
/** Alt kategoriler (ağaç sırasıyla, derinlik 1+); tutarları kendi tutarlarıdır. */
|
||||||
|
children: CategoryShare[];
|
||||||
|
/** Üst kategoriye doğrudan (alt kategori seçilmeden) yazılan tutar. */
|
||||||
|
ownMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CategoryBreakdown = {
|
||||||
|
totalMinor: number;
|
||||||
|
prevTotalMinor: number;
|
||||||
|
rows: TopCategoryShare[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Üst kategori toplamları (alt ağaç dahil) ve alt kategori kırılımı. Tutarı ve önceki tutarı
|
||||||
|
* sıfır olan kategoriler gösterilmez. Satırlar tutara göre büyükten küçüğe.
|
||||||
|
*/
|
||||||
|
export function categoryBreakdown(
|
||||||
|
categories: readonly ReportCategory[],
|
||||||
|
current: ReadonlyMap<string, number>,
|
||||||
|
previous: ReadonlyMap<string, number>,
|
||||||
|
): CategoryBreakdown {
|
||||||
|
const byId = new Map(categories.map((c) => [c.id, c]));
|
||||||
|
const children = new Map<string, ReportCategory[]>();
|
||||||
|
const roots: ReportCategory[] = [];
|
||||||
|
for (const c of categories) {
|
||||||
|
if (c.parentId && byId.has(c.parentId))
|
||||||
|
children.set(c.parentId, [...(children.get(c.parentId) ?? []), c]);
|
||||||
|
else roots.push(c);
|
||||||
|
}
|
||||||
|
const sorted = (list: ReportCategory[]) =>
|
||||||
|
[...list].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr"));
|
||||||
|
const totalMinor = [...current.values()].reduce((a, b) => a + b, 0);
|
||||||
|
const prevTotalMinor = [...previous.values()].reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
const share = (id: string, name: string, depth: number, cur: number, prev: number) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
depth,
|
||||||
|
amountMinor: cur,
|
||||||
|
sharePct: sharePct(cur, totalMinor),
|
||||||
|
prevMinor: prev,
|
||||||
|
changeMinor: cur - prev,
|
||||||
|
changePct: changePct(cur, prev),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows: TopCategoryShare[] = [];
|
||||||
|
for (const root of roots) {
|
||||||
|
const kids: CategoryShare[] = [];
|
||||||
|
let cur = current.get(root.id) ?? 0;
|
||||||
|
let prev = previous.get(root.id) ?? 0;
|
||||||
|
const walk = (list: ReportCategory[], depth: number, seen: Set<string>) => {
|
||||||
|
for (const c of sorted(list)) {
|
||||||
|
if (seen.has(c.id)) continue;
|
||||||
|
seen.add(c.id);
|
||||||
|
const a = current.get(c.id) ?? 0;
|
||||||
|
const p = previous.get(c.id) ?? 0;
|
||||||
|
cur += a;
|
||||||
|
prev += p;
|
||||||
|
const sub = children.get(c.id) ?? [];
|
||||||
|
const before = kids.length;
|
||||||
|
kids.push(share(c.id, c.name, depth, a, p));
|
||||||
|
walk(sub, depth + 1, seen);
|
||||||
|
// Kendisi ve altı boşsa satırı çıkar.
|
||||||
|
if (a === 0 && p === 0 && kids.length === before + 1) kids.pop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(children.get(root.id) ?? [], 1, new Set([root.id]));
|
||||||
|
if (cur === 0 && prev === 0) continue;
|
||||||
|
rows.push({
|
||||||
|
...share(root.id, root.name, 0, cur, prev),
|
||||||
|
ownMinor: current.get(root.id) ?? 0,
|
||||||
|
children: kids,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows.sort((a, b) => b.amountMinor - a.amountMinor || a.name.localeCompare(b.name, "tr"));
|
||||||
|
return { totalMinor, prevTotalMinor, rows };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user