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/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 }; +}