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:
+2
-1
@@ -1,7 +1,7 @@
|
||||
# 🧠 Proje Hafızası
|
||||
|
||||
## ▶️ 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 ✅.
|
||||
|
||||
## 📌 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 2:** Kapatma backend: `services/closing.rs` — `settle(realized, to_savings)` (< 0 → borç, birikim istenemez; ≥ 0 → varsayılan tamamı birikime, kalan artı), `close` (tek transaction: dönem `closed` + `closed_at/by` + `to_savings`, bu dönemden eski devirler soft delete, yeni devir; `to_period_id` = sonraki dönem ya da null), `reopen` (sahip; `closed` → **`correcting`**; ardından kapalı/düzeltmede dönem varsa 409). `PeriodStatus::Correcting` + `is_writable()` → `get_open`/`update` buna bakar; `current()` yalnız `open`. `period::create` artık transaction: önceki dönemin bağsız devrini bağlar. `POST /periods/{id}/close` (`PeriodCloseRequest`/`PeriodCloseResult`/`Carryover`), `POST /periods/{id}/reopen`. Period DTO: `to_savings`, `closed_at`. `tests/closing.rs` (4) + `settle` birim testi. Mutasyon 3/3 yakalandı; hata zorlanınca transaction geri aldı (dönem açık, sürüm aynı). Backend 113 test. Karar: yeniden açma = düzeltme modu (kullanıcı "a").
|
||||
- **Adım 3:** Değerlendirme sayfası (`features/review/`): `Questions` (4 kart; 1. kart hesap açıklaması, 3. bütçe yüzdesi, 4. hedefe göre durum `lib/review.ts` → `savingsStatus`: above/below/deficit), `breakdowns.tsx` (`GroupComparison`, `TopCategories` ilk 5 + "Tümünü göster", `WeeklyDistribution`; ortak `Bar`, `ratio`), `Reflection` (görüntüle/düzenle, `useUpdatePeriod` + `version`, 409 formda; şema `features/review/schema.ts`), `NotesSection collapsed` (yalnız başlık, `aria-expanded` ile açılır). Ortak `components/markdown-tabs.tsx` (Yaz/Önizle, `useId` kimlikler; `NoteEditor` da kullanıyor). `useReview` + `queryKeys.review` (dönem önekinde → kayıt değişince yenilenir). Dönem sürüyorsa bilgi satırı. Frontend 84 test; e2e 24 kontrol (Ekim kapatılıp Kasım'a artı devri dahil). Mobil haftalık satır sıkıştırıldı.
|
||||
- **Adım 4:** Kapatma sihirbazı `/degerlendirme/kapat` (`features/review/close-page.tsx`): Özet (4 soru, erken kapatma uyarısı, "Nasıl daha iyi" boşsa uyarı + bağlantı) → Birikim (artıda `MoneyInput` varsayılan tamamı, Tamamı/Hiçbiri, canlı önizleme; açıkta/sıfırda yalnız bilgi) → Onay (`useClosePeriod`, `version`; 409 formda, `onSettled` tüm `['period']` + liste yenilenir → ikinci deneme güncel sürümle) → Sonuç (sayfa durumunda; kapanınca `current` null). `lib/review.ts` → `settlePreview` (backend `settle` ile aynı tutulmalı), şema `close-schema.ts` (0..gerçekleşen). `Stepper` artık `steps`/`label` alıyor. Dönem açma sihirbazı tarih önerisi: `suggestRanges(today, prevEnd)` / `rangeAfter` — son dönemin bitişinden başlar, çakışan ay önerilmez (erken kapatılan ay tekrar önerilmezdi hatası önlendi). Not: `moneyToText(0)` → `''` (plan taslağı için); 0 göstermek gerekiyorsa `formatNumber`. Frontend 89 test; e2e 33 kontrol (DB silindi, 8091 kapatıldı).
|
||||
|
||||
+2
-2
@@ -64,5 +64,5 @@
|
||||
- ✅ 1. Backend: değerlendirme (`GET /periods/{id}/review`: 4 soru, grup/kategori/hafta) + `reflection_md`
|
||||
- ✅ 2. Backend: dönem kapatma (transaction: durum, devir, birikime aktarılan tutar) + yeni dönemde devrin bağlanması + yeniden açma (sahip)
|
||||
- ✅ 3. Frontend: Değerlendirme sayfası (4 soru, planlanan/gerçekleşen, en çok harcanan kategoriler, haftalık dağılım, "Nasıl daha iyi yapabilirim?", notlar)
|
||||
- ⏳ 4. Frontend: dönem kapatma sihirbazı (+ yeni dönemi kopyalayarak açma)
|
||||
- ⏳ 5. Frontend: kapalı dönemler listesi + yeniden açma (sahip)
|
||||
- ✅ 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
|
||||
|
||||
@@ -23,6 +23,8 @@ export const paths = {
|
||||
savings: '/birikim',
|
||||
more: '/diger',
|
||||
review: '/degerlendirme',
|
||||
/** Dönem kapatma sihirbazı */
|
||||
closePeriod: '/degerlendirme/kapat',
|
||||
reports: '/raporlar',
|
||||
settings: '/ayarlar',
|
||||
login: '/giris',
|
||||
|
||||
@@ -6,8 +6,10 @@ import { FormError, FormField } from '@/components/form-field'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { daysInclusive, isIsoDate, lastToEnd, monthRange } from '@/lib/period-dates'
|
||||
import { useCreatePeriod, useUpdatePeriod } from '@/lib/periods'
|
||||
import { PageError, PageLoading } from '@/components/page'
|
||||
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 type { Period } from '@/types/Period'
|
||||
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' })
|
||||
|
||||
/** 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 }) {
|
||||
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 update = useUpdatePeriod()
|
||||
|
||||
const [today] = useState(() => new Date()) // render başına değil, bir kez
|
||||
const presets = [0, 1].map((offset) => ({
|
||||
label: monthName.format(new Date(today.getFullYear(), today.getMonth() + offset, 1)),
|
||||
range: monthRange(today, offset),
|
||||
const presets = suggestRanges(today, prevEnd).map((range) => ({
|
||||
label: rangeLabel(range),
|
||||
range,
|
||||
}))
|
||||
|
||||
const {
|
||||
|
||||
@@ -7,12 +7,20 @@ import { cn } from '@/lib/utils'
|
||||
const s = t.wizard
|
||||
|
||||
/** Adım göstergesi: mobilde "Adım 2/5 · Gelirler" + çubuk, geniş ekranda numaralı liste. */
|
||||
export function Stepper({ current }: { current: number }) {
|
||||
const total = s.steps.length
|
||||
export function Stepper({
|
||||
current,
|
||||
steps = s.steps,
|
||||
label = s.title,
|
||||
}: {
|
||||
current: number
|
||||
steps?: readonly string[]
|
||||
label?: string
|
||||
}) {
|
||||
const total = steps.length
|
||||
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">
|
||||
{s.stepOf(current, total)} · {s.steps[current - 1]}
|
||||
{s.stepOf(current, total)} · {steps[current - 1]}
|
||||
</p>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted sm:hidden" aria-hidden>
|
||||
<div
|
||||
@@ -21,13 +29,13 @@ export function Stepper({ current }: { current: number }) {
|
||||
/>
|
||||
</div>
|
||||
<ol className="hidden gap-2 sm:flex">
|
||||
{s.steps.map((label, i) => {
|
||||
{steps.map((name, i) => {
|
||||
const n = i + 1
|
||||
const done = n < current
|
||||
const active = n === current
|
||||
return (
|
||||
<li
|
||||
key={label}
|
||||
key={name}
|
||||
aria-current={active ? 'step' : undefined}
|
||||
className={cn(
|
||||
'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}
|
||||
</span>
|
||||
{label}
|
||||
{name}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>>
|
||||
@@ -1,6 +1,9 @@
|
||||
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 { PeriodHeading } from '@/features/planning/plan-read'
|
||||
@@ -61,6 +64,17 @@ function ReviewView({ period }: { period: Period }) {
|
||||
<WeeklyDistribution review={review.data} />
|
||||
<Reflection period={period} readOnly={readOnly} />
|
||||
<NotesSection periodId={period.id} context="review" readOnly={readOnly} collapsed />
|
||||
{!readOnly && (
|
||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-semibold">{t.review.closeTitle}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t.review.closeHint}</p>
|
||||
</div>
|
||||
<Button asChild size="lg" className="shrink-0">
|
||||
<Link to={paths.closePeriod}>{t.review.closeAction}</Link>
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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', () => {
|
||||
it('validates ISO dates', () => {
|
||||
@@ -37,4 +45,28 @@ describe('period-dates', () => {
|
||||
it('converts last day to exclusive end', () => {
|
||||
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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,3 +51,35 @@ export function monthRange(today: Date, monthOffset = 0): { start: string; last:
|
||||
export function lastToEnd(last: string): string {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { CopyPreviousResult } from '@/types/CopyPreviousResult'
|
||||
import type { GroupAmounts } from '@/types/GroupAmounts'
|
||||
import type { GroupBudget } from '@/types/GroupBudget'
|
||||
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 { PeriodReview } from '@/types/PeriodReview'
|
||||
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 ----------
|
||||
|
||||
export function usePlanItems(periodId: string) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ratio, savingsStatus } from './review'
|
||||
import { ratio, savingsStatus, settlePreview } from './review'
|
||||
|
||||
describe('review', () => {
|
||||
it('classifies realized savings against the goal', () => {
|
||||
@@ -28,4 +28,18 @@ describe('review', () => {
|
||||
expect(ratio(10, 0)).toBe(1)
|
||||
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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,3 +24,20 @@ export function ratio(part: number, total: number, cap = true): number {
|
||||
const r = part / total
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -328,6 +328,49 @@ export const t = {
|
||||
reflectionTooLong: 'Metin en fazla 20.000 karakter olabilir',
|
||||
edit: 'Düzenle',
|
||||
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: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MorePage } from '@/features/more/page'
|
||||
import { PeriodWizardPage } from '@/features/period-wizard/page'
|
||||
import { PlanningPage } from '@/features/planning/page'
|
||||
import { ReportsPage } from '@/features/reports/page'
|
||||
import { ClosePeriodPage } from '@/features/review/close-page'
|
||||
import { ReviewPage } from '@/features/review/page'
|
||||
import { SavingsPage } from '@/features/savings/page'
|
||||
import { SettingsPage } from '@/features/settings/page'
|
||||
@@ -38,6 +39,7 @@ const routes: RouteObject[] = [
|
||||
{ path: paths.periodWizard, element: <PeriodWizardPage /> },
|
||||
{ path: paths.savings, element: <SavingsPage /> },
|
||||
{ path: paths.review, element: <ReviewPage /> },
|
||||
{ path: paths.closePeriod, element: <ClosePeriodPage /> },
|
||||
{ path: paths.reports, element: <ReportsPage /> },
|
||||
{ path: paths.settings, element: <SettingsPage /> },
|
||||
{ path: paths.more, element: <MorePage /> },
|
||||
|
||||
Reference in New Issue
Block a user