+ {/* Yalnız yazdırmada: kağıtta hangi kapsamın basıldığı belli olsun */}
+
+ {describeFilter(filter, {
+ period: (id) => periods.data.find((p) => p.id === id)?.name,
+ category: (id) => categories.data.find((c) => c.id === id)?.name,
+ method: (id) => methods.data.find((m) => m.id === id)?.name,
+ })}
+
+
+ {breakdown.isError ? (
+
+ ) : !breakdown.data ? (
+
+ ) : (
+
+ )}
+
+ {stats.data && (
+
+ )}
+ {portfolio.data &&
}
+
+ )}
>
)
}
diff --git a/frontend/src/features/reports/section.tsx b/frontend/src/features/reports/section.tsx
new file mode 100644
index 0000000..0ac45e5
--- /dev/null
+++ b/frontend/src/features/reports/section.tsx
@@ -0,0 +1,145 @@
+import { useRef, type ReactNode } from 'react'
+import { Download, Printer } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { downloadCsv, toCsv, type Cell } from '@/lib/csv'
+import { formatMoney, formatPercent, toIsoDate } from '@/lib/format'
+import { printSection } from '@/lib/print'
+import { t } from '@/lib/strings'
+import { cn } from '@/lib/utils'
+
+const s = t.reports
+
+export type CsvData = { file: string; headers: string[]; rows: Cell[][] }
+
+/**
+ * Rapor bölümü: başlık + açıklama + "CSV indir" / "Yazdır" (yalnızca bu bölüm).
+ * `data-report` → yazdırma stili (lib/print.ts, index.css).
+ */
+export function ReportSection({
+ id,
+ title,
+ hint,
+ note,
+ csv,
+ children,
+}: {
+ id: string
+ title: string
+ hint?: string
+ /** "Filtrelere göre" / "Filtrelerden bağımsız" */
+ note?: string
+ csv?: CsvData
+ children: ReactNode
+}) {
+ const ref = useRef(null)
+ return (
+
+
+
+
+ {title}
+
+ {(hint || note) && (
+
+ {hint}
+ {hint && note && ' · '}
+ {note}
+
+ )}
+
+
+ {csv && (
+
+ )}
+
+
+
+ {children}
+
+ )
+}
+
+/** Tablo + satır içi pay çubuğu (yazdırmada da görünür; grafik kütüphanesi gerekmez) */
+export function ShareTable({
+ rows,
+ total,
+ firstHeader,
+ showCount = true,
+}: {
+ rows: { key: string; label: ReactNode; amount: number; count?: number; color?: string }[]
+ total: number
+ firstHeader: string
+ showCount?: boolean
+}) {
+ return (
+
+
+
+
+ | {firstHeader} |
+ {showCount && {s.count} | }
+ {s.share} |
+ {s.amount} |
+
+
+
+ {rows.map((r) => {
+ const ratio = total > 0 ? r.amount / total : 0
+ return (
+
+ |
+ {r.label}
+
+ |
+ {showCount && {r.count ?? ''} | }
+
+ {formatPercent(ratio)}
+ |
+
+ {formatMoney(r.amount)}
+ |
+
+ )
+ })}
+
+
+
+ | {s.total} |
+ {showCount && (
+
+ {rows.reduce((a, r) => a + (r.count ?? 0), 0)}
+ |
+ )}
+ |
+ {formatMoney(total)} |
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/reports/sections.tsx b/frontend/src/features/reports/sections.tsx
new file mode 100644
index 0000000..7f17673
--- /dev/null
+++ b/frontend/src/features/reports/sections.tsx
@@ -0,0 +1,559 @@
+import { lazy, Suspense, type ReactNode } from 'react'
+import { PageError, PageLoading } from '@/components/page'
+import { Skeleton } from '@/components/skeleton'
+import { csvMoney, csvPercent } from '@/lib/csv'
+import { formatE6 } from '@/lib/decimal'
+import { formatDate, formatDayMonth, formatMoney, formatPercent } from '@/lib/format'
+import { group } from '@/lib/groups'
+import { useBudget } from '@/lib/periods'
+import { deviation, formatMonth, savingsRate } from '@/lib/reports'
+import { formatPlPercent, plRatio, qty } from '@/lib/savings'
+import { t } from '@/lib/strings'
+import { cn } from '@/lib/utils'
+import type { CatalogItem } from '@/types/CatalogItem'
+import type { Period } from '@/types/Period'
+import type { PeriodStat } from '@/types/PeriodStat'
+import type { Portfolio } from '@/types/Portfolio'
+import type { ReportBreakdown } from '@/types/ReportBreakdown'
+import { ReportSection, ShareTable } from './section'
+
+const s = t.reports
+const ReportChart = lazy(() => import('./charts'))
+const chartFallback =
+
+const signed = (kurus: number) => formatMoney(kurus, 'TRY', { signed: true })
+const pct = (r: number | null) => (r === null ? '—' : formatPercent(r))
+
+// ---------- Dönemler (filtrelerden bağımsız) ----------
+
+export function PeriodsReport({ periods }: { periods: PeriodStat[] }) {
+ return (
+ [
+ p.name,
+ formatDate(p.start_date),
+ formatDate(p.last_date),
+ csvMoney(p.income_total),
+ csvMoney(p.fixed_expense_total),
+ csvMoney(p.spent),
+ csvMoney(p.realized_savings),
+ csvPercent(savingsRate(p)),
+ ]),
+ }}
+ >
+ {periods.length === 0 ? (
+ {s.noPeriods}
+ ) : (
+ <>
+
+
+
+ {s.periodsSummary(periods.length)}
+
+
+
+
+ | {s.period} |
+ {s.income} |
+ {s.expense} |
+ {s.realized} |
+ {s.rate} |
+
+
+
+ {[...periods].reverse().map((p) => (
+
+ | {p.name} |
+
+ {formatMoney(p.income_total)}
+ |
+
+ {formatMoney(p.fixed_expense_total + p.spent)}
+ |
+
+ {formatMoney(p.realized_savings)}
+ |
+ {pct(savingsRate(p))} |
+
+ ))}
+
+
+
+ >
+ )}
+
+ )
+}
+
+export function RateReport({ periods }: { periods: PeriodStat[] }) {
+ const last = periods.at(-1)
+ return (
+
+ {periods.length === 0 ? (
+ {s.noPeriods}
+ ) : (
+ <>
+
+
+
+ {last && (
+
+ {s.rateSummary(last.name, pct(savingsRate(last)))}
+
+ )}
+ >
+ )}
+
+ )
+}
+
+// ---------- Kırılımlar (filtrelere göre) ----------
+
+export function GroupsReport({ b }: { b: ReportBreakdown }) {
+ return (
+ [
+ group(r.group).label,
+ r.count,
+ csvPercent(b.total ? r.total / b.total : 0),
+ csvMoney(r.total),
+ ]),
+ }}
+ >
+ {
+ const g = group(r.group)
+ return {
+ key: r.group,
+ label: (
+
+ {g.emoji} {g.label}
+
+ ),
+ amount: r.total,
+ count: r.count,
+ color: g.className.fill,
+ }
+ })}
+ />
+
+ )
+}
+
+export function CategoriesReport({
+ b,
+ categories,
+}: {
+ b: ReportBreakdown
+ categories: CatalogItem[]
+}) {
+ const name = (id: string) => categories.find((c) => c.id === id)
+ return (
+ [
+ name(r.category_id)?.name ?? '—',
+ group(r.group).label,
+ r.count,
+ csvPercent(b.total ? r.total / b.total : 0),
+ csvMoney(r.total),
+ ]),
+ }}
+ >
+ {b.categories.length === 0 ? (
+ {s.empty}
+ ) : (
+ {
+ const c = name(r.category_id)
+ const g = group(r.group)
+ return {
+ key: r.category_id,
+ label: (
+ <>
+ {c?.emoji ? `${c.emoji} ` : ''}
+ {c?.name ?? '—'}{' '}
+ {g.label}
+ >
+ ),
+ amount: r.total,
+ count: r.count,
+ color: g.className.fill,
+ }
+ })}
+ />
+ )}
+
+ )
+}
+
+export function MethodsReport({ b, methods }: { b: ReportBreakdown; methods: CatalogItem[] }) {
+ const name = (id: string | null) =>
+ id ? (methods.find((m) => m.id === id)?.name ?? '—') : s.noMethod
+ return (
+ [
+ name(r.payment_method_id),
+ r.count,
+ csvPercent(b.total ? r.total / b.total : 0),
+ csvMoney(r.total),
+ ]),
+ }}
+ >
+ {b.payment_methods.length === 0 ? (
+ {s.empty}
+ ) : (
+ ({
+ key: r.payment_method_id ?? 'none',
+ label: name(r.payment_method_id),
+ amount: r.total,
+ count: r.count,
+ }))}
+ />
+ )}
+
+ )
+}
+
+export function MonthsReport({ b }: { b: ReportBreakdown }) {
+ return (
+ [formatMonth(r.month), r.count, csvMoney(r.total)]),
+ }}
+ >
+ {b.months.length === 0 ? (
+ {s.empty}
+ ) : (
+ ({
+ key: r.month,
+ label: formatMonth(r.month),
+ amount: r.total,
+ count: r.count,
+ }))}
+ />
+ )}
+
+ )
+}
+
+// ---------- Bütçe sapması (tek dönem) ----------
+
+export function DeviationReport({ period }: { period: Period | null }) {
+ if (!period) {
+ return (
+
+ {s.noDeviationPeriod}
+
+ )
+ }
+ return
+}
+
+function DeviationView({ period }: { period: Period }) {
+ const budget = useBudget(period.id)
+ const rows = budget.data ? deviation(budget.data) : []
+ const weeks = period.weeks.map((w, i) => {
+ const planned = budget.data?.reduce((a, b) => a + (b.weeks[i]?.amount ?? 0), 0) ?? 0
+ const spent = budget.data?.reduce((a, b) => a + (b.weeks[i]?.spent ?? 0), 0) ?? 0
+ return { w, planned, spent, diff: planned - spent }
+ })
+ const totals = rows.reduce(
+ (a, r) => ({ planned: a.planned + r.planned, spent: a.spent + r.spent }),
+ {
+ planned: 0,
+ spent: 0,
+ },
+ )
+ return (
+ [
+ group(r.group).label,
+ csvMoney(r.planned),
+ csvMoney(r.spent),
+ csvMoney(r.diff),
+ csvPercent(r.used),
+ ]),
+ ...weeks.map(({ w, planned, spent, diff }) => [
+ s.week(w.index, `${formatDayMonth(w.start_date)}–${formatDayMonth(w.last_date)}`),
+ csvMoney(planned),
+ csvMoney(spent),
+ csvMoney(diff),
+ csvPercent(planned ? spent / planned : null),
+ ]),
+ ],
+ }}
+ >
+ {budget.isError ? (
+
+ ) : !budget.data ? (
+
+ ) : (
+
+ {
+ const g = group(r.group)
+ return {
+ key: r.group,
+ label: (
+
+ {g.emoji} {g.label}
+
+ ),
+ ...r,
+ }
+ }),
+ ]}
+ total={{ ...totals, diff: totals.planned - totals.spent }}
+ />
+ {s.weeksTitle}
+ ({
+ key: String(w.index),
+ label: s.week(
+ w.index,
+ `${formatDayMonth(w.start_date)}–${formatDayMonth(w.last_date)}`,
+ ),
+ planned,
+ spent,
+ diff,
+ used: planned ? spent / planned : null,
+ }))}
+ />
+
+ )}
+
+ )
+}
+
+function DevTable({
+ first,
+ rows,
+ total,
+}: {
+ first: string
+ rows: {
+ key: string
+ label: ReactNode
+ planned: number
+ spent: number
+ diff: number
+ used: number | null
+ }[]
+ total?: { planned: number; spent: number; diff: number }
+}) {
+ return (
+
+
+
+ | {first} |
+ {s.planned} |
+ {s.spent} |
+ {s.diff} |
+ {s.used} |
+
+
+
+ {rows.map((r) => (
+
+ | {r.label} |
+
+ {formatMoney(r.planned)}
+ |
+ {formatMoney(r.spent)} |
+
+ {signed(r.diff)}
+ |
+ 1 && 'text-expense')}>
+ {pct(r.used)}
+ |
+
+ ))}
+
+ {total && (
+
+
+ | {s.total} |
+
+ {formatMoney(total.planned)}
+ |
+
+ {formatMoney(total.spent)}
+ |
+
+ {signed(total.diff)}
+ |
+
+ {pct(total.planned ? total.spent / total.planned : null)}
+ |
+
+
+ )}
+
+ )
+}
+
+// ---------- Varlıklar ----------
+
+export function AssetsReport({ p }: { p: Portfolio }) {
+ const items = p.items.filter((i) => i.asset.quantity_e6 > 0 || i.asset.realized_pl !== 0)
+ return (
+ [
+ i.asset.name,
+ `${formatE6(i.asset.quantity_e6)} ${i.asset.unit_label}`,
+ csvMoney(i.asset.cost),
+ i.value === null ? '' : csvMoney(i.value),
+ i.unrealized_pl === null ? '' : csvMoney(i.unrealized_pl),
+ i.unrealized_pl === null ? '' : csvPercent(plRatio(i.unrealized_pl, i.asset.cost)),
+ csvMoney(i.asset.realized_pl),
+ ]),
+ }}
+ >
+ {items.length === 0 ? (
+ {s.noAssets}
+ ) : (
+
+
+
+
+ | {s.asset} |
+ {s.cost} |
+ {s.value} |
+ {s.unrealized} |
+
+
+
+ {items.map((i) => {
+ const pl = i.unrealized_pl
+ const r = pl === null ? null : formatPlPercent(plRatio(pl, i.asset.cost))
+ return (
+
+ |
+ {i.asset.name}
+ {qty(i.asset)}
+ |
+
+ {formatMoney(i.asset.cost)}
+ |
+
+ {i.value === null ? '—' : formatMoney(i.value)}
+ |
+ 0 ? 'text-income' : (pl ?? 0) < 0 ? 'text-expense' : '',
+ )}
+ >
+ {pl === null ? '—' : signed(pl)}
+ {r && {r} }
+ |
+
+ )
+ })}
+
+
+
+ | {s.total} |
+
+ {formatMoney(p.cost_total)}
+ |
+
+ {formatMoney(p.value_total)}
+ |
+
+ {signed(p.unrealized_pl_total)}
+ |
+
+
+
+ {p.realized_pl_total !== 0 && (
+
+ {s.realizedPl}: {signed(p.realized_pl_total)}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 6952eec..55361f2 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -265,3 +265,18 @@
@apply border-border;
}
}
+
+/* Yazdırma (lib/print.ts): tek bölüm seçildiyse diğer rapor bölümleri gizlenir;
+ kartlar sayfa ortasında bölünmesin; arka plan renkleri (çubuklar) basılsın. */
+@media print {
+ html[data-print-one] main [data-report]:not([data-print-target]) {
+ display: none;
+ }
+ main [data-report] {
+ break-inside: avoid;
+ }
+ * {
+ print-color-adjust: exact;
+ -webkit-print-color-adjust: exact;
+ }
+}
diff --git a/frontend/src/lib/csv.test.ts b/frontend/src/lib/csv.test.ts
new file mode 100644
index 0000000..2c22ac8
--- /dev/null
+++ b/frontend/src/lib/csv.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+import { csvMoney, csvPercent, toCsv } from './csv'
+
+describe('csv', () => {
+ it('Excel-friendly: BOM, semicolon, CRLF, quoting', () => {
+ const csv = toCsv(
+ ['Kategori', 'Tutar'],
+ [
+ ['Market', '1250,50'],
+ ['Dışarıda yemek; kafe', '10,00'],
+ ['"Özel" ad', null],
+ ],
+ )
+ expect(csv.charCodeAt(0)).toBe(0xfeff)
+ expect(csv.slice(1)).toBe(
+ 'Kategori;Tutar\r\nMarket;1250,50\r\n"Dışarıda yemek; kafe";10,00\r\n"""Özel"" ad";\r\n',
+ )
+ })
+
+ it('money and percent without thousands separators', () => {
+ expect(csvMoney(125_050)).toBe('1250,50')
+ expect(csvMoney(100_000_000)).toBe('1000000,00')
+ expect(csvMoney(-5)).toBe('-0,05')
+ expect(csvMoney(0)).toBe('0,00')
+ expect(csvPercent(0.125)).toBe('12,5')
+ expect(csvPercent(-0.0058)).toBe('-0,6')
+ expect(csvPercent(null)).toBe('')
+ })
+})
diff --git a/frontend/src/lib/csv.ts b/frontend/src/lib/csv.ts
new file mode 100644
index 0000000..24c0bb5
--- /dev/null
+++ b/frontend/src/lib/csv.ts
@@ -0,0 +1,43 @@
+/**
+ * CSV dışa aktarma — Türkçe Excel uyumlu (KAKEIBO_PROMPT §8.9):
+ * UTF-8 **BOM** (Türkçe karakterler), `;` ayırıcı, satır sonu CRLF.
+ * Tutarlar Excel'in sayı sanması için binlik ayırıcısız ve ondalık virgüllü (`1250,50`).
+ */
+
+export type Cell = string | number | null | undefined
+
+const BOM = ''
+
+function quote(v: Cell): string {
+ if (v === null || v === undefined) return ''
+ const s = String(v)
+ return /[;"\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
+}
+
+export function toCsv(headers: string[], rows: Cell[][]): string {
+ return BOM + [headers, ...rows].map((r) => r.map(quote).join(';')).join('\r\n') + '\r\n'
+}
+
+/** Kuruş → `1250,50` (CSV için; binlik ayırıcı yok) */
+export function csvMoney(kurus: number): string {
+ const neg = kurus < 0
+ const abs = Math.abs(kurus)
+ return `${neg ? '-' : ''}${Math.floor(abs / 100)},${String(abs % 100).padStart(2, '0')}`
+}
+
+/** Oran → `12,5` (yüzde, 1 ondalık; CSV için) */
+export function csvPercent(ratio: number | null): string {
+ return ratio === null ? '' : (Math.round(ratio * 1000) / 10).toString().replace('.', ',')
+}
+
+/** Tarayıcıda dosya indirir */
+export function downloadCsv(filename: string, csv: string) {
+ const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }))
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`
+ document.body.append(a)
+ a.click()
+ a.remove()
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
+}
diff --git a/frontend/src/lib/entries.ts b/frontend/src/lib/entries.ts
index f121b09..3a3f283 100644
--- a/frontend/src/lib/entries.ts
+++ b/frontend/src/lib/entries.ts
@@ -23,6 +23,7 @@ function refresh(qc: QueryClient, periodId: string) {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
qc.invalidateQueries({ queryKey: queryKeys.stats }),
+ qc.invalidateQueries({ queryKey: queryKeys.reports }),
])
}
diff --git a/frontend/src/lib/print.ts b/frontend/src/lib/print.ts
new file mode 100644
index 0000000..9b59a15
--- /dev/null
+++ b/frontend/src/lib/print.ts
@@ -0,0 +1,32 @@
+/**
+ * Yazdırma: her zaman açık tema (koyu temada açık renk yazı beyaz kâğıtta okunmaz) ve
+ * istenirse yalnızca tek bir rapor bölümü. Tarayıcının kendi Ctrl+P'si de aynı kuralı izler.
+ */
+
+let installed = false
+
+/** Uygulama açılışında bir kez: yazdırmadan önce açık tema, sonra eski haline */
+export function installPrintTheme() {
+ if (installed || typeof window === 'undefined') return
+ installed = true
+ const html = document.documentElement
+ let wasDark = false
+ window.addEventListener('beforeprint', () => {
+ wasDark = html.classList.contains('dark')
+ html.classList.remove('dark')
+ })
+ window.addEventListener('afterprint', () => {
+ if (wasDark) html.classList.add('dark')
+ html.removeAttribute('data-print-one')
+ document.querySelector('[data-print-target]')?.removeAttribute('data-print-target')
+ })
+}
+
+/** Yalnızca `el`'i yazdır (diğer rapor bölümleri gizlenir); `el` yoksa tüm sayfa */
+export function printSection(el?: HTMLElement | null) {
+ if (el) {
+ document.documentElement.setAttribute('data-print-one', '')
+ el.setAttribute('data-print-target', '')
+ }
+ window.print()
+}
diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts
index 882c2ff..da0d448 100644
--- a/frontend/src/lib/query.ts
+++ b/frontend/src/lib/query.ts
@@ -22,6 +22,9 @@ export const queryKeys = {
portfolio: ['portfolio'] as const,
assetTxs: (assetId: string) => ['portfolio', 'txs', assetId] as const,
pendingSavings: ['portfolio', 'pending'] as const,
+ /** Rapor kırılımı (`query` = sunucu sorgu metni); kayıt değişince `reports` öneki yenilenir */
+ reportBreakdown: (query: string) => ['reports', 'breakdown', query] as const,
+ reports: ['reports'] as const,
period: (id: string) => ['period', id] as const,
planItems: (id: string) => ['period', id, 'plan-items'] as const,
summary: (id: string) => ['period', id, 'summary'] as const,
diff --git a/frontend/src/lib/reports.test.ts b/frontend/src/lib/reports.test.ts
new file mode 100644
index 0000000..17ef593
--- /dev/null
+++ b/frontend/src/lib/reports.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest'
+import type { GroupBudget } from '@/types/GroupBudget'
+import {
+ activeFilterCount,
+ describeFilter,
+ deviation,
+ filterToParams,
+ filterToQuery,
+ formatMonth,
+ parseFilter,
+ savingsRate,
+} from './reports'
+
+describe('report filter', () => {
+ it('parses Turkish params; period wins over dates', () => {
+ const f = parseFilter(new URLSearchParams('donem=p1&baslangic=2026-01-01&grup=needs&odeme=yok'))
+ expect(f).toEqual({ period: 'p1', group: 'needs', method: 'none' })
+ expect(filterToQuery(f)).toBe('?period_id=p1&group=needs&payment_method_id=none')
+ expect(filterToParams(f)).toEqual({ donem: 'p1', grup: 'needs', odeme: 'yok' })
+ expect(activeFilterCount(f)).toBe(3)
+ })
+
+ it('date range; invalid values ignored', () => {
+ const f = parseFilter(
+ new URLSearchParams('baslangic=2026-01-01&bitis=2026-02-30&grup=xyz&kategori=c1'),
+ )
+ expect(f).toEqual({ from: '2026-01-01', category: 'c1' })
+ expect(filterToQuery(f)).toBe('?from=2026-01-01&category_id=c1')
+ expect(filterToQuery({})).toBe('')
+ expect(activeFilterCount({})).toBe(0)
+ })
+})
+
+describe('filter description (printed reports)', () => {
+ const names = {
+ period: (id: string) => (id === 'p1' ? 'Kasım 2026' : undefined),
+ category: () => 'Market',
+ method: () => 'Nakit',
+ }
+ it('describes scope and filters', () => {
+ expect(describeFilter({}, names)).toBe('Kapsam: Tüm zamanlar')
+ expect(describeFilter({ period: 'p1', group: 'needs', method: 'none' }, names)).toBe(
+ 'Kapsam: Kasım 2026 · Grup: Zorunlu · Ödeme türü: Girilmemiş',
+ )
+ expect(describeFilter({ from: '2026-01-01', to: '2026-11-13', category: 'c' }, names)).toBe(
+ 'Kapsam: 01.01.2026 – 13.11.2026 · Kategori: Market',
+ )
+ expect(describeFilter({ from: '2026-01-01' }, names)).toBe('Kapsam: 01.01.2026 – …')
+ })
+})
+
+describe('report calculations', () => {
+ it('savings rate', () => {
+ expect(savingsRate({ realized_savings: 250, income_total: 1_000 })).toBe(0.25)
+ expect(savingsRate({ realized_savings: -100, income_total: 1_000 })).toBe(-0.1)
+ expect(savingsRate({ realized_savings: 0, income_total: 0 })).toBeNull()
+ expect(formatMonth('2026-11')).toBe('Kasım 2026')
+ expect(formatMonth('2027-01')).toBe('Ocak 2027')
+ })
+
+ it('budget deviation monthly and weekly', () => {
+ const wk = (amount: number, spent: number) => ({
+ index: 1,
+ start_date: '',
+ last_date: '',
+ days: 7,
+ amount,
+ is_manual: false,
+ spent,
+ })
+ const budget = [
+ {
+ group: 'needs',
+ monthly: 1_000,
+ spent: 1_200,
+ weekly_over: false,
+ weeks: [wk(300, 100), wk(700, 1_100)],
+ },
+ { group: 'wants', monthly: 0, spent: 50, weekly_over: false, weeks: [wk(0, 50), wk(0, 0)] },
+ ] as GroupBudget[]
+ expect(deviation(budget)).toEqual([
+ { group: 'needs', planned: 1_000, spent: 1_200, diff: -200, used: 1.2 },
+ { group: 'wants', planned: 0, spent: 50, diff: -50, used: null },
+ ])
+ expect(deviation(budget, 2)[0]).toEqual({
+ group: 'needs',
+ planned: 700,
+ spent: 1_100,
+ diff: -400,
+ used: 1_100 / 700,
+ })
+ })
+})
diff --git a/frontend/src/lib/reports.ts b/frontend/src/lib/reports.ts
new file mode 100644
index 0000000..907241d
--- /dev/null
+++ b/frontend/src/lib/reports.ts
@@ -0,0 +1,167 @@
+/**
+ * Raporlar: filtre (adreste), sunucu kancası ve saf hesaplar.
+ *
+ * Adres parametreleri: `donem`, `baslangic`, `bitis`, `grup`, `kategori`, `odeme`
+ * (`odeme=yok` → ödeme türü girilmemiş). Dönem seçiliyse tarih aralığı yok sayılır.
+ */
+import { keepPreviousData, useQuery } from '@tanstack/react-query'
+import type { GroupBudget } from '@/types/GroupBudget'
+import type { PeriodStat } from '@/types/PeriodStat'
+import type { ReportBreakdown } from '@/types/ReportBreakdown'
+import { api } from './api'
+import { formatDate } from './format'
+import { group as groupMeta, GROUPS, type GroupCode } from './groups'
+import { isIsoDate } from './period-dates'
+import { queryKeys } from './query'
+
+export type ReportFilter = {
+ period?: string
+ from?: string
+ to?: string
+ group?: GroupCode
+ category?: string
+ /** `'none'` → girilmemiş */
+ method?: string
+}
+
+const P = {
+ period: 'donem',
+ from: 'baslangic',
+ to: 'bitis',
+ group: 'grup',
+ category: 'kategori',
+ method: 'odeme',
+} as const
+
+export function parseFilter(params: URLSearchParams): ReportFilter {
+ const get = (k: string) => params.get(k)?.trim() || undefined
+ const f: ReportFilter = {}
+ const period = get(P.period)
+ if (period) f.period = period
+ else {
+ const from = get(P.from)
+ const to = get(P.to)
+ if (from && isIsoDate(from)) f.from = from
+ if (to && isIsoDate(to)) f.to = to
+ }
+ const group = get(P.group)
+ if (group && GROUPS.some((g) => g.code === group)) f.group = group as GroupCode
+ const category = get(P.category)
+ if (category) f.category = category
+ const method = get(P.method)
+ if (method) f.method = method === 'yok' ? 'none' : method
+ return f
+}
+
+export function filterToParams(f: ReportFilter): Record {
+ const out: Record = {}
+ if (f.period) out[P.period] = f.period
+ else {
+ if (f.from) out[P.from] = f.from
+ if (f.to) out[P.to] = f.to
+ }
+ if (f.group) out[P.group] = f.group
+ if (f.category) out[P.category] = f.category
+ if (f.method) out[P.method] = f.method === 'none' ? 'yok' : f.method
+ return out
+}
+
+/** Sunucu sorgusu (`/reports/breakdown?...`) */
+export function filterToQuery(f: ReportFilter): string {
+ const q = new URLSearchParams()
+ if (f.period) q.set('period_id', f.period)
+ else {
+ if (f.from) q.set('from', f.from)
+ if (f.to) q.set('to', f.to)
+ }
+ if (f.group) q.set('group', f.group)
+ if (f.category) q.set('category_id', f.category)
+ if (f.method) q.set('payment_method_id', f.method)
+ const s = q.toString()
+ return s ? `?${s}` : ''
+}
+
+/**
+ * Filtrenin okunur özeti (yazdırılan raporun başında: kağıtta hangi kapsamın basıldığı belli olsun).
+ * Örn. `Kapsam: Kasım 2026 · Grup: Zorunlu · Ödeme türü: Girilmemiş`
+ */
+export function describeFilter(
+ f: ReportFilter,
+ names: {
+ period: (id: string) => string | undefined
+ category: (id: string) => string | undefined
+ method: (id: string) => string | undefined
+ },
+): string {
+ const scope = f.period
+ ? (names.period(f.period) ?? '—')
+ : f.from || f.to
+ ? `${f.from ? formatDate(f.from) : '…'} – ${f.to ? formatDate(f.to) : '…'}`
+ : 'Tüm zamanlar'
+ const parts = [`Kapsam: ${scope}`]
+ if (f.group) parts.push(`Grup: ${groupMeta(f.group).label}`)
+ if (f.category) parts.push(`Kategori: ${names.category(f.category) ?? '—'}`)
+ if (f.method)
+ parts.push(
+ `Ödeme türü: ${f.method === 'none' ? 'Girilmemiş' : (names.method(f.method) ?? '—')}`,
+ )
+ return parts.join(' · ')
+}
+
+export function activeFilterCount(f: ReportFilter): number {
+ return [f.period || f.from || f.to, f.group, f.category, f.method].filter(Boolean).length
+}
+
+export function useReportBreakdown(f: ReportFilter) {
+ const query = filterToQuery(f)
+ return useQuery({
+ queryKey: queryKeys.reportBreakdown(query),
+ queryFn: () => api.get(`/reports/breakdown${query}`),
+ placeholderData: keepPreviousData,
+ })
+}
+
+/** Birikim oranı: gerçekleşen birikim ÷ gelir (gelir yoksa `null`) */
+export function savingsRate(
+ p: Pick,
+): number | null {
+ return p.income_total > 0 ? p.realized_savings / p.income_total : null
+}
+
+const monthName = new Intl.DateTimeFormat('tr-TR', {
+ month: 'long',
+ year: 'numeric',
+ timeZone: 'UTC',
+})
+
+/** `"2026-11"` → `Kasım 2026` */
+export const formatMonth = (ym: string) => monthName.format(new Date(`${ym}-01T00:00:00Z`))
+
+/** Pay (0–1); toplam 0 ise 0 */
+export const share = (part: number, total: number) => (total > 0 ? part / total : 0)
+
+export type DeviationRow = {
+ group: GroupCode
+ planned: number
+ spent: number
+ /** planned − spent (eksi → aşım) */
+ diff: number
+ /** spent ÷ planned (plan yoksa `null`) */
+ used: number | null
+}
+
+/** Bütçe sapması: grup başına aylık (ya da `week` verilirse o hafta) planlanan vs gerçekleşen */
+export function deviation(budget: GroupBudget[], week?: number): DeviationRow[] {
+ return budget.map((b) => {
+ const w = week ? b.weeks[week - 1] : undefined
+ const planned = week ? (w?.amount ?? 0) : b.monthly
+ const spent = week ? (w?.spent ?? 0) : b.spent
+ return {
+ group: b.group,
+ planned,
+ spent,
+ diff: planned - spent,
+ used: planned > 0 ? spent / planned : null,
+ }
+ })
+}
diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts
index af13600..1474f37 100644
--- a/frontend/src/lib/strings.ts
+++ b/frontend/src/lib/strings.ts
@@ -494,6 +494,83 @@ export const t = {
added: (name: string) => `${name} birikimi eklendi`,
},
+ reports: {
+ printAll: 'Tümünü yazdır',
+ print: 'Yazdır',
+ csv: 'CSV indir',
+ filters: 'Filtreler',
+ filterCount: (n: number) => (n ? `Filtreler (${n})` : 'Filtreler'),
+ scope: 'Kapsam',
+ allTime: 'Tüm zamanlar',
+ byPeriod: 'Dönem',
+ byRange: 'Tarih aralığı',
+ from: 'Başlangıç',
+ to: 'Bitiş',
+ group: 'Grup',
+ category: 'Kategori',
+ method: 'Ödeme türü',
+ any: 'Tümü',
+ noMethod: 'Girilmemiş',
+ clear: 'Filtreleri temizle',
+ filteredNote: 'Filtrelere göre',
+ unfilteredNote: 'Filtrelerden bağımsız',
+ empty: 'Bu filtrelerle harcama yok.',
+ total: 'Toplam',
+ count: 'Kayıt',
+ share: 'Pay',
+ amount: 'Tutar',
+ // Gelir–gider
+ periodsTitle: 'Gelir ve gider (dönemler)',
+ periodsHint: 'Son 12 dönem: gelir ile sabit giderler + harcamaların karşılaştırması.',
+ period: 'Dönem',
+ income: 'Gelir',
+ fixed: 'Sabit gider',
+ spent: 'Harcanan',
+ expense: 'Gider',
+ realized: 'Gerçekleşen birikim',
+ rate: 'Birikim oranı',
+ rateTitle: 'Birikim oranı trendi',
+ rateHint: 'Gelirin yüzde kaçı birikti (gerçekleşen birikim ÷ gelir).',
+ rateSummary: (name: string, rate: string) => `Son dönem (${name}) birikim oranı: ${rate}.`,
+ periodsSummary: (n: number) => `${n} dönemin gelir ve gider karşılaştırması aşağıdaki tabloda.`,
+ noPeriods: 'Henüz dönem yok.',
+ // Kırılımlar
+ groupsTitle: 'Gruplara göre harcama',
+ categoriesTitle: 'Kategorilere göre harcama',
+ categoriesHint: 'Harcama olan tüm kategoriler, çoktan aza.',
+ methodsTitle: 'Ödeme türüne göre harcama',
+ monthsTitle: 'Aylara göre harcama',
+ // Bütçe sapması
+ deviationTitle: 'Bütçe sapması',
+ deviationHint: (name: string) => `${name}: grup bütçeleri ile gerçekleşen harcama.`,
+ planned: 'Planlanan',
+ diff: 'Fark',
+ used: 'Kullanım',
+ over: 'aşıldı',
+ weeksTitle: 'Haftalara göre (tüm gruplar)',
+ week: (n: number, range: string) => `${n}. hafta (${range})`,
+ noDeviationPeriod: 'Bütçe sapması için bir dönem gerekiyor.',
+ // Varlıklar
+ assetsTitle: 'Birikim / varlık performansı',
+ assetsHint: 'Eldeki varlıkların güncel değeri ve kâr/zarar.',
+ asset: 'Varlık',
+ quantity: 'Miktar',
+ cost: 'Maliyet',
+ value: 'Güncel değer',
+ unrealized: 'Kâr / zarar',
+ realizedPl: 'Gerçekleşen kâr / zarar',
+ noAssets: 'Henüz birikim kaydı yok.',
+ file: {
+ periods: 'gelir-gider',
+ groups: 'gruplar',
+ categories: 'kategoriler',
+ methods: 'odeme-turleri',
+ months: 'aylar',
+ deviation: 'butce-sapmasi',
+ assets: 'varliklar',
+ },
+ },
+
periods: {
picker: 'Dönem',
status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' },
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 12aba2b..0a52d41 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -5,9 +5,12 @@ import { RouterProvider } from 'react-router'
import './index.css'
import { queryClient } from '@/lib/query'
import { Toaster } from '@/components/toaster'
+import { installPrintTheme } from '@/lib/print'
import { ThemeProvider } from '@/lib/theme'
import { router } from './router'
+installPrintTheme()
+
createRoot(document.getElementById('root')!).render(