feat(değerlendirme): geçmiş dönemler ve düzeltme için yeniden açma

- Defter, Plan ve Değerlendirme'de dönem seçici (?donem=)
- Kapalı dönem salt okunur; düzeltmedeki dönem düzenlenebilir ve yeniden kapatılır
- Hane sahibi için onaylı "Düzeltme için yeniden aç"
- Geçmiş dönemler listesi, dönem durum rozeti

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 23:11:04 +03:00
parent 2c16cf4163
commit 861bfba85a
16 changed files with 500 additions and 127 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası
## ▶️ Sıradaki Adım
**Faz 6 – Adım 5:** Kapalı dönemler listesi (Değerlendirme'de ya da ayrı sayfada; ad, tarih, gerçekleşen/birikime aktarılan, devir) + kapalı dönemin salt okunur değerlendirmesi + **sahip** için "Düzeltme için yeniden aç" (`POST /reopen`, 409 zincir uyarısı) + `correcting` dönemi Defter/Plan/Değerlendirme'de seçme (ör. `?donem=`) ve "Yeniden kapat" (aynı sihirbaz). Plan sayfası şu an yalnız `status === 'open'` için düzenlenebilir.
**Faz 7 – Adım 1:** Dashboard planı — önce `KAKEIBO_PROMPT.md` Dashboard bölümünü oku, adımları `docs/PHASES.md`'ye yaz ve kullanıcıya onaylat (özet kartları, grafikler; Recharts).
**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
@@ -111,3 +111,4 @@
- **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ı.
- **Adım 4:** Kapatma sihirbazı `/degerlendirme/kapat` (`features/review/close-page.tsx`): Özet (4 soru, erken kapatma uyarısı, "Nasıl daha iyi" boşsa uyarı + bağlantı) → Birikim (artıda `MoneyInput` varsayılan tamamı, Tamamı/Hiçbiri, canlı önizleme; açıkta/sıfırda yalnız bilgi) → Onay (`useClosePeriod`, `version`; 409 formda, `onSettled` tüm `['period']` + liste yenilenir → ikinci deneme güncel sürümle) → Sonuç (sayfa durumunda; kapanınca `current` null). `lib/review.ts` → `settlePreview` (backend `settle` ile aynı tutulmalı), şema `close-schema.ts` (0..gerçekleşen). `Stepper` artık `steps`/`label` alıyor. Dönem açma sihirbazı tarih önerisi: `suggestRanges(today, prevEnd)` / `rangeAfter` — son dönemin bitişinden başlar, çakışan ay önerilmez (erken kapatılan ay tekrar önerilmezdi hatası önlendi). Not: `moneyToText(0)` → `''` (plan taslağı için); 0 göstermek gerekiyorsa `formatNumber`. Frontend 89 test; e2e 33 kontrol (DB silindi, 8091 kapatıldı).
- **Adım 5:** Dönem seçimi: `lib/period-select.ts` (`useSelectedPeriod`: `?donem=` → listeden; yoksa açık dönem, o da yoksa düzeltmedeki; `pickPeriod`, `isWritable`, `canReopen` = backend kuralı, `periodPath`), `features/periods/` (`PeriodPicker` başlıkta `<select>`, açık döneme dönünce parametre kalkar; `SelectedPeriod` yükleniyor/hata/bulunamadı/yok kapısı). Defter, Plan, Değerlendirme, kapatma sihirbazı buna geçti (Panel ve dönem sihirbazı hâlâ `useCurrentPeriod`). Defter kapalı dönemde salt okunur (form yok, `EntryList readOnly`); hafta değişimi `donem`'i korur. Plan: düzenleme `isWritable`, kopyalama yalnız açık. `PeriodHeading` durum rozeti. Değerlendirme sonu `ClosingSection` (açık → Kapat · düzeltmede → Defter'de/Plan'da düzelt + Yeniden Kapat · kapalı → kapanış bilgisi + **sahip** için satır içi onaylı "Düzeltme için yeniden aç") + `PeriodHistory`. `useReopenPeriod`; `useUpdatePeriod` liste önbelleğini de günceller (seçili dönem listeden → art arda kayıtta 409 olmasın). **Ders:** başarıda bileşen kalkıyorsa `mutate(…, {onSuccess})` çalışmaz → `mutateAsync` kullan. Frontend 92 test; e2e 36 + kapatma 33 yeniden (DB silindi). Faz 6 ✅.
+2 -2
View File
@@ -10,7 +10,7 @@
| **Faz 3** | 🚀 İlk canlıya alma: Dockerfile, Dokploy, canlı MongoDB, domain + HTTPS, yedekleme | Giriş ekranı canlıda çalışıyor | ✅ |
| **Faz 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ✅ |
| **Faz 5** | Günlük defter | Harcama girişi | 🔄 |
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | 🔄 |
| **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 | ⏳ |
@@ -65,4 +65,4 @@
- ✅ 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)
- ✅ 4. Frontend: dönem kapatma sihirbazı (+ yeni dönemi kopyalayarak açma)
- ⏳ 5. Frontend: kapalı dönemler listesi + yeniden açma (sahip) + düzeltmedeki dönemi Defter/Plan/Değerlendirme'de seçme
- ✅ 5. Frontend: kapalı dönemler listesi + yeniden açma (sahip) + düzeltmedeki dönemi Defter/Plan/Değerlendirme'de seçme
+26 -21
View File
@@ -22,6 +22,7 @@ export function EntryList({
categories,
methods,
editingId,
readOnly = false,
onEdit,
onDeleted,
}: {
@@ -30,6 +31,8 @@ export function EntryList({
categories: CatalogItem[]
methods: CatalogItem[]
editingId?: string
/** Kapalı dönem: düzenle/sil yok */
readOnly?: boolean
onEdit: (e: Entry) => void
onDeleted: (id: string) => void
}) {
@@ -115,27 +118,29 @@ export function EntryList({
</p>
</div>
<span className="money shrink-0 font-medium">{formatMoney(e.amount)}</span>
<div className="flex shrink-0">
<Button
variant="ghost"
size="icon"
aria-label={s.edit(name)}
aria-pressed={editing}
onClick={() => onEdit(e)}
>
<Pencil aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={s.remove(name)}
className="text-muted-foreground hover:text-expense"
disabled={del.isPending && del.variables === e.id}
onClick={() => remove(e, name)}
>
<Trash2 aria-hidden />
</Button>
</div>
{!readOnly && (
<div className="flex shrink-0">
<Button
variant="ghost"
size="icon"
aria-label={s.edit(name)}
aria-pressed={editing}
onClick={() => onEdit(e)}
>
<Pencil aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={s.remove(name)}
className="text-muted-foreground hover:text-expense"
disabled={del.isPending && del.variables === e.id}
onClick={() => remove(e, name)}
>
<Trash2 aria-hidden />
</Button>
</div>
)}
</li>
)
})}
+52 -36
View File
@@ -1,13 +1,16 @@
import { useRef, useState } from 'react'
import { useSearchParams } from 'react-router'
import { Info } from 'lucide-react'
import { PageError, PageHeader, PageLoading } from '@/components/page'
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
import { PeriodPicker } from '@/features/periods/period-picker'
import { SelectedPeriod } from '@/features/periods/selected-period'
import { Section } from '@/features/planning/plan-read'
import { useCategories, usePaymentMethods } from '@/lib/catalog'
import { useEntries } from '@/lib/entries'
import { toIsoDate } from '@/lib/format'
import { parseWeekParam, weekIndexFor } from '@/lib/ledger'
import { useBudget, useCurrentPeriod } from '@/lib/periods'
import { isWritable } from '@/lib/period-select'
import { useBudget } from '@/lib/periods'
import { t } from '@/lib/strings'
import type { Entry } from '@/types/Entry'
import type { Period } from '@/types/Period'
@@ -17,19 +20,14 @@ import { WeekPicker } from './week-picker'
import { WeekSummary } from './week-summary'
export function LedgerPage() {
const current = useCurrentPeriod()
return (
<>
<PageHeader title={t.pages.ledger.title} description={t.pages.ledger.description} />
{current.isPending ? (
<PageLoading />
) : current.isError ? (
<PageError message={current.error.message} onRetry={current.refetch} />
) : current.data ? (
<LedgerView period={current.data} />
) : (
<NoPeriodCard />
)}
<PageHeader
title={t.pages.ledger.title}
description={t.pages.ledger.description}
actions={<PeriodPicker />}
/>
<SelectedPeriod>{(p) => <LedgerView key={p.id} period={p} />}</SelectedPeriod>
</>
)
}
@@ -48,6 +46,7 @@ function LedgerView({ period }: { period: Period }) {
const index =
parseWeekParam(params.get('hafta'), period.weeks.length) ?? weekIndexFor(period.weeks, today)
const week = period.weeks[index - 1]
const readOnly = !isWritable(period)
const budget = useBudget(period.id)
const categories = useCategories(true)
@@ -56,7 +55,15 @@ function LedgerView({ period }: { period: Period }) {
const goTo = (n: number) => {
setEditing(null)
setParams(n === todayIndex ? {} : { hafta: String(n) }, { replace: true })
setParams(
(prev) => {
const next = new URLSearchParams(prev) // seçili dönem (`donem`) korunur
if (n === todayIndex) next.delete('hafta')
else next.set('hafta', String(n))
return next
},
{ replace: true },
)
}
function edit(e: Entry) {
@@ -78,29 +85,37 @@ function LedgerView({ period }: { period: Period }) {
onChange={goTo}
/>
</div>
{readOnly && (
<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 />
{t.ledger.readOnly}
</p>
)}
<div className="grid items-start gap-4 lg:grid-cols-2">
<div ref={formRef} className="scroll-mt-4 lg:sticky lg:top-4">
<Section title={editing ? t.ledger.editTitle : t.ledger.formTitle}>
{catalogFailed?.error ? (
<PageError message={catalogFailed.error.message} onRetry={catalogFailed.refetch} />
) : !catalog ? (
<PageLoading />
) : (
<EntryForm
key={editing?.id ?? 'new'}
period={period}
week={week}
today={today}
categories={catalog.c}
methods={catalog.m}
entry={editing ?? undefined}
onSaved={(date) => goTo(weekIndexFor(period.weeks, date))}
onCancel={() => setEditing(null)}
/>
)}
</Section>
</div>
<div className="space-y-4">
{!readOnly && (
<div ref={formRef} className="scroll-mt-4 lg:sticky lg:top-4">
<Section title={editing ? t.ledger.editTitle : t.ledger.formTitle}>
{catalogFailed?.error ? (
<PageError message={catalogFailed.error.message} onRetry={catalogFailed.refetch} />
) : !catalog ? (
<PageLoading />
) : (
<EntryForm
key={editing?.id ?? 'new'}
period={period}
week={week}
today={today}
categories={catalog.c}
methods={catalog.m}
entry={editing ?? undefined}
onSaved={(date) => goTo(weekIndexFor(period.weeks, date))}
onCancel={() => setEditing(null)}
/>
)}
</Section>
</div>
)}
<div className={readOnly ? 'space-y-4 lg:col-span-2' : 'space-y-4'}>
<Section title={t.ledger.summaryTitle}>
{budget.isError ? (
<PageError message={budget.error.message} onRetry={budget.refetch} />
@@ -120,6 +135,7 @@ function LedgerView({ period }: { period: Period }) {
categories={catalog.c}
methods={catalog.m}
editingId={editing?.id}
readOnly={readOnly}
onEdit={edit}
onDeleted={(id) => editing?.id === id && setEditing(null)}
/>
@@ -0,0 +1,38 @@
import { useSearchParams } from 'react-router'
import { PERIOD_PARAM, useSelectedPeriod } from '@/lib/period-select'
import { t } from '@/lib/strings'
const s = t.periods
/**
* Sayfa başlığında dönem seçimi (birden fazla dönem varsa). Seçim adrese yazılır
* (`?donem=`); açık dönem varsayılan olduğundan onda parametre kalkar. Hafta vb. sıfırlanır.
*/
export function PeriodPicker() {
const { period, periods } = useSelectedPeriod()
const [, setParams] = useSearchParams()
if (periods.length < 2) return null
return (
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{s.picker}</span>
<select
name="period"
value={period?.id ?? ''}
onChange={(e) => {
const p = periods.find((x) => x.id === e.target.value)
if (p) setParams(p.status === 'open' ? {} : { [PERIOD_PARAM]: p.id })
}}
className="h-11 max-w-72 rounded-lg border bg-background px-3 text-sm"
>
{!period && <option value="">—</option>}
{periods.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.status !== 'open' ? ` · ${s.status[p.status]}` : ''}
</option>
))}
</select>
</label>
)
}
@@ -0,0 +1,30 @@
import type { ReactNode } from 'react'
import { PageError, PageLoading } from '@/components/page'
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
import { useSelectedPeriod } from '@/lib/period-select'
import { t } from '@/lib/strings'
import type { Period } from '@/types/Period'
/**
* Seçili dönemi (`?donem=` ya da açık dönem) yükler; yükleniyor / hata / bulunamadı /
* dönem yok durumlarını tek yerde gösterir. Dönem varsa `children(dönem, tüm dönemler)`.
*/
export function SelectedPeriod({
empty = <NoPeriodCard />,
children,
}: {
empty?: ReactNode
children: (period: Period, periods: Period[]) => ReactNode
}) {
const sel = useSelectedPeriod()
if (sel.error) return <PageError message={sel.error.message} onRetry={sel.refetch} />
if (sel.isPending) return <PageLoading />
if (sel.period === undefined)
return (
<p role="alert" className="rounded-xl bg-muted p-4 text-muted-foreground">
{t.periods.notFound}
</p>
)
if (sel.period === null) return empty
return children(sel.period, sel.periods)
}
+12 -15
View File
@@ -4,8 +4,10 @@ import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { NotesSection } from '@/features/notes/notes-section'
import { CopyPrevious } from '@/features/period-wizard/step-items'
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
import { useBudget, useCurrentPeriod, usePlanItems, useSummary } from '@/lib/periods'
import { PeriodPicker } from '@/features/periods/period-picker'
import { SelectedPeriod } from '@/features/periods/selected-period'
import { isWritable } from '@/lib/period-select'
import { useBudget, usePlanItems, useSummary } from '@/lib/periods'
import { t } from '@/lib/strings'
import type { Period } from '@/types/Period'
import { PlanEdit } from './plan-edit'
@@ -13,19 +15,14 @@ import { BudgetRead, ItemsList, PeriodHeading } from './plan-read'
import { SummaryTiles } from './summary-tiles'
export function PlanningPage() {
const current = useCurrentPeriod()
return (
<>
<PageHeader title={t.pages.planning.title} description={t.pages.planning.description} />
{current.isPending ? (
<PageLoading />
) : current.isError ? (
<PageError message={current.error.message} onRetry={current.refetch} />
) : current.data ? (
<PlanView period={current.data} />
) : (
<NoPeriodCard />
)}
<PageHeader
title={t.pages.planning.title}
description={t.pages.planning.description}
actions={<PeriodPicker />}
/>
<SelectedPeriod>{(p) => <PlanView key={p.id} period={p} />}</SelectedPeriod>
</>
)
}
@@ -56,7 +53,7 @@ function PlanView({ period }: { period: Period }) {
)
}
const editable = period.status === 'open'
const editable = isWritable(period) // açık ya da düzeltmede
return (
<div className="space-y-6">
<PeriodHeading
@@ -75,7 +72,7 @@ function PlanView({ period }: { period: Period }) {
<ItemsList kind="income" list={items.data.filter((i) => i.kind === 'income')} />
<ItemsList kind="expense" list={items.data.filter((i) => i.kind === 'expense')} />
</div>
{editable && <CopyPrevious period={period} />}
{period.status === 'open' && <CopyPrevious period={period} />}
<BudgetRead budget={budget.data} summary={summary.data} />
<NotesSection periodId={period.id} context="planning" readOnly={!editable} />
</div>
+13 -1
View File
@@ -18,7 +18,19 @@ export function PeriodHeading({ period, actions }: { period: Period; actions?: R
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">{period.name}</h2>
<h2 className="flex flex-wrap items-center gap-2 text-lg font-semibold">
{period.name}
{period.status !== 'open' && (
<span
className={cn(
'rounded-full px-2.5 py-0.5 text-xs font-medium',
period.status === 'closed' ? 'bg-muted text-muted-foreground' : 'bg-warning/15',
)}
>
{t.periods.status[period.status]}
</span>
)}
</h2>
<p className="text-sm text-muted-foreground">
{formatDate(period.start_date)} – {formatDate(period.last_date)} ·{' '}
{s.periodInfo(period.days, period.weeks.length)}
+29 -20
View File
@@ -9,10 +9,12 @@ import { MoneyInput } from '@/components/money-input'
import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
import { SelectedPeriod } from '@/features/periods/selected-period'
import { StepActions, StepIntro, Stepper } from '@/features/period-wizard/wizard-ui'
import { PeriodHeading } from '@/features/planning/plan-read'
import { formatDate, formatMoney, formatNumber, parseMoney, toIsoDate } from '@/lib/format'
import { useClosePeriod, useCurrentPeriod, useReview } from '@/lib/periods'
import { periodPath } from '@/lib/period-select'
import { useClosePeriod, useReview } from '@/lib/periods'
import { settlePreview } from '@/lib/review'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
@@ -27,32 +29,36 @@ const s = t.closing
/**
* Dönem kapatma sihirbazı: Özet → Birikim → Onay → Sonuç.
*
* Kapanınca açık dönem kalmaz (`current` → `null`); sonuç bu yüzden sayfanın kendi
* durumunda tutulur. Adımlar adreste değil: yarıda yenilenirse baştan başlar (hiçbir şey
* Kapanınca dönem artık seçilemez (açık dönem kalmaz); sonuç bu yüzden sayfanın kendi
* durumunda tutulur. `?donem=` ile düzeltmedeki dönem yeniden kapatılır. Adımlar adreste değil: yarıda yenilenirse baştan başlar (hiçbir şey
* kaydedilmemiştir, kapatma yalnızca son adımda tek istekle yapılır).
*/
export function ClosePeriodPage() {
const current = useCurrentPeriod()
const [result, setResult] = useState<PeriodCloseResult | null>(null)
let body: ReactNode
if (result) body = <Result result={result} />
else if (current.isPending) body = <PageLoading />
else if (current.isError)
body = <PageError message={current.error.message} onRetry={current.refetch} />
else if (!current.data)
body = (
<div className="space-y-4">
<p className="text-muted-foreground">{s.noOpen}</p>
<NoPeriodCard />
</div>
)
else body = <Wizard period={current.data} onClosed={setResult} />
const empty = (
<div className="space-y-4">
<p className="text-muted-foreground">{s.noOpen}</p>
<NoPeriodCard />
</div>
)
return (
<div className="mx-auto max-w-3xl">
<PageHeader title={s.title} description={s.description} />
{body}
{result ? (
<Result result={result} />
) : (
// Açık dönem ya da `?donem=` ile düzeltmedeki dönem (yeniden kapatma)
<SelectedPeriod empty={empty}>
{(p) =>
p.status === 'closed' ? (
<p className="text-muted-foreground">{s.alreadyClosed}</p>
) : (
<Wizard key={p.id} period={p} onClosed={setResult} />
)
}
</SelectedPeriod>
)}
</div>
)
}
@@ -128,7 +134,10 @@ function StepSummary({
{!period.reflection_md && (
<Notice>
{s.noReflection}{' '}
<Link to={paths.review} className="font-medium text-foreground underline">
<Link
to={periodPath(paths.review, period)}
className="font-medium text-foreground underline"
>
{s.writeReflection}
</Link>
</Notice>
+17 -30
View File
@@ -1,44 +1,40 @@
import { useState } from 'react'
import { Info } from 'lucide-react'
import { Link } from 'react-router'
import { paths } from '@/components/layout/nav-items'
import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { NotesSection } from '@/features/notes/notes-section'
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
import { PeriodPicker } from '@/features/periods/period-picker'
import { SelectedPeriod } from '@/features/periods/selected-period'
import { PeriodHeading } from '@/features/planning/plan-read'
import { useCategories } from '@/lib/catalog'
import { formatDate, toIsoDate } from '@/lib/format'
import { useCurrentPeriod, useReview } from '@/lib/periods'
import { useReview } from '@/lib/periods'
import { t } from '@/lib/strings'
import type { Period } from '@/types/Period'
import { GroupComparison, TopCategories, WeeklyDistribution } from './breakdowns'
import { ClosingSection, PeriodHistory } from './period-status'
import { Questions } from './questions'
import { Reflection } from './reflection'
export function ReviewPage() {
const current = useCurrentPeriod()
return (
<>
<PageHeader title={t.pages.review.title} description={t.pages.review.description} />
{current.isPending ? (
<PageLoading />
) : current.isError ? (
<PageError message={current.error.message} onRetry={current.refetch} />
) : current.data ? (
<ReviewView period={current.data} />
) : (
<NoPeriodCard />
)}
<PageHeader
title={t.pages.review.title}
description={t.pages.review.description}
actions={<PeriodPicker />}
/>
<SelectedPeriod>
{(p, all) => <ReviewView key={p.id} period={p} periods={all} />}
</SelectedPeriod>
</>
)
}
/**
* Hesaplar sorgu anında backend'de yapılır (`GET /periods/{id}/review`); defterde
* bir kayıt değişince bu sayfa da yenilenir. Kapalı dönem: salt okunur (Adım 5'te seçilebilir).
* bir kayıt değişince bu sayfa da yenilenir. Kapalı dönem salt okunur; `?donem=` ile seçilir.
*/
function ReviewView({ period }: { period: Period }) {
function ReviewView({ period, periods }: { period: Period; periods: Period[] }) {
const review = useReview(period.id)
const categories = useCategories(true)
const [today] = useState(() => toIsoDate(new Date()))
@@ -50,7 +46,7 @@ function ReviewView({ period }: { period: Period }) {
return (
<div className="space-y-6">
<PeriodHeading period={period} />
{!readOnly && today <= period.last_date && (
{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 />
{t.review.ongoing(formatDate(period.last_date))}
@@ -64,17 +60,8 @@ function ReviewView({ period }: { period: Period }) {
<WeeklyDistribution review={review.data} />
<Reflection period={period} readOnly={readOnly} />
<NotesSection periodId={period.id} context="review" readOnly={readOnly} collapsed />
{!readOnly && (
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<h2 className="font-semibold">{t.review.closeTitle}</h2>
<p className="text-sm text-muted-foreground">{t.review.closeHint}</p>
</div>
<Button asChild size="lg" className="shrink-0">
<Link to={paths.closePeriod}>{t.review.closeAction}</Link>
</Button>
</section>
)}
<ClosingSection period={period} periods={periods} />
<PeriodHistory periods={periods} selected={period.id} />
</div>
)
}
@@ -0,0 +1,141 @@
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { Link } from 'react-router'
import { toast } from 'sonner'
import { paths } from '@/components/layout/nav-items'
import { Button } from '@/components/ui/button'
import { useMe } from '@/lib/auth'
import { formatDate, formatMoney, toIsoDate } from '@/lib/format'
import { canReopen, periodPath } from '@/lib/period-select'
import { useReopenPeriod } from '@/lib/periods'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { Period } from '@/types/Period'
const s = t.review
/**
* Sayfa sonundaki kapanış bölümü, dönemin durumuna göre:
* açık → "Dönemi Kapat" · düzeltmede → "Yeniden Kapat" · kapalı → bilgi + (sahip) yeniden aç.
*/
export function ClosingSection({ period, periods }: { period: Period; periods: Period[] }) {
if (period.status === 'closed') return <ClosedInfo period={period} periods={periods} />
const correcting = period.status === 'correcting'
return (
<section
className={cn(
'flex flex-col gap-4 rounded-2xl border bg-card p-5 lg:flex-row lg:items-center lg:justify-between',
correcting && 'border-warning/50',
)}
>
<div className="space-y-1">
<h2 className="font-semibold">{correcting ? s.correctingTitle : s.closeTitle}</h2>
<p className="text-sm text-muted-foreground">
{correcting ? s.correctingHint : s.closeHint}
</p>
</div>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row">
{correcting && (
<>
<Button asChild size="lg" variant="outline">
<Link to={periodPath(paths.ledger, period)}>{s.fixInLedger}</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link to={periodPath(paths.planning, period)}>{s.fixInPlan}</Link>
</Button>
</>
)}
<Button asChild size="lg">
<Link to={periodPath(paths.closePeriod, period)}>
{correcting ? s.recloseAction : s.closeAction}
</Link>
</Button>
</div>
</section>
)
}
function ClosedInfo({ period, periods }: { period: Period; periods: Period[] }) {
const me = useMe()
const reopen = useReopenPeriod()
const [asking, setAsking] = useState(false)
const owner = me.data?.user.role === 'owner'
const allowed = canReopen(periods, period)
const closedAt = period.closed_at ? formatDate(toIsoDate(new Date(period.closed_at))) : '—'
// `mutateAsync`: başarıda dönem "düzeltmede" olur ve bu bileşen kalkar; `mutate`'in
// bileşene bağlı geri çağrıları o zaman çalışmazdı (bildirim görünmezdi)
async function confirm() {
try {
await reopen.mutateAsync(period.id)
toast.success(s.reopened)
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e))
}
}
return (
<section className="space-y-3 rounded-2xl border bg-card p-5">
<p>{s.closedInfo(closedAt, formatMoney(period.to_savings ?? 0))}</p>
{owner &&
(!allowed ? (
<p className="text-sm text-muted-foreground">{s.reopenBlocked}</p>
) : asking ? (
<div className="space-y-3 rounded-xl bg-warning/10 p-3">
<p className="text-sm">{s.reopenConfirm}</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Button onClick={() => void confirm()} disabled={reopen.isPending}>
{s.reopenYes}
</Button>
<Button variant="outline" onClick={() => setAsking(false)}>
{s.reopenNo}
</Button>
</div>
</div>
) : (
<Button variant="outline" onClick={() => setAsking(true)}>
{s.reopen}
</Button>
))}
</section>
)
}
/** Kapalı ve düzeltmedeki dönemler; tıklayınca o dönemin değerlendirmesi */
export function PeriodHistory({ periods, selected }: { periods: Period[]; selected: string }) {
const past = periods.filter((p) => p.status !== 'open')
if (past.length === 0) return null
return (
<section className="rounded-2xl border bg-card p-4 sm:p-5">
<h3 className="mb-2 font-semibold">{t.periods.history}</h3>
<ul className="divide-y">
{past.map((p) => (
<li key={p.id}>
<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',
p.id === selected && 'bg-muted',
)}
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{p.name}</p>
<p className="text-xs text-muted-foreground">
{formatDate(p.start_date)} – {formatDate(p.last_date)} ·{' '}
{t.periods.status[p.status]}
</p>
</div>
{p.to_savings !== null && (
<span className="money shrink-0 text-sm text-income">
{t.periods.toSavings(formatMoney(p.to_savings))}
</span>
)}
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</Link>
</li>
))}
</ul>
</section>
)
}
+3 -1
View File
@@ -39,7 +39,9 @@ export function Reflection({ period, readOnly }: { period: Period; readOnly: boo
) : text ? (
<Markdown>{text}</Markdown>
) : (
<p className="text-sm text-muted-foreground">{s.reflectionEmpty}</p>
<p className="text-sm text-muted-foreground">
{readOnly ? s.reflectionNone : s.reflectionEmpty}
</p>
)}
</Section>
)
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import type { Period } from '@/types/Period'
import { canReopen, periodPath, pickPeriod } from './period-select'
const p = (id: string, start: string, status: Period['status']) =>
({ id, start_date: start, status }) as Period
describe('period select', () => {
const sep = p('sep', '2026-09-01', 'closed')
const oct = p('oct', '2026-10-01', 'closed')
const nov = p('nov', '2026-11-01', 'open')
const all = [nov, oct, sep] // en yeni başta
it('picks the requested, else open, else correcting', () => {
expect(pickPeriod(all, nov, 'sep')).toBe(sep)
expect(pickPeriod(all, nov, 'yok')).toBeUndefined()
expect(pickPeriod(all, nov, null)).toBe(nov)
const fixing = p('oct', '2026-10-01', 'correcting')
expect(pickPeriod([fixing, sep], null, null)).toBe(fixing)
expect(pickPeriod([sep], null, null)).toBeNull()
})
it('reopens only the last closed period', () => {
expect(canReopen(all, oct)).toBe(true) // sonrası yalnızca açık Kasım
expect(canReopen(all, sep)).toBe(false) // Ekim kapalı
expect(canReopen(all, nov)).toBe(false) // kapalı değil
const fixing = p('oct', '2026-10-01', 'correcting')
expect(canReopen([nov, fixing, sep], sep)).toBe(false) // Ekim düzeltmede
})
it('links keep the period unless it is the open one', () => {
expect(periodPath('/defter', nov)).toBe('/defter')
expect(periodPath('/defter', oct)).toBe('/defter?donem=oct')
})
})
+56
View File
@@ -0,0 +1,56 @@
/**
* Hangi dönem gösteriliyor? Defter, Plan, Değerlendirme ve kapatma sihirbazı ortak kullanır.
*
* Adreste `?donem=<id>` varsa o dönem (kapalı ya da düzeltmede olabilir); yoksa açık dönem,
* o da yoksa düzeltmedeki dönem. Dönem verisi listeden gelir (liste tüm alanları içerir).
*/
import { useSearchParams } from 'react-router'
import type { Period } from '@/types/Period'
import { useCurrentPeriod, usePeriods } from './periods'
export const PERIOD_PARAM = 'donem'
/** `undefined` → adresteki dönem bulunamadı; `null` → gösterilecek dönem yok */
export function pickPeriod(
periods: Period[],
current: Period | null,
id: string | null,
): Period | null | undefined {
if (id) return periods.find((p) => p.id === id)
return current ?? periods.find((p) => p.status === 'correcting') ?? null
}
/** Kayıt/plan değiştirilebilir mi? (backend `PeriodStatus::is_writable`) */
export const isWritable = (p: Period) => p.status !== 'closed'
/**
* Yeniden açılabilir mi? Yalnızca kapalı dönem ve ondan sonra kapalı/düzeltmede dönem yoksa
* (backend `closing::reopen` ile aynı kural; aksi hâlde sonraki devirler zincirleme bozulurdu).
*/
export function canReopen(periods: Period[], p: Period): boolean {
return (
p.status === 'closed' &&
!periods.some((q) => q.start_date > p.start_date && q.status !== 'open')
)
}
/** Sayfa adresi + dönem: açık dönem varsayılan olduğundan parametresiz */
export function periodPath(path: string, p: Pick<Period, 'id' | 'status'>): string {
return p.status === 'open' ? path : `${path}?${PERIOD_PARAM}=${p.id}`
}
export function useSelectedPeriod() {
const [params] = useSearchParams()
const id = params.get(PERIOD_PARAM)
const current = useCurrentPeriod()
const periods = usePeriods()
const failed = [current, periods].find((q) => q.isError)
const ready = current.isSuccess && periods.isSuccess
return {
isPending: !failed && !ready,
error: failed?.error ?? null,
refetch: () => Promise.all([current.refetch(), periods.refetch()]),
period: ready ? pickPeriod(periods.data, current.data, id) : undefined,
periods: periods.data ?? [],
}
}
+17
View File
@@ -62,6 +62,10 @@ export function useUpdatePeriod() {
api.patch<Period>(`/periods/${id}`, body),
onSuccess: (p) => {
if (p.status === 'open') qc.setQueryData(queryKeys.currentPeriod, p)
// Seçili dönem listeden okunur → yeni sürüm hemen görünsün (art arda kayıtta 409 olmasın)
qc.setQueryData<Period[]>(queryKeys.periods, (list) =>
list?.map((x) => (x.id === p.id ? p : x)),
)
void qc.invalidateQueries({ queryKey: queryKeys.periods, exact: true })
void refreshPeriod(qc, p.id)
},
@@ -86,6 +90,19 @@ export function useClosePeriod() {
})
}
/** **Sahip:** kapalı dönemi düzeltme için yeniden aç (`correcting`) */
export function useReopenPeriod() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.post<Period>(`/periods/${id}/reopen`),
onSettled: () =>
Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.periods }),
qc.invalidateQueries({ queryKey: ['period'] }),
]),
})
}
// ---------- Plan kalemleri ----------
export function usePlanItems(periodId: string) {
+27
View File
@@ -250,6 +250,8 @@ export const t = {
},
ledger: {
readOnly:
'Bu dönem kapalı; kayıtlar yalnızca görüntülenebilir. Düzeltme için hane sahibi Değerlendirme’den yeniden açabilir.',
week: (n: number, count: number) => `${n}. hafta / ${count}`,
prevWeek: 'Önceki hafta',
nextWeek: 'Sonraki hafta',
@@ -322,6 +324,7 @@ export const t = {
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.',
reflectionNone: 'Bu dönem için yazılmamış.',
reflectionPlaceholder:
'- Dışarıda yemeği haftada bire indir\n- Market alışverişini listeyle yap',
reflectionSaved: 'Kaydedildi',
@@ -332,6 +335,29 @@ export const t = {
closeHint:
'Değerlendirme bittiyse dönemi kapat: birikimin ayrılır, kalan artı ya da açık sonraki döneme devreder.',
closeAction: '🔒 Dönemi Kapat',
closedInfo: (date: string, saved: string) =>
`Bu dönem ${date} tarihinde kapandı. Birikime aktarılan: ${saved}.`,
reopen: 'Düzeltme için yeniden aç',
reopenConfirm:
'Dönem düzeltmeye açılır: kayıt ve plan değiştirilebilir. Bitince yeniden kapatman gerekir; devir o zaman yeniden hesaplanır.',
reopenYes: 'Evet, yeniden aç',
reopenNo: 'Vazgeç',
reopened: 'Dönem düzeltmeye açıldı',
reopenBlocked: 'Sonraki dönem kapalı olduğu için bu dönem yeniden açılamaz.',
correctingTitle: 'Bu dönem düzeltmede',
correctingHint:
'Kayıtları ve planı düzelt, bitince yeniden kapat; sonraki döneme devir güncellenir.',
recloseAction: '🔒 Yeniden Kapat',
fixInLedger: 'Defter’de düzelt',
fixInPlan: 'Plan’da düzelt',
},
periods: {
picker: 'Dönem',
status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' },
notFound: 'Dönem bulunamadı. Listeden başka bir dönem seç.',
history: 'Geçmiş dönemler',
toSavings: (x: string) => `Birikime ${x}`,
},
closing: {
@@ -371,6 +397,7 @@ export const t = {
toPlan: 'Plana git',
toDashboard: 'Panele dön',
noOpen: 'Kapatılacak açık dönem yok.',
alreadyClosed: 'Bu dönem zaten kapalı.',
},
common: {