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:
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ 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 ✅.
|
**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
|
## 📌 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 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 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 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
@@ -10,7 +10,7 @@
|
|||||||
| **Faz 3** | 🚀 İlk canlıya alma: Dockerfile, Dokploy, canlı MongoDB, domain + HTTPS, yedekleme | Giriş ekranı canlıda çalışıyor | ✅ |
|
| **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 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ✅ |
|
||||||
| **Faz 5** | Günlük defter | Harcama girişi | 🔄 |
|
| **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 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ⏳ |
|
||||||
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ⏳ |
|
| **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 | ⏳ |
|
| **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)
|
- ✅ 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)
|
- ✅ 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
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function EntryList({
|
|||||||
categories,
|
categories,
|
||||||
methods,
|
methods,
|
||||||
editingId,
|
editingId,
|
||||||
|
readOnly = false,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
}: {
|
}: {
|
||||||
@@ -30,6 +31,8 @@ export function EntryList({
|
|||||||
categories: CatalogItem[]
|
categories: CatalogItem[]
|
||||||
methods: CatalogItem[]
|
methods: CatalogItem[]
|
||||||
editingId?: string
|
editingId?: string
|
||||||
|
/** Kapalı dönem: düzenle/sil yok */
|
||||||
|
readOnly?: boolean
|
||||||
onEdit: (e: Entry) => void
|
onEdit: (e: Entry) => void
|
||||||
onDeleted: (id: string) => void
|
onDeleted: (id: string) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -115,27 +118,29 @@ export function EntryList({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="money shrink-0 font-medium">{formatMoney(e.amount)}</span>
|
<span className="money shrink-0 font-medium">{formatMoney(e.amount)}</span>
|
||||||
<div className="flex shrink-0">
|
{!readOnly && (
|
||||||
<Button
|
<div className="flex shrink-0">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
aria-label={s.edit(name)}
|
size="icon"
|
||||||
aria-pressed={editing}
|
aria-label={s.edit(name)}
|
||||||
onClick={() => onEdit(e)}
|
aria-pressed={editing}
|
||||||
>
|
onClick={() => onEdit(e)}
|
||||||
<Pencil aria-hidden />
|
>
|
||||||
</Button>
|
<Pencil aria-hidden />
|
||||||
<Button
|
</Button>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
aria-label={s.remove(name)}
|
size="icon"
|
||||||
className="text-muted-foreground hover:text-expense"
|
aria-label={s.remove(name)}
|
||||||
disabled={del.isPending && del.variables === e.id}
|
className="text-muted-foreground hover:text-expense"
|
||||||
onClick={() => remove(e, name)}
|
disabled={del.isPending && del.variables === e.id}
|
||||||
>
|
onClick={() => remove(e, name)}
|
||||||
<Trash2 aria-hidden />
|
>
|
||||||
</Button>
|
<Trash2 aria-hidden />
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router'
|
import { useSearchParams } from 'react-router'
|
||||||
|
import { Info } from 'lucide-react'
|
||||||
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
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 { Section } from '@/features/planning/plan-read'
|
||||||
import { useCategories, usePaymentMethods } from '@/lib/catalog'
|
import { useCategories, usePaymentMethods } from '@/lib/catalog'
|
||||||
import { useEntries } from '@/lib/entries'
|
import { useEntries } from '@/lib/entries'
|
||||||
import { toIsoDate } from '@/lib/format'
|
import { toIsoDate } from '@/lib/format'
|
||||||
import { parseWeekParam, weekIndexFor } from '@/lib/ledger'
|
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 { t } from '@/lib/strings'
|
||||||
import type { Entry } from '@/types/Entry'
|
import type { Entry } from '@/types/Entry'
|
||||||
import type { Period } from '@/types/Period'
|
import type { Period } from '@/types/Period'
|
||||||
@@ -17,19 +20,14 @@ import { WeekPicker } from './week-picker'
|
|||||||
import { WeekSummary } from './week-summary'
|
import { WeekSummary } from './week-summary'
|
||||||
|
|
||||||
export function LedgerPage() {
|
export function LedgerPage() {
|
||||||
const current = useCurrentPeriod()
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={t.pages.ledger.title} description={t.pages.ledger.description} />
|
<PageHeader
|
||||||
{current.isPending ? (
|
title={t.pages.ledger.title}
|
||||||
<PageLoading />
|
description={t.pages.ledger.description}
|
||||||
) : current.isError ? (
|
actions={<PeriodPicker />}
|
||||||
<PageError message={current.error.message} onRetry={current.refetch} />
|
/>
|
||||||
) : current.data ? (
|
<SelectedPeriod>{(p) => <LedgerView key={p.id} period={p} />}</SelectedPeriod>
|
||||||
<LedgerView period={current.data} />
|
|
||||||
) : (
|
|
||||||
<NoPeriodCard />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -48,6 +46,7 @@ function LedgerView({ period }: { period: Period }) {
|
|||||||
const index =
|
const index =
|
||||||
parseWeekParam(params.get('hafta'), period.weeks.length) ?? weekIndexFor(period.weeks, today)
|
parseWeekParam(params.get('hafta'), period.weeks.length) ?? weekIndexFor(period.weeks, today)
|
||||||
const week = period.weeks[index - 1]
|
const week = period.weeks[index - 1]
|
||||||
|
const readOnly = !isWritable(period)
|
||||||
|
|
||||||
const budget = useBudget(period.id)
|
const budget = useBudget(period.id)
|
||||||
const categories = useCategories(true)
|
const categories = useCategories(true)
|
||||||
@@ -56,7 +55,15 @@ function LedgerView({ period }: { period: Period }) {
|
|||||||
|
|
||||||
const goTo = (n: number) => {
|
const goTo = (n: number) => {
|
||||||
setEditing(null)
|
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) {
|
function edit(e: Entry) {
|
||||||
@@ -78,29 +85,37 @@ function LedgerView({ period }: { period: Period }) {
|
|||||||
onChange={goTo}
|
onChange={goTo}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 className="grid items-start gap-4 lg:grid-cols-2">
|
||||||
<div ref={formRef} className="scroll-mt-4 lg:sticky lg:top-4">
|
{!readOnly && (
|
||||||
<Section title={editing ? t.ledger.editTitle : t.ledger.formTitle}>
|
<div ref={formRef} className="scroll-mt-4 lg:sticky lg:top-4">
|
||||||
{catalogFailed?.error ? (
|
<Section title={editing ? t.ledger.editTitle : t.ledger.formTitle}>
|
||||||
<PageError message={catalogFailed.error.message} onRetry={catalogFailed.refetch} />
|
{catalogFailed?.error ? (
|
||||||
) : !catalog ? (
|
<PageError message={catalogFailed.error.message} onRetry={catalogFailed.refetch} />
|
||||||
<PageLoading />
|
) : !catalog ? (
|
||||||
) : (
|
<PageLoading />
|
||||||
<EntryForm
|
) : (
|
||||||
key={editing?.id ?? 'new'}
|
<EntryForm
|
||||||
period={period}
|
key={editing?.id ?? 'new'}
|
||||||
week={week}
|
period={period}
|
||||||
today={today}
|
week={week}
|
||||||
categories={catalog.c}
|
today={today}
|
||||||
methods={catalog.m}
|
categories={catalog.c}
|
||||||
entry={editing ?? undefined}
|
methods={catalog.m}
|
||||||
onSaved={(date) => goTo(weekIndexFor(period.weeks, date))}
|
entry={editing ?? undefined}
|
||||||
onCancel={() => setEditing(null)}
|
onSaved={(date) => goTo(weekIndexFor(period.weeks, date))}
|
||||||
/>
|
onCancel={() => setEditing(null)}
|
||||||
)}
|
/>
|
||||||
</Section>
|
)}
|
||||||
</div>
|
</Section>
|
||||||
<div className="space-y-4">
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={readOnly ? 'space-y-4 lg:col-span-2' : 'space-y-4'}>
|
||||||
<Section title={t.ledger.summaryTitle}>
|
<Section title={t.ledger.summaryTitle}>
|
||||||
{budget.isError ? (
|
{budget.isError ? (
|
||||||
<PageError message={budget.error.message} onRetry={budget.refetch} />
|
<PageError message={budget.error.message} onRetry={budget.refetch} />
|
||||||
@@ -120,6 +135,7 @@ function LedgerView({ period }: { period: Period }) {
|
|||||||
categories={catalog.c}
|
categories={catalog.c}
|
||||||
methods={catalog.m}
|
methods={catalog.m}
|
||||||
editingId={editing?.id}
|
editingId={editing?.id}
|
||||||
|
readOnly={readOnly}
|
||||||
onEdit={edit}
|
onEdit={edit}
|
||||||
onDeleted={(id) => editing?.id === id && setEditing(null)}
|
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)
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import { PageError, PageHeader, PageLoading } from '@/components/page'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { NotesSection } from '@/features/notes/notes-section'
|
import { NotesSection } from '@/features/notes/notes-section'
|
||||||
import { CopyPrevious } from '@/features/period-wizard/step-items'
|
import { CopyPrevious } from '@/features/period-wizard/step-items'
|
||||||
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
import { PeriodPicker } from '@/features/periods/period-picker'
|
||||||
import { useBudget, useCurrentPeriod, usePlanItems, useSummary } from '@/lib/periods'
|
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 { t } from '@/lib/strings'
|
||||||
import type { Period } from '@/types/Period'
|
import type { Period } from '@/types/Period'
|
||||||
import { PlanEdit } from './plan-edit'
|
import { PlanEdit } from './plan-edit'
|
||||||
@@ -13,19 +15,14 @@ import { BudgetRead, ItemsList, PeriodHeading } from './plan-read'
|
|||||||
import { SummaryTiles } from './summary-tiles'
|
import { SummaryTiles } from './summary-tiles'
|
||||||
|
|
||||||
export function PlanningPage() {
|
export function PlanningPage() {
|
||||||
const current = useCurrentPeriod()
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={t.pages.planning.title} description={t.pages.planning.description} />
|
<PageHeader
|
||||||
{current.isPending ? (
|
title={t.pages.planning.title}
|
||||||
<PageLoading />
|
description={t.pages.planning.description}
|
||||||
) : current.isError ? (
|
actions={<PeriodPicker />}
|
||||||
<PageError message={current.error.message} onRetry={current.refetch} />
|
/>
|
||||||
) : current.data ? (
|
<SelectedPeriod>{(p) => <PlanView key={p.id} period={p} />}</SelectedPeriod>
|
||||||
<PlanView period={current.data} />
|
|
||||||
) : (
|
|
||||||
<NoPeriodCard />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PeriodHeading
|
<PeriodHeading
|
||||||
@@ -75,7 +72,7 @@ function PlanView({ period }: { period: Period }) {
|
|||||||
<ItemsList kind="income" list={items.data.filter((i) => i.kind === 'income')} />
|
<ItemsList kind="income" list={items.data.filter((i) => i.kind === 'income')} />
|
||||||
<ItemsList kind="expense" list={items.data.filter((i) => i.kind === 'expense')} />
|
<ItemsList kind="expense" list={items.data.filter((i) => i.kind === 'expense')} />
|
||||||
</div>
|
</div>
|
||||||
{editable && <CopyPrevious period={period} />}
|
{period.status === 'open' && <CopyPrevious period={period} />}
|
||||||
<BudgetRead budget={budget.data} summary={summary.data} />
|
<BudgetRead budget={budget.data} summary={summary.data} />
|
||||||
<NotesSection periodId={period.id} context="planning" readOnly={!editable} />
|
<NotesSection periodId={period.id} context="planning" readOnly={!editable} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,19 @@ export function PeriodHeading({ period, actions }: { period: Period; actions?: R
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<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">
|
<p className="text-sm text-muted-foreground">
|
||||||
{formatDate(period.start_date)} – {formatDate(period.last_date)} ·{' '}
|
{formatDate(period.start_date)} – {formatDate(period.last_date)} ·{' '}
|
||||||
{s.periodInfo(period.days, period.weeks.length)}
|
{s.periodInfo(period.days, period.weeks.length)}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import { MoneyInput } from '@/components/money-input'
|
|||||||
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
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 { StepActions, StepIntro, Stepper } from '@/features/period-wizard/wizard-ui'
|
||||||
import { PeriodHeading } from '@/features/planning/plan-read'
|
import { PeriodHeading } from '@/features/planning/plan-read'
|
||||||
import { formatDate, formatMoney, formatNumber, parseMoney, toIsoDate } from '@/lib/format'
|
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 { settlePreview } from '@/lib/review'
|
||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -27,32 +29,36 @@ const s = t.closing
|
|||||||
/**
|
/**
|
||||||
* Dönem kapatma sihirbazı: Özet → Birikim → Onay → Sonuç.
|
* Dönem kapatma sihirbazı: Özet → Birikim → Onay → Sonuç.
|
||||||
*
|
*
|
||||||
* Kapanınca açık dönem kalmaz (`current` → `null`); sonuç bu yüzden sayfanın kendi
|
* Kapanınca dönem artık seçilemez (açık dönem kalmaz); sonuç bu yüzden sayfanın kendi
|
||||||
* durumunda tutulur. Adımlar adreste değil: yarıda yenilenirse baştan başlar (hiçbir şey
|
* 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).
|
* kaydedilmemiştir, kapatma yalnızca son adımda tek istekle yapılır).
|
||||||
*/
|
*/
|
||||||
export function ClosePeriodPage() {
|
export function ClosePeriodPage() {
|
||||||
const current = useCurrentPeriod()
|
|
||||||
const [result, setResult] = useState<PeriodCloseResult | null>(null)
|
const [result, setResult] = useState<PeriodCloseResult | null>(null)
|
||||||
|
const empty = (
|
||||||
let body: ReactNode
|
<div className="space-y-4">
|
||||||
if (result) body = <Result result={result} />
|
<p className="text-muted-foreground">{s.noOpen}</p>
|
||||||
else if (current.isPending) body = <PageLoading />
|
<NoPeriodCard />
|
||||||
else if (current.isError)
|
</div>
|
||||||
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} />
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl">
|
<div className="mx-auto max-w-3xl">
|
||||||
<PageHeader title={s.title} description={s.description} />
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -128,7 +134,10 @@ function StepSummary({
|
|||||||
{!period.reflection_md && (
|
{!period.reflection_md && (
|
||||||
<Notice>
|
<Notice>
|
||||||
{s.noReflection}{' '}
|
{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}
|
{s.writeReflection}
|
||||||
</Link>
|
</Link>
|
||||||
</Notice>
|
</Notice>
|
||||||
|
|||||||
@@ -1,44 +1,40 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Info } from 'lucide-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 { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { NotesSection } from '@/features/notes/notes-section'
|
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 { PeriodHeading } from '@/features/planning/plan-read'
|
||||||
import { useCategories } from '@/lib/catalog'
|
import { useCategories } from '@/lib/catalog'
|
||||||
import { formatDate, toIsoDate } from '@/lib/format'
|
import { formatDate, toIsoDate } from '@/lib/format'
|
||||||
import { useCurrentPeriod, useReview } from '@/lib/periods'
|
import { useReview } from '@/lib/periods'
|
||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
import type { Period } from '@/types/Period'
|
import type { Period } from '@/types/Period'
|
||||||
import { GroupComparison, TopCategories, WeeklyDistribution } from './breakdowns'
|
import { GroupComparison, TopCategories, WeeklyDistribution } from './breakdowns'
|
||||||
|
import { ClosingSection, PeriodHistory } from './period-status'
|
||||||
import { Questions } from './questions'
|
import { Questions } from './questions'
|
||||||
import { Reflection } from './reflection'
|
import { Reflection } from './reflection'
|
||||||
|
|
||||||
export function ReviewPage() {
|
export function ReviewPage() {
|
||||||
const current = useCurrentPeriod()
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={t.pages.review.title} description={t.pages.review.description} />
|
<PageHeader
|
||||||
{current.isPending ? (
|
title={t.pages.review.title}
|
||||||
<PageLoading />
|
description={t.pages.review.description}
|
||||||
) : current.isError ? (
|
actions={<PeriodPicker />}
|
||||||
<PageError message={current.error.message} onRetry={current.refetch} />
|
/>
|
||||||
) : current.data ? (
|
<SelectedPeriod>
|
||||||
<ReviewView period={current.data} />
|
{(p, all) => <ReviewView key={p.id} period={p} periods={all} />}
|
||||||
) : (
|
</SelectedPeriod>
|
||||||
<NoPeriodCard />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hesaplar sorgu anında backend'de yapılır (`GET /periods/{id}/review`); defterde
|
* 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 review = useReview(period.id)
|
||||||
const categories = useCategories(true)
|
const categories = useCategories(true)
|
||||||
const [today] = useState(() => toIsoDate(new Date()))
|
const [today] = useState(() => toIsoDate(new Date()))
|
||||||
@@ -50,7 +46,7 @@ function ReviewView({ period }: { period: Period }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PeriodHeading period={period} />
|
<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">
|
<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 />
|
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||||
{t.review.ongoing(formatDate(period.last_date))}
|
{t.review.ongoing(formatDate(period.last_date))}
|
||||||
@@ -64,17 +60,8 @@ function ReviewView({ period }: { period: Period }) {
|
|||||||
<WeeklyDistribution review={review.data} />
|
<WeeklyDistribution review={review.data} />
|
||||||
<Reflection period={period} readOnly={readOnly} />
|
<Reflection period={period} readOnly={readOnly} />
|
||||||
<NotesSection periodId={period.id} context="review" readOnly={readOnly} collapsed />
|
<NotesSection periodId={period.id} context="review" readOnly={readOnly} collapsed />
|
||||||
{!readOnly && (
|
<ClosingSection period={period} periods={periods} />
|
||||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 sm:flex-row sm:items-center sm:justify-between">
|
<PeriodHistory periods={periods} selected={period.id} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -39,7 +39,9 @@ export function Reflection({ period, readOnly }: { period: Period; readOnly: boo
|
|||||||
) : text ? (
|
) : text ? (
|
||||||
<Markdown>{text}</Markdown>
|
<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>
|
</Section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,10 @@ export function useUpdatePeriod() {
|
|||||||
api.patch<Period>(`/periods/${id}`, body),
|
api.patch<Period>(`/periods/${id}`, body),
|
||||||
onSuccess: (p) => {
|
onSuccess: (p) => {
|
||||||
if (p.status === 'open') qc.setQueryData(queryKeys.currentPeriod, 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 qc.invalidateQueries({ queryKey: queryKeys.periods, exact: true })
|
||||||
void refreshPeriod(qc, p.id)
|
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 ----------
|
// ---------- Plan kalemleri ----------
|
||||||
|
|
||||||
export function usePlanItems(periodId: string) {
|
export function usePlanItems(periodId: string) {
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ export const t = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ledger: {
|
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}`,
|
week: (n: number, count: number) => `${n}. hafta / ${count}`,
|
||||||
prevWeek: 'Önceki hafta',
|
prevWeek: 'Önceki hafta',
|
||||||
nextWeek: 'Sonraki hafta',
|
nextWeek: 'Sonraki hafta',
|
||||||
@@ -322,6 +324,7 @@ export const t = {
|
|||||||
week: (n: number) => `${n}. hafta`,
|
week: (n: number) => `${n}. hafta`,
|
||||||
reflectionTitle: 'Nasıl daha iyi yapabilirim?',
|
reflectionTitle: 'Nasıl daha iyi yapabilirim?',
|
||||||
reflectionEmpty: 'Bu ay neler iyi gitti, neyi değiştirmek istersin? Kısa notlar yeterli.',
|
reflectionEmpty: 'Bu ay neler iyi gitti, neyi değiştirmek istersin? Kısa notlar yeterli.',
|
||||||
|
reflectionNone: 'Bu dönem için yazılmamış.',
|
||||||
reflectionPlaceholder:
|
reflectionPlaceholder:
|
||||||
'- Dışarıda yemeği haftada bire indir\n- Market alışverişini listeyle yap',
|
'- Dışarıda yemeği haftada bire indir\n- Market alışverişini listeyle yap',
|
||||||
reflectionSaved: 'Kaydedildi',
|
reflectionSaved: 'Kaydedildi',
|
||||||
@@ -332,6 +335,29 @@ export const t = {
|
|||||||
closeHint:
|
closeHint:
|
||||||
'Değerlendirme bittiyse dönemi kapat: birikimin ayrılır, kalan artı ya da açık sonraki döneme devreder.',
|
'Değerlendirme bittiyse dönemi kapat: birikimin ayrılır, kalan artı ya da açık sonraki döneme devreder.',
|
||||||
closeAction: '🔒 Dönemi Kapat',
|
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: {
|
closing: {
|
||||||
@@ -371,6 +397,7 @@ export const t = {
|
|||||||
toPlan: 'Plana git',
|
toPlan: 'Plana git',
|
||||||
toDashboard: 'Panele dön',
|
toDashboard: 'Panele dön',
|
||||||
noOpen: 'Kapatılacak açık dönem yok.',
|
noOpen: 'Kapatılacak açık dönem yok.',
|
||||||
|
alreadyClosed: 'Bu dönem zaten kapalı.',
|
||||||
},
|
},
|
||||||
|
|
||||||
common: {
|
common: {
|
||||||
|
|||||||
Reference in New Issue
Block a user