feat(değerlendirme): dönem kapatma sihirbazı

- /degerlendirme/kapat: Özet → Birikim → Onay → Sonuç
- Birikime aktarılacak tutar (varsayılan tamamı), kalan artı/açık devri önizlemesi
- Değerlendirme sayfasına "Dönemi Kapat" bölümü
- Yeni dönem önerisi son dönemin bitişinden başlar (erken kapatılan ay önerilmez)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 22:59:38 +03:00
parent 311cfc8c60
commit 2c16cf4163
16 changed files with 639 additions and 18 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası # 🧠 Proje Hafızası
## ▶️ Sıradaki Adım ## ▶️ Sıradaki Adım
**Faz 6 – Adım 4:** Kapatma sihirbazı (`/degerlendirme/kapat`, Değerlendirme'de "🔒 Dönemi Kapat"): 1) özet + planlanan/gerçekleşen (review verisi), 2) gerçekleşen birikim → artıdaysa "birikime aktar" tutarı (MoneyInput, varsayılan tamamı; kalan artı devreder), açıktaysa borç bilgisi, 3) onay (`POST /close`, `version`), 4) sonuç + "Yeni dönemi aç" (sihirbaz `/plan/donem-ac`, önceki aydan kopyala adımları zaten var). Kapanınca `currentPeriod` null → sorgular yenilenir. **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.
**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
@@ -110,3 +110,4 @@
- **Adım 1:** Değerlendirme backend: `services/review.rs` (`budget::summary` + `budget::view` + kategori aggregation), `routes/review.rs` → `GET /periods/{id}/review` (`PeriodReview`: `available` = gelir − sabit − borç + artı, `savings_goal`, `spent_total`, `realized_savings` = available − harcanan, `savings_gap`; `groups` planned/spent, `categories` çoktan aza (+count), `weeks` 4 grubun toplamı). Kapalı dönemde okunur. `periods.reflection_md` (PATCH, ≤ 20.000 karakter, `""` → null, yalnızca açık dönem). `tests/review.rs` (3). Mutasyon: 4. soruya hedef düşmek / silinenleri saymak → yakalandı. Not: `TestApp::register` adı e-postaya çevirir → Türkçe karakterli ad kullanma. - **Adım 1:** Değerlendirme backend: `services/review.rs` (`budget::summary` + `budget::view` + kategori aggregation), `routes/review.rs` → `GET /periods/{id}/review` (`PeriodReview`: `available` = gelir − sabit − borç + artı, `savings_goal`, `spent_total`, `realized_savings` = available − harcanan, `savings_gap`; `groups` planned/spent, `categories` çoktan aza (+count), `weeks` 4 grubun toplamı). Kapalı dönemde okunur. `periods.reflection_md` (PATCH, ≤ 20.000 karakter, `""` → null, yalnızca açık dönem). `tests/review.rs` (3). Mutasyon: 4. soruya hedef düşmek / silinenleri saymak → yakalandı. Not: `TestApp::register` adı e-postaya çevirir → Türkçe karakterli ad kullanma.
- **Adım 2:** Kapatma backend: `services/closing.rs` — `settle(realized, to_savings)` (< 0 → borç, birikim istenemez; ≥ 0 → varsayılan tamamı birikime, kalan artı), `close` (tek transaction: dönem `closed` + `closed_at/by` + `to_savings`, bu dönemden eski devirler soft delete, yeni devir; `to_period_id` = sonraki dönem ya da null), `reopen` (sahip; `closed` → **`correcting`**; ardından kapalı/düzeltmede dönem varsa 409). `PeriodStatus::Correcting` + `is_writable()` → `get_open`/`update` buna bakar; `current()` yalnız `open`. `period::create` artık transaction: önceki dönemin bağsız devrini bağlar. `POST /periods/{id}/close` (`PeriodCloseRequest`/`PeriodCloseResult`/`Carryover`), `POST /periods/{id}/reopen`. Period DTO: `to_savings`, `closed_at`. `tests/closing.rs` (4) + `settle` birim testi. Mutasyon 3/3 yakalandı; hata zorlanınca transaction geri aldı (dönem açık, sürüm aynı). Backend 113 test. Karar: yeniden açma = düzeltme modu (kullanıcı "a"). - **Adım 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ı).
+2 -2
View File
@@ -64,5 +64,5 @@
- ✅ 1. Backend: değerlendirme (`GET /periods/{id}/review`: 4 soru, grup/kategori/hafta) + `reflection_md` - ✅ 1. Backend: değerlendirme (`GET /periods/{id}/review`: 4 soru, grup/kategori/hafta) + `reflection_md`
- ✅ 2. Backend: dönem kapatma (transaction: durum, devir, birikime aktarılan tutar) + yeni dönemde devrin bağlanması + yeniden açma (sahip) - ✅ 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) - ⏳ 5. Frontend: kapalı dönemler listesi + yeniden açma (sahip) + düzeltmedeki dönemi Defter/Plan/Değerlendirme'de seçme
@@ -23,6 +23,8 @@ export const paths = {
savings: '/birikim', savings: '/birikim',
more: '/diger', more: '/diger',
review: '/degerlendirme', review: '/degerlendirme',
/** Dönem kapatma sihirbazı */
closePeriod: '/degerlendirme/kapat',
reports: '/raporlar', reports: '/raporlar',
settings: '/ayarlar', settings: '/ayarlar',
login: '/giris', login: '/giris',
@@ -6,8 +6,10 @@ import { FormError, FormField } from '@/components/form-field'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { ApiError } from '@/lib/api' import { ApiError } from '@/lib/api'
import { daysInclusive, isIsoDate, lastToEnd, monthRange } from '@/lib/period-dates' import { PageError, PageLoading } from '@/components/page'
import { useCreatePeriod, useUpdatePeriod } from '@/lib/periods' import { formatDate } from '@/lib/format'
import { daysInclusive, isIsoDate, lastToEnd, suggestRanges } from '@/lib/period-dates'
import { useCreatePeriod, usePeriods, useUpdatePeriod } 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 { datesSchema, type DatesForm } from './schemas' import { datesSchema, type DatesForm } from './schemas'
@@ -17,15 +19,45 @@ const s = t.wizard.dates
const monthName = new Intl.DateTimeFormat('tr-TR', { month: 'long', year: 'numeric' }) const monthName = new Intl.DateTimeFormat('tr-TR', { month: 'long', year: 'numeric' })
/** 1. adım: dönem yoksa açar, varsa (geri gelindiyse) günceller. */ /** Hızlı seçim etiketi: takvim ayıysa "Aralık 2026", değilse "15.11.2026 – 14.12.2026" */
function rangeLabel(r: { start: string; last: string }) {
const [y, m, d] = r.start.split('-').map(Number)
const monthEnd = new Date(Date.UTC(y, m, 0)).toISOString().slice(0, 10)
return d === 1 && r.last === monthEnd
? monthName.format(new Date(y, m - 1, 1))
: `${formatDate(r.start)} – ${formatDate(r.last)}`
}
/**
* 1. adım: dönem yoksa açar, varsa (geri gelindiyse) günceller.
* Öneriler son dönemin bitişinden başlar (erken kapatılan ay tekrar önerilmez).
*/
export function StepDates({ period, onDone }: { period: Period | null; onDone: () => void }) { export function StepDates({ period, onDone }: { period: Period | null; onDone: () => void }) {
const periods = usePeriods()
if (periods.isPending) return <PageLoading />
if (periods.isError)
return <PageError message={periods.error.message} onRetry={periods.refetch} />
// Liste en yeni başta; sihirbazda açık dönem varsa (geri gelindi) o hariç tutulur
const previous = periods.data.find((p) => p.id !== period?.id)
return <DatesStepForm period={period} prevEnd={previous?.end_date} onDone={onDone} />
}
function DatesStepForm({
period,
prevEnd,
onDone,
}: {
period: Period | null
prevEnd?: string
onDone: () => void
}) {
const create = useCreatePeriod() const create = useCreatePeriod()
const update = useUpdatePeriod() const update = useUpdatePeriod()
const [today] = useState(() => new Date()) // render başına değil, bir kez const [today] = useState(() => new Date()) // render başına değil, bir kez
const presets = [0, 1].map((offset) => ({ const presets = suggestRanges(today, prevEnd).map((range) => ({
label: monthName.format(new Date(today.getFullYear(), today.getMonth() + offset, 1)), label: rangeLabel(range),
range: monthRange(today, offset), range,
})) }))
const { const {
@@ -7,12 +7,20 @@ import { cn } from '@/lib/utils'
const s = t.wizard const s = t.wizard
/** Adım göstergesi: mobilde "Adım 2/5 · Gelirler" + çubuk, geniş ekranda numaralı liste. */ /** Adım göstergesi: mobilde "Adım 2/5 · Gelirler" + çubuk, geniş ekranda numaralı liste. */
export function Stepper({ current }: { current: number }) { export function Stepper({
const total = s.steps.length current,
steps = s.steps,
label = s.title,
}: {
current: number
steps?: readonly string[]
label?: string
}) {
const total = steps.length
return ( return (
<nav aria-label={s.title} className="mb-6"> <nav aria-label={label} className="mb-6">
<p className="mb-2 text-sm font-medium sm:hidden"> <p className="mb-2 text-sm font-medium sm:hidden">
{s.stepOf(current, total)} · {s.steps[current - 1]} {s.stepOf(current, total)} · {steps[current - 1]}
</p> </p>
<div className="h-1.5 overflow-hidden rounded-full bg-muted sm:hidden" aria-hidden> <div className="h-1.5 overflow-hidden rounded-full bg-muted sm:hidden" aria-hidden>
<div <div
@@ -21,13 +29,13 @@ export function Stepper({ current }: { current: number }) {
/> />
</div> </div>
<ol className="hidden gap-2 sm:flex"> <ol className="hidden gap-2 sm:flex">
{s.steps.map((label, i) => { {steps.map((name, i) => {
const n = i + 1 const n = i + 1
const done = n < current const done = n < current
const active = n === current const active = n === current
return ( return (
<li <li
key={label} key={name}
aria-current={active ? 'step' : undefined} aria-current={active ? 'step' : undefined}
className={cn( className={cn(
'flex flex-1 items-center gap-2 rounded-lg border px-3 py-2 text-sm', 'flex flex-1 items-center gap-2 rounded-lg border px-3 py-2 text-sm',
@@ -43,7 +51,7 @@ export function Stepper({ current }: { current: number }) {
> >
{done ? <Check className="size-3.5" aria-hidden /> : n} {done ? <Check className="size-3.5" aria-hidden /> : n}
</span> </span>
{label} {name}
</li> </li>
) )
})} })}
+367
View File
@@ -0,0 +1,367 @@
import { useState, type ReactNode } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { CircleCheck, Info, TriangleAlert } from 'lucide-react'
import { Controller, useForm, useWatch } from 'react-hook-form'
import { Link } from 'react-router'
import { paths } from '@/components/layout/nav-items'
import { FormError, FormField } from '@/components/form-field'
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 { 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 { settlePreview } from '@/lib/review'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { Period } from '@/types/Period'
import type { PeriodCloseResult } from '@/types/PeriodCloseResult'
import type { PeriodReview } from '@/types/PeriodReview'
import { closeSchema, type CloseForm } from './close-schema'
import { Questions } from './questions'
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
* 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} />
return (
<div className="mx-auto max-w-3xl">
<PageHeader title={s.title} description={s.description} />
{body}
</div>
)
}
function Wizard({
period,
onClosed,
}: {
period: Period
onClosed: (r: PeriodCloseResult) => void
}) {
const review = useReview(period.id)
const [step, setStep] = useState(1)
/** Seçilen tutar (kuruş); seçilmediyse birikimin tamamı */
const [toSavings, setToSavings] = useState<number | null>(null)
if (review.isError) return <PageError message={review.error.message} onRetry={review.refetch} />
if (!review.data) return <PageLoading />
const realized = review.data.realized_savings
const go = (n: number) => {
setStep(n)
window.scrollTo({ top: 0 })
}
return (
<>
<Stepper current={step} steps={s.steps} label={s.title} />
{step === 1 && <StepSummary period={period} review={review.data} onNext={() => go(2)} />}
{step === 2 && (
<StepSavings
realized={realized}
initial={toSavings ?? Math.max(realized, 0)}
onBack={() => go(1)}
onNext={(v) => {
setToSavings(v)
go(3)
}}
/>
)}
{step === 3 && (
<StepConfirm
period={period}
realized={realized}
toSavings={toSavings ?? Math.max(realized, 0)}
onBack={() => go(2)}
onClosed={onClosed}
/>
)}
</>
)
}
// ---------- 1. Özet ----------
function StepSummary({
period,
review,
onNext,
}: {
period: Period
review: PeriodReview
onNext: () => void
}) {
const [today] = useState(() => toIsoDate(new Date()))
return (
<div className="space-y-5">
<PeriodHeading period={period} />
{today <= period.last_date && (
<Notice tone="warning">{s.early(formatDate(period.last_date))}</Notice>
)}
<Questions review={review} />
{!period.reflection_md && (
<Notice>
{s.noReflection}{' '}
<Link to={paths.review} className="font-medium text-foreground underline">
{s.writeReflection}
</Link>
</Notice>
)}
<StepActions onNext={onNext} />
</div>
)
}
// ---------- 2. Birikim ----------
function StepSavings({
realized,
initial,
onBack,
onNext,
}: {
realized: number
initial: number
onBack: () => void
onNext: (toSavings: number) => void
}) {
if (realized <= 0) {
return (
<div>
<StepIntro
title={s.savingsTitle}
hint={realized < 0 ? s.deficitHint(formatMoney(-realized)) : s.zeroHint}
/>
<StepActions onBack={onBack} onNext={() => onNext(0)} />
</div>
)
}
return <SavingsForm realized={realized} initial={initial} onBack={onBack} onNext={onNext} />
}
function SavingsForm({
realized,
initial,
onBack,
onNext,
}: {
realized: number
initial: number
onBack: () => void
onNext: (toSavings: number) => void
}) {
const {
control,
handleSubmit,
setValue,
formState: { errors },
} = useForm<CloseForm>({
resolver: zodResolver(closeSchema(realized)),
mode: 'onTouched',
defaultValues: { to_savings: formatNumber(initial / 100) },
})
const value = useWatch({ control, name: 'to_savings' })
const preview = settlePreview(realized, parseMoney(value) ?? 0)
return (
<form onSubmit={handleSubmit((v) => onNext(parseMoney(v.to_savings) ?? 0))} noValidate>
<StepIntro title={s.savingsTitle} hint={s.savingsHint(formatMoney(realized))} />
<div className="space-y-4">
<Controller
control={control}
name="to_savings"
render={({ field }) => (
<FormField label={s.toSavings} error={errors.to_savings}>
{(f) => (
<MoneyInput
{...f}
ref={field.ref}
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
className="h-14 text-2xl font-semibold md:text-2xl"
/>
)}
</FormField>
)}
/>
<div className="flex gap-2">
{[
{ label: s.all, amount: realized },
{ label: s.none, amount: 0 },
].map((o) => (
<Button
key={o.label}
type="button"
variant="outline"
onClick={() =>
setValue('to_savings', formatNumber(o.amount / 100), { shouldValidate: true })
}
>
{o.label}
</Button>
))}
</div>
<Breakdown toSavings={preview.toSavings} carry={preview.carry} realized={realized} />
</div>
<StepActions onBack={onBack} />
</form>
)
}
// ---------- 3. Onay ----------
function StepConfirm({
period,
realized,
toSavings,
onBack,
onClosed,
}: {
period: Period
realized: number
toSavings: number
onBack: () => void
onClosed: (r: PeriodCloseResult) => void
}) {
const close = useClosePeriod()
const [error, setError] = useState<string>()
const preview = settlePreview(realized, toSavings)
async function submit() {
setError(undefined)
try {
const r = await close.mutateAsync({
id: period.id,
version: period.version,
// Açıkta birikim istenmez (backend reddeder)
to_savings: realized > 0 ? preview.toSavings : undefined,
})
onClosed(r)
window.scrollTo({ top: 0 })
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
return (
<div>
<StepIntro title={s.confirmTitle} hint={s.confirmHint} />
<div className="space-y-4">
<Notice tone="warning">{s.willClose(period.name)}</Notice>
<Breakdown toSavings={preview.toSavings} carry={preview.carry} realized={realized} />
<p className="text-sm text-muted-foreground">{s.reopenNote}</p>
<FormError message={error} />
</div>
<StepActions onBack={onBack} onNext={submit} pending={close.isPending} nextLabel={s.submit} />
</div>
)
}
// ---------- Sonuç ----------
function Result({ result: r }: { result: PeriodCloseResult }) {
const attached = !!r.carryover?.to_period_id
return (
<section className="space-y-5 rounded-2xl border bg-card p-6">
<div className="flex items-center gap-3">
<CircleCheck className="size-8 shrink-0 text-income" aria-hidden />
<h2 className="text-xl font-semibold">{s.done(r.period.name)}</h2>
</div>
<Breakdown toSavings={r.to_savings} carry={r.carryover} realized={r.realized_savings} />
<p className="text-muted-foreground">
{r.carryover ? (attached ? s.resultAttached : s.resultHint) : s.noCarry}
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Button asChild size="lg">
<Link to={attached ? paths.planning : paths.periodWizard}>
{attached ? s.toPlan : s.newPeriod}
</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link to={paths.dashboard}>{s.toDashboard}</Link>
</Button>
</div>
</section>
)
}
// ---------- ortak ----------
/** Gerçekleşen birikim → birikime aktarılan + devreden (önizleme ve sonuçta aynı görünüm) */
function Breakdown({
realized,
toSavings,
carry,
}: {
realized: number
toSavings: number
carry: { kind: 'debt' | 'surplus'; amount: number } | null
}) {
const rows = [
{ label: s.realized, amount: realized, tone: realized < 0 ? 'text-expense' : '' },
...(realized >= 0 ? [{ label: s.savingsLine, amount: toSavings, tone: 'text-income' }] : []),
carry
? {
label: carry.kind === 'debt' ? s.debtLine : s.surplusLine,
amount: carry.amount,
tone: carry.kind === 'debt' ? 'text-expense' : 'text-income',
}
: { label: s.carryLine, amount: 0, tone: 'text-muted-foreground' },
]
return (
<dl className="divide-y rounded-xl border">
{rows.map((row) => (
<div key={row.label} className="flex items-baseline justify-between gap-3 px-4 py-3">
<dt className="text-sm">{row.label}</dt>
<dd className={cn('money font-semibold', row.tone)}>{formatMoney(row.amount)}</dd>
</div>
))}
</dl>
)
}
function Notice({ tone = 'info', children }: { tone?: 'info' | 'warning'; children: ReactNode }) {
const Icon = tone === 'warning' ? TriangleAlert : Info
return (
<p
className={cn(
'flex items-start gap-2 rounded-xl px-3 py-2 text-sm',
tone === 'warning' ? 'bg-warning/10 text-foreground' : 'bg-muted text-muted-foreground',
)}
>
<Icon
className={cn('mt-0.5 size-4 shrink-0', tone === 'warning' && 'text-warning')}
aria-hidden
/>
<span>{children}</span>
</p>
)
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { closeSchema } from './close-schema'
describe('close schema', () => {
const schema = closeSchema(1_000_000) // 10.000 ₺
it('accepts 0 up to the realized savings', () => {
expect(schema.safeParse({ to_savings: '0' }).success).toBe(true)
expect(schema.safeParse({ to_savings: '4.000,50' }).success).toBe(true)
expect(schema.safeParse({ to_savings: '10.000,00' }).success).toBe(true)
})
it('rejects empty, negative and too much', () => {
expect(schema.safeParse({ to_savings: '' }).success).toBe(false)
expect(schema.safeParse({ to_savings: '-5' }).success).toBe(false)
const r = schema.safeParse({ to_savings: '10.000,01' })
expect(r.success).toBe(false)
expect(r.error?.issues[0].message).toBe('En fazla 10.000,00 ₺ aktarabilirsin')
})
})
@@ -0,0 +1,17 @@
import { z } from 'zod'
import { formatMoney } from '@/lib/format'
import { moneyMessages, moneyText, toKurus } from '@/lib/money-schema'
import { t } from '@/lib/strings'
/** Birikime aktarılacak tutar: 0 ile gerçekleşen birikim arasında (backend `closing::settle`) */
export function closeSchema(realized: number) {
return z.object({
to_savings: moneyText('optional').superRefine((v, ctx) => {
if (v === '') return ctx.addIssue({ code: 'custom', message: moneyMessages.required })
if (toKurus(v) > realized)
ctx.addIssue({ code: 'custom', message: t.closing.tooMuch(formatMoney(realized)) })
}),
})
}
export type CloseForm = z.infer<ReturnType<typeof closeSchema>>
+14
View File
@@ -1,6 +1,9 @@
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 { NoPeriodCard } from '@/features/period-wizard/no-period-card'
import { PeriodHeading } from '@/features/planning/plan-read' import { PeriodHeading } from '@/features/planning/plan-read'
@@ -61,6 +64,17 @@ 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 && (
<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>
)}
</div> </div>
) )
} }
+33 -1
View File
@@ -1,5 +1,13 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { addDays, daysInclusive, isIsoDate, lastToEnd, monthRange } from './period-dates' import {
addDays,
daysInclusive,
isIsoDate,
lastToEnd,
monthRange,
rangeAfter,
suggestRanges,
} from './period-dates'
describe('period-dates', () => { describe('period-dates', () => {
it('validates ISO dates', () => { it('validates ISO dates', () => {
@@ -37,4 +45,28 @@ describe('period-dates', () => {
it('converts last day to exclusive end', () => { it('converts last day to exclusive end', () => {
expect(lastToEnd('2026-12-31')).toBe('2027-01-01') expect(lastToEnd('2026-12-31')).toBe('2027-01-01')
}) })
it('continues after the previous period', () => {
expect(rangeAfter('2026-12-01')).toEqual({ start: '2026-12-01', last: '2026-12-31' })
expect(rangeAfter('2027-01-01')).toEqual({ start: '2027-01-01', last: '2027-01-31' })
expect(rangeAfter('2026-11-15')).toEqual({ start: '2026-11-15', last: '2026-12-14' })
expect(rangeAfter('2027-01-31')).toEqual({ start: '2027-01-31', last: '2027-02-27' })
})
it('suggests ranges that do not overlap the previous period', () => {
const today = new Date(2026, 10, 12) // 12.11.2026
expect(suggestRanges(today)).toEqual([
{ start: '2026-11-01', last: '2026-11-30' },
{ start: '2026-12-01', last: '2026-12-31' },
])
// Kasım erken kapatıldı → Kasım önerilmez, Aralık bir kez
expect(suggestRanges(today, '2026-12-01')).toEqual([
{ start: '2026-12-01', last: '2026-12-31' },
])
// Ayın 15'inde başlayan dönemler
expect(suggestRanges(today, '2026-11-15')).toEqual([
{ start: '2026-11-15', last: '2026-12-14' },
{ start: '2026-12-01', last: '2026-12-31' },
])
})
}) })
+32
View File
@@ -51,3 +51,35 @@ export function monthRange(today: Date, monthOffset = 0): { start: string; last:
export function lastToEnd(last: string): string { export function lastToEnd(last: string): string {
return addDays(last, 1) return addDays(last, 1)
} }
/**
* Önceki dönemin bitişinden (hariç `end_date` = yeni dönemin ilk günü) bir aylık dönem:
* ayın 1'iyse takvim ayı, değilse sonraki ayın aynı gününün bir öncesi (15.11 → 14.12).
*/
export function rangeAfter(prevEnd: string): { start: string; last: string } {
const [y, m, d] = prevEnd.split('-').map(Number)
if (d === 1) return { start: prevEnd, last: addDays(fromUtc(Date.UTC(y, m, 1)), -1) }
// Sonraki ayda o gün yoksa (31.01 → şubat) ayın son gününde kalır
const lastOfNext = new Date(Date.UTC(y, m + 1, 0)).getUTCDate()
const same = fromUtc(Date.UTC(y, m, Math.min(d, lastOfNext)))
return { start: prevEnd, last: addDays(same, -1) }
}
/**
* Dönem açarken hızlı seçenekler: önceki dönem varsa önce onun devamı, sonra bu ay / gelecek ay
* (önceki dönemle çakışanlar elenir). İlk seçenek varsayılandır.
*/
export function suggestRanges(today: Date, prevEnd?: string): { start: string; last: string }[] {
const candidates = [
...(prevEnd ? [rangeAfter(prevEnd)] : []),
monthRange(today, 0),
monthRange(today, 1),
]
const seen = new Set<string>()
return candidates.filter((r) => {
const key = `${r.start}/${r.last}`
if (seen.has(key) || (prevEnd && r.start < prevEnd)) return false
seen.add(key)
return true
})
}
+20
View File
@@ -10,6 +10,8 @@ import type { CopyPreviousResult } from '@/types/CopyPreviousResult'
import type { GroupAmounts } from '@/types/GroupAmounts' import type { GroupAmounts } from '@/types/GroupAmounts'
import type { GroupBudget } from '@/types/GroupBudget' import type { GroupBudget } from '@/types/GroupBudget'
import type { Period } from '@/types/Period' import type { Period } from '@/types/Period'
import type { PeriodCloseRequest } from '@/types/PeriodCloseRequest'
import type { PeriodCloseResult } from '@/types/PeriodCloseResult'
import type { PeriodCreateRequest } from '@/types/PeriodCreateRequest' import type { PeriodCreateRequest } from '@/types/PeriodCreateRequest'
import type { PeriodReview } from '@/types/PeriodReview' import type { PeriodReview } from '@/types/PeriodReview'
import type { PeriodSummary } from '@/types/PeriodSummary' import type { PeriodSummary } from '@/types/PeriodSummary'
@@ -66,6 +68,24 @@ export function useUpdatePeriod() {
}) })
} }
/**
* Dönemi kapat (`POST /periods/{id}/close`). Devir sonraki döneme de dokunabildiği için
* tüm dönem verileri ve liste yenilenir. Hata (ör. 409 başkası değiştirdi) olursa da
* yenilenir → yeniden denemede güncel sürüm gönderilir.
*/
export function useClosePeriod() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: PeriodCloseRequest & { id: string }) =>
api.post<PeriodCloseResult>(`/periods/${id}/close`, body),
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) {
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { ratio, savingsStatus } from './review' import { ratio, savingsStatus, settlePreview } from './review'
describe('review', () => { describe('review', () => {
it('classifies realized savings against the goal', () => { it('classifies realized savings against the goal', () => {
@@ -28,4 +28,18 @@ describe('review', () => {
expect(ratio(10, 0)).toBe(1) expect(ratio(10, 0)).toBe(1)
expect(ratio(0, 0)).toBe(0) expect(ratio(0, 0)).toBe(0)
}) })
it('splits realized savings like the backend', () => {
expect(settlePreview(-5000, 0)).toEqual({ toSavings: 0, carry: { kind: 'debt', amount: 5000 } })
expect(settlePreview(0, 0)).toEqual({ toSavings: 0, carry: null })
expect(settlePreview(10_000, 10_000)).toEqual({ toSavings: 10_000, carry: null })
expect(settlePreview(10_000, 4_000)).toEqual({
toSavings: 4_000,
carry: { kind: 'surplus', amount: 6_000 },
})
expect(settlePreview(10_000, 0)).toEqual({
toSavings: 0,
carry: { kind: 'surplus', amount: 10_000 },
})
})
}) })
+17
View File
@@ -24,3 +24,20 @@ export function ratio(part: number, total: number, cap = true): number {
const r = part / total const r = part / total
return cap ? Math.min(r, 1) : r return cap ? Math.min(r, 1) : r
} }
/** Kapanışta sonraki döneme devreden (backend `closing::settle` ile aynı kural) */
export type CarryPreview = { kind: 'debt' | 'surplus'; amount: number } | null
/**
* Gerçekleşen birikimin bölünüşü: açık varsa tamamı borç olarak devreder;
* birikim varsa `toSavings` kadarı birikime, kalanı artı olarak devreder.
*/
export function settlePreview(
realized: number,
toSavings: number,
): { toSavings: number; carry: CarryPreview } {
if (realized < 0) return { toSavings: 0, carry: { kind: 'debt', amount: -realized } }
const saved = Math.min(Math.max(toSavings, 0), realized)
const rest = realized - saved
return { toSavings: saved, carry: rest > 0 ? { kind: 'surplus', amount: rest } : null }
}
+43
View File
@@ -328,6 +328,49 @@ export const t = {
reflectionTooLong: 'Metin en fazla 20.000 karakter olabilir', reflectionTooLong: 'Metin en fazla 20.000 karakter olabilir',
edit: 'Düzenle', edit: 'Düzenle',
write: 'Yaz', write: 'Yaz',
closeTitle: 'Dönemi kapat',
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',
},
closing: {
title: 'Dönemi Kapat',
description: 'Birkaç adımda dönemi kapat ve devri belirle.',
steps: ['Özet', 'Birikim', 'Onay'],
early: (last: string) =>
`Dönem ${last} tarihinde bitiyor. Şimdi kapatırsan kalan günler için kayıt giremezsin.`,
noReflection: '"Nasıl daha iyi yapabilirim?" henüz boş. Kapanınca değiştirilemez.',
writeReflection: 'Değerlendirmeye dön ve yaz',
savingsTitle: 'Birikim ve devir',
savingsHint: (x: string) =>
`Bu dönem ${x} biriktirdin. Ne kadarını birikime ayırmak istersin? Kalanı sonraki döneme artı olarak devreder.`,
toSavings: 'Birikime aktarılacak',
all: 'Tamamı',
none: 'Hiçbiri',
tooMuch: (x: string) => `En fazla ${x} aktarabilirsin`,
deficitHint: (x: string) =>
`Bu dönem ${x} açık verdin. Açık, sonraki dönemin "Ne kadar param vardı?" hesabından borç olarak düşülür.`,
zeroHint: 'Bu dönemde ne birikim ne açık var; sonraki döneme bir şey devretmez.',
confirmTitle: 'Onayla',
confirmHint: 'Kapatmadan önce son bir kez kontrol et.',
willClose: (name: string) => `${name} kapanacak; kayıt eklenemez ve değiştirilemez.`,
savingsLine: 'Birikime aktarılacak',
surplusLine: 'Sonraki döneme artı olarak devreden',
debtLine: 'Sonraki döneme borç olarak devreden',
carryLine: 'Sonraki döneme devreden',
noCarry: 'Sonraki döneme devreden yok.',
reopenNote: 'Hata fark edersen hane sahibi dönemi düzeltme için yeniden açabilir.',
submit: '🔒 Dönemi Kapat',
submitting: 'Kapatılıyor…',
done: (name: string) => `${name} kapandı`,
resultHint: 'Sonraki dönemi açtığında devir otomatik olarak eklenir.',
resultAttached: 'Devir açık olan sonraki döneme eklendi.',
realized: 'Gerçekleşen birikim',
newPeriod: 'Yeni dönemi aç',
toPlan: 'Plana git',
toDashboard: 'Panele dön',
noOpen: 'Kapatılacak açık dönem yok.',
}, },
common: { common: {
+2
View File
@@ -12,6 +12,7 @@ import { MorePage } from '@/features/more/page'
import { PeriodWizardPage } from '@/features/period-wizard/page' import { PeriodWizardPage } from '@/features/period-wizard/page'
import { PlanningPage } from '@/features/planning/page' import { PlanningPage } from '@/features/planning/page'
import { ReportsPage } from '@/features/reports/page' import { ReportsPage } from '@/features/reports/page'
import { ClosePeriodPage } from '@/features/review/close-page'
import { ReviewPage } from '@/features/review/page' import { ReviewPage } from '@/features/review/page'
import { SavingsPage } from '@/features/savings/page' import { SavingsPage } from '@/features/savings/page'
import { SettingsPage } from '@/features/settings/page' import { SettingsPage } from '@/features/settings/page'
@@ -38,6 +39,7 @@ const routes: RouteObject[] = [
{ path: paths.periodWizard, element: <PeriodWizardPage /> }, { path: paths.periodWizard, element: <PeriodWizardPage /> },
{ path: paths.savings, element: <SavingsPage /> }, { path: paths.savings, element: <SavingsPage /> },
{ path: paths.review, element: <ReviewPage /> }, { path: paths.review, element: <ReviewPage /> },
{ path: paths.closePeriod, element: <ClosePeriodPage /> },
{ path: paths.reports, element: <ReportsPage /> }, { path: paths.reports, element: <ReportsPage /> },
{ path: paths.settings, element: <SettingsPage /> }, { path: paths.settings, element: <SettingsPage /> },
{ path: paths.more, element: <MorePage /> }, { path: paths.more, element: <MorePage /> },