feat(raporlar): raporlar sayfası, CSV dışa aktarma ve yazdırma

- Filtreler (kapsam, grup, kategori, ödeme türü) adreste
- Gruplar, tüm kategoriler, ödeme türleri, aylar; bütçe sapması; gelir–gider ve birikim oranı; varlık performansı
- Her bölümde Excel uyumlu CSV (BOM, ;) ve tek bölüm yazdırma
- Yazdırmada açık tema, menüler gizli, kapsam satırı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 01:59:58 +03:00
parent 87409590a6
commit feb2e1d16c
18 changed files with 1594 additions and 10 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası # 🧠 Proje Hafızası
## ▶️ Sıradaki Adım ## ▶️ Sıradaki Adım
**Faz 9 – Adım 2:** Raporlar sayfası (`/raporlar`, `features/reports/`): filtreler adreste (`?donem=`/`baslangic`/`bitis`/`grup`/`kategori`/`odeme`), raporlar: gelir–gider (dönemler, `useStats` → çubuk), birikim oranı trendi (`realized_savings ÷ income_total`, çizgi), kategori kırılımı (**tüm** kategoriler tablo + yatay çubuk, `useReportBreakdown`), ödeme türü dağılımı, bütçe sapması (seçili dönem `useBudget`: grup aylık + hafta), varlık performansı (`usePortfolio` kâr/zarar). Her bölümde CSV (UTF-8 BOM, `;`, ondalık virgül — `lib/csv.ts` + test) ve Yazdır (`@media print`: menü/düğmeler gizli). Recharts lazy, ham CSS renkleri. **Faz 9 – Adım 3:** Uygulamanın kendi CSV'si (harcamalar, plan kalemleri, varlık işlemleri): dışa aktarma (backend ya da frontend; `lib/csv.ts` biçimi: BOM, `;`, `1250,50`, GG.AA.YYYY) + içe aktarma (önizleme → satır satır hata → geçerliler aktarılır; kategori/ödeme türü **adla** eşleşir; dönem tarihten bulunur, yazılamaz dönem → satır hatası). Adım 4 (ekstre) bunun üstüne kurulacak: ortak satır doğrulama/aktarma servisi tasarla.
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅. **Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
## 📌 Alınan Kararlar ## 📌 Alınan Kararlar
@@ -134,3 +134,4 @@
### 2026-11-13 — Faz 9 ### 2026-11-13 — Faz 9
- **Adım 1:** Rapor verileri. `/stats/periods` → `PeriodStat` artık `summary: budget::Summary` taşır; DTO'ya `income_total`, `fixed_expense_total`, `savings_goal`, `carryover_debt/surplus`, `realized_savings` (= review 4. soru) eklendi. `services/reports.rs` + `GET /reports/breakdown` (tek `$facet`: toplam, 4 grup sabit sırada (0 dahil), **tüm** kategoriler çoktan aza, ödeme türü (`$ifNull` → `null` = girilmemiş), ay `$substrBytes`; filtre dönem/tarih aralığı (dahil, başlangıç>bitiş 400)/grup/kategori/ödeme türü (`none`)). Bütçe sapması için yeni uç nokta yok (mevcut `/budget`). `tests/reports.rs` (1, kapsamlı) + stats testi genişledi. Mutasyon 2/2. Backend 155 test. - **Adım 1:** Rapor verileri. `/stats/periods` → `PeriodStat` artık `summary: budget::Summary` taşır; DTO'ya `income_total`, `fixed_expense_total`, `savings_goal`, `carryover_debt/surplus`, `realized_savings` (= review 4. soru) eklendi. `services/reports.rs` + `GET /reports/breakdown` (tek `$facet`: toplam, 4 grup sabit sırada (0 dahil), **tüm** kategoriler çoktan aza, ödeme türü (`$ifNull` → `null` = girilmemiş), ay `$substrBytes`; filtre dönem/tarih aralığı (dahil, başlangıç>bitiş 400)/grup/kategori/ödeme türü (`none`)). Bütçe sapması için yeni uç nokta yok (mevcut `/budget`). `tests/reports.rs` (1, kapsamlı) + stats testi genişledi. Mutasyon 2/2. Backend 155 test.
- **Adım 2:** Raporlar sayfası (`features/reports/`): `page.tsx` (filtre adreste: `donem`/`baslangic`/`bitis`/`grup`/`kategori`/`odeme` (`yok` = girilmemiş), dönem varsa tarih yok sayılır), `filters.tsx` (Kapsam: tüm zamanlar / dönem / tarih aralığı (yıl başı → bugün varsayılan), grup seçilince kategori listesi daralır, mobilde katlanır), `section.tsx` (`ReportSection`: `data-report`, CSV indir + bölüm yazdır; `ShareTable`: satır içi CSS pay çubukları — yazdırmada da görünür), `sections.tsx` (gruplar, ödeme türleri, **tüm** kategoriler, aylar = filtreli; bütçe sapması = seçili/açık/son dönem, grup + hafta; gelir–gider + birikim oranı = son 12 dönem; varlıklar), `charts.tsx` lazy (bar + çizgi). `lib/csv.ts` (BOM, `;`, CRLF, tırnaklama, `csvMoney` binliksiz), `lib/reports.ts` (`parseFilter`/`filterToParams`/`filterToQuery`, `describeFilter` → yazdırmada kapsam satırı, `savingsRate`, `deviation`, `formatMonth`), `lib/print.ts` (`installPrintTheme`: beforeprint'te koyu tema kaldırılır; `printSection`: `data-print-one` + `data-print-target`), index.css `@media print`, layout `print:hidden`. Kayıt değişince `['reports']` yenilenir. Frontend 121 test; e2e 34 kontrol (CSV baytları, yazdırma hedefi, tema, filtreler). Not: `Blob.text()` BOM'u siler → BOM'u baytlardan doğrula.
+1 -1
View File
@@ -80,7 +80,7 @@
## Faz 9 Adımları ## Faz 9 Adımları
- ✅ 1. Backend: rapor verileri — dönem istatistiklerine gelir/sabit gider/devir/gerçekleşen birikim (birikim oranı için) + `GET /reports/breakdown` (grup / **tüm kategoriler** / ödeme türü / ay; filtre: dönem ya da tarih aralığı, grup, kategori, ödeme türü). Bütçe sapması mevcut `budget` verisinden - ✅ 1. Backend: rapor verileri — dönem istatistiklerine gelir/sabit gider/devir/gerçekleşen birikim (birikim oranı için) + `GET /reports/breakdown` (grup / **tüm kategoriler** / ödeme türü / ay; filtre: dönem ya da tarih aralığı, grup, kategori, ödeme türü). Bütçe sapması mevcut `budget` verisinden
- ⏳ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır - ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır
- ⏳ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu - ⏳ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
- ⏳ 4. **Banka / kredi kartı ekstresi içe aktarma** (kullanıcı isteği): CSV/Excel, sütun eşleştirme (banka profili kaydedilir), harcama/gelir yönü, tekrar eden satır tespiti, açıklamadan kategori önerisi (öğrenen kural), grup/kategori eşleştirmesi kullanıcıda; eksik aylar için **geçmiş (arşiv) dönemler** — amaç yıl başından beri veri → sonraki aylara referans (ortalama önerileri) - ⏳ 4. **Banka / kredi kartı ekstresi içe aktarma** (kullanıcı isteği): CSV/Excel, sütun eşleştirme (banka profili kaydedilir), harcama/gelir yönü, tekrar eden satır tespiti, açıklamadan kategori önerisi (öğrenen kural), grup/kategori eşleştirmesi kullanıcıda; eksik aylar için **geçmiş (arşiv) dönemler** — amaç yıl başından beri veri → sonraki aylara referans (ortalama önerileri)
- ⏳ 5. JSON tam hane yedeği: sürümlü dışa aktarma; geri yüklemede önizleme + onay + sunucuda otomatik geri dönüş noktası ("geri yüklemeyi geri al"); başka haneden taşıma - ⏳ 5. JSON tam hane yedeği: sürümlü dışa aktarma; geri yüklemede önizleme + onay + sunucuda otomatik geri dönüş noktası ("geri yüklemeyi geri al"); başka haneden taşıma
@@ -19,14 +19,14 @@ export function AppLayout() {
<Sidebar /> <Sidebar />
{/* Mobil üst çubuk */} {/* Mobil üst çubuk */}
<header className="sticky top-0 z-20 flex h-14 items-center border-b bg-background/90 px-4 backdrop-blur md:hidden"> <header className="sticky top-0 z-20 flex h-14 items-center border-b bg-background/90 px-4 backdrop-blur md:hidden print:hidden">
<Logo size={28} withText /> <Logo size={28} withText />
</header> </header>
{/* md:pl-64 → kenar çubuğu kadar boşluk; içerik bu alanda ortalanır. {/* md:pl-64 → kenar çubuğu kadar boşluk; içerik bu alanda ortalanır.
pb-28 → mobilde alt menünün altında içerik kalmasın. */} pb-28 → mobilde alt menünün altında içerik kalmasın. */}
<div className="md:pl-64"> <div className="md:pl-64 print:pl-0">
<main className="mx-auto w-full max-w-5xl px-4 pt-6 pb-28 md:px-8 md:pt-8 md:pb-10"> <main className="mx-auto w-full max-w-5xl px-4 pt-6 pb-28 md:px-8 md:pt-8 md:pb-10 print:max-w-none print:p-0">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
@@ -39,7 +39,7 @@ export function AppLayout() {
function Sidebar() { function Sidebar() {
return ( return (
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r bg-sidebar md:flex"> <aside className="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r bg-sidebar md:flex print:hidden">
<div className="flex h-16 items-center px-5"> <div className="flex h-16 items-center px-5">
<Logo size={32} withText /> <Logo size={32} withText />
</div> </div>
@@ -78,7 +78,7 @@ function BottomNav() {
return ( return (
<nav <nav
aria-label={t.nav.main} aria-label={t.nav.main}
className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden" className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden print:hidden"
> >
<ul className="grid grid-cols-5"> <ul className="grid grid-cols-5">
{bottomNav.map(({ to, label, icon: Icon }) => ( {bottomNav.map(({ to, label, icon: Icon }) => (
+127
View File
@@ -0,0 +1,127 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { formatCompact, formatMoney, formatPercent } from '@/lib/format'
import { savingsRate } from '@/lib/reports'
import { t } from '@/lib/strings'
import type { PeriodStat } from '@/types/PeriodStat'
const s = t.reports
/** Ham CSS değişkenleri (`--color-*` `@theme inline` yüzünden derlenmiş CSS'te olmayabilir) */
const C = {
income: 'var(--income)',
expense: 'var(--expense)',
primary: 'var(--primary)',
grid: 'var(--border)',
axis: 'var(--muted-foreground)',
}
const tooltipStyle = {
background: 'var(--popover)',
color: 'var(--popover-foreground)',
border: '1px solid var(--border)',
borderRadius: 12,
fontSize: 13,
}
/** Dönem adını eksende kısalt: "Kasım 2026" → "Kas 26" */
const short = (name: string) => {
const m = /^(\S+)\s+(\d{4})$/.exec(name)
return m ? `${m[1].slice(0, 3)} ${m[2].slice(2)}` : name
}
/** Raporlar sayfasının grafikleri (Recharts → ayrı parça) */
export default function ReportChart({
kind,
periods,
}: {
kind: 'periods' | 'rate'
periods: PeriodStat[]
}) {
const data = periods.map((p) => ({
label: short(p.name),
name: p.name,
income: p.income_total,
expense: p.fixed_expense_total + p.spent,
rate: savingsRate(p),
}))
return (
<div className="h-64 sm:h-72" aria-hidden>
<ResponsiveContainer width="100%" height="100%">
{kind === 'periods' ? (
<BarChart data={data} margin={{ top: 5, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={C.grid} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" stroke={C.axis} tick={{ fontSize: 12 }} tickLine={false} />
<YAxis
stroke={C.axis}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
width={48}
tickFormatter={(v: number) => formatCompact(v)}
/>
<Tooltip
formatter={(v) => (typeof v === 'number' ? formatMoney(v) : '—')}
labelFormatter={(_, p) => p?.[0]?.payload?.name ?? ''}
contentStyle={tooltipStyle}
/>
<Legend wrapperStyle={{ fontSize: 12 }} itemSorter={null} />
<Bar
dataKey="income"
name={s.income}
fill={C.income}
radius={[4, 4, 0, 0]}
isAnimationActive={false}
/>
<Bar
dataKey="expense"
name={s.expense}
fill={C.expense}
radius={[4, 4, 0, 0]}
isAnimationActive={false}
/>
</BarChart>
) : (
<LineChart data={data} margin={{ top: 5, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={C.grid} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" stroke={C.axis} tick={{ fontSize: 12 }} tickLine={false} />
<YAxis
stroke={C.axis}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
width={48}
tickFormatter={(v: number) => formatPercent(v, 0)}
/>
<ReferenceLine y={0} stroke={C.axis} />
<Tooltip
formatter={(v) => (typeof v === 'number' ? formatPercent(v) : '—')}
labelFormatter={(_, p) => p?.[0]?.payload?.name ?? ''}
contentStyle={tooltipStyle}
/>
<Line
dataKey="rate"
name={s.rate}
stroke={C.primary}
strokeWidth={3}
dot={{ r: 3, strokeWidth: 2, fill: 'var(--card)' }}
connectNulls
isAnimationActive={false}
/>
</LineChart>
)}
</ResponsiveContainer>
</div>
)
}
+186
View File
@@ -0,0 +1,186 @@
import { useState, type ReactNode } from 'react'
import { ChevronRight, X } from 'lucide-react'
import { Segmented } from '@/components/segmented'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toIsoDate } from '@/lib/format'
import { GROUPS, type GroupCode } from '@/lib/groups'
import { activeFilterCount, type ReportFilter } from '@/lib/reports'
import { t } from '@/lib/strings'
import type { CatalogItem } from '@/types/CatalogItem'
import type { Period } from '@/types/Period'
const s = t.reports
const select = 'h-11 w-full rounded-lg border bg-background px-3 text-sm'
type Scope = 'all' | 'period' | 'range'
/** Rapor filtreleri. Mobilde katlanır; seçim adrese yazılır (sayfa yenilense de kalır). */
export function Filters({
filter: f,
onChange,
periods,
categories,
methods,
}: {
filter: ReportFilter
onChange: (f: ReportFilter) => void
periods: Period[]
categories: CatalogItem[]
methods: CatalogItem[]
}) {
const [open] = useState(
() => activeFilterCount(f) > 0 || window.matchMedia('(min-width: 768px)').matches,
)
const scope: Scope = f.period ? 'period' : f.from || f.to ? 'range' : 'all'
const count = activeFilterCount(f)
const cats = categories.filter((c) => !f.group || c.group === f.group)
function setScope(next: Scope) {
const rest = { ...f, period: undefined, from: undefined, to: undefined }
if (next === 'period') onChange({ ...rest, period: periods[0]?.id })
else if (next === 'range') {
const today = toIsoDate(new Date())
onChange({ ...rest, from: `${today.slice(0, 4)}-01-01`, to: today })
} else onChange(rest)
}
return (
<details open={open} className="group rounded-2xl border bg-card p-4 sm:p-5 print:hidden">
<summary className="flex min-h-11 cursor-pointer items-center justify-between font-semibold">
{s.filterCount(count)}
<ChevronRight className="size-4 transition-transform group-open:rotate-90" aria-hidden />
</summary>
<div className="mt-3 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">{s.scope}</p>
<Segmented
label={s.scope}
value={scope}
onChange={setScope}
options={[
{ value: 'all', label: s.allTime },
{ value: 'period', label: s.byPeriod },
{ value: 'range', label: s.byRange },
]}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{scope === 'period' && (
<Field label={s.byPeriod} id="f-period">
<select
id="f-period"
name="period"
className={select}
value={f.period ?? ''}
onChange={(e) => onChange({ ...f, period: e.target.value || undefined })}
>
{periods.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</Field>
)}
{scope === 'range' && (
<>
<Field label={s.from} id="f-from">
<Input
id="f-from"
name="from"
type="date"
value={f.from ?? ''}
max={f.to}
onChange={(e) => onChange({ ...f, from: e.target.value || undefined })}
/>
</Field>
<Field label={s.to} id="f-to">
<Input
id="f-to"
name="to"
type="date"
value={f.to ?? ''}
min={f.from}
onChange={(e) => onChange({ ...f, to: e.target.value || undefined })}
/>
</Field>
</>
)}
<Field label={s.group} id="f-group">
<select
id="f-group"
name="group"
className={select}
value={f.group ?? ''}
onChange={(e) => {
const group = (e.target.value || undefined) as GroupCode | undefined
// Seçili kategori başka gruptaysa bırak
const keep = categories.find((c) => c.id === f.category)?.group === group
onChange({ ...f, group, category: keep ? f.category : undefined })
}}
>
<option value="">{s.any}</option>
{GROUPS.map((g) => (
<option key={g.code} value={g.code}>
{g.emoji} {g.label}
</option>
))}
</select>
</Field>
<Field label={s.category} id="f-category">
<select
id="f-category"
name="category"
className={select}
value={f.category ?? ''}
onChange={(e) => onChange({ ...f, category: e.target.value || undefined })}
>
<option value="">{s.any}</option>
{cats.map((c) => (
<option key={c.id} value={c.id}>
{c.emoji ? `${c.emoji} ` : ''}
{c.name}
</option>
))}
</select>
</Field>
<Field label={s.method} id="f-method">
<select
id="f-method"
name="method"
className={select}
value={f.method ?? ''}
onChange={(e) => onChange({ ...f, method: e.target.value || undefined })}
>
<option value="">{s.any}</option>
<option value="none">{s.noMethod}</option>
{methods.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</Field>
</div>
{count > 0 && (
<Button variant="ghost" onClick={() => onChange({})}>
<X aria-hidden />
{s.clear}
</Button>
)}
</div>
</details>
)
}
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
return (
<div className="space-y-2">
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
{children}
</div>
)
}
+106 -3
View File
@@ -1,11 +1,114 @@
import { ComingSoon, PageHeader } from '@/components/page' import { Printer } from 'lucide-react'
import { useSearchParams } from 'react-router'
import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Skeleton } from '@/components/skeleton'
import { Button } from '@/components/ui/button'
import { usePortfolio } from '@/lib/assets'
import { useCategories, usePaymentMethods } from '@/lib/catalog'
import { useCurrentPeriod, usePeriods, useStats } from '@/lib/periods'
import { printSection } from '@/lib/print'
import {
describeFilter,
filterToParams,
parseFilter,
useReportBreakdown,
type ReportFilter,
} from '@/lib/reports'
import { t } from '@/lib/strings' import { t } from '@/lib/strings'
import { Filters } from './filters'
import {
AssetsReport,
CategoriesReport,
DeviationReport,
GroupsReport,
MethodsReport,
MonthsReport,
PeriodsReport,
RateReport,
} from './sections'
const s = t.reports
/**
* Raporlar: filtreler adreste (`?donem=`, `baslangic`, `bitis`, `grup`, `kategori`, `odeme`).
* Kırılımlar filtreye göre; dönemler arası özet ve varlıklar filtrelerden bağımsız;
* bütçe sapması seçili dönem (yoksa açık / son dönem). Her bölüm CSV + yazdır.
*/
export function ReportsPage() { export function ReportsPage() {
const [params, setParams] = useSearchParams()
const filter = parseFilter(params)
const periods = usePeriods()
const current = useCurrentPeriod()
const categories = useCategories(true)
const methods = usePaymentMethods(true)
const stats = useStats(12)
const breakdown = useReportBreakdown(filter)
const portfolio = usePortfolio()
const setFilter = (f: ReportFilter) => setParams(filterToParams(f), { replace: true })
const failed = [periods, categories, methods].find((q) => q.isError)
const devPeriod =
periods.data?.find((p) => p.id === filter.period) ?? current.data ?? periods.data?.[0] ?? null
return ( return (
<> <>
<PageHeader title={t.pages.reports.title} description={t.pages.reports.description} /> <PageHeader
<ComingSoon when="Faz 9'da" /> title={t.pages.reports.title}
description={t.pages.reports.description}
actions={
<Button variant="outline" className="print:hidden" onClick={() => printSection()}>
<Printer aria-hidden />
{s.printAll}
</Button>
}
/>
{failed?.error ? (
<PageError message={failed.error.message} onRetry={failed.refetch} />
) : !periods.data || !categories.data || !methods.data ? (
<PageLoading />
) : (
<div className="space-y-4">
{/* Yalnız yazdırmada: kağıtta hangi kapsamın basıldığı belli olsun */}
<p className="hidden text-sm print:block">
{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,
})}
</p>
<Filters
filter={filter}
onChange={setFilter}
periods={periods.data}
categories={categories.data}
methods={methods.data}
/>
{breakdown.isError ? (
<PageError message={breakdown.error.message} onRetry={breakdown.refetch} />
) : !breakdown.data ? (
<Skeleton className="h-64" />
) : (
<div className="grid items-start gap-4 lg:grid-cols-2 print:block print:space-y-4">
<GroupsReport b={breakdown.data} />
<MethodsReport b={breakdown.data} methods={methods.data} />
<div className="lg:col-span-2">
<CategoriesReport b={breakdown.data} categories={categories.data} />
</div>
<div className="lg:col-span-2">
<MonthsReport b={breakdown.data} />
</div>
</div>
)}
<DeviationReport period={devPeriod} />
{stats.data && (
<div className="grid items-start gap-4 lg:grid-cols-2 print:block print:space-y-4">
<PeriodsReport periods={stats.data.periods} />
<RateReport periods={stats.data.periods} />
</div>
)}
{portfolio.data && <AssetsReport p={portfolio.data} />}
</div>
)}
</> </>
) )
} }
+145
View File
@@ -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<HTMLElement>(null)
return (
<section
ref={ref}
data-report
aria-labelledby={`${id}-title`}
className="rounded-2xl border bg-card p-4 sm:p-5 print:rounded-none print:border-0 print:p-0 print:pb-6"
>
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h2 id={`${id}-title`} className="font-semibold">
{title}
</h2>
{(hint || note) && (
<p className="text-sm text-muted-foreground">
{hint}
{hint && note && ' · '}
{note}
</p>
)}
</div>
<div className="flex gap-1 print:hidden">
{csv && (
<Button
variant="ghost"
size="sm"
onClick={() =>
downloadCsv(
`kakeibo-${csv.file}-${toIsoDate(new Date())}`,
toCsv(csv.headers, csv.rows),
)
}
>
<Download aria-hidden />
{s.csv}
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => printSection(ref.current)}>
<Printer aria-hidden />
{s.print}
</Button>
</div>
</div>
{children}
</section>
)
}
/** 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 (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="py-2 pr-2 font-medium">{firstHeader}</th>
{showCount && <th className="px-2 py-2 text-right font-medium">{s.count}</th>}
<th className="px-2 py-2 text-right font-medium">{s.share}</th>
<th className="py-2 pl-2 text-right font-medium">{s.amount}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const ratio = total > 0 ? r.amount / total : 0
return (
<tr key={r.key} className="border-b last:border-0">
<td className="py-2 pr-2">
<div className="min-w-32">{r.label}</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
<div
className={cn('h-full rounded-full', r.color ?? 'bg-primary')}
style={{ width: `${ratio * 100}%` }}
/>
</div>
</td>
{showCount && <td className="money px-2 py-2 text-right">{r.count ?? ''}</td>}
<td className="money px-2 py-2 text-right text-muted-foreground">
{formatPercent(ratio)}
</td>
<td className="money py-2 pl-2 text-right font-medium whitespace-nowrap">
{formatMoney(r.amount)}
</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr className="border-t font-semibold">
<td className="py-2 pr-2">{s.total}</td>
{showCount && (
<td className="money px-2 py-2 text-right">
{rows.reduce((a, r) => a + (r.count ?? 0), 0)}
</td>
)}
<td className="px-2 py-2" />
<td className="money py-2 pl-2 text-right whitespace-nowrap">{formatMoney(total)}</td>
</tr>
</tfoot>
</table>
</div>
)
}
+559
View File
@@ -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 = <Skeleton className="h-64 sm:h-72" />
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 (
<ReportSection
id="r-periods"
title={s.periodsTitle}
hint={s.periodsHint}
note={s.unfilteredNote}
csv={{
file: s.file.periods,
headers: [s.period, s.from, s.to, s.income, s.fixed, s.spent, s.realized, `${s.rate} (%)`],
rows: periods.map((p) => [
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 ? (
<p className="text-sm text-muted-foreground">{s.noPeriods}</p>
) : (
<>
<Suspense fallback={chartFallback}>
<ReportChart kind="periods" periods={periods} />
</Suspense>
<p className="sr-only">{s.periodsSummary(periods.length)}</p>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="py-2 pr-2 font-medium">{s.period}</th>
<th className="px-2 py-2 text-right font-medium">{s.income}</th>
<th className="px-2 py-2 text-right font-medium">{s.expense}</th>
<th className="px-2 py-2 text-right font-medium">{s.realized}</th>
<th className="py-2 pl-2 text-right font-medium">{s.rate}</th>
</tr>
</thead>
<tbody>
{[...periods].reverse().map((p) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2 pr-2 whitespace-nowrap">{p.name}</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(p.income_total)}
</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(p.fixed_expense_total + p.spent)}
</td>
<td
className={cn(
'money px-2 py-2 text-right whitespace-nowrap',
p.realized_savings < 0 && 'text-expense',
)}
>
{formatMoney(p.realized_savings)}
</td>
<td className="money py-2 pl-2 text-right">{pct(savingsRate(p))}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</ReportSection>
)
}
export function RateReport({ periods }: { periods: PeriodStat[] }) {
const last = periods.at(-1)
return (
<ReportSection id="r-rate" title={s.rateTitle} hint={s.rateHint} note={s.unfilteredNote}>
{periods.length === 0 ? (
<p className="text-sm text-muted-foreground">{s.noPeriods}</p>
) : (
<>
<Suspense fallback={chartFallback}>
<ReportChart kind="rate" periods={periods} />
</Suspense>
{last && (
<p className="mt-3 text-sm text-muted-foreground">
{s.rateSummary(last.name, pct(savingsRate(last)))}
</p>
)}
</>
)}
</ReportSection>
)
}
// ---------- Kırılımlar (filtrelere göre) ----------
export function GroupsReport({ b }: { b: ReportBreakdown }) {
return (
<ReportSection
id="r-groups"
title={s.groupsTitle}
note={s.filteredNote}
csv={{
file: s.file.groups,
headers: [s.group, s.count, `${s.share} (%)`, s.amount],
rows: b.groups.map((r) => [
group(r.group).label,
r.count,
csvPercent(b.total ? r.total / b.total : 0),
csvMoney(r.total),
]),
}}
>
<ShareTable
firstHeader={s.group}
total={b.total}
rows={b.groups.map((r) => {
const g = group(r.group)
return {
key: r.group,
label: (
<span className={g.className.text}>
{g.emoji} {g.label}
</span>
),
amount: r.total,
count: r.count,
color: g.className.fill,
}
})}
/>
</ReportSection>
)
}
export function CategoriesReport({
b,
categories,
}: {
b: ReportBreakdown
categories: CatalogItem[]
}) {
const name = (id: string) => categories.find((c) => c.id === id)
return (
<ReportSection
id="r-categories"
title={s.categoriesTitle}
hint={s.categoriesHint}
note={s.filteredNote}
csv={{
file: s.file.categories,
headers: [s.category, s.group, s.count, `${s.share} (%)`, s.amount],
rows: b.categories.map((r) => [
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 ? (
<p className="text-sm text-muted-foreground">{s.empty}</p>
) : (
<ShareTable
firstHeader={s.category}
total={b.total}
rows={b.categories.map((r) => {
const c = name(r.category_id)
const g = group(r.group)
return {
key: r.category_id,
label: (
<>
{c?.emoji ? `${c.emoji} ` : ''}
{c?.name ?? '—'}{' '}
<span className={cn('text-xs', g.className.text)}>{g.label}</span>
</>
),
amount: r.total,
count: r.count,
color: g.className.fill,
}
})}
/>
)}
</ReportSection>
)
}
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 (
<ReportSection
id="r-methods"
title={s.methodsTitle}
note={s.filteredNote}
csv={{
file: s.file.methods,
headers: [s.method, s.count, `${s.share} (%)`, s.amount],
rows: b.payment_methods.map((r) => [
name(r.payment_method_id),
r.count,
csvPercent(b.total ? r.total / b.total : 0),
csvMoney(r.total),
]),
}}
>
{b.payment_methods.length === 0 ? (
<p className="text-sm text-muted-foreground">{s.empty}</p>
) : (
<ShareTable
firstHeader={s.method}
total={b.total}
rows={b.payment_methods.map((r) => ({
key: r.payment_method_id ?? 'none',
label: name(r.payment_method_id),
amount: r.total,
count: r.count,
}))}
/>
)}
</ReportSection>
)
}
export function MonthsReport({ b }: { b: ReportBreakdown }) {
return (
<ReportSection
id="r-months"
title={s.monthsTitle}
note={s.filteredNote}
csv={{
file: s.file.months,
headers: [s.period, s.count, s.amount],
rows: b.months.map((r) => [formatMonth(r.month), r.count, csvMoney(r.total)]),
}}
>
{b.months.length === 0 ? (
<p className="text-sm text-muted-foreground">{s.empty}</p>
) : (
<ShareTable
firstHeader={s.period}
total={b.total}
rows={b.months.map((r) => ({
key: r.month,
label: formatMonth(r.month),
amount: r.total,
count: r.count,
}))}
/>
)}
</ReportSection>
)
}
// ---------- Bütçe sapması (tek dönem) ----------
export function DeviationReport({ period }: { period: Period | null }) {
if (!period) {
return (
<ReportSection id="r-deviation" title={s.deviationTitle}>
<p className="text-sm text-muted-foreground">{s.noDeviationPeriod}</p>
</ReportSection>
)
}
return <DeviationView period={period} />
}
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 (
<ReportSection
id="r-deviation"
title={s.deviationTitle}
hint={s.deviationHint(period.name)}
csv={{
file: s.file.deviation,
headers: [s.group, s.planned, s.spent, s.diff, `${s.used} (%)`],
rows: [
...rows.map((r) => [
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 ? (
<PageError message={budget.error.message} onRetry={budget.refetch} />
) : !budget.data ? (
<PageLoading />
) : (
<div className="space-y-4 overflow-x-auto">
<DevTable
first={s.group}
rows={[
...rows.map((r) => {
const g = group(r.group)
return {
key: r.group,
label: (
<span className={g.className.text}>
{g.emoji} {g.label}
</span>
),
...r,
}
}),
]}
total={{ ...totals, diff: totals.planned - totals.spent }}
/>
<h3 className="pt-2 text-sm font-medium">{s.weeksTitle}</h3>
<DevTable
first={s.period}
rows={weeks.map(({ w, planned, spent, diff }) => ({
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,
}))}
/>
</div>
)}
</ReportSection>
)
}
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 (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="py-2 pr-2 font-medium">{first}</th>
<th className="px-2 py-2 text-right font-medium">{s.planned}</th>
<th className="px-2 py-2 text-right font-medium">{s.spent}</th>
<th className="px-2 py-2 text-right font-medium">{s.diff}</th>
<th className="py-2 pl-2 text-right font-medium">{s.used}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.key} className="border-b last:border-0">
<td className="py-2 pr-2 whitespace-nowrap">{r.label}</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(r.planned)}
</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">{formatMoney(r.spent)}</td>
<td
className={cn(
'money px-2 py-2 text-right whitespace-nowrap',
r.diff < 0 && 'text-expense',
)}
>
{signed(r.diff)}
</td>
<td className={cn('money py-2 pl-2 text-right', (r.used ?? 0) > 1 && 'text-expense')}>
{pct(r.used)}
</td>
</tr>
))}
</tbody>
{total && (
<tfoot>
<tr className="border-t font-semibold">
<td className="py-2 pr-2">{s.total}</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(total.planned)}
</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(total.spent)}
</td>
<td
className={cn(
'money px-2 py-2 text-right whitespace-nowrap',
total.diff < 0 && 'text-expense',
)}
>
{signed(total.diff)}
</td>
<td className="money py-2 pl-2 text-right">
{pct(total.planned ? total.spent / total.planned : null)}
</td>
</tr>
</tfoot>
)}
</table>
)
}
// ---------- 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 (
<ReportSection
id="r-assets"
title={s.assetsTitle}
hint={s.assetsHint}
note={s.unfilteredNote}
csv={{
file: s.file.assets,
headers: [
s.asset,
s.quantity,
s.cost,
s.value,
s.unrealized,
`${s.unrealized} (%)`,
s.realizedPl,
],
rows: items.map((i) => [
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 ? (
<p className="text-sm text-muted-foreground">{s.noAssets}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="py-2 pr-2 font-medium">{s.asset}</th>
<th className="px-2 py-2 text-right font-medium">{s.cost}</th>
<th className="px-2 py-2 text-right font-medium">{s.value}</th>
<th className="py-2 pl-2 text-right font-medium">{s.unrealized}</th>
</tr>
</thead>
<tbody>
{items.map((i) => {
const pl = i.unrealized_pl
const r = pl === null ? null : formatPlPercent(plRatio(pl, i.asset.cost))
return (
<tr key={i.asset.id} className="border-b last:border-0">
<td className="py-2 pr-2">
<div className="font-medium">{i.asset.name}</div>
<div className="money text-xs text-muted-foreground">{qty(i.asset)}</div>
</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(i.asset.cost)}
</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{i.value === null ? '—' : formatMoney(i.value)}
</td>
<td
className={cn(
'money py-2 pl-2 text-right whitespace-nowrap',
(pl ?? 0) > 0 ? 'text-income' : (pl ?? 0) < 0 ? 'text-expense' : '',
)}
>
{pl === null ? '—' : signed(pl)}
{r && <div className="text-xs">{r}</div>}
</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr className="border-t font-semibold">
<td className="py-2 pr-2">{s.total}</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(p.cost_total)}
</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(p.value_total)}
</td>
<td className="money py-2 pl-2 text-right whitespace-nowrap">
{signed(p.unrealized_pl_total)}
</td>
</tr>
</tfoot>
</table>
{p.realized_pl_total !== 0 && (
<p className="mt-2 text-sm text-muted-foreground">
{s.realizedPl}: <span className="money">{signed(p.realized_pl_total)}</span>
</p>
)}
</div>
)}
</ReportSection>
)
}
+15
View File
@@ -265,3 +265,18 @@
@apply border-border; @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;
}
}
+29
View File
@@ -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('')
})
})
+43
View File
@@ -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)
}
+1
View File
@@ -23,6 +23,7 @@ function refresh(qc: QueryClient, periodId: string) {
return Promise.all([ return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }), qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
qc.invalidateQueries({ queryKey: queryKeys.stats }), qc.invalidateQueries({ queryKey: queryKeys.stats }),
qc.invalidateQueries({ queryKey: queryKeys.reports }),
]) ])
} }
+32
View File
@@ -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()
}
+3
View File
@@ -22,6 +22,9 @@ export const queryKeys = {
portfolio: ['portfolio'] as const, portfolio: ['portfolio'] as const,
assetTxs: (assetId: string) => ['portfolio', 'txs', assetId] as const, assetTxs: (assetId: string) => ['portfolio', 'txs', assetId] as const,
pendingSavings: ['portfolio', 'pending'] 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, period: (id: string) => ['period', id] as const,
planItems: (id: string) => ['period', id, 'plan-items'] as const, planItems: (id: string) => ['period', id, 'plan-items'] as const,
summary: (id: string) => ['period', id, 'summary'] as const, summary: (id: string) => ['period', id, 'summary'] as const,
+93
View File
@@ -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,
})
})
})
+167
View File
@@ -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<string, string> {
const out: Record<string, string> = {}
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<ReportBreakdown>(`/reports/breakdown${query}`),
placeholderData: keepPreviousData,
})
}
/** Birikim oranı: gerçekleşen birikim ÷ gelir (gelir yoksa `null`) */
export function savingsRate(
p: Pick<PeriodStat, 'realized_savings' | 'income_total'>,
): 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,
}
})
}
+77
View File
@@ -494,6 +494,83 @@ export const t = {
added: (name: string) => `${name} birikimi eklendi`, 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: { periods: {
picker: 'Dönem', picker: 'Dönem',
status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' }, status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' },
+3
View File
@@ -5,9 +5,12 @@ import { RouterProvider } from 'react-router'
import './index.css' import './index.css'
import { queryClient } from '@/lib/query' import { queryClient } from '@/lib/query'
import { Toaster } from '@/components/toaster' import { Toaster } from '@/components/toaster'
import { installPrintTheme } from '@/lib/print'
import { ThemeProvider } from '@/lib/theme' import { ThemeProvider } from '@/lib/theme'
import { router } from './router' import { router } from './router'
installPrintTheme()
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>