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:
+2
-1
@@ -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
@@ -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 }) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user