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
@@ -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>
)
})}
+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 { 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>
)
}