-
{period.name}
+
+ {period.name}
+ {period.status !== 'open' && (
+
+ {t.periods.status[period.status]}
+
+ )}
+
{formatDate(period.start_date)} – {formatDate(period.last_date)} ·{' '}
{s.periodInfo(period.days, period.weeks.length)}
diff --git a/frontend/src/features/review/close-page.tsx b/frontend/src/features/review/close-page.tsx
index 607f5c3..6d3bf07 100644
--- a/frontend/src/features/review/close-page.tsx
+++ b/frontend/src/features/review/close-page.tsx
@@ -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(null)
-
- let body: ReactNode
- if (result) body =
- else if (current.isPending) body =
- else if (current.isError)
- body =
- else if (!current.data)
- body = (
-
- )
- else body =
+ const empty = (
+
+ )
return (
- {body}
+ {result ? (
+
+ ) : (
+ // Açık dönem ya da `?donem=` ile düzeltmedeki dönem (yeniden kapatma)
+
+ {(p) =>
+ p.status === 'closed' ? (
+ {s.alreadyClosed}
+ ) : (
+
+ )
+ }
+
+ )}
)
}
@@ -128,7 +134,10 @@ function StepSummary({
{!period.reflection_md && (
{s.noReflection}{' '}
-
+
{s.writeReflection}
diff --git a/frontend/src/features/review/page.tsx b/frontend/src/features/review/page.tsx
index d261994..b6dd06f 100644
--- a/frontend/src/features/review/page.tsx
+++ b/frontend/src/features/review/page.tsx
@@ -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 (
<>
-
- {current.isPending ? (
-
- ) : current.isError ? (
-
- ) : current.data ? (
-
- ) : (
-
- )}
+ }
+ />
+
+ {(p, all) => }
+
>
)
}
/**
* 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 (
- {!readOnly && today <= period.last_date && (
+ {period.status === 'open' && today <= period.last_date && (
{t.review.ongoing(formatDate(period.last_date))}
@@ -64,17 +60,8 @@ function ReviewView({ period }: { period: Period }) {
- {!readOnly && (
-
-
-
{t.review.closeTitle}
-
{t.review.closeHint}
-
-
- {t.review.closeAction}
-
-
- )}
+
+
)
}
diff --git a/frontend/src/features/review/period-status.tsx b/frontend/src/features/review/period-status.tsx
new file mode 100644
index 0000000..1e79559
--- /dev/null
+++ b/frontend/src/features/review/period-status.tsx
@@ -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
+ const correcting = period.status === 'correcting'
+ return (
+
+
+
{correcting ? s.correctingTitle : s.closeTitle}
+
+ {correcting ? s.correctingHint : s.closeHint}
+
+
+
+ {correcting && (
+ <>
+
+ {s.fixInLedger}
+
+
+ {s.fixInPlan}
+
+ >
+ )}
+
+
+ {correcting ? s.recloseAction : s.closeAction}
+
+
+
+
+ )
+}
+
+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 (
+
+ {s.closedInfo(closedAt, formatMoney(period.to_savings ?? 0))}
+ {owner &&
+ (!allowed ? (
+ {s.reopenBlocked}
+ ) : asking ? (
+
+
{s.reopenConfirm}
+
+ void confirm()} disabled={reopen.isPending}>
+ {s.reopenYes}
+
+ setAsking(false)}>
+ {s.reopenNo}
+
+
+
+ ) : (
+ setAsking(true)}>
+ {s.reopen}
+
+ ))}
+
+ )
+}
+
+/** 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 (
+
+ {t.periods.history}
+
+ {past.map((p) => (
+
+
+
+
{p.name}
+
+ {formatDate(p.start_date)} – {formatDate(p.last_date)} ·{' '}
+ {t.periods.status[p.status]}
+
+
+ {p.to_savings !== null && (
+
+ {t.periods.toSavings(formatMoney(p.to_savings))}
+
+ )}
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/features/review/reflection.tsx b/frontend/src/features/review/reflection.tsx
index 123db92..786106b 100644
--- a/frontend/src/features/review/reflection.tsx
+++ b/frontend/src/features/review/reflection.tsx
@@ -39,7 +39,9 @@ export function Reflection({ period, readOnly }: { period: Period; readOnly: boo
) : text ? (
{text}
) : (
- {s.reflectionEmpty}
+
+ {readOnly ? s.reflectionNone : s.reflectionEmpty}
+
)}
)
diff --git a/frontend/src/lib/period-select.test.ts b/frontend/src/lib/period-select.test.ts
new file mode 100644
index 0000000..6ce7224
--- /dev/null
+++ b/frontend/src/lib/period-select.test.ts
@@ -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')
+ })
+})
diff --git a/frontend/src/lib/period-select.ts b/frontend/src/lib/period-select.ts
new file mode 100644
index 0000000..fabdce1
--- /dev/null
+++ b/frontend/src/lib/period-select.ts
@@ -0,0 +1,56 @@
+/**
+ * Hangi dönem gösteriliyor? Defter, Plan, Değerlendirme ve kapatma sihirbazı ortak kullanır.
+ *
+ * Adreste `?donem=` 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): 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 ?? [],
+ }
+}
diff --git a/frontend/src/lib/periods.ts b/frontend/src/lib/periods.ts
index 06f496c..6c3209d 100644
--- a/frontend/src/lib/periods.ts
+++ b/frontend/src/lib/periods.ts
@@ -62,6 +62,10 @@ export function useUpdatePeriod() {
api.patch(`/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(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(`/periods/${id}/reopen`),
+ onSettled: () =>
+ Promise.all([
+ qc.invalidateQueries({ queryKey: queryKeys.periods }),
+ qc.invalidateQueries({ queryKey: ['period'] }),
+ ]),
+ })
+}
+
// ---------- Plan kalemleri ----------
export function usePlanItems(periodId: string) {
diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts
index 1e2ff38..f1a4933 100644
--- a/frontend/src/lib/strings.ts
+++ b/frontend/src/lib/strings.ts
@@ -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: {