diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d49a70..bbefafb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [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 Faz 13: alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu ve alışveriş sonrası giriş (13a ve 13b alt fazları). diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 2d29186..ed67087 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -3,6 +3,8 @@ import Link from "next/link"; import { Suspense } from "react"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { OverviewCard } from "@/features/dashboard/components/overview-card"; import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; import { DailyPlanCard } from "@/features/budgets/components/daily-plan-card"; import { BudgetWarningBanner } from "@/features/budgets/components/warning-banner"; @@ -84,7 +86,7 @@ async function SummaryCards({ userId, householdId }: { userId: string; household ); } -export default async function DashboardPage() { +export default async function DashboardPage({ searchParams }: PageProps<"/dashboard">) { const { user, household } = await requireActiveHousehold("viewer"); return ( <> @@ -104,6 +106,9 @@ export default async function DashboardPage() { }> + }> + + diff --git a/src/app/(app)/raporlar/butce/csv/route.ts b/src/app/(app)/raporlar/butce/csv/route.ts new file mode 100644 index 0000000..00d04ea --- /dev/null +++ b/src/app/(app)/raporlar/butce/csv/route.ts @@ -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)); +} diff --git a/src/app/(app)/raporlar/butce/page.tsx b/src/app/(app)/raporlar/butce/page.tsx new file mode 100644 index 0000000..721b758 --- /dev/null +++ b/src/app/(app)/raporlar/butce/page.tsx @@ -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 ( + + + + + + + } + > + + + ); +} diff --git a/src/app/(app)/raporlar/kategoriler/csv/route.ts b/src/app/(app)/raporlar/kategoriler/csv/route.ts new file mode 100644 index 0000000..b4c26f5 --- /dev/null +++ b/src/app/(app)/raporlar/kategoriler/csv/route.ts @@ -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)); +} diff --git a/src/app/(app)/raporlar/kategoriler/page.tsx b/src/app/(app)/raporlar/kategoriler/page.tsx new file mode 100644 index 0000000..b8e279d --- /dev/null +++ b/src/app/(app)/raporlar/kategoriler/page.tsx @@ -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 ( + + + + + + + } + > + {data.current ?

{t.partialMonth}

: null} + {data.includesHidden ? ( +

{tr.reports.includesHidden}

+ ) : null} + +
+ ); +} diff --git a/src/app/(app)/raporlar/kisi-hesap/csv/route.ts b/src/app/(app)/raporlar/kisi-hesap/csv/route.ts new file mode 100644 index 0000000..f9086a9 --- /dev/null +++ b/src/app/(app)/raporlar/kisi-hesap/csv/route.ts @@ -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), + ); +} diff --git a/src/app/(app)/raporlar/kisi-hesap/page.tsx b/src/app/(app)/raporlar/kisi-hesap/page.tsx new file mode 100644 index 0000000..fea216c --- /dev/null +++ b/src/app/(app)/raporlar/kisi-hesap/page.tsx @@ -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 ( + + + + + + + + } + > + {data.includesHidden ? ( +

{tr.reports.includesHidden}

+ ) : null} + +
+ ); +} diff --git a/src/app/(app)/raporlar/page.tsx b/src/app/(app)/raporlar/page.tsx new file mode 100644 index 0000000..6a06ea7 --- /dev/null +++ b/src/app/(app)/raporlar/page.tsx @@ -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 ( + <> + +
+

+ {t.hub.thisMonth}: {monthLabel(m.month)}{" "} + ({t.trend.ongoing}) +

+
+ {stats.map((s) => ( +
+
{s.label}
+
{s.value}
+
+ ))} +
+ {top.length ? ( +
+

{t.hub.topCategories}

+
    + {top.map((r) => ( +
  • + {r.name} + + {formatMoney(r.amountMinor)}{" "} + · {formatPct(r.sharePct)} + +
  • + ))} +
+
+ ) : null} +
+ + + ); +} diff --git a/src/app/(app)/raporlar/trend/csv/route.ts b/src/app/(app)/raporlar/trend/csv/route.ts new file mode 100644 index 0000000..8bab3fe --- /dev/null +++ b/src/app/(app)/raporlar/trend/csv/route.ts @@ -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())); +} diff --git a/src/app/(app)/raporlar/trend/page.tsx b/src/app/(app)/raporlar/trend/page.tsx new file mode 100644 index 0000000..9727e27 --- /dev/null +++ b/src/app/(app)/raporlar/trend/page.tsx @@ -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 ( + + + + } + > + + + ); +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index d5d5d74..fd2012e 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -28,7 +28,7 @@ export type NavItem = { /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ -export const CURRENT_PHASE = 13.2; +export const CURRENT_PHASE = 14.1; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; @@ -44,7 +44,7 @@ export const MAIN_NAV: NavItem[] = [ { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 }, { href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 }, { href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13.1 }, - { href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 }, + { href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14.1 }, { href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 }, ]; diff --git a/src/features/budgets/components/daily-plan-view.tsx b/src/features/budgets/components/daily-plan-view.tsx index 407594e..6d840a3 100644 --- a/src/features/budgets/components/daily-plan-view.tsx +++ b/src/features/budgets/components/daily-plan-view.tsx @@ -1,17 +1,8 @@ "use client"; import Link from "next/link"; -import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart"; import { tr } from "@/i18n/tr"; import { cn } from "@/lib/utils"; import { formatDate, formatMoney } from "@/shared/format"; @@ -19,24 +10,13 @@ import type { DailyPlanView as Data } from "../plan"; const t = tr.budget.daily; const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`); -const dayFmt = new Intl.DateTimeFormat("tr-TR", { - day: "numeric", - month: "short", - timeZone: "Europe/Istanbul", -}); const monthFmt = new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric", timeZone: "Europe/Istanbul", }); -const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 }); const signed = (minor: number) => `${minor < 0 ? "−" : ""}${formatMoney(Math.abs(minor))}`; -const config = { - planned: { label: t.cumPlanned, color: "var(--budget)" }, - actual: { label: t.cumActual, color: "var(--expense)" }, -} satisfies ChartConfig; - function Stat({ label, value, @@ -57,7 +37,7 @@ function Stat({ ); } -/** Dashboard: ayın günlük planı, kümülatif grafik ve gelirle karşılaştırma. */ +/** Dashboard: ayın günlük planı ve gelirle karşılaştırma (grafik ana grafikte: Aylık). */ export function DailyPlanView({ data }: { data: Data }) { const p = data.plan; if (!data.hasBudget) { @@ -75,14 +55,6 @@ export function DailyPlanView({ data }: { data: Data }) { ); } - const chart = p.days.map((day, i) => ({ - date: day.date, - label: dayFmt.format(new Date(`${day.date}T12:00:00+03:00`)), - planned: day.cumPlannedMinor / 100, - // Gerçekleşen yalnızca bugüne kadar çizilir. - actual: p.todayIndex === -1 || i <= p.todayIndex ? day.cumActualMinor / 100 : null, - })); - const income = data.income.expectedMinor / 100; const nextDiff = data.next.budgetMinor === null ? null : data.next.expectedIncomeMinor - data.next.budgetMinor; @@ -116,82 +88,6 @@ export function DailyPlanView({ data }: { data: Data }) { /> - - - - - compact.format(v)} - /> - {income > 0 ? ( - - ) : null} - { - const date = payload?.[0]?.payload?.date as string | undefined; - return date ? d(date) : ""; - }} - formatter={(value, name) => ( -
- - {config[name as keyof typeof config]?.label} - - - {formatMoney(Math.round(Number(value) * 100))} - -
- )} - /> - } - /> - } /> - - -
-
-

{t.modes[data.mode]} {p.fixedMinor ? t.fixedNote(formatMoney(p.fixedMinor)) : ""}

diff --git a/src/features/budgets/plan.ts b/src/features/budgets/plan.ts index c17e4b6..85da0fb 100644 --- a/src/features/budgets/plan.ts +++ b/src/features/budgets/plan.ts @@ -60,7 +60,7 @@ export type IncomePlan = { manual: { id: string; name: string; amountMinor: number }[]; }; -async function incomePlan(ctx: Ctx, s: Settings, month: string): Promise { +export async function incomePlan(ctx: Ctx, s: Settings, month: string): Promise { const { from, to } = budgetPeriod(month, s.startDay); const [docs, budget] = await Promise.all([ TransactionModel.find(baseMatch(ctx, s, "income", from, to)) @@ -145,7 +145,7 @@ export async function saveIncomeItems( // --- Günlük plan --- /** 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[] = [ { $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 }; } +/** 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 = { month: string; period: { from: string; to: string }; @@ -199,6 +213,40 @@ export type DailyPlanView = { 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 { + 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ı. */ export async function getDailyPlan( userId: string, @@ -212,34 +260,14 @@ export async function getDailyPlan( const period = budgetPeriod(month, s.startDay); const nextMonth = shiftMonth(month, 1); 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), - expensesByDay(ctx, s, period.from, period.to), incomePlan(ctx, s, month), incomePlan(ctx, s, nextMonth), getBudget(userId, householdId, nextMonth), ]); if (!budget) return null; - - 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 plan = await monthDailyPlan(ctx, s, month, budget.totals.limitMinor, today); const forecastExpense = budget.totals.forecastMinor; return { month, diff --git a/src/features/budgets/service.ts b/src/features/budgets/service.ts index b22336a..fa5a63e 100644 --- a/src/features/budgets/service.ts +++ b/src/features/budgets/service.ts @@ -191,7 +191,11 @@ export async function getBudget( ): Promise { const ctx = await loadContext(userId, householdId, "viewer"); 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 { const today = todayIstanbul(); const currentMonth = periodOfDate(today, s.startDay); const m = month ?? currentMonth; diff --git a/src/features/dashboard/components/overview-card.tsx b/src/features/dashboard/components/overview-card.tsx new file mode 100644 index 0000000..e280780 --- /dev/null +++ b/src/features/dashboard/components/overview-card.tsx @@ -0,0 +1,24 @@ +import { OVERVIEW_PERIODS, type OverviewPeriod } from "@/shared/overview"; +import { getOverview } from "../service"; +import { OverviewChart } from "./overview-chart"; + +type Search = Record; + +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; +}) { + const q = await searchParams; + const data = await getOverview(userId, householdId, periodOf(q.periyot)); + if (!data) return null; + return ; +} diff --git a/src/features/dashboard/components/overview-chart.tsx b/src/features/dashboard/components/overview-chart.tsx new file mode 100644 index 0000000..23ce3a5 --- /dev/null +++ b/src/features/dashboard/components/overview-chart.tsx @@ -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 ( + + +
+ {t.title} + {t.description(rangeText)} +
+
+ + +
+
+ + + + + + compact.format(v)} + /> + { + 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) => ( +
+ + {config[name as keyof typeof config]?.label} + + + {formatMoney(Math.round(Number(value) * 100))} + +
+ )} + /> + } + /> + } /> + {SERIES.map((k) => ( + + ))} +
+
+ {hasGap ?

{t.noBudget}

: null} +
+ {t.table} +
+ + + + + {SERIES.map((k) => ( + + ))} + + + + {points.map((p) => ( + + + {SERIES.map((k) => ( + + ))} + + ))} + +
{t.bucket[data.range.granularity]} + {t.series[k]} +
+ {p.key.length === 7 + ? monthLong.format(asDate(p.key)) + : formatDate(asDate(p.key))} + + {p[k] === null ? "—" : formatMoney(p[k])} +
+
+
+
+
+ ); +} diff --git a/src/features/dashboard/service.ts b/src/features/dashboard/service.ts new file mode 100644 index 0000000..cebd81d --- /dev/null +++ b/src/features/dashboard/service.ts @@ -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 { + 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(); + const actual = new Map(); + const income = new Map(); + 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 }; +} diff --git a/src/features/reports/components/budget-report-view.tsx b/src/features/reports/components/budget-report-view.tsx new file mode 100644 index 0000000..3bb3afe --- /dev/null +++ b/src/features/reports/components/budget-report-view.tsx @@ -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 = { + 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 ( +
+ {!hasBudget ?

{t.noBudget}

: null} +
+ + + + + + + + + + + + {data.lines.map((l) => ( + + + + + + + + ))} + + + + + + + + + + +
{t.category}{t.limit}{t.actual}{t.remaining}{t.ratio}
+ {l.name} + + {l.limitMinor === null ? ( + + {l.covered ? t.covered : "—"} + + ) : ( + formatMoney(l.limitMinor) + )} + {formatMoney(l.actualMinor)} + {l.remainingMinor === null + ? "—" + : `${l.remainingMinor < 0 ? "−" : ""}${formatMoney(Math.abs(l.remainingMinor))}`} + + {l.ratioBp === null ? ( + — + ) : ( + + + + + {ratio(l.ratioBp)} + + )} +
{tr.reports.categories.total} + {hasBudget ? formatMoney(totals.limitMinor) : "—"} + {formatMoney(totals.actualMinor)} + {hasBudget + ? `${totals.remainingMinor < 0 ? "−" : ""}${formatMoney(Math.abs(totals.remainingMinor))}` + : "—"} + {ratio(totals.ratioBp)}
+
+ +
+

+ {t.historyTitle} +

+ + + + + compact.format(v)} + /> + { + const m = payload?.[0]?.payload?.month as string | undefined; + return m ? monthLabel(m) : ""; + }} + formatter={(value, name) => ( +
+ + {config[name as keyof typeof config]?.label} + + + {formatMoney(Math.round(Number(value) * 100))} + +
+ )} + /> + } + /> + } /> + + +
+
+ + + + + + + + + + + {data.history.map((h) => ( + + + + + + + ))} + +
{t.month}{t.limit}{t.actual}{t.ratio}
{monthLabel(h.month)} + {h.limitMinor === null ? t.noLimit : formatMoney(h.limitMinor)} + {formatMoney(h.actualMinor)}{ratio(h.ratioBp)}
+
+
+ ); +} diff --git a/src/features/reports/components/categories-view.tsx b/src/features/reports/components/categories-view.tsx new file mode 100644 index 0000000..5da1e8d --- /dev/null +++ b/src/features/reports/components/categories-view.tsx @@ -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 ( +

+ {tr.reports.noData} +

+ ); + + return ( +
+
+
+ + + ( +
+ {item.payload.name} + + {formatMoney(Math.round(Number(value) * 100))} ·{" "} + {formatPct(item.payload.share)} + +
+ )} + /> + } + /> + + {slices.map((s) => ( + + ))} + +
+
+
+ {t.total} + + {formatMoney(breakdown.totalMinor)} + +
+
+
    + {slices.map((s) => ( +
  • + + {s.name} + {formatPct(s.share)} +
  • + ))} +
+
+ +
+ + + + + + + + + + + + {breakdown.rows.map((r) => { + const a = appearance[r.id]; + return [ + + + + + + + , + ...(r.children.length && r.ownMinor + ? [ + + + + , + ] + : []), + ...r.children.map((c) => ( + + + + + + + + )), + ]; + })} + + + + + + + + + + +
{t.category}{t.amount}{t.share}{t.previous}{t.change}
+ + {a ? : null} + {r.name} + + + {formatMoney(r.amountMinor)} + {formatPct(r.sharePct)} + {formatMoney(r.prevMinor)} + + +
{t.own} + {formatMoney(r.ownMinor)} + +
1 ? "pl-14" : "pl-10")}>{c.name} + {formatMoney(c.amountMinor)} + + {formatPct(c.sharePct)} + + {formatMoney(c.prevMinor)} + + +
{t.total} + {formatMoney(breakdown.totalMinor)} + %100 + {formatMoney(breakdown.prevTotalMinor)} + + +
+
+
+ ); +} diff --git a/src/features/reports/components/people-view.tsx b/src/features/reports/components/people-view.tsx new file mode 100644 index 0000000..549a867 --- /dev/null +++ b/src/features/reports/components/people-view.tsx @@ -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 ( +
+
+
+

+ {t.peopleTitle} +

+

{t.peopleHint}

+
+ {data.people.length ? ( +
    + {data.people.map((p) => ( +
  • +
    + {p.name} + + {p.count} {t.count.toLocaleLowerCase("tr-TR")} + + + {formatMoney(p.expenseMinor)} + + + {formatPct(p.sharePct)} + +
    +
    +
    +
    +
  • + ))} +
  • + {tr.reports.categories.total} + {formatMoney(data.peopleTotalMinor)} +
  • +
+ ) : ( +

+ {tr.reports.noData} +

+ )} +
+ +
+
+

+ {t.accountsTitle} +

+

{t.accountsHint}

+
+
+ + + + + + + + + + + + + + {data.accounts.map((a) => ( + + + + + + + + + + ))} + +
{t.account}{t.income}{t.expense}{t.transferIn}{t.transferOut}{t.net}{t.count}
+ + {a.name} + {a.archived ? {t.archived} : null} + + {formatMoney(a.incomeMinor)}{formatMoney(a.expenseMinor)}{formatMoney(a.transferInMinor)}{formatMoney(a.transferOutMinor)} 0 ? "text-income" : "", + )} + > + {signed(a.netMinor)} + {a.count}
+
+
+
+ ); +} diff --git a/src/features/reports/components/report-frame.tsx b/src/features/reports/components/report-frame.tsx new file mode 100644 index 0000000..8eaf5c3 --- /dev/null +++ b/src/features/reports/components/report-frame.tsx @@ -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 ( + + ); +} + +/** 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 ( +
+ + + {tools ?
{tools}
: null} + {children} +
+ ); +} + +export function CsvButton({ href, label }: { href: string; label?: string }) { + return ( + + ); +} + +/** Araç çubuğunun sağ tarafı (CSV düğmeleri). */ +export function ToolsEnd({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +/** İş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 0; + const up = minor > 0; + return ( + + {up ? "+" : "−"} + {format(Math.abs(minor))} + {pct !== null ? ` (${up ? "+" : "−"}%${Math.abs(pct).toLocaleString("tr-TR")})` : ""} + + ); +} diff --git a/src/features/reports/components/trend-view.tsx b/src/features/reports/components/trend-view.tsx new file mode 100644 index 0000000..a24aece --- /dev/null +++ b/src/features/reports/components/trend-view.tsx @@ -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 = ( + { + const m = payload?.[0]?.payload?.month as string | undefined; + return m ? monthLabel(m) : ""; + }} + formatter={(value, name) => ( +
+ + {barConfig[name as keyof typeof barConfig]?.label} + + + {formatMoney(Math.round(Number(value) * 100))} + +
+ )} + /> + ); + + return ( +
+
+ + + + + compact.format(v)} + /> + + } /> + + + + +
+ +
+

{t.rateTitle}

+

+ {t.rateHint} {t.partialHint} +

+ + + + + `%${v}`} + /> + + { + const m = payload?.[0]?.payload?.month as string | undefined; + return m ? monthLabel(m) : ""; + }} + formatter={(value) => ( + + {formatPct(Number(value))} + + )} + /> + } + /> + + + +
+ +
+ + + + + + + + + + + + {[...data.months].reverse().map((m) => ( + + + + + + + + ))} + + + + + + + + + + +
{t.month}{t.income}{t.expense}{t.net}{t.savings}
+ {monthLabel(m.month)} + {m.month === data.currentMonth ? ( + ({t.ongoing}) + ) : null} + {formatMoney(m.incomeMinor)}{formatMoney(m.expenseMinor)} + {m.netMinor < 0 ? "−" : ""} + {formatMoney(Math.abs(m.netMinor))} + {formatPct(m.savingsPct)}
{t.total}{formatMoney(data.totals.incomeMinor)}{formatMoney(data.totals.expenseMinor)} + {data.totals.netMinor < 0 ? "−" : ""} + {formatMoney(Math.abs(data.totals.netMinor))} + {formatPct(data.totals.savingsPct)}
+
+
+ ); +} diff --git a/src/features/reports/csv.ts b/src/features/reports/csv.ts new file mode 100644 index 0000000..5b7eae8 --- /dev/null +++ b/src/features/reports/csv.ts @@ -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(".", ",")); diff --git a/src/features/reports/service.int.test.ts b/src/features/reports/service.int.test.ts new file mode 100644 index 0000000..106f093 --- /dev/null +++ b/src/features/reports/service.int.test.ts @@ -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>; +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(); + }); +}); diff --git a/src/features/reports/service.ts b/src/features/reports/service.ts new file mode 100644 index 0000000..166c90f --- /dev/null +++ b/src/features/reports/service.ts @@ -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 { + 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(); + 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; + includesHidden: boolean; +}; + +export async function getCategoryReport( + userId: string, + householdId: string, + month?: string, +): Promise { + 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 { + 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 { + 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(); + 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(); + 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>(); + 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 { + 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, + })), + }; +} diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 4a36f6d..472c9f0 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -1862,6 +1862,144 @@ export const tr = { identifierRequired: "Kullanıcı adı veya e-posta 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: { title: "Ana sayfa", welcome: (name: string) => `Hoş geldin, ${name}`, @@ -1875,6 +2013,23 @@ export const tr = { netWorth: "Net varlık", }, 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: { title: "Kullanıcılar", diff --git a/src/shared/overview.test.ts b/src/shared/overview.test.ts new file mode 100644 index 0000000..1817cbe --- /dev/null +++ b/src/shared/overview.test.ts @@ -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], + ]); + }); +}); diff --git a/src/shared/overview.ts b/src/shared/overview.ts new file mode 100644 index 0000000..e13e8a3 --- /dev/null +++ b/src/shared/overview.ts @@ -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; + }); +} diff --git a/src/shared/reports.test.ts b/src/shared/reports.test.ts new file mode 100644 index 0000000..54a24b0 --- /dev/null +++ b/src/shared/reports.test.ts @@ -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); + }); +}); diff --git a/src/shared/reports.ts b/src/shared/reports.ts new file mode 100644 index 0000000..76ed794 --- /dev/null +++ b/src/shared/reports.ts @@ -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, + previous: ReadonlyMap, +): CategoryBreakdown { + const byId = new Map(categories.map((c) => [c.id, c])); + const children = new Map(); + 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) => { + 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 }; +}