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:
@@ -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.
|
||||
- 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
|
||||
|
||||
@@ -686,13 +686,21 @@ pub fn analyze(f: &Facts) -> Report {
|
||||
let weeks: Vec<WeekRow> = 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");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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, `<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 ✅.
|
||||
|
||||
## 📌 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" (`<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 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
@@ -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ı)
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -9,6 +9,12 @@ import { cn } from '@/lib/utils'
|
||||
*/
|
||||
const components: Components = {
|
||||
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 }) {
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">{title}</h1>
|
||||
{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}
|
||||
</Link>
|
||||
</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">
|
||||
<Link to={paths.dashboard}>{s.toDashboard}</Link>
|
||||
</Button>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<PeriodHeading period={period} />
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<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 && (
|
||||
<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 />
|
||||
|
||||
@@ -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
|
||||
<h3 className="mb-2 font-semibold">{t.periods.history}</h3>
|
||||
<ul className="divide-y">
|
||||
{past.map((p) => (
|
||||
<li key={p.id}>
|
||||
<li key={p.id} className="flex items-center gap-1">
|
||||
<Link
|
||||
to={periodPath(paths.review, p)}
|
||||
aria-current={p.id === selected ? 'page' : undefined}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
@@ -141,6 +141,16 @@ export function PeriodHistory({ periods, selected }: { periods: Period[]; select
|
||||
)}
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</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>
|
||||
))}
|
||||
</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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
const raw = body instanceof TextBody ? body : null
|
||||
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 (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<T>(method: Method, path: string, body?: unknown): Promise
|
||||
|
||||
export const api = {
|
||||
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 ?? {}),
|
||||
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
|
||||
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
|
||||
|
||||
+11
-2
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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ı?',
|
||||
|
||||
@@ -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: <AssetPage /> },
|
||||
{ path: paths.review, element: <ReviewPage /> },
|
||||
{ path: paths.closePeriod, element: <ClosePeriodPage /> },
|
||||
{ path: paths.monthlyReport, element: <MonthlyReportPage /> },
|
||||
{ path: paths.reports, element: <ReportsPage /> },
|
||||
{ path: paths.settings, element: <SettingsPage /> },
|
||||
{ path: paths.statement, element: <StatementPage /> },
|
||||
|
||||
@@ -189,4 +189,4 @@ _Bu dönem için not yazılmamış. Kakeibo'nun özü bu kısım: Değerlendirme
|
||||
{% 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 }}._
|
||||
|
||||
Reference in New Issue
Block a user