diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 7074dac..9d3dcd4 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -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ı). diff --git a/docs/PHASES.md b/docs/PHASES.md index eab82ca..176d101 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -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 diff --git a/frontend/src/components/layout/nav-items.ts b/frontend/src/components/layout/nav-items.ts index f080065..8d6c62a 100644 --- a/frontend/src/components/layout/nav-items.ts +++ b/frontend/src/components/layout/nav-items.ts @@ -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', diff --git a/frontend/src/features/period-wizard/step-dates.tsx b/frontend/src/features/period-wizard/step-dates.tsx index 6170e6c..e5afca7 100644 --- a/frontend/src/features/period-wizard/step-dates.tsx +++ b/frontend/src/features/period-wizard/step-dates.tsx @@ -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 + if (periods.isError) + return + // 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 +} + +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 { diff --git a/frontend/src/features/period-wizard/wizard-ui.tsx b/frontend/src/features/period-wizard/wizard-ui.tsx index 8191bb8..223c9d3 100644 --- a/frontend/src/features/period-wizard/wizard-ui.tsx +++ b/frontend/src/features/period-wizard/wizard-ui.tsx @@ -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 ( - + - {s.stepOf(current, total)} · {s.steps[current - 1]} + {s.stepOf(current, total)} · {steps[current - 1]} - {s.steps.map((label, i) => { + {steps.map((name, i) => { const n = i + 1 const done = n < current const active = n === current return ( {done ? : n} - {label} + {name} ) })} diff --git a/frontend/src/features/review/close-page.tsx b/frontend/src/features/review/close-page.tsx new file mode 100644 index 0000000..607f5c3 --- /dev/null +++ b/frontend/src/features/review/close-page.tsx @@ -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(null) + + let body: ReactNode + if (result) body = + else if (current.isPending) body = + else if (current.isError) + body = + else if (!current.data) + body = ( + + {s.noOpen} + + + ) + else body = + + return ( + + + {body} + + ) +} + +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(null) + + if (review.isError) return + if (!review.data) return + + const realized = review.data.realized_savings + const go = (n: number) => { + setStep(n) + window.scrollTo({ top: 0 }) + } + + return ( + <> + + {step === 1 && go(2)} />} + {step === 2 && ( + go(1)} + onNext={(v) => { + setToSavings(v) + go(3) + }} + /> + )} + {step === 3 && ( + go(2)} + onClosed={onClosed} + /> + )} + > + ) +} + +// ---------- 1. Özet ---------- + +function StepSummary({ + period, + review, + onNext, +}: { + period: Period + review: PeriodReview + onNext: () => void +}) { + const [today] = useState(() => toIsoDate(new Date())) + return ( + + + {today <= period.last_date && ( + {s.early(formatDate(period.last_date))} + )} + + {!period.reflection_md && ( + + {s.noReflection}{' '} + + {s.writeReflection} + + + )} + + + ) +} + +// ---------- 2. Birikim ---------- + +function StepSavings({ + realized, + initial, + onBack, + onNext, +}: { + realized: number + initial: number + onBack: () => void + onNext: (toSavings: number) => void +}) { + if (realized <= 0) { + return ( + + + onNext(0)} /> + + ) + } + return +} + +function SavingsForm({ + realized, + initial, + onBack, + onNext, +}: { + realized: number + initial: number + onBack: () => void + onNext: (toSavings: number) => void +}) { + const { + control, + handleSubmit, + setValue, + formState: { errors }, + } = useForm({ + 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 ( + onNext(parseMoney(v.to_savings) ?? 0))} noValidate> + + + ( + + {(f) => ( + + )} + + )} + /> + + {[ + { label: s.all, amount: realized }, + { label: s.none, amount: 0 }, + ].map((o) => ( + + setValue('to_savings', formatNumber(o.amount / 100), { shouldValidate: true }) + } + > + {o.label} + + ))} + + + + + + ) +} + +// ---------- 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() + 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 ( + + + + {s.willClose(period.name)} + + {s.reopenNote} + + + + + ) +} + +// ---------- Sonuç ---------- + +function Result({ result: r }: { result: PeriodCloseResult }) { + const attached = !!r.carryover?.to_period_id + return ( + + + + {s.done(r.period.name)} + + + + {r.carryover ? (attached ? s.resultAttached : s.resultHint) : s.noCarry} + + + + + {attached ? s.toPlan : s.newPeriod} + + + + {s.toDashboard} + + + + ) +} + +// ---------- 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 ( + + {rows.map((row) => ( + + {row.label} + {formatMoney(row.amount)} + + ))} + + ) +} + +function Notice({ tone = 'info', children }: { tone?: 'info' | 'warning'; children: ReactNode }) { + const Icon = tone === 'warning' ? TriangleAlert : Info + return ( + + + {children} + + ) +} diff --git a/frontend/src/features/review/close-schema.test.ts b/frontend/src/features/review/close-schema.test.ts new file mode 100644 index 0000000..668283c --- /dev/null +++ b/frontend/src/features/review/close-schema.test.ts @@ -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') + }) +}) diff --git a/frontend/src/features/review/close-schema.ts b/frontend/src/features/review/close-schema.ts new file mode 100644 index 0000000..b31e068 --- /dev/null +++ b/frontend/src/features/review/close-schema.ts @@ -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> diff --git a/frontend/src/features/review/page.tsx b/frontend/src/features/review/page.tsx index b76895d..d261994 100644 --- a/frontend/src/features/review/page.tsx +++ b/frontend/src/features/review/page.tsx @@ -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 }) { + {!readOnly && ( + + + {t.review.closeTitle} + {t.review.closeHint} + + + {t.review.closeAction} + + + )} ) } diff --git a/frontend/src/lib/period-dates.test.ts b/frontend/src/lib/period-dates.test.ts index 848b471..43ca709 100644 --- a/frontend/src/lib/period-dates.test.ts +++ b/frontend/src/lib/period-dates.test.ts @@ -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' }, + ]) + }) }) diff --git a/frontend/src/lib/period-dates.ts b/frontend/src/lib/period-dates.ts index 70cd157..d04fa6b 100644 --- a/frontend/src/lib/period-dates.ts +++ b/frontend/src/lib/period-dates.ts @@ -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() + 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 + }) +} diff --git a/frontend/src/lib/periods.ts b/frontend/src/lib/periods.ts index c254c3c..06f496c 100644 --- a/frontend/src/lib/periods.ts +++ b/frontend/src/lib/periods.ts @@ -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(`/periods/${id}/close`, body), + onSettled: () => + Promise.all([ + qc.invalidateQueries({ queryKey: queryKeys.periods }), + qc.invalidateQueries({ queryKey: ['period'] }), + ]), + }) +} + // ---------- Plan kalemleri ---------- export function usePlanItems(periodId: string) { diff --git a/frontend/src/lib/review.test.ts b/frontend/src/lib/review.test.ts index 2b14740..9b80d19 100644 --- a/frontend/src/lib/review.test.ts +++ b/frontend/src/lib/review.test.ts @@ -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 }, + }) + }) }) diff --git a/frontend/src/lib/review.ts b/frontend/src/lib/review.ts index f491393..f0d8329 100644 --- a/frontend/src/lib/review.ts +++ b/frontend/src/lib/review.ts @@ -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 } +} diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index 83a3e87..1e2ff38 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -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: { diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index def6c1c..3ea2064 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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: }, { path: paths.savings, element: }, { path: paths.review, element: }, + { path: paths.closePeriod, element: }, { path: paths.reports, element: }, { path: paths.settings, element: }, { path: paths.more, element: },
- {s.stepOf(current, total)} · {s.steps[current - 1]} + {s.stepOf(current, total)} · {steps[current - 1]}
{s.noOpen}
{s.reopenNote}
+ {r.carryover ? (attached ? s.resultAttached : s.resultHint) : s.noCarry} +
+ + {children} +
{t.review.closeHint}