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:
@@ -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 /> },
|
||||
|
||||
Reference in New Issue
Block a user