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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user