diff --git a/docs/MEMORY.md b/docs/MEMORY.md
index 2453034..7074dac 100644
--- a/docs/MEMORY.md
+++ b/docs/MEMORY.md
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası
## ▶️ Sıradaki Adım
-**Faz 6 – Adım 3:** Frontend Değerlendirme sayfası (`/degerlendirme`, açık dönem; ileride `?donem=`): 4 soru kartı (`PeriodReview`), grup planlanan/gerçekleşen çubukları, en çok harcanan kategoriler (`useCategories(true)` ile ad), haftalık dağılım, "Nasıl daha iyi yapabilirim?" (Markdown editör → `PATCH reflection_md`), notlar `NotesSection context="review"` (yalnızca başlık listesi, tıklayınca açılır), "🔒 Dönemi Kapat" düğmesi (sihirbaz Adım 4).
+**Faz 6 – Adım 4:** Kapatma sihirbazı (`/degerlendirme/kapat`, Değerlendirme'de "🔒 Dönemi Kapat"): 1) özet + planlanan/gerçekleşen (review verisi), 2) gerçekleşen birikim → artıdaysa "birikime aktar" tutarı (MoneyInput, varsayılan tamamı; kalan artı devreder), açıktaysa borç bilgisi, 3) onay (`POST /close`, `version`), 4) sonuç + "Yeni dönemi aç" (sihirbaz `/plan/donem-ac`, önceki aydan kopyala adımları zaten var). Kapanınca `currentPeriod` null → sorgular yenilenir.
**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
@@ -109,3 +109,4 @@
### 2026-11-12 — Faz 6
- **Adım 1:** Değerlendirme backend: `services/review.rs` (`budget::summary` + `budget::view` + kategori aggregation), `routes/review.rs` → `GET /periods/{id}/review` (`PeriodReview`: `available` = gelir − sabit − borç + artı, `savings_goal`, `spent_total`, `realized_savings` = available − harcanan, `savings_gap`; `groups` planned/spent, `categories` çoktan aza (+count), `weeks` 4 grubun toplamı). Kapalı dönemde okunur. `periods.reflection_md` (PATCH, ≤ 20.000 karakter, `""` → null, yalnızca açık dönem). `tests/review.rs` (3). Mutasyon: 4. soruya hedef düşmek / silinenleri saymak → yakalandı. Not: `TestApp::register` adı e-postaya çevirir → Türkçe karakterli ad kullanma.
- **Adım 2:** Kapatma backend: `services/closing.rs` — `settle(realized, to_savings)` (< 0 → borç, birikim istenemez; ≥ 0 → varsayılan tamamı birikime, kalan artı), `close` (tek transaction: dönem `closed` + `closed_at/by` + `to_savings`, bu dönemden eski devirler soft delete, yeni devir; `to_period_id` = sonraki dönem ya da null), `reopen` (sahip; `closed` → **`correcting`**; ardından kapalı/düzeltmede dönem varsa 409). `PeriodStatus::Correcting` + `is_writable()` → `get_open`/`update` buna bakar; `current()` yalnız `open`. `period::create` artık transaction: önceki dönemin bağsız devrini bağlar. `POST /periods/{id}/close` (`PeriodCloseRequest`/`PeriodCloseResult`/`Carryover`), `POST /periods/{id}/reopen`. Period DTO: `to_savings`, `closed_at`. `tests/closing.rs` (4) + `settle` birim testi. Mutasyon 3/3 yakalandı; hata zorlanınca transaction geri aldı (dönem açık, sürüm aynı). Backend 113 test. Karar: yeniden açma = düzeltme modu (kullanıcı "a").
+- **Adım 3:** Değerlendirme sayfası (`features/review/`): `Questions` (4 kart; 1. kart hesap açıklaması, 3. bütçe yüzdesi, 4. hedefe göre durum `lib/review.ts` → `savingsStatus`: above/below/deficit), `breakdowns.tsx` (`GroupComparison`, `TopCategories` ilk 5 + "Tümünü göster", `WeeklyDistribution`; ortak `Bar`, `ratio`), `Reflection` (görüntüle/düzenle, `useUpdatePeriod` + `version`, 409 formda; şema `features/review/schema.ts`), `NotesSection collapsed` (yalnız başlık, `aria-expanded` ile açılır). Ortak `components/markdown-tabs.tsx` (Yaz/Önizle, `useId` kimlikler; `NoteEditor` da kullanıyor). `useReview` + `queryKeys.review` (dönem önekinde → kayıt değişince yenilenir). Dönem sürüyorsa bilgi satırı. Frontend 84 test; e2e 24 kontrol (Ekim kapatılıp Kasım'a artı devri dahil). Mobil haftalık satır sıkıştırıldı.
diff --git a/docs/PHASES.md b/docs/PHASES.md
index eb8dc15..eab82ca 100644
--- a/docs/PHASES.md
+++ b/docs/PHASES.md
@@ -63,6 +63,6 @@
## Faz 6 Adımları (taslak)
- ✅ 1. Backend: değerlendirme (`GET /periods/{id}/review`: 4 soru, grup/kategori/hafta) + `reflection_md`
- ✅ 2. Backend: dönem kapatma (transaction: durum, devir, birikime aktarılan tutar) + yeni dönemde devrin bağlanması + yeniden açma (sahip)
-- ⏳ 3. Frontend: Değerlendirme sayfası (4 soru, planlanan/gerçekleşen, en çok harcanan kategoriler, haftalık dağılım, "Nasıl daha iyi yapabilirim?", notlar)
+- ✅ 3. Frontend: Değerlendirme sayfası (4 soru, planlanan/gerçekleşen, en çok harcanan kategoriler, haftalık dağılım, "Nasıl daha iyi yapabilirim?", notlar)
- ⏳ 4. Frontend: dönem kapatma sihirbazı (+ yeni dönemi kopyalayarak açma)
- ⏳ 5. Frontend: kapalı dönemler listesi + yeniden açma (sahip)
diff --git a/frontend/src/components/markdown-tabs.tsx b/frontend/src/components/markdown-tabs.tsx
new file mode 100644
index 0000000..5172570
--- /dev/null
+++ b/frontend/src/components/markdown-tabs.tsx
@@ -0,0 +1,67 @@
+import { useId, useState, type ReactNode } from 'react'
+import { Markdown } from '@/components/markdown'
+import { t } from '@/lib/strings'
+import { cn } from '@/lib/utils'
+
+/**
+ * Markdown alanı için "Yaz / Önizle" sekmeleri. `children` = yazma paneli (genelde `Textarea`).
+ * Kimlikler `useId` ile üretilir → aynı sayfada birden çok editör olabilir.
+ */
+export function MarkdownTabs({
+ label,
+ value,
+ children,
+}: {
+ /** Sekme listesinin erişilebilir adı */
+ label: string
+ /** Önizlenecek metin */
+ value: string
+ children: ReactNode
+}) {
+ const id = useId()
+ const [tab, setTab] = useState<'write' | 'preview'>('write')
+
+ const tabButton = (v: typeof tab, text: string) => (
+
+ )
+
+ return (
+
+
+ {tabButton('write', t.notes.write)}
+ {tabButton('preview', t.notes.preview)}
+
+ {tab === 'write' ? (
+
+ {children}
+
+ ) : (
+
+ {value.trim() ? (
+
{value}
+ ) : (
+
{t.notes.previewEmpty}
+ )}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/features/notes/note-editor.tsx b/frontend/src/features/notes/note-editor.tsx
index 7c936ee..e5ac4f0 100644
--- a/frontend/src/features/notes/note-editor.tsx
+++ b/frontend/src/features/notes/note-editor.tsx
@@ -1,14 +1,12 @@
-import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2 } from 'lucide-react'
import { useForm, useWatch } from 'react-hook-form'
import { FormError, FormField } from '@/components/form-field'
-import { Markdown } from '@/components/markdown'
+import { MarkdownTabs } from '@/components/markdown-tabs'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { t } from '@/lib/strings'
-import { cn } from '@/lib/utils'
import { noteSchema, type NoteForm } from './schema'
const s = t.notes
@@ -26,7 +24,6 @@ export function NoteEditor({
onSave: (v: NoteForm) => Promise
onCancel: () => void
}) {
- const [tab, setTab] = useState<'write' | 'preview'>('write')
const {
register,
control,
@@ -48,23 +45,6 @@ export function NoteEditor({
}
})
- const tabButton = (value: typeof tab, label: string) => (
-
- )
-
return (
+ )
+}
diff --git a/frontend/src/features/review/schema.test.ts b/frontend/src/features/review/schema.test.ts
new file mode 100644
index 0000000..a0e093d
--- /dev/null
+++ b/frontend/src/features/review/schema.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import { reflectionSchema } from './schema'
+
+describe('reflection', () => {
+ it('counts characters like the backend and allows empty', () => {
+ expect(reflectionSchema.safeParse({ text: '' }).success).toBe(true)
+ expect(reflectionSchema.safeParse({ text: 'ş'.repeat(20_000) }).success).toBe(true)
+ expect(reflectionSchema.safeParse({ text: ` ${'a'.repeat(20_000)} ` }).success).toBe(true)
+ const r = reflectionSchema.safeParse({ text: 'a'.repeat(20_001) })
+ expect(r.error?.issues[0].message).toBe('Metin en fazla 20.000 karakter olabilir')
+ })
+})
diff --git a/frontend/src/features/review/schema.ts b/frontend/src/features/review/schema.ts
new file mode 100644
index 0000000..bb85e1d
--- /dev/null
+++ b/frontend/src/features/review/schema.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod'
+import { t } from '@/lib/strings'
+
+/** Backend gibi Unicode karakter sayar (`services/period.rs` → `MAX_REFLECTION`) */
+export const MAX_REFLECTION = 20_000
+
+export const reflectionSchema = z.object({
+ text: z
+ .string()
+ .refine((v) => [...v.trim()].length <= MAX_REFLECTION, t.review.reflectionTooLong),
+})
+export type ReflectionForm = z.infer
diff --git a/frontend/src/lib/periods.ts b/frontend/src/lib/periods.ts
index 5602d66..c254c3c 100644
--- a/frontend/src/lib/periods.ts
+++ b/frontend/src/lib/periods.ts
@@ -11,6 +11,7 @@ import type { GroupAmounts } from '@/types/GroupAmounts'
import type { GroupBudget } from '@/types/GroupBudget'
import type { Period } from '@/types/Period'
import type { PeriodCreateRequest } from '@/types/PeriodCreateRequest'
+import type { PeriodReview } from '@/types/PeriodReview'
import type { PeriodSummary } from '@/types/PeriodSummary'
import type { PeriodUpdateRequest } from '@/types/PeriodUpdateRequest'
import type { PlanItem } from '@/types/PlanItem'
@@ -108,6 +109,14 @@ export function useSummary(periodId: string) {
})
}
+/** Ay sonu değerlendirmesi: 4 soru + grup/kategori/hafta dökümü (kayıt değişince yenilenir) */
+export function useReview(periodId: string) {
+ return useQuery({
+ queryKey: queryKeys.review(periodId),
+ queryFn: () => api.get(`/periods/${periodId}/review`),
+ })
+}
+
export function useBudget(periodId: string) {
return useQuery({
queryKey: queryKeys.budget(periodId),
diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts
index 5058dd2..c11871e 100644
--- a/frontend/src/lib/query.ts
+++ b/frontend/src/lib/query.ts
@@ -19,6 +19,7 @@ export const queryKeys = {
period: (id: string) => ['period', id] as const,
planItems: (id: string) => ['period', id, 'plan-items'] as const,
summary: (id: string) => ['period', id, 'summary'] as const,
+ review: (id: string) => ['period', id, 'review'] 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/review.test.ts b/frontend/src/lib/review.test.ts
new file mode 100644
index 0000000..2b14740
--- /dev/null
+++ b/frontend/src/lib/review.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { ratio, savingsStatus } from './review'
+
+describe('review', () => {
+ it('classifies realized savings against the goal', () => {
+ expect(savingsStatus({ realized_savings: 700, savings_gap: 200 })).toEqual({
+ kind: 'above',
+ amount: 200,
+ })
+ expect(savingsStatus({ realized_savings: 500, savings_gap: 0 })).toEqual({
+ kind: 'above',
+ amount: 0,
+ })
+ expect(savingsStatus({ realized_savings: 300, savings_gap: -200 })).toEqual({
+ kind: 'below',
+ amount: 200,
+ })
+ expect(savingsStatus({ realized_savings: -50, savings_gap: -550 })).toEqual({
+ kind: 'deficit',
+ amount: 50,
+ })
+ })
+
+ it('computes bar ratios safely', () => {
+ expect(ratio(50, 200)).toBe(0.25)
+ expect(ratio(300, 200)).toBe(1)
+ expect(ratio(300, 200, false)).toBe(1.5)
+ expect(ratio(10, 0)).toBe(1)
+ expect(ratio(0, 0)).toBe(0)
+ })
+})
diff --git a/frontend/src/lib/review.ts b/frontend/src/lib/review.ts
new file mode 100644
index 0000000..f491393
--- /dev/null
+++ b/frontend/src/lib/review.ts
@@ -0,0 +1,26 @@
+/**
+ * Değerlendirme sayfasının saf hesapları. Tutarlar backend'den gelir (`PeriodReview`);
+ * burada yalnızca yorum (durum, oran) yapılır.
+ */
+import type { PeriodReview } from '@/types/PeriodReview'
+
+/** 4. soru: birikim hedefe göre nerede? */
+export type SavingsStatus =
+ | { kind: 'above'; amount: number } // hedefe ulaşıldı/aşıldı (amount ≥ 0)
+ | { kind: 'below'; amount: number } // birikim var ama hedefin altında
+ | { kind: 'deficit'; amount: number } // açık var → borç olarak devreder
+
+export function savingsStatus(
+ r: Pick,
+): SavingsStatus {
+ if (r.realized_savings < 0) return { kind: 'deficit', amount: -r.realized_savings }
+ if (r.savings_gap < 0) return { kind: 'below', amount: -r.savings_gap }
+ return { kind: 'above', amount: r.savings_gap }
+}
+
+/** Oran (0–1); payda 0 ise pay varsa 1, yoksa 0. Çubuk genişliği için 1'de kesilir. */
+export function ratio(part: number, total: number, cap = true): number {
+ if (total <= 0) return part > 0 ? 1 : 0
+ const r = part / total
+ return cap ? Math.min(r, 1) : r
+}
diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts
index 5963e11..83a3e87 100644
--- a/frontend/src/lib/strings.ts
+++ b/frontend/src/lib/strings.ts
@@ -294,6 +294,42 @@ export const t = {
over: 'Aşıldı',
},
+ review: {
+ ongoing: (last: string) => `Dönem ${last} tarihinde bitiyor; değerler kayıt girdikçe değişir.`,
+ q1: 'Ne kadar param vardı?',
+ q1Detail: (income: string, fixed: string) => `Gelir ${income} − sabit giderler ${fixed}`,
+ q1Debt: (debt: string) => ` − devreden borç ${debt}`,
+ q1Surplus: (surplus: string) => ` + devreden artı ${surplus}`,
+ q2: 'Ne kadar biriktirmek istedim?',
+ q2Detail: 'Dönem başında belirlenen birikim hedefi',
+ q3: 'Ne kadar harcadım?',
+ q3Detail: (budget: string, pct: string) => `Bütçenin ${pct} kadarı (${budget})`,
+ q3NoBudget: 'Bütçe dağıtılmamış',
+ q4: 'Ne kadar biriktirdim?',
+ above: (x: string) => (x === '0,00 ₺' ? 'Hedefe tam ulaşıldı' : `Hedefin ${x} üstünde 🎉`),
+ below: (x: string) => `Hedefin ${x} altında`,
+ deficit: (x: string) => `${x} açık; borç olarak sonraki döneme devreder`,
+ groupsTitle: 'Planlanan ve gerçekleşen',
+ groupLine: (spent: string, planned: string) => `${spent} / ${planned}`,
+ left: (x: string) => `${x} kaldı`,
+ over: (x: string) => `${x} aşıldı`,
+ categoriesTitle: 'En çok harcanan kategoriler',
+ categoriesEmpty: 'Bu dönemde henüz harcama yok.',
+ count: (n: number) => `${n} kayıt`,
+ showAll: (n: number) => `Tümünü göster (${n})`,
+ showLess: 'Daha az göster',
+ weeksTitle: 'Haftalık dağılım',
+ week: (n: number) => `${n}. hafta`,
+ reflectionTitle: 'Nasıl daha iyi yapabilirim?',
+ reflectionEmpty: 'Bu ay neler iyi gitti, neyi değiştirmek istersin? Kısa notlar yeterli.',
+ reflectionPlaceholder:
+ '- Dışarıda yemeği haftada bire indir\n- Market alışverişini listeyle yap',
+ reflectionSaved: 'Kaydedildi',
+ reflectionTooLong: 'Metin en fazla 20.000 karakter olabilir',
+ edit: 'Düzenle',
+ write: 'Yaz',
+ },
+
common: {
loading: 'Yükleniyor…',
retry: 'Tekrar dene',