From c3ec91b2af67bac16ac4374ab0babccab4027054 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 13 Nov 2026 21:54:37 +0300 Subject: [PATCH] =?UTF-8?q?feat(rapor):=20ayl=C4=B1k=20rapor=20ekran=C4=B1?= =?UTF-8?q?=20(g=C3=B6r=C3=BCnt=C3=BCle,=20yazd=C4=B1r/PDF,=20Markdown=20i?= =?UTF-8?q?ndir)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- backend/src/services/monthly_report.rs | 35 ++++++--- docs/MEMORY.md | 3 +- docs/PHASES.md | 4 +- frontend/src/components/layout/nav-items.ts | 1 + frontend/src/components/markdown.tsx | 6 ++ frontend/src/components/page.tsx | 11 ++- frontend/src/features/review/close-page.tsx | 3 + frontend/src/features/review/page.tsx | 16 ++++- .../src/features/review/period-status.tsx | 16 ++++- frontend/src/features/review/report-page.tsx | 72 +++++++++++++++++++ frontend/src/index.css | 52 +++++++++++++- frontend/src/lib/api.ts | 10 ++- frontend/src/lib/csv.ts | 13 +++- frontend/src/lib/monthly-report.test.ts | 13 ++++ frontend/src/lib/monthly-report.ts | 20 ++++++ frontend/src/lib/query.ts | 2 + frontend/src/lib/strings.ts | 11 +++ frontend/src/router.tsx | 2 + templates/monthly_report.md | 2 +- 20 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 frontend/src/features/review/report-page.tsx create mode 100644 frontend/src/lib/monthly-report.test.ts create mode 100644 frontend/src/lib/monthly-report.ts diff --git a/CLAUDE.md b/CLAUDE.md index b96f35d..6e73eae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ npm run db:restore -- [--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. - 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). -- 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. ## Ortam diff --git a/backend/src/services/monthly_report.rs b/backend/src/services/monthly_report.rs index 05233cb..4bb6853 100644 --- a/backend/src/services/monthly_report.rs +++ b/backend/src/services/monthly_report.rs @@ -686,13 +686,21 @@ pub fn analyze(f: &Facts) -> Report { let weeks: Vec = f .weeks .iter() - .map(|w| WeekRow { - index: w.index, - range: format!("{} – {}", short(&w.start), short(&w.last)), - planned: w.planned, - spent: w.spent, - usage: ratio(w.spent, w.planned), - mark: mark(w.spent, w.planned), + .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, + range: format!("{} – {}", short(&w.start), short(&w.last)), + planned: w.planned, + spent: w.spent, + usage: if future { + None + } else { + ratio(w.spent, w.planned) + }, + mark: if future { "" } else { mark(w.spent, w.planned) }, + } }) .collect(); let finished_weeks: Vec<&WeekFact> = f @@ -1310,6 +1318,12 @@ mod tests { "3. hafta henüz bitmedi" ); 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(); assert!( @@ -1342,6 +1356,11 @@ mod tests { assert!(md.contains("1. Market için haftalık")); assert!(md.contains("- [ ] ____")); 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"); } } diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 8c9f68c..5fcf6dc 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ 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, `` dipnot), "Markdown indir" (Blob, dosya adı `kakeibo-rapor-.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 ✅. ## 📌 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 5b (2026-11-13):** `features/settings/backup-section.tsx` (Veri bölümünün en üstü): "Yedeği indir" (``, 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 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 `` olmasın). Bulunan/düzeltilen: (1) şablondaki `` 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). diff --git a/docs/PHASES.md b/docs/PHASES.md index 5f325af..3178ce6 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -13,7 +13,7 @@ | **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 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 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 - ✅ 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ı) -- 🔄 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ı) diff --git a/frontend/src/components/layout/nav-items.ts b/frontend/src/components/layout/nav-items.ts index 4c66c2f..c5fad07 100644 --- a/frontend/src/components/layout/nav-items.ts +++ b/frontend/src/components/layout/nav-items.ts @@ -27,6 +27,7 @@ export const paths = { review: '/degerlendirme', /** Dönem kapatma sihirbazı */ closePeriod: '/degerlendirme/kapat', + monthlyReport: '/degerlendirme/rapor', reports: '/raporlar', settings: '/ayarlar', /** Banka / kart ekstresi içe aktarma */ diff --git a/frontend/src/components/markdown.tsx b/frontend/src/components/markdown.tsx index e7efa21..5b358e7 100644 --- a/frontend/src/components/markdown.tsx +++ b/frontend/src/components/markdown.tsx @@ -9,6 +9,12 @@ import { cn } from '@/lib/utils' */ const components: Components = { a: ({ node: _node, ...props }) => , + // Geniş tablo dar ekranda sayfayı taşırmasın: yalnızca tablo yatay kayar + table: ({ node: _node, ...props }) => ( +
+ + + ), } export function Markdown({ children, className }: { children: string; className?: string }) { diff --git a/frontend/src/components/page.tsx b/frontend/src/components/page.tsx index c38eab8..8bb36e0 100644 --- a/frontend/src/components/page.tsx +++ b/frontend/src/components/page.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react' import { Construction, Loader2, RotateCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { t } from '@/lib/strings' +import { cn } from '@/lib/utils' import { useDocumentTitle } from '@/lib/use-document-title' /** Sayfa başlığı + açıklama; sekme başlığını da ayarlar. */ @@ -9,14 +10,22 @@ export function PageHeader({ title, description, actions, + printHidden = false, }: { title: string description?: string actions?: ReactNode + /** Yazdırırken gizle (belge kendi başlığını taşıyorsa, ör. aylık rapor) */ + printHidden?: boolean }) { useDocumentTitle(title) return ( -
+

{title}

{description &&

{description}

} diff --git a/frontend/src/features/review/close-page.tsx b/frontend/src/features/review/close-page.tsx index cafb1c7..2cbf917 100644 --- a/frontend/src/features/review/close-page.tsx +++ b/frontend/src/features/review/close-page.tsx @@ -323,6 +323,9 @@ function Result({ result: r }: { result: PeriodCloseResult }) { {attached ? s.toPlan : s.newPeriod} + diff --git a/frontend/src/features/review/page.tsx b/frontend/src/features/review/page.tsx index b6dd06f..5c19640 100644 --- a/frontend/src/features/review/page.tsx +++ b/frontend/src/features/review/page.tsx @@ -1,5 +1,9 @@ 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 { NotesSection } from '@/features/notes/notes-section' import { PeriodPicker } from '@/features/periods/period-picker' @@ -45,7 +49,15 @@ function ReviewView({ period, periods }: { period: Period; periods: Period[] }) return (
- +
+ + +
{period.status === 'open' && today <= period.last_date && (

diff --git a/frontend/src/features/review/period-status.tsx b/frontend/src/features/review/period-status.tsx index 5d9c37c..2856c7b 100644 --- a/frontend/src/features/review/period-status.tsx +++ b/frontend/src/features/review/period-status.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { ChevronRight } from 'lucide-react' +import { ChevronRight, FileText } from 'lucide-react' import { Link } from 'react-router' import { toast } from 'sonner' import { paths } from '@/components/layout/nav-items' @@ -118,12 +118,12 @@ export function PeriodHistory({ periods, selected }: { periods: Period[]; select

{t.periods.history}

    {past.map((p) => ( -
  • +
  • @@ -141,6 +141,16 @@ export function PeriodHistory({ periods, selected }: { periods: Period[]; select )} + {/* Ayrı bağlantı (iç içe bağlantı olmasın): dönemin aylık raporu */} +
  • ))}
diff --git a/frontend/src/features/review/report-page.tsx b/frontend/src/features/review/report-page.tsx new file mode 100644 index 0000000..c95b4ef --- /dev/null +++ b/frontend/src/features/review/report-page.tsx @@ -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 ( + <> + } + printHidden + /> + {(p) => } + + ) +} + +function ReportView({ period }: { period: Period }) { + const report = useMonthlyReport(period.id) + + if (report.isError) return + if (report.data === undefined) return + + return ( +
+
+ + + +
+

{s.printHint}

+
+ {report.data} +
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 55361f2..90377d1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -251,8 +251,11 @@ .markdown pre code { @apply bg-transparent p-0; } + .markdown .table-wrap { + @apply max-w-full overflow-x-auto; + } .markdown table { - @apply block w-full overflow-x-auto border-collapse; + @apply w-full border-collapse; } .markdown th, .markdown td { @@ -264,11 +267,58 @@ .markdown hr { @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; kartlar sayfa ortasında bölünmesin; arka plan renkleri (çubuklar) basılsın. */ @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]) { display: none; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f1c93c1..abaeef6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -41,7 +41,12 @@ class TextBody { } } -async function request(method: Method, path: string, body?: unknown): Promise { +async function request( + method: Method, + path: string, + body?: unknown, + expect: 'json' | 'text' = 'json', +): Promise { let res: Response const raw = body instanceof TextBody ? body : null try { @@ -57,6 +62,7 @@ async function request(method: Method, path: string, body?: unknown): Promise } 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) @@ -72,6 +78,8 @@ async function request(method: Method, path: string, body?: unknown): Promise export const api = { get: (path: string) => request('GET', path), + /** Metin yanıt (Markdown rapor vb.); hata yine Türkçe `ApiError` */ + getText: (path: string) => request('GET', path, undefined, 'text'), post: (path: string, body?: unknown) => request('POST', path, body ?? {}), put: (path: string, body: unknown) => request('PUT', path, body), patch: (path: string, body: unknown) => request('PATCH', path, body), diff --git a/frontend/src/lib/csv.ts b/frontend/src/lib/csv.ts index 24c0bb5..b1a7d22 100644 --- a/frontend/src/lib/csv.ts +++ b/frontend/src/lib/csv.ts @@ -32,10 +32,19 @@ export function csvPercent(ratio: number | null): string { /** 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' })) + 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') a.href = url - a.download = filename.endsWith('.csv') ? filename : `${filename}.csv` + a.download = filename document.body.append(a) a.click() a.remove() diff --git a/frontend/src/lib/monthly-report.test.ts b/frontend/src/lib/monthly-report.test.ts new file mode 100644 index 0000000..4a82df0 --- /dev/null +++ b/frontend/src/lib/monthly-report.test.ts @@ -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') + }) +}) diff --git a/frontend/src/lib/monthly-report.ts b/frontend/src/lib/monthly-report.ts new file mode 100644 index 0000000..758772a --- /dev/null +++ b/frontend/src/lib/monthly-report.ts @@ -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` +} diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts index 17d7b8c..207e09c 100644 --- a/frontend/src/lib/query.ts +++ b/frontend/src/lib/query.ts @@ -33,6 +33,8 @@ export const queryKeys = { review: (id: string) => ['period', id, 'review'] as const, /** Panel grafiği: günlük harcama (kayıt değişince dönem önekiyle yenilenir) */ 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, suggestion: (id: string, method: string) => ['period', id, 'suggestion', method] as const, notes: (id: string, context: string) => ['period', id, 'notes', context] as const, diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index 0b74ab2..744d821 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -298,6 +298,17 @@ export const t = { 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: { ongoing: (last: string) => `Dönem ${last} tarihinde bitiyor; değerler kayıt girdikçe değişir.`, q1: 'Ne kadar param vardı?', diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 9fcf374..b0cb37b 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -13,6 +13,7 @@ import { PeriodWizardPage } from '@/features/period-wizard/page' import { PlanningPage } from '@/features/planning/page' import { ReportsPage } from '@/features/reports/page' import { ClosePeriodPage } from '@/features/review/close-page' +import { MonthlyReportPage } from '@/features/review/report-page' import { ReviewPage } from '@/features/review/page' import { AssetPage } from '@/features/savings/asset-page' import { SavingsPage } from '@/features/savings/page' @@ -43,6 +44,7 @@ const routes: RouteObject[] = [ { path: paths.asset, element: }, { path: paths.review, element: }, { path: paths.closePeriod, element: }, + { path: paths.monthlyReport, element: }, { path: paths.reports, element: }, { path: paths.settings, element: }, { path: paths.statement, element: }, diff --git a/templates/monthly_report.md b/templates/monthly_report.md index dee4158..58aeab9 100644 --- a/templates/monthly_report.md +++ b/templates/monthly_report.md @@ -189,4 +189,4 @@ _Bu dönem için not yazılmamış. Kakeibo'nun özü bu kısım: Değerlendirme {% endif %} --- -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 }}. +_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 }}._