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
@@ -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 */
+6
View File
@@ -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 }) {
+10 -1
View File
@@ -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>
+14 -2
View File
@@ -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 />
+13 -3
View File
@@ -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
View File
@@ -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;
}
+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
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
View File
@@ -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()
+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,
/** 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,
+11
View File
@@ -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ı?',
+2
View File
@@ -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 /> },