feat(rapor): aylık rapor ekranı (görüntüle, yazdır/PDF, Markdown indir)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 21:54:37 +03:00
parent dc0d783e46
commit c3ec91b2af
20 changed files with 271 additions and 23 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ npm run db:restore -- <dosya|latest> <hedef_db> [--force]
- Excel okuma: SheetJS **yerel paket** `frontend/vendor/xlsx-0.20.3.tgz` (npm 12 uzak tarball'u engeller; npm'deki `xlsx` eski/açıklı). Güncellerken: cdn.sheetjs.com'dan indir, içeriği/sha256'yı kontrol et, `npm i ./vendor/…`; Dockerfile `vendor/`'u `npm ci`'den önce kopyalar. Lazy yüklenir. - Excel okuma: SheetJS **yerel paket** `frontend/vendor/xlsx-0.20.3.tgz` (npm 12 uzak tarball'u engeller; npm'deki `xlsx` eski/açıklı). Güncellerken: cdn.sheetjs.com'dan indir, içeriği/sha256'yı kontrol et, `npm i ./vendor/…`; Dockerfile `vendor/`'u `npm ci`'den önce kopyalar. Lazy yüklenir.
- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç. - Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç.
- JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b. Arayüz: `features/settings/backup-section.tsx`, `lib/backup.ts` (geri yükleme/geri alma sonrası tüm sorgular yenilenir). - JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b. Arayüz: `features/settings/backup-section.tsx`, `lib/backup.ts` (geri yükleme/geri alma sonrası tüm sorgular yenilenir).
- Aylık rapor: `services/monthly_report.rs` (`gather` → `Facts` → saf `analyze` → `render`), şablon `templates/monthly_report.md` (`include_str!`; Dockerfile `templates/`'u kopyalar). Filtreler `tl`/`pct`/`cell`. Cümlelerde sayıdan sonra ek kullanma ("%429'u" gibi ünlü uyumu gerekir) → "X / Y", "kullanımı %N". - Aylık rapor: `services/monthly_report.rs` (`gather` → `Facts` → saf `analyze` → `render`), şablon `templates/monthly_report.md` (`include_str!`; Dockerfile `templates/`'u kopyalar). Filtreler `tl`/`pct`/`cell`. Şablonda ham HTML yok (arayüz göstermez). Arayüz `features/review/report-page.tsx` (`/degerlendirme/rapor?donem=`), `lib/monthly-report.ts`; `api.getText`; `Markdown` tabloları `.table-wrap` içinde kayar; yazdırma `@page A4` + `PageHeader printHidden`. Cümlelerde sayıdan sonra ek kullanma ("%429'u" gibi ünlü uyumu gerekir) → "X / Y", "kullanımı %N".
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404. - Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
## Ortam ## Ortam
+23 -4
View File
@@ -686,13 +686,21 @@ pub fn analyze(f: &Facts) -> Report {
let weeks: Vec<WeekRow> = f let weeks: Vec<WeekRow> = f
.weeks .weeks
.iter() .iter()
.map(|w| WeekRow { .map(|w| {
// Ara raporda henüz başlamamış hafta: oran ve işaret yok ("✅ %0" yanıltır)
let future = interim && w.start.as_str() > f.today.as_str();
WeekRow {
index: w.index, index: w.index,
range: format!("{} – {}", short(&w.start), short(&w.last)), range: format!("{} – {}", short(&w.start), short(&w.last)),
planned: w.planned, planned: w.planned,
spent: w.spent, spent: w.spent,
usage: ratio(w.spent, w.planned), usage: if future {
mark: mark(w.spent, w.planned), None
} else {
ratio(w.spent, w.planned)
},
mark: if future { "" } else { mark(w.spent, w.planned) },
}
}) })
.collect(); .collect();
let finished_weeks: Vec<&WeekFact> = f let finished_weeks: Vec<&WeekFact> = f
@@ -1310,6 +1318,12 @@ mod tests {
"3. hafta henüz bitmedi" "3. hafta henüz bitmedi"
); );
assert_eq!(r.suggestions[0].basis, "bu dönemin harcaması"); assert_eq!(r.suggestions[0].basis, "bu dönemin harcaması");
// 13 Kasım: 3. ve 4. hafta henüz başlamadı → oran / işaret yok
assert_eq!(
r.weeks[1].mark, "⚠️",
"2. hafta (6.000 / 6.650 = %90,2) sürüyor, oranı var"
);
assert!(r.weeks[2].usage.is_none() && r.weeks[3].mark.is_empty());
let md = render(&r).unwrap(); let md = render(&r).unwrap();
assert!( assert!(
@@ -1342,6 +1356,11 @@ mod tests {
assert!(md.contains("1. Market için haftalık")); assert!(md.contains("1. Market için haftalık"));
assert!(md.contains("- [ ] ____")); assert!(md.contains("- [ ] ____"));
assert!(md.contains("| Kredi Kartı | 20.000,00 ₺ | %72 |")); assert!(md.contains("| Kredi Kartı | 20.000,00 ₺ | %72 |"));
assert!(md.contains("Rapor tarihi: 02.12.2026.")); assert!(md.contains("Rapor tarihi: 02.12.2026._"));
assert!(
md.contains("\n_Nasıl hesaplandı:"),
"dipnot düz Markdown (ham HTML gösterilmez)"
);
assert!(!md.contains('<'), "şablonda ham HTML yok");
} }
} }
+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 6b:** Rapor arayüzü — Değerlendirme → "Aylık rapor" (`/degerlendirme/rapor?donem=`): `GET /periods/{id}/report.md` metnini `react-markdown` + `remark-gfm` ile göster (tablolar, görev kutuları), Yazdır/PDF (A4, açık tema, `print:` sınıfları, tablolar sayfa arasında bölünmesin, `<small>` dipnot), "Markdown indir" (Blob, dosya adı `kakeibo-rapor-<dönem>.md`), dönem seçici; kapatma sihirbazı sonucu + dönem geçmişinden "Raporu aç". Sonra Faz 9 ✅ → Faz 10. **Faz 10 planı:** PWA (vite-plugin-pwa, kurulabilir, çevrimdışı kabuk), performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** (ekle, düzenle, emoji, sırala, pasifleştir; raporlar tüm kategorileri gösteriyor). Önce kapsamı ve adımları kullanıcıya sun.
**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
@@ -146,3 +146,4 @@
- **Faz 9 – Adım 5a (2026-11-13):** `services/backup.rs` + `routes/backup.rs` (`/backup/download|status|preview|restore`, `/backup/restore-points/{id}/restore`; preview/restore/undo **sahip**, 20 MB). Biçim ham BSON belgeleri üzerinden (modeller eksik alanlı olduğu için tür tabanlı değil): `$uuid`/`$date`, `INT32_KEYS` (version, sort_order) + ayarlar i32 (`small_ints`), SHA-256 kanonik (sıralı anahtar) → yeniden biçimlendirilmiş dosya geçerli, değiştirilmiş reddedilir. **Silinmiş kayıtlar dahil** (tasarımdan sapma: referans bütünlüğü + birebir geri al). Migration v3 `restore_points` (transaction içinde ilk yazım için) + indeks. `flate2`, `sha2` doğrudan eklendi (zaten bağımlılık ağacında). Testler: 4 birim + 3 entegrasyon (aynı hane + geri al zinciri + 3 nokta sınırı, başka haneye iki kez kopya, bozuk/yabancı/yeni sürüm dosya, üye 403, başka hane 404). 3 bilinçli bozma yakalandı. Backend 190 test. - **Faz 9 – Adım 5a (2026-11-13):** `services/backup.rs` + `routes/backup.rs` (`/backup/download|status|preview|restore`, `/backup/restore-points/{id}/restore`; preview/restore/undo **sahip**, 20 MB). Biçim ham BSON belgeleri üzerinden (modeller eksik alanlı olduğu için tür tabanlı değil): `$uuid`/`$date`, `INT32_KEYS` (version, sort_order) + ayarlar i32 (`small_ints`), SHA-256 kanonik (sıralı anahtar) → yeniden biçimlendirilmiş dosya geçerli, değiştirilmiş reddedilir. **Silinmiş kayıtlar dahil** (tasarımdan sapma: referans bütünlüğü + birebir geri al). Migration v3 `restore_points` (transaction içinde ilk yazım için) + indeks. `flate2`, `sha2` doğrudan eklendi (zaten bağımlılık ağacında). Testler: 4 birim + 3 entegrasyon (aynı hane + geri al zinciri + 3 nokta sınırı, başka haneye iki kez kopya, bozuk/yabancı/yeni sürüm dosya, üye 403, başka hane 404). 3 bilinçli bozma yakalandı. Backend 190 test.
- **Faz 9 – Adım 5b (2026-11-13):** `features/settings/backup-section.tsx` (Veri bölümünün en üstü): "Yedeği indir" (`<a download>`, tıklayınca 1,5 sn sonra durum yenilenir) + hatırlatma (`daysSince`, hiç yok ya da ≥ 30 gün → uyarı rengi); sahip: dosya → önizleme (kaynak, aynı/başka hane, dönem aralığı, sayılar yedek/şimdi — farklı olan uyarı rengi, uyarılar) → onay kutusu (`#backup-confirm`; Radix `name`'i düğmeye taşımaz) → "Geri yükle" (destructive) → sonuç kutusu + "Geri al"; geri dönüş noktaları listesi (`window.confirm`). Üye: yalnız indirme + "yalnız sahip" notu. `lib/backup.ts` (geri yükleme/geri alma → `qc.invalidateQueries()` tümü), `api.postJsonText`, `queryKeys.backup`, `t.backup`. Frontend 134 test; e2e 27 kontrol (8091, DB silindi). - **Faz 9 – Adım 5b (2026-11-13):** `features/settings/backup-section.tsx` (Veri bölümünün en üstü): "Yedeği indir" (`<a download>`, tıklayınca 1,5 sn sonra durum yenilenir) + hatırlatma (`daysSince`, hiç yok ya da ≥ 30 gün → uyarı rengi); sahip: dosya → önizleme (kaynak, aynı/başka hane, dönem aralığı, sayılar yedek/şimdi — farklı olan uyarı rengi, uyarılar) → onay kutusu (`#backup-confirm`; Radix `name`'i düğmeye taşımaz) → "Geri yükle" (destructive) → sonuç kutusu + "Geri al"; geri dönüş noktaları listesi (`window.confirm`). Üye: yalnız indirme + "yalnız sahip" notu. `lib/backup.ts` (geri yükleme/geri alma → `qc.invalidateQueries()` tümü), `api.postJsonText`, `queryKeys.backup`, `t.backup`. Frontend 134 test; e2e 27 kontrol (8091, DB silindi).
- **Faz 9 – Adım 6a (2026-11-13):** `services/monthly_report.rs` + `routes/monthly_report.rs` (`GET /periods/{id}/report.md`, `text/markdown`), `templates/monthly_report.md` (minijinja 3.1, `serde` özelliği; trim/lstrip blocks; art arda boş satırlar sadeleştirilir). `minijinja` yeni bağımlılık (şablonda koşul/döngü). Dockerfile: `COPY templates/ /app/templates/` (`include_str!("../../../templates/…")`), `docker build --target api` geçti. Testler: 5 birim (biçim, eşikler, kapalı dönem yorumları, ara rapor + ilk dönem, tam şablon + hücre kaçışı) + 1 entegrasyon (Ekim kapalı → Kasım raporu: karşılaştırma, notlar, devir, öneri, 404). 5 bilinçli bozmadan 2'si önce kaçtı → eşik testleri eklendi, hepsi yakalandı. bson `DateTime::to_time_0_3` crate-içi → `from_unix_timestamp`. Backend 196 test. - **Faz 9 – Adım 6a (2026-11-13):** `services/monthly_report.rs` + `routes/monthly_report.rs` (`GET /periods/{id}/report.md`, `text/markdown`), `templates/monthly_report.md` (minijinja 3.1, `serde` özelliği; trim/lstrip blocks; art arda boş satırlar sadeleştirilir). `minijinja` yeni bağımlılık (şablonda koşul/döngü). Dockerfile: `COPY templates/ /app/templates/` (`include_str!("../../../templates/…")`), `docker build --target api` geçti. Testler: 5 birim (biçim, eşikler, kapalı dönem yorumları, ara rapor + ilk dönem, tam şablon + hücre kaçışı) + 1 entegrasyon (Ekim kapalı → Kasım raporu: karşılaştırma, notlar, devir, öneri, 404). 5 bilinçli bozmadan 2'si önce kaçtı → eşik testleri eklendi, hepsi yakalandı. bson `DateTime::to_time_0_3` crate-içi → `from_unix_timestamp`. Backend 196 test.
- **Faz 9 – Adım 6b (2026-11-13) → Faz 9 ✅:** `features/review/report-page.tsx` (`paths.monthlyReport = /degerlendirme/rapor`, dönem seçici, Yazdır/PDF `printSection()`, "Markdown indir" `downloadFile` (csv.ts'e genel yardımcı), `reportFileName` = backend slug). Bağlantılar: Değerlendirme başlığı, kapatma sonucu, dönem geçmişinde ayrı simge bağlantı (iç içe `<a>` olmasın). Bulunan/düzeltilen: (1) şablondaki `<small>` dipnot react-markdown'da hiç görünmüyordu → düz `_…_`; (2) telefonda tablolar sayfayı 541 px'e taşırıyordu (mobil emülasyonda `innerWidth` da büyür → kontrol 390 ile) → `Markdown` tablo sarmalayıcısı `.table-wrap`; (3) PDF ilk sayfada sayfa başlığı/dönem seçici → `PageHeader printHidden`; (4) ara raporda başlamamış haftalar "✅ %0" → "—"; (5) hedef kutuları sayfa sonunda bölünüyordu. PDF: Chrome `printToPDF` → 4 sayfa A4, `pdftoppm` ile gözle kontrol. Frontend 135, backend 196 test; e2e 22 kontrol (DB silindi).
+2 -2
View File
@@ -13,7 +13,7 @@
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ✅ | | **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ✅ |
| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ | | **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ |
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ✅ | | **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ✅ |
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | 🔄 | | **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | ✅ |
| **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | ⏳ | | **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | ⏳ |
| **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) | Masaüstü ve mobil uygulamalar | ⏳ | | **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) | Masaüstü ve mobil uygulamalar | ⏳ |
@@ -84,4 +84,4 @@
- ✅ 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** — 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) + 4b arayüz (`/ayarlar/ekstre`: Excel/CSV, sütun eşleştirme + profil, gözden geçirme) - ✅ 4. **Banka / kredi kartı ekstresi içe aktarma** — 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) + 4b arayüz (`/ayarlar/ekstre`: Excel/CSV, sütun eşleştirme + profil, gözden geçirme)
- ✅ 5. JSON tam hane yedeği — 5a backend (sürümlü dosya + SHA-256, önizleme, tam değiştirme tek transaction, geri dönüş noktaları + "geri al", başka haneye kimlik çevirerek taşıma) · 5b arayüz (Ayarlar → Veri: indir + "son yedek" hatırlatması, sahip için önizleme → onay → geri yükle → "Geri al", geri dönüş noktaları) - ✅ 5. JSON tam hane yedeği — 5a backend (sürümlü dosya + SHA-256, önizleme, tam değiştirme tek transaction, geri dönüş noktaları + "geri al", başka haneye kimlik çevirerek taşıma) · 5b arayüz (Ayarlar → Veri: indir + "son yedek" hatırlatması, sahip için önizleme → onay → geri yükle → "Geri al", geri dönüş noktaları)
- 🔄 6. Aylık değerlendirme çıktısı — ✅ 6a backend (`templates/monthly_report.md` + minijinja, `GET /periods/{id}/report.md`, kural tabanlı yorum ve öneriler) · ⏳ 6b arayüz (rapor sayfası, yazdır/PDF, Markdown indir). Plan: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF - ✅ 6. Aylık değerlendirme çıktısı — 6a backend (`templates/monthly_report.md` + minijinja, `GET /periods/{id}/report.md`, kural tabanlı yorum ve öneriler) · 6b arayüz (`/degerlendirme/rapor`: uygulama içi görünüm, Yazdır/PDF A4, Markdown indir; Değerlendirme, kapatma sonucu ve dönem geçmişinden bağlantı)
@@ -27,6 +27,7 @@ export const paths = {
review: '/degerlendirme', review: '/degerlendirme',
/** Dönem kapatma sihirbazı */ /** Dönem kapatma sihirbazı */
closePeriod: '/degerlendirme/kapat', closePeriod: '/degerlendirme/kapat',
monthlyReport: '/degerlendirme/rapor',
reports: '/raporlar', reports: '/raporlar',
settings: '/ayarlar', settings: '/ayarlar',
/** Banka / kart ekstresi içe aktarma */ /** Banka / kart ekstresi içe aktarma */
+6
View File
@@ -9,6 +9,12 @@ import { cn } from '@/lib/utils'
*/ */
const components: Components = { const components: Components = {
a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />, a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
// Geniş tablo dar ekranda sayfayı taşırmasın: yalnızca tablo yatay kayar
table: ({ node: _node, ...props }) => (
<div className="table-wrap">
<table {...props} />
</div>
),
} }
export function Markdown({ children, className }: { children: string; className?: string }) { export function Markdown({ children, className }: { children: string; className?: string }) {
+10 -1
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import { Construction, Loader2, RotateCw } from 'lucide-react' import { Construction, Loader2, RotateCw } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { t } from '@/lib/strings' import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import { useDocumentTitle } from '@/lib/use-document-title' import { useDocumentTitle } from '@/lib/use-document-title'
/** Sayfa başlığı + açıklama; sekme başlığını da ayarlar. */ /** Sayfa başlığı + açıklama; sekme başlığını da ayarlar. */
@@ -9,14 +10,22 @@ export function PageHeader({
title, title,
description, description,
actions, actions,
printHidden = false,
}: { }: {
title: string title: string
description?: string description?: string
actions?: ReactNode actions?: ReactNode
/** Yazdırırken gizle (belge kendi başlığını taşıyorsa, ör. aylık rapor) */
printHidden?: boolean
}) { }) {
useDocumentTitle(title) useDocumentTitle(title)
return ( return (
<div className="mb-6 flex flex-wrap items-start justify-between gap-4"> <div
className={cn(
'mb-6 flex flex-wrap items-start justify-between gap-4',
printHidden && 'print:hidden',
)}
>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">{title}</h1> <h1 className="text-2xl font-bold tracking-tight md:text-3xl">{title}</h1>
{description && <p className="mt-1 text-muted-foreground">{description}</p>} {description && <p className="mt-1 text-muted-foreground">{description}</p>}
@@ -323,6 +323,9 @@ function Result({ result: r }: { result: PeriodCloseResult }) {
{attached ? s.toPlan : s.newPeriod} {attached ? s.toPlan : s.newPeriod}
</Link> </Link>
</Button> </Button>
<Button asChild size="lg" variant="outline">
<Link to={periodPath(paths.monthlyReport, r.period)}>{t.monthlyReport.open}</Link>
</Button>
<Button asChild size="lg" variant="outline"> <Button asChild size="lg" variant="outline">
<Link to={paths.dashboard}>{s.toDashboard}</Link> <Link to={paths.dashboard}>{s.toDashboard}</Link>
</Button> </Button>
+13 -1
View File
@@ -1,5 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { Info } from 'lucide-react' import { FileText, Info } from 'lucide-react'
import { Link } from 'react-router'
import { paths } from '@/components/layout/nav-items'
import { Button } from '@/components/ui/button'
import { periodPath } from '@/lib/period-select'
import { PageError, PageHeader, PageLoading } from '@/components/page' import { PageError, PageHeader, PageLoading } from '@/components/page'
import { NotesSection } from '@/features/notes/notes-section' import { NotesSection } from '@/features/notes/notes-section'
import { PeriodPicker } from '@/features/periods/period-picker' import { PeriodPicker } from '@/features/periods/period-picker'
@@ -45,7 +49,15 @@ function ReviewView({ period, periods }: { period: Period; periods: Period[] })
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<PeriodHeading period={period} /> <PeriodHeading period={period} />
<Button asChild variant="outline">
<Link to={periodPath(paths.monthlyReport, period)}>
<FileText aria-hidden />
{t.monthlyReport.open}
</Link>
</Button>
</div>
{period.status === 'open' && today <= period.last_date && ( {period.status === 'open' && today <= period.last_date && (
<p className="flex items-start gap-2 rounded-xl bg-muted px-3 py-2 text-sm text-muted-foreground"> <p className="flex items-start gap-2 rounded-xl bg-muted px-3 py-2 text-sm text-muted-foreground">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden /> <Info className="mt-0.5 size-4 shrink-0" aria-hidden />
+13 -3
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { ChevronRight } from 'lucide-react' import { ChevronRight, FileText } from 'lucide-react'
import { Link } from 'react-router' import { Link } from 'react-router'
import { toast } from 'sonner' import { toast } from 'sonner'
import { paths } from '@/components/layout/nav-items' import { paths } from '@/components/layout/nav-items'
@@ -118,12 +118,12 @@ export function PeriodHistory({ periods, selected }: { periods: Period[]; select
<h3 className="mb-2 font-semibold">{t.periods.history}</h3> <h3 className="mb-2 font-semibold">{t.periods.history}</h3>
<ul className="divide-y"> <ul className="divide-y">
{past.map((p) => ( {past.map((p) => (
<li key={p.id}> <li key={p.id} className="flex items-center gap-1">
<Link <Link
to={periodPath(paths.review, p)} to={periodPath(paths.review, p)}
aria-current={p.id === selected ? 'page' : undefined} aria-current={p.id === selected ? 'page' : undefined}
className={cn( className={cn(
'-mx-2 flex min-h-11 items-center gap-3 rounded-lg px-2 py-2 hover:bg-muted', '-ml-2 flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-lg px-2 py-2 hover:bg-muted',
p.id === selected && 'bg-muted', p.id === selected && 'bg-muted',
)} )}
> >
@@ -141,6 +141,16 @@ export function PeriodHistory({ periods, selected }: { periods: Period[]; select
)} )}
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden /> <ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</Link> </Link>
{/* Ayrı bağlantı (iç içe bağlantı olmasın): dönemin aylık raporu */}
<Button asChild variant="ghost" size="icon" className="shrink-0">
<Link
to={periodPath(paths.monthlyReport, p)}
aria-label={t.monthlyReport.openFor(p.name)}
title={t.monthlyReport.short}
>
<FileText aria-hidden />
</Link>
</Button>
</li> </li>
))} ))}
</ul> </ul>
@@ -0,0 +1,72 @@
import { ArrowLeft, Download, Printer } from 'lucide-react'
import { Link } from 'react-router'
import { Markdown } from '@/components/markdown'
import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { paths } from '@/components/layout/nav-items'
import { PeriodPicker } from '@/features/periods/period-picker'
import { SelectedPeriod } from '@/features/periods/selected-period'
import { downloadFile } from '@/lib/csv'
import { reportFileName, useMonthlyReport } from '@/lib/monthly-report'
import { periodPath } from '@/lib/period-select'
import { printSection } from '@/lib/print'
import { t } from '@/lib/strings'
import type { Period } from '@/types/Period'
const s = t.monthlyReport
/**
* Aylık değerlendirme raporu: backend'in ürettiği Markdown (`templates/monthly_report.md`)
* uygulama içinde gösterilir; yazdırılır (A4, açık tema) ya da `.md` olarak indirilir.
* Dönem `?donem=` ile seçilir (açık dönem → ara rapor).
*/
export function MonthlyReportPage() {
return (
<>
<PageHeader
title={s.title}
description={s.description}
actions={<PeriodPicker />}
printHidden
/>
<SelectedPeriod>{(p) => <ReportView key={p.id} period={p} />}</SelectedPeriod>
</>
)
}
function ReportView({ period }: { period: Period }) {
const report = useMonthlyReport(period.id)
if (report.isError) return <PageError message={report.error.message} onRetry={report.refetch} />
if (report.data === undefined) return <PageLoading />
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2 print:hidden">
<Button onClick={() => printSection()}>
<Printer aria-hidden />
{s.print}
</Button>
<Button
variant="outline"
onClick={() =>
downloadFile(reportFileName(period.name), report.data, 'text/markdown;charset=utf-8')
}
>
<Download aria-hidden />
{s.download}
</Button>
<Button asChild variant="ghost" className="sm:ml-auto">
<Link to={periodPath(paths.review, period)}>
<ArrowLeft aria-hidden />
{s.back}
</Link>
</Button>
</div>
<p className="text-xs text-muted-foreground print:hidden">{s.printHint}</p>
<article className="rounded-2xl border bg-card p-4 sm:p-8 print:rounded-none print:border-0 print:p-0">
<Markdown className="report">{report.data}</Markdown>
</article>
</div>
)
}
+51 -1
View File
@@ -251,8 +251,11 @@
.markdown pre code { .markdown pre code {
@apply bg-transparent p-0; @apply bg-transparent p-0;
} }
.markdown .table-wrap {
@apply max-w-full overflow-x-auto;
}
.markdown table { .markdown table {
@apply block w-full overflow-x-auto border-collapse; @apply w-full border-collapse;
} }
.markdown th, .markdown th,
.markdown td { .markdown td {
@@ -264,11 +267,58 @@
.markdown hr { .markdown hr {
@apply border-border; @apply border-border;
} }
/* Aylık rapor (features/review/report-page.tsx): belge görünümü, tablolar tam genişlik */
.markdown.report {
@apply space-y-4 text-[0.95rem];
}
.markdown.report h1 {
@apply text-2xl font-bold tracking-tight;
}
.markdown.report h2 {
@apply mt-8 border-t pt-6 text-xl font-semibold;
}
.markdown.report h3 {
@apply mt-5 text-base;
}
.markdown.report h1 + p {
@apply text-muted-foreground;
}
.markdown.report blockquote {
@apply rounded-r-lg border-primary bg-muted py-2 text-base text-foreground;
}
.markdown.report td {
@apply whitespace-nowrap;
}
.markdown.report td:first-child {
@apply whitespace-normal;
}
.markdown.report hr + p {
@apply text-xs text-muted-foreground;
}
} }
/* Yazdırma (lib/print.ts): tek bölüm seçildiyse diğer rapor bölümleri gizlenir; /* 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. */ kartlar sayfa ortasında bölünmesin; arka plan renkleri (çubuklar) basılsın. */
@media print { @media print {
@page {
size: A4;
margin: 14mm 12mm;
}
.markdown.report {
font-size: 10.5pt;
}
.markdown.report table,
.markdown.report blockquote,
.markdown.report li,
.markdown.report ul:has(> li > input[type='checkbox']) {
break-inside: avoid;
}
.markdown.report h2,
.markdown.report h3,
.markdown.report h4 {
break-after: avoid;
}
html[data-print-one] main [data-report]:not([data-print-target]) { html[data-print-one] main [data-report]:not([data-print-target]) {
display: none; display: none;
} }
+9 -1
View File
@@ -41,7 +41,12 @@ class TextBody {
} }
} }
async function request<T>(method: Method, path: string, body?: unknown): Promise<T> { async function request<T>(
method: Method,
path: string,
body?: unknown,
expect: 'json' | 'text' = 'json',
): Promise<T> {
let res: Response let res: Response
const raw = body instanceof TextBody ? body : null const raw = body instanceof TextBody ? body : null
try { try {
@@ -57,6 +62,7 @@ async function request<T>(method: Method, path: string, body?: unknown): Promise
} }
if (res.status === 204) return undefined as T if (res.status === 204) return undefined as T
if (expect === 'text' && res.ok) return (await res.text()) as T
const data: unknown = await res.json().catch(() => null) const data: unknown = await res.json().catch(() => null)
@@ -72,6 +78,8 @@ async function request<T>(method: Method, path: string, body?: unknown): Promise
export const api = { export const api = {
get: <T>(path: string) => request<T>('GET', path), get: <T>(path: string) => request<T>('GET', path),
/** Metin yanıt (Markdown rapor vb.); hata yine Türkçe `ApiError` */
getText: (path: string) => request<string>('GET', path, undefined, 'text'),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}), post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}),
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body), put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body), patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
+11 -2
View File
@@ -32,10 +32,19 @@ export function csvPercent(ratio: number | null): string {
/** Tarayıcıda dosya indirir */ /** Tarayıcıda dosya indirir */
export function downloadCsv(filename: string, csv: string) { export function downloadCsv(filename: string, csv: string) {
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })) downloadFile(
filename.endsWith('.csv') ? filename : `${filename}.csv`,
csv,
'text/csv;charset=utf-8',
)
}
/** Metni dosya olarak indirir (bellekteki Blob → geçici bağlantı) */
export function downloadFile(filename: string, text: string, type: string) {
const url = URL.createObjectURL(new Blob([text], { type }))
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = filename.endsWith('.csv') ? filename : `${filename}.csv` a.download = filename
document.body.append(a) document.body.append(a)
a.click() a.click()
a.remove() a.remove()
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { reportFileName } from './monthly-report'
describe('monthly report', () => {
it('builds an ASCII file name from the period name', () => {
expect(reportFileName('Kasım 2026')).toBe('kakeibo-rapor-kasim-2026.md')
expect(reportFileName('15 Ara 2026 – 14 Oca 2027')).toBe(
'kakeibo-rapor-15-ara-2026-14-oca-2027.md',
)
expect(reportFileName('İŞ / Çalışma')).toBe('kakeibo-rapor-is-calisma.md')
expect(reportFileName('***')).toBe('kakeibo-rapor-donem.md')
})
})
+20
View File
@@ -0,0 +1,20 @@
/** Aylık değerlendirme raporu: `GET /periods/{id}/report.md` (hesaplar ve şablon backend'de). */
import { useQuery } from '@tanstack/react-query'
import { api } from './api'
import { queryKeys } from './query'
import { fold } from './statement'
export function useMonthlyReport(periodId: string) {
return useQuery({
queryKey: queryKeys.monthlyReport(periodId),
queryFn: () => api.getText(`/periods/${periodId}/report.md`),
})
}
/** `Kasım 2026` → `kakeibo-rapor-kasim-2026.md` (backend `backup::slug` ile aynı) */
export function reportFileName(periodName: string): string {
const slug = fold(periodName)
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return `kakeibo-rapor-${slug || 'donem'}.md`
}
+2
View File
@@ -33,6 +33,8 @@ export const queryKeys = {
review: (id: string) => ['period', id, 'review'] as const, review: (id: string) => ['period', id, 'review'] as const,
/** Panel grafiği: günlük harcama (kayıt değişince dönem önekiyle yenilenir) */ /** Panel grafiği: günlük harcama (kayıt değişince dönem önekiyle yenilenir) */
daily: (id: string) => ['period', id, 'daily'] as const, daily: (id: string) => ['period', id, 'daily'] as const,
/** Aylık değerlendirme raporu (Markdown); kayıt değişince dönem önekiyle yenilenir */
monthlyReport: (id: string) => ['period', id, 'report'] as const,
budget: (id: string) => ['period', id, 'budget'] as const, budget: (id: string) => ['period', id, 'budget'] as const,
suggestion: (id: string, method: string) => ['period', id, 'suggestion', method] as const, suggestion: (id: string, method: string) => ['period', id, 'suggestion', method] as const,
notes: (id: string, context: string) => ['period', id, 'notes', context] as const, notes: (id: string, context: string) => ['period', id, 'notes', context] as const,
+11
View File
@@ -298,6 +298,17 @@ export const t = {
over: 'Aşıldı', over: 'Aşıldı',
}, },
monthlyReport: {
title: 'Aylık Rapor',
description: 'Dönemin değerlendirmesi: ne yaptın, ne iyi gitti, gelecek ay için öneriler.',
open: 'Aylık raporu aç',
short: 'Rapor',
openFor: (name: string) => `${name} raporunu aç`,
print: 'Yazdır / PDF',
download: 'Markdown indir',
back: 'Değerlendirmeye dön',
printHint: 'PDF için yazdırma penceresinde hedef olarak “PDF olarak kaydet”i seç.',
},
review: { review: {
ongoing: (last: string) => `Dönem ${last} tarihinde bitiyor; değerler kayıt girdikçe değişir.`, ongoing: (last: string) => `Dönem ${last} tarihinde bitiyor; değerler kayıt girdikçe değişir.`,
q1: 'Ne kadar param vardı?', q1: 'Ne kadar param vardı?',
+2
View File
@@ -13,6 +13,7 @@ import { PeriodWizardPage } from '@/features/period-wizard/page'
import { PlanningPage } from '@/features/planning/page' import { PlanningPage } from '@/features/planning/page'
import { ReportsPage } from '@/features/reports/page' import { ReportsPage } from '@/features/reports/page'
import { ClosePeriodPage } from '@/features/review/close-page' import { ClosePeriodPage } from '@/features/review/close-page'
import { MonthlyReportPage } from '@/features/review/report-page'
import { ReviewPage } from '@/features/review/page' import { ReviewPage } from '@/features/review/page'
import { AssetPage } from '@/features/savings/asset-page' import { AssetPage } from '@/features/savings/asset-page'
import { SavingsPage } from '@/features/savings/page' import { SavingsPage } from '@/features/savings/page'
@@ -43,6 +44,7 @@ const routes: RouteObject[] = [
{ path: paths.asset, element: <AssetPage /> }, { path: paths.asset, element: <AssetPage /> },
{ path: paths.review, element: <ReviewPage /> }, { path: paths.review, element: <ReviewPage /> },
{ path: paths.closePeriod, element: <ClosePeriodPage /> }, { path: paths.closePeriod, element: <ClosePeriodPage /> },
{ path: paths.monthlyReport, element: <MonthlyReportPage /> },
{ path: paths.reports, element: <ReportsPage /> }, { path: paths.reports, element: <ReportsPage /> },
{ path: paths.settings, element: <SettingsPage /> }, { path: paths.settings, element: <SettingsPage /> },
{ path: paths.statement, element: <StatementPage /> }, { path: paths.statement, element: <StatementPage /> },
+1 -1
View File
@@ -189,4 +189,4 @@ _Bu dönem için not yazılmamış. Kakeibo'nun özü bu kısım: Değerlendirme
{% endif %} {% endif %}
--- ---
<small>Nasıl hesaplandı: Kalan = bütçe − harcanan. Ortalamalar, bu dönemden önceki en fazla 3 kapalı ya da arşiv dönemden. “İyi gitti / zorladı” için grup bütçesinin %90 ve %100'ü, kategorilerde ortalamaya göre en az %20 ve 250 ₺ fark aranır. Önerilen bütçe: son en fazla 4 dönemin ortalama harcaması, 50 ₺'ye yuvarlanmış. Rapor tarihi: {{ generated_on }}.</small> _Nasıl hesaplandı: Kalan = bütçe − harcanan. Ortalamalar, bu dönemden önceki en fazla 3 kapalı ya da arşiv dönemden. “İyi gitti / zorladı” için grup bütçesinin %90 ve %100'ü, kategorilerde ortalamaya göre en az %20 ve 250 ₺ fark aranır. Önerilen bütçe: son en fazla 4 dönemin ortalama harcaması, 50 ₺'ye yuvarlanmış. Rapor tarihi: {{ generated_on }}._