feat(reports): rapor ve dashboard grafiği motorları
- periyot aralıkları (haftalık, aylık, altı aylık, yıllık) ve kümülatif dönüşüm - kategori payı, alt kategori kırılımı, önceki aya göre değişim, tasarruf oranı - kategori, trend, kişi/hesap ve bütçe raporu servisleri; gizli hesap kuralları; testler Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import "server-only";
|
||||
import { budgetFor, settingsOf } from "@/features/budgets/service";
|
||||
import { incomeByDay, incomePlan, monthDailyPlan } from "@/features/budgets/plan";
|
||||
import { loadContext } from "@/features/transactions/service";
|
||||
import { budgetPeriod, periodOfDate } from "@/shared/budget";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import {
|
||||
overviewRange,
|
||||
type OverviewPeriod,
|
||||
type OverviewPoint,
|
||||
type OverviewRange,
|
||||
} from "@/shared/overview";
|
||||
|
||||
/**
|
||||
* Dashboard ana grafiği (Bölüm 6.13). Bütçe ve gelir planı motorlarıyla aynı kurallar: bütçe ayı,
|
||||
* taksit modu ve gizli hesap ayarı.
|
||||
*
|
||||
* Aylık noktalar: beklenen gelir (gerçekleşen + planlı + elle), bütçe toplamı (bütçe yoksa boş),
|
||||
* gerçekleşen gider. Günlük noktalar: o günün gelirleri (elle eklenen beklenen gelir dönemin ilk
|
||||
* gününe), günlük plan payı, o günün gerçekleşen gideri; gelecek günlerde gerçekleşen boş.
|
||||
*/
|
||||
export type OverviewData = {
|
||||
period: OverviewPeriod;
|
||||
range: OverviewRange;
|
||||
points: OverviewPoint[];
|
||||
today: string;
|
||||
};
|
||||
|
||||
export async function getOverview(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
period: OverviewPeriod,
|
||||
): Promise<OverviewData | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const s = await settingsOf(ctx.m.householdId);
|
||||
const today = todayIstanbul();
|
||||
const range = overviewRange(period, today, s.startDay);
|
||||
|
||||
if (range.granularity === "month") {
|
||||
const points = await Promise.all(
|
||||
range.buckets.map(async (month) => {
|
||||
const [budget, income] = await Promise.all([
|
||||
budgetFor(ctx, s, month),
|
||||
incomePlan(ctx, s, month),
|
||||
]);
|
||||
return {
|
||||
key: month,
|
||||
plannedIncome: income.expectedMinor,
|
||||
budget: budget.hasBudget ? budget.totals.limitMinor : null,
|
||||
actual: budget.totals.actualMinor,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return { period, range, points, today };
|
||||
}
|
||||
|
||||
// Günlük: aralığın dokunduğu bütçe ayları (hafta iki aya yayılabilir).
|
||||
const months = [...new Set([range.from, range.to].map((d) => periodOfDate(d, s.startDay)))];
|
||||
const planned = new Map<string, number | null>();
|
||||
const actual = new Map<string, number>();
|
||||
const income = new Map<string, number>();
|
||||
for (const month of months) {
|
||||
const [budget, plan] = await Promise.all([budgetFor(ctx, s, month), incomePlan(ctx, s, month)]);
|
||||
const dp = await monthDailyPlan(ctx, s, month, budget.totals.limitMinor, today);
|
||||
for (const d of dp.days) {
|
||||
planned.set(d.date, budget.hasBudget ? d.plannedMinor : null);
|
||||
actual.set(d.date, d.actualMinor);
|
||||
}
|
||||
const first = budgetPeriod(month, s.startDay).from;
|
||||
if (plan.manualMinor) income.set(first, (income.get(first) ?? 0) + plan.manualMinor);
|
||||
}
|
||||
for (const [d, v] of await incomeByDay(ctx, s, range.from, range.to))
|
||||
income.set(d, (income.get(d) ?? 0) + v);
|
||||
|
||||
const points = range.buckets.map((d) => ({
|
||||
key: d,
|
||||
plannedIncome: income.get(d) ?? 0,
|
||||
budget: planned.get(d) ?? null,
|
||||
actual: d <= today ? (actual.get(d) ?? 0) : null,
|
||||
}));
|
||||
return { period, range, points, today };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import "server-only";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { getHouseholdContext } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
/** CSV rotaları için ortak: oturum + aktif hane, ?ay= doğrulaması ve yanıt üretimi. */
|
||||
export async function csvContext(request: Request) {
|
||||
const ctx = await getHouseholdContext();
|
||||
if (!ctx?.household) return null;
|
||||
const params = new URL(request.url).searchParams;
|
||||
const ay = budgetMonthSchema.safeParse(params.get("ay"));
|
||||
return {
|
||||
userId: ctx.user.id,
|
||||
householdId: ctx.household.id,
|
||||
month: ay.success ? ay.data : undefined,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export const unauthorized = () => new Response(tr.common.unauthorized, { status: 401 });
|
||||
export const forbidden = () => new Response(tr.common.unauthorized, { status: 403 });
|
||||
|
||||
export function csvResponse(csv: string, filename: string) {
|
||||
return new Response(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 14,3 → "14,3" (CSV ondalık virgül). */
|
||||
export const csvPct = (n: number | null) => (n === null ? "" : String(n).replace(".", ","));
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getBudget } from "@/features/budgets/service";
|
||||
import { getOverview } from "@/features/dashboard/service";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
getBudgetReport,
|
||||
getCategoryReport,
|
||||
getPeopleAccountsReport,
|
||||
getTrendReport,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
describe("kategori dağılımı", () => {
|
||||
it("toplam bütçenin gerçekleşeniyle aynı; oranlar ~%100; başka hane göremez", async () => {
|
||||
const r = (await getCategoryReport(ayse, cakir))!;
|
||||
const budget = (await getBudget(ayse, cakir))!;
|
||||
expect(r.breakdown.totalMinor).toBe(budget.totals.actualMinor);
|
||||
expect(r.breakdown.totalMinor).toBeGreaterThan(0);
|
||||
const sum = r.breakdown.rows.reduce((a, x) => a + x.amountMinor, 0);
|
||||
expect(sum).toBe(r.breakdown.totalMinor);
|
||||
const pct = r.breakdown.rows.reduce((a, x) => a + x.sharePct, 0);
|
||||
expect(Math.abs(pct - 100)).toBeLessThan(1);
|
||||
// Alt kategori kırılımı: çocukların ve doğrudan tutarın toplamı üst satıra eşit.
|
||||
for (const row of r.breakdown.rows) {
|
||||
const kids = row.children.reduce((a, c) => a + c.amountMinor, 0);
|
||||
expect(kids + row.ownMinor).toBe(row.amountMinor);
|
||||
}
|
||||
expect(r.appearance[r.breakdown.rows[0]!.id]).toBeDefined();
|
||||
expect(await getCategoryReport(fatma, cakir)).toBeNull();
|
||||
});
|
||||
|
||||
it("önceki ay seçilebilir", async () => {
|
||||
const cur = (await getCategoryReport(ayse, cakir))!;
|
||||
const prev = (await getCategoryReport(ayse, cakir, cur.previous))!;
|
||||
expect(prev.current).toBe(false);
|
||||
expect(prev.breakdown.totalMinor).toBe(cur.breakdown.prevTotalMinor);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aylık trend", () => {
|
||||
it("12 ay; içinde bulunulan ayın gideri bütçeyle aynı; tasarruf oranı tutarlı", async () => {
|
||||
const r = (await getTrendReport(ayse, cakir))!;
|
||||
expect(r.months).toHaveLength(12);
|
||||
const last = r.months.at(-1)!;
|
||||
expect(last.month).toBe(r.currentMonth);
|
||||
expect(last.expenseMinor).toBe((await getBudget(ayse, cakir))!.totals.actualMinor);
|
||||
for (const m of r.months) {
|
||||
expect(m.netMinor).toBe(m.incomeMinor - m.expenseMinor);
|
||||
if (m.incomeMinor === 0) expect(m.savingsPct).toBeNull();
|
||||
}
|
||||
expect(r.months.filter((m) => m.incomeMinor > 0).length).toBeGreaterThan(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("kişi ve hesap bazlı", () => {
|
||||
it("kişi toplamları ve oranları; gizli hesap yalnızca sahibine görünür", async () => {
|
||||
const own = (await getPeopleAccountsReport(ayse, cakir))!;
|
||||
expect(own.people.reduce((a, p) => a + p.expenseMinor, 0)).toBe(own.peopleTotalMinor);
|
||||
expect(own.people.every((p) => p.name !== "—")).toBe(true);
|
||||
expect(own.accounts.map((a) => a.name)).toContain("Ayşe — Pluxee");
|
||||
for (const a of own.accounts)
|
||||
expect(a.netMinor).toBe(
|
||||
a.incomeMinor + a.transferInMinor - a.expenseMinor - a.transferOutMinor,
|
||||
);
|
||||
const other = (await getPeopleAccountsReport(zeynep, cakir))!;
|
||||
expect(other.accounts.map((a) => a.name)).not.toContain("Ayşe — Pluxee");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bütçe ve gerçekleşen", () => {
|
||||
it("son 6 ay; son satır seçilen ay", async () => {
|
||||
const r = (await getBudgetReport(ayse, cakir))!;
|
||||
expect(r.history).toHaveLength(6);
|
||||
expect(r.history.at(-1)!.month).toBe(r.month);
|
||||
expect(r.history.at(-1)!.actualMinor).toBe(r.budget.totals.actualMinor);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard ana grafik", () => {
|
||||
it("periyotlar: hafta 7 gün, ay günleri, 6 ve 12 ay; gelecek günlerde gerçekleşen boş", async () => {
|
||||
const week = (await getOverview(ayse, cakir, "week"))!;
|
||||
expect(week.points).toHaveLength(7);
|
||||
expect(week.points.filter((p) => p.key > week.today).every((p) => p.actual === null)).toBe(
|
||||
true,
|
||||
);
|
||||
const month = (await getOverview(ayse, cakir, "month"))!;
|
||||
const budget = (await getBudget(ayse, cakir))!;
|
||||
const actual = month.points.reduce((a, p) => a + (p.actual ?? 0), 0);
|
||||
expect(actual).toBe(budget.totals.actualMinor);
|
||||
if (budget.hasBudget) {
|
||||
const planned = month.points.reduce((a, p) => a + (p.budget ?? 0), 0);
|
||||
expect(planned).toBe(budget.totals.limitMinor);
|
||||
}
|
||||
const half = (await getOverview(ayse, cakir, "half"))!;
|
||||
expect(half.points).toHaveLength(6);
|
||||
expect(half.points.at(-1)!.actual).toBe(budget.totals.actualMinor);
|
||||
expect((await getOverview(ayse, cakir, "year"))!.points).toHaveLength(12);
|
||||
expect(await getOverview(fatma, cakir, "month")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
import "server-only";
|
||||
import { Types } from "mongoose";
|
||||
import { incomePlan } from "@/features/budgets/plan";
|
||||
import {
|
||||
budgetFor,
|
||||
expenseAmounts,
|
||||
settingsOf,
|
||||
type BudgetLineView,
|
||||
type BudgetView,
|
||||
type Settings,
|
||||
} from "@/features/budgets/service";
|
||||
import { loadContext, userNames, type Ctx } from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { AccountType } from "@/shared/accounts";
|
||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||
import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget";
|
||||
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||
import { categoryBreakdown, savingsRate, sharePct, type CategoryBreakdown } from "@/shared/reports";
|
||||
|
||||
/**
|
||||
* Raporlar (Bölüm 6.14). Okuma: viewer. "Ay" bütçe ayıdır (hane ayarı); gider kuralları bütçeyle
|
||||
* aynı: gerçekleşmiş işlemler, bölünmüş işlemler kalem bazında, taksit modu ve başkasının
|
||||
* "yalnızca ben" hesabındaki giderler hane ayarına göre (yalnızca tutar). Başkasının gizli
|
||||
* hesabı hesap raporunda hiç görünmez.
|
||||
*/
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const ORPHAN = "__none__";
|
||||
|
||||
type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string };
|
||||
|
||||
async function base(userId: string, householdId: string): Promise<Base | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const s = await settingsOf(ctx.m.householdId);
|
||||
const today = todayIstanbul();
|
||||
return { ctx, s, today, currentMonth: periodOfDate(today, s.startDay) };
|
||||
}
|
||||
|
||||
/** Kategori bazında gerçekleşen gider (kendi tutarı); kategorisiz/bilinmeyen tek satırda. */
|
||||
async function actualByCategory(b: Base, month: string) {
|
||||
const { from, to } = budgetPeriod(month, b.s.startDay);
|
||||
const amounts = await expenseAmounts(b.ctx, b.s, from, to);
|
||||
const out = new Map<string, number>();
|
||||
for (const [id, a] of amounts) {
|
||||
const v = a.fixedMinor + a.variableMinor;
|
||||
if (!v) continue;
|
||||
const known = id && b.ctx.categoryMap.get(id)?.kind === "expense";
|
||||
const key = known ? id : ORPHAN;
|
||||
out.set(key, (out.get(key) ?? 0) + v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type MonthInfo = {
|
||||
month: string;
|
||||
from: string;
|
||||
to: string;
|
||||
current: boolean;
|
||||
currentMonth: string;
|
||||
};
|
||||
|
||||
const monthInfo = (b: Base, month: string): MonthInfo => ({
|
||||
month,
|
||||
...budgetPeriod(month, b.s.startDay),
|
||||
current: month === b.currentMonth,
|
||||
currentMonth: b.currentMonth,
|
||||
});
|
||||
|
||||
// --- Kategori dağılımı ---
|
||||
|
||||
export type CategoryReport = MonthInfo & {
|
||||
previous: string;
|
||||
breakdown: CategoryBreakdown;
|
||||
appearance: Record<string, { icon: IconKey; color: Swatch; path: string }>;
|
||||
includesHidden: boolean;
|
||||
};
|
||||
|
||||
export async function getCategoryReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
month?: string,
|
||||
): Promise<CategoryReport | null> {
|
||||
const b = await base(userId, householdId);
|
||||
if (!b) return null;
|
||||
const m = month ?? b.currentMonth;
|
||||
const prev = shiftMonth(m, -1);
|
||||
const [current, previous] = await Promise.all([
|
||||
actualByCategory(b, m),
|
||||
actualByCategory(b, prev),
|
||||
]);
|
||||
const cats = b.ctx.flatCategories
|
||||
.filter((c) => c.kind === "expense")
|
||||
.map((c) => ({ id: c.id, parentId: c.parentId, name: c.name, order: c.order }));
|
||||
cats.push({ id: ORPHAN, parentId: null, name: tr.reports.uncategorized, order: 1e9 });
|
||||
const appearance: CategoryReport["appearance"] = {
|
||||
[ORPHAN]: { icon: "tag", color: "slate", path: tr.reports.uncategorized },
|
||||
};
|
||||
for (const c of b.ctx.categoryMap.values())
|
||||
if (c.kind === "expense")
|
||||
appearance[String(c.id)] = { icon: c.icon, color: c.color, path: c.path };
|
||||
return {
|
||||
...monthInfo(b, m),
|
||||
previous: prev,
|
||||
breakdown: categoryBreakdown(cats, current, previous),
|
||||
appearance,
|
||||
includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Aylık trend ---
|
||||
|
||||
export type TrendRow = {
|
||||
month: string;
|
||||
incomeMinor: number;
|
||||
expenseMinor: number;
|
||||
netMinor: number;
|
||||
savingsPct: number | null;
|
||||
};
|
||||
|
||||
export type TrendReport = {
|
||||
months: TrendRow[];
|
||||
totals: {
|
||||
incomeMinor: number;
|
||||
expenseMinor: number;
|
||||
netMinor: number;
|
||||
savingsPct: number | null;
|
||||
};
|
||||
currentMonth: string;
|
||||
};
|
||||
|
||||
/** Son `count` bütçe ayı (içinde bulunulan dahil): gerçekleşen gelir, gider, net, tasarruf oranı. */
|
||||
export async function getTrendReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
count = 12,
|
||||
): Promise<TrendReport | null> {
|
||||
const b = await base(userId, householdId);
|
||||
if (!b) return null;
|
||||
const months = Array.from({ length: count }, (_, i) => shiftMonth(b.currentMonth, i - count + 1));
|
||||
const rows = await Promise.all(
|
||||
months.map(async (month) => {
|
||||
const [income, expense] = await Promise.all([
|
||||
incomePlan(b.ctx, b.s, month),
|
||||
actualByCategory(b, month),
|
||||
]);
|
||||
const incomeMinor = income.actualMinor;
|
||||
const expenseMinor = [...expense.values()].reduce((a, v) => a + v, 0);
|
||||
return {
|
||||
month,
|
||||
incomeMinor,
|
||||
expenseMinor,
|
||||
netMinor: incomeMinor - expenseMinor,
|
||||
savingsPct: savingsRate(incomeMinor, expenseMinor),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const incomeMinor = rows.reduce((a, r) => a + r.incomeMinor, 0);
|
||||
const expenseMinor = rows.reduce((a, r) => a + r.expenseMinor, 0);
|
||||
return {
|
||||
months: rows,
|
||||
totals: {
|
||||
incomeMinor,
|
||||
expenseMinor,
|
||||
netMinor: incomeMinor - expenseMinor,
|
||||
savingsPct: savingsRate(incomeMinor, expenseMinor),
|
||||
},
|
||||
currentMonth: b.currentMonth,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Kişi ve hesap bazlı ---
|
||||
|
||||
export type PersonRow = {
|
||||
userId: string;
|
||||
name: string;
|
||||
expenseMinor: number;
|
||||
count: number;
|
||||
sharePct: number;
|
||||
};
|
||||
|
||||
export type AccountRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: AccountType;
|
||||
archived: boolean;
|
||||
incomeMinor: number;
|
||||
expenseMinor: number;
|
||||
transferInMinor: number;
|
||||
transferOutMinor: number;
|
||||
netMinor: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type PeopleAccountsReport = MonthInfo & {
|
||||
people: PersonRow[];
|
||||
peopleTotalMinor: number;
|
||||
accounts: AccountRow[];
|
||||
includesHidden: boolean;
|
||||
};
|
||||
|
||||
type TxLean = {
|
||||
type: "income" | "expense" | "transfer";
|
||||
amountMinor: number;
|
||||
accountId: Types.ObjectId | null;
|
||||
toAccountId: Types.ObjectId | null;
|
||||
createdBy: Types.ObjectId;
|
||||
};
|
||||
|
||||
export async function getPeopleAccountsReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
month?: string,
|
||||
): Promise<PeopleAccountsReport | null> {
|
||||
const b = await base(userId, householdId);
|
||||
if (!b) return null;
|
||||
const m = month ?? b.currentMonth;
|
||||
const info = monthInfo(b, m);
|
||||
const docs = await TransactionModel.find({
|
||||
householdId: b.ctx.m.householdId,
|
||||
deletedAt: null,
|
||||
status: "posted",
|
||||
date: {
|
||||
$gte: istanbulDayStart(info.from)!,
|
||||
$lt: new Date(istanbulDayStart(info.to)!.getTime() + DAY_MS),
|
||||
},
|
||||
})
|
||||
.select({ type: 1, amountMinor: 1, accountId: 1, toAccountId: 1, createdBy: 1 })
|
||||
.lean<TxLean[]>();
|
||||
const hidden = new Set(b.ctx.hiddenAccountIds.map(String));
|
||||
|
||||
// Kişi: kaydı giren üye; gizli hesap giderleri hane ayarına göre (yalnızca tutar).
|
||||
const people = new Map<string, { expense: number; count: number }>();
|
||||
for (const d of docs) {
|
||||
if (d.type !== "expense") continue;
|
||||
if (d.accountId && hidden.has(String(d.accountId)) && !b.s.includePrivate) continue;
|
||||
const key = String(d.createdBy);
|
||||
const p = people.get(key) ?? { expense: 0, count: 0 };
|
||||
p.expense += d.amountMinor;
|
||||
p.count++;
|
||||
people.set(key, p);
|
||||
}
|
||||
const names = await userNames([...people.keys()].map((id) => new Types.ObjectId(id)));
|
||||
const peopleTotal = [...people.values()].reduce((a, p) => a + p.expense, 0);
|
||||
|
||||
// Hesap: başkasının gizli hesabı hiç görünmez.
|
||||
const acc = new Map<string, Omit<AccountRow, "id" | "name" | "type" | "archived" | "netMinor">>();
|
||||
const bump = (
|
||||
id: Types.ObjectId | null,
|
||||
k: "incomeMinor" | "expenseMinor" | "transferInMinor" | "transferOutMinor",
|
||||
v: number,
|
||||
) => {
|
||||
if (!id || hidden.has(String(id))) return;
|
||||
const key = String(id);
|
||||
const row = acc.get(key) ?? {
|
||||
incomeMinor: 0,
|
||||
expenseMinor: 0,
|
||||
transferInMinor: 0,
|
||||
transferOutMinor: 0,
|
||||
count: 0,
|
||||
};
|
||||
row[k] += v;
|
||||
row.count++;
|
||||
acc.set(key, row);
|
||||
};
|
||||
for (const d of docs) {
|
||||
if (d.type === "income") bump(d.accountId, "incomeMinor", d.amountMinor);
|
||||
else if (d.type === "expense") bump(d.accountId, "expenseMinor", d.amountMinor);
|
||||
else {
|
||||
bump(d.accountId, "transferOutMinor", d.amountMinor);
|
||||
bump(d.toAccountId, "transferInMinor", d.amountMinor);
|
||||
}
|
||||
}
|
||||
const accounts: AccountRow[] = [];
|
||||
for (const a of b.ctx.accountMap.values()) {
|
||||
if (a.hidden) continue;
|
||||
const row = acc.get(String(a.id));
|
||||
if (!row && a.archived) continue;
|
||||
const r = row ?? {
|
||||
incomeMinor: 0,
|
||||
expenseMinor: 0,
|
||||
transferInMinor: 0,
|
||||
transferOutMinor: 0,
|
||||
count: 0,
|
||||
};
|
||||
accounts.push({
|
||||
id: String(a.id),
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
archived: a.archived,
|
||||
...r,
|
||||
netMinor: r.incomeMinor + r.transferInMinor - r.expenseMinor - r.transferOutMinor,
|
||||
});
|
||||
}
|
||||
accounts.sort((x, y) => y.count - x.count || x.name.localeCompare(y.name, "tr"));
|
||||
|
||||
return {
|
||||
...info,
|
||||
people: [...people.entries()]
|
||||
.map(([id, p]) => ({
|
||||
userId: id,
|
||||
name: names.get(id) ?? "—",
|
||||
expenseMinor: p.expense,
|
||||
count: p.count,
|
||||
sharePct: sharePct(p.expense, peopleTotal),
|
||||
}))
|
||||
.sort((x, y) => y.expenseMinor - x.expenseMinor),
|
||||
peopleTotalMinor: peopleTotal,
|
||||
accounts,
|
||||
includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Bütçe vs. gerçekleşen ---
|
||||
|
||||
export type BudgetHistoryRow = {
|
||||
month: string;
|
||||
limitMinor: number | null;
|
||||
actualMinor: number;
|
||||
ratioBp: number | null;
|
||||
};
|
||||
|
||||
export type BudgetReport = MonthInfo & {
|
||||
budget: BudgetView;
|
||||
lines: BudgetLineView[];
|
||||
history: BudgetHistoryRow[];
|
||||
};
|
||||
|
||||
/** Seçilen ayın kategori bazında bütçe/gerçekleşen tablosu ve son 6 ayın toplamları. */
|
||||
export async function getBudgetReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
month?: string,
|
||||
): Promise<BudgetReport | null> {
|
||||
const b = await base(userId, householdId);
|
||||
if (!b) return null;
|
||||
const m = month ?? b.currentMonth;
|
||||
const months = Array.from({ length: 6 }, (_, i) => shiftMonth(m, i - 5));
|
||||
const budgets = await Promise.all(months.map((x) => budgetFor(b.ctx, b.s, x)));
|
||||
const budget = budgets.at(-1)!;
|
||||
return {
|
||||
...monthInfo(b, m),
|
||||
budget,
|
||||
lines: budget.lines,
|
||||
history: budgets.map((x) => ({
|
||||
month: x.month,
|
||||
limitMinor: x.hasBudget ? x.totals.limitMinor : null,
|
||||
actualMinor: x.totals.actualMinor,
|
||||
ratioBp: x.hasBudget ? x.totals.ratioBp : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
+155
@@ -1862,6 +1862,144 @@ export const tr = {
|
||||
identifierRequired: "Kullanıcı adı veya e-posta girin",
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cumulative, overviewRange, weekday } from "./overview";
|
||||
|
||||
describe("overviewRange", () => {
|
||||
it("haftalık: Pazartesi–Pazar, bugünü içerir", () => {
|
||||
// 2026-11-07 Cumartesi.
|
||||
expect(weekday("2026-11-07")).toBe(5);
|
||||
const r = overviewRange("week", "2026-11-07");
|
||||
expect(r).toMatchObject({ granularity: "day", from: "2026-11-02", to: "2026-11-08" });
|
||||
expect(r.buckets).toHaveLength(7);
|
||||
// Ay sınırını aşan hafta.
|
||||
expect(overviewRange("week", "2026-12-01").from).toBe("2026-11-30");
|
||||
});
|
||||
|
||||
it("aylık: bütçe ayının günleri (başlangıç günü 15)", () => {
|
||||
const r = overviewRange("month", "2026-11-07", 15);
|
||||
expect(r.from).toBe("2026-10-15");
|
||||
expect(r.to).toBe("2026-11-14");
|
||||
expect(r.buckets).toHaveLength(31);
|
||||
expect(overviewRange("month", "2026-11-07").buckets).toHaveLength(30);
|
||||
});
|
||||
|
||||
it("altı aylık ve yıllık: bu ay dahil son 6 / 12 bütçe ayı", () => {
|
||||
const half = overviewRange("half", "2026-11-07");
|
||||
expect(half).toMatchObject({ granularity: "month", from: "2026-06-01", to: "2026-11-30" });
|
||||
expect(half.buckets).toEqual([
|
||||
"2026-06",
|
||||
"2026-07",
|
||||
"2026-08",
|
||||
"2026-09",
|
||||
"2026-10",
|
||||
"2026-11",
|
||||
]);
|
||||
const year = overviewRange("year", "2026-01-20", 15);
|
||||
expect(year.buckets[0]).toBe("2025-02");
|
||||
expect(year.buckets.at(-1)).toBe("2026-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cumulative", () => {
|
||||
it("seriler ayrı birikir; boş nokta boş kalır, toplamı bozmaz", () => {
|
||||
const out = cumulative([
|
||||
{ key: "a", plannedIncome: 100, budget: 50, actual: 10 },
|
||||
{ key: "b", plannedIncome: 0, budget: null, actual: 20 },
|
||||
{ key: "c", plannedIncome: 50, budget: 30, actual: null },
|
||||
]);
|
||||
expect(out.map((p) => [p.plannedIncome, p.budget, p.actual])).toEqual([
|
||||
[100, 50, 10],
|
||||
[100, null, 30],
|
||||
[150, 80, null],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { budgetPeriod, daysBetween, periodOfDate, shiftMonth } from "./budget";
|
||||
import { addDays } from "./recurrence";
|
||||
|
||||
/**
|
||||
* Dashboard ana grafiği (Bölüm 6.13): Planlanan Gelir, Planlanan Gider (Bütçe), Gerçekleşen Gider.
|
||||
* Saf fonksiyonlar: periyot aralığı ve kümülatif dönüşüm.
|
||||
*
|
||||
* Periyotlar: Haftalık (bu hafta, Pazartesi–Pazar, günlük), Aylık (içinde bulunulan bütçe ayı,
|
||||
* günlük), Altı Aylık ve Yıllık (bu bütçe ayı dahil son 6 / 12 bütçe ayı, aylık).
|
||||
*/
|
||||
|
||||
export const OVERVIEW_PERIODS = ["week", "month", "half", "year"] as const;
|
||||
export type OverviewPeriod = (typeof OVERVIEW_PERIODS)[number];
|
||||
|
||||
export type OverviewRange = {
|
||||
granularity: "day" | "month";
|
||||
/** Günlükte "YYYY-MM-DD", aylıkta bütçe ayı "YYYY-MM". */
|
||||
buckets: string[];
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
/** "YYYY-MM-DD" → haftanın günü (0 = Pazartesi … 6 = Pazar). */
|
||||
export function weekday(ymd: string): number {
|
||||
const d = new Date(`${ymd}T12:00:00Z`).getUTCDay();
|
||||
return (d + 6) % 7;
|
||||
}
|
||||
|
||||
export function overviewRange(period: OverviewPeriod, today: string, startDay = 1): OverviewRange {
|
||||
if (period === "week") {
|
||||
const from = addDays(today, -weekday(today));
|
||||
const buckets = Array.from({ length: 7 }, (_, i) => addDays(from, i));
|
||||
return { granularity: "day", buckets, from, to: buckets[6]! };
|
||||
}
|
||||
const current = periodOfDate(today, startDay);
|
||||
if (period === "month") {
|
||||
const { from, to } = budgetPeriod(current, startDay);
|
||||
const buckets = Array.from({ length: daysBetween(from, to) }, (_, i) => addDays(from, i));
|
||||
return { granularity: "day", buckets, from, to };
|
||||
}
|
||||
const n = period === "half" ? 6 : 12;
|
||||
const buckets = Array.from({ length: n }, (_, i) => shiftMonth(current, i - n + 1));
|
||||
return {
|
||||
granularity: "month",
|
||||
buckets,
|
||||
from: budgetPeriod(buckets[0]!, startDay).from,
|
||||
to: budgetPeriod(buckets.at(-1)!, startDay).to,
|
||||
};
|
||||
}
|
||||
|
||||
export type OverviewPoint = {
|
||||
key: string;
|
||||
plannedIncome: number | null;
|
||||
budget: number | null;
|
||||
actual: number | null;
|
||||
};
|
||||
|
||||
const SERIES = ["plannedIncome", "budget", "actual"] as const;
|
||||
|
||||
/**
|
||||
* Kümülatif görünüm: her seri kendi içinde birikir. Boş (null) nokta boş kalır ama birikime 0
|
||||
* katar; böylece bütçesi olmayan ay ya da gelecek günler çizgiyi koparır, toplamı bozmaz.
|
||||
*/
|
||||
export function cumulative(points: readonly OverviewPoint[]): OverviewPoint[] {
|
||||
const run = { plannedIncome: 0, budget: 0, actual: 0 };
|
||||
return points.map((p) => {
|
||||
const out: OverviewPoint = { ...p };
|
||||
for (const k of SERIES) {
|
||||
const v = p[k];
|
||||
if (v === null) continue;
|
||||
run[k] += v;
|
||||
out[k] = run[k];
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { categoryBreakdown, changePct, savingsRate, sharePct } from "./reports";
|
||||
|
||||
describe("oranlar", () => {
|
||||
it("toplam gidere oran bir ondalıklı (35.000 içinde 5.000 → %14,3)", () => {
|
||||
expect(sharePct(5_000_00, 35_000_00)).toBe(14.3);
|
||||
expect(sharePct(1, 0)).toBe(0);
|
||||
});
|
||||
it("önceki aya göre değişim; önceki 0 ise null", () => {
|
||||
expect(changePct(120, 100)).toBe(20);
|
||||
expect(changePct(90, 120)).toBe(-25);
|
||||
expect(changePct(50, 0)).toBeNull();
|
||||
});
|
||||
it("tasarruf oranı; gelir yoksa null, gider gelirden fazlaysa negatif", () => {
|
||||
expect(savingsRate(50_000_00, 35_000_00)).toBe(30);
|
||||
expect(savingsRate(10_000_00, 12_000_00)).toBe(-20);
|
||||
expect(savingsRate(0, 100)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("categoryBreakdown", () => {
|
||||
const cats = [
|
||||
{ id: "fatura", parentId: null, name: "Faturalar", order: 1 },
|
||||
{ id: "elektrik", parentId: "fatura", name: "Elektrik", order: 1 },
|
||||
{ id: "su", parentId: "fatura", name: "Su", order: 2 },
|
||||
{ id: "market", parentId: null, name: "Market", order: 2 },
|
||||
{ id: "gida", parentId: "market", name: "Gıda", order: 1 },
|
||||
{ id: "ulasim", parentId: null, name: "Ulaşım", order: 3 },
|
||||
];
|
||||
|
||||
it("üst kategori alt ağacı toplar; oranlar toplam gidere göre; boşlar gizli", () => {
|
||||
const current = new Map([
|
||||
["elektrik", 3_000_00],
|
||||
["su", 2_000_00],
|
||||
["gida", 25_000_00],
|
||||
["market", 5_000_00],
|
||||
]);
|
||||
const previous = new Map([
|
||||
["elektrik", 2_500_00],
|
||||
["gida", 20_000_00],
|
||||
["ulasim", 1_000_00],
|
||||
]);
|
||||
const b = categoryBreakdown(cats, current, previous);
|
||||
expect(b.totalMinor).toBe(35_000_00);
|
||||
expect(b.prevTotalMinor).toBe(23_500_00);
|
||||
expect(b.rows.map((r) => [r.id, r.amountMinor, r.sharePct])).toEqual([
|
||||
["market", 30_000_00, 85.7],
|
||||
["fatura", 5_000_00, 14.3],
|
||||
// Bu ay harcama yok ama geçen ay vardı: değişimi göstermek için kalır.
|
||||
["ulasim", 0, 0],
|
||||
]);
|
||||
const fatura = b.rows.find((r) => r.id === "fatura")!;
|
||||
expect(fatura.children.map((c) => [c.id, c.amountMinor, c.changePct])).toEqual([
|
||||
["elektrik", 3_000_00, 20],
|
||||
["su", 2_000_00, null],
|
||||
]);
|
||||
const market = b.rows.find((r) => r.id === "market")!;
|
||||
expect(market.ownMinor).toBe(5_000_00);
|
||||
expect(market.changePct).toBe(50);
|
||||
expect(b.rows.find((r) => r.id === "ulasim")!.changeMinor).toBe(-1_000_00);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Raporlar (Bölüm 6.14). Saf hesaplar: kategori dağılımı (üst kategori toplamları ve alt kategori
|
||||
* kırılımı), toplam gidere oran, önceki aya göre değişim, tasarruf oranı. Tutarlar kuruş; oranlar
|
||||
* bir ondalıklı yüzde (14,3).
|
||||
*/
|
||||
|
||||
/** Payın toplamdaki yüzdesi, bir ondalık (toplam 0 ise 0). */
|
||||
export function sharePct(part: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((part * 1000) / total) / 10;
|
||||
}
|
||||
|
||||
/** Önceki döneme göre yüzde değişim; önceki 0 ise hesaplanamaz (null). */
|
||||
export function changePct(current: number, previous: number): number | null {
|
||||
if (previous === 0) return null;
|
||||
return Math.round(((current - previous) * 1000) / previous) / 10;
|
||||
}
|
||||
|
||||
/** Tasarruf oranı = (gelir − gider) / gelir; gelir yoksa null. */
|
||||
export function savingsRate(incomeMinor: number, expenseMinor: number): number | null {
|
||||
if (incomeMinor <= 0) return null;
|
||||
return sharePct(incomeMinor - expenseMinor, incomeMinor);
|
||||
}
|
||||
|
||||
export type ReportCategory = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type CategoryShare = {
|
||||
id: string;
|
||||
name: string;
|
||||
depth: number;
|
||||
amountMinor: number;
|
||||
sharePct: number;
|
||||
prevMinor: number;
|
||||
changeMinor: number;
|
||||
changePct: number | null;
|
||||
};
|
||||
|
||||
export type TopCategoryShare = CategoryShare & {
|
||||
/** Alt kategoriler (ağaç sırasıyla, derinlik 1+); tutarları kendi tutarlarıdır. */
|
||||
children: CategoryShare[];
|
||||
/** Üst kategoriye doğrudan (alt kategori seçilmeden) yazılan tutar. */
|
||||
ownMinor: number;
|
||||
};
|
||||
|
||||
export type CategoryBreakdown = {
|
||||
totalMinor: number;
|
||||
prevTotalMinor: number;
|
||||
rows: TopCategoryShare[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Üst kategori toplamları (alt ağaç dahil) ve alt kategori kırılımı. Tutarı ve önceki tutarı
|
||||
* sıfır olan kategoriler gösterilmez. Satırlar tutara göre büyükten küçüğe.
|
||||
*/
|
||||
export function categoryBreakdown(
|
||||
categories: readonly ReportCategory[],
|
||||
current: ReadonlyMap<string, number>,
|
||||
previous: ReadonlyMap<string, number>,
|
||||
): CategoryBreakdown {
|
||||
const byId = new Map(categories.map((c) => [c.id, c]));
|
||||
const children = new Map<string, ReportCategory[]>();
|
||||
const roots: ReportCategory[] = [];
|
||||
for (const c of categories) {
|
||||
if (c.parentId && byId.has(c.parentId))
|
||||
children.set(c.parentId, [...(children.get(c.parentId) ?? []), c]);
|
||||
else roots.push(c);
|
||||
}
|
||||
const sorted = (list: ReportCategory[]) =>
|
||||
[...list].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr"));
|
||||
const totalMinor = [...current.values()].reduce((a, b) => a + b, 0);
|
||||
const prevTotalMinor = [...previous.values()].reduce((a, b) => a + b, 0);
|
||||
|
||||
const share = (id: string, name: string, depth: number, cur: number, prev: number) => ({
|
||||
id,
|
||||
name,
|
||||
depth,
|
||||
amountMinor: cur,
|
||||
sharePct: sharePct(cur, totalMinor),
|
||||
prevMinor: prev,
|
||||
changeMinor: cur - prev,
|
||||
changePct: changePct(cur, prev),
|
||||
});
|
||||
|
||||
const rows: TopCategoryShare[] = [];
|
||||
for (const root of roots) {
|
||||
const kids: CategoryShare[] = [];
|
||||
let cur = current.get(root.id) ?? 0;
|
||||
let prev = previous.get(root.id) ?? 0;
|
||||
const walk = (list: ReportCategory[], depth: number, seen: Set<string>) => {
|
||||
for (const c of sorted(list)) {
|
||||
if (seen.has(c.id)) continue;
|
||||
seen.add(c.id);
|
||||
const a = current.get(c.id) ?? 0;
|
||||
const p = previous.get(c.id) ?? 0;
|
||||
cur += a;
|
||||
prev += p;
|
||||
const sub = children.get(c.id) ?? [];
|
||||
const before = kids.length;
|
||||
kids.push(share(c.id, c.name, depth, a, p));
|
||||
walk(sub, depth + 1, seen);
|
||||
// Kendisi ve altı boşsa satırı çıkar.
|
||||
if (a === 0 && p === 0 && kids.length === before + 1) kids.pop();
|
||||
}
|
||||
};
|
||||
walk(children.get(root.id) ?? [], 1, new Set([root.id]));
|
||||
if (cur === 0 && prev === 0) continue;
|
||||
rows.push({
|
||||
...share(root.id, root.name, 0, cur, prev),
|
||||
ownMinor: current.get(root.id) ?? 0,
|
||||
children: kids,
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => b.amountMinor - a.amountMinor || a.name.localeCompare(b.name, "tr"));
|
||||
return { totalMinor, prevTotalMinor, rows };
|
||||
}
|
||||
Reference in New Issue
Block a user