861bfba85a
- Defter, Plan ve Değerlendirme'de dönem seçici (?donem=) - Kapalı dönem salt okunur; düzeltmedeki dönem düzenlenebilir ve yeniden kapatılır - Hane sahibi için onaylı "Düzeltme için yeniden aç" - Geçmiş dönemler listesi, dönem durum rozeti Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
377 lines
11 KiB
TypeScript
377 lines
11 KiB
TypeScript
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 { SelectedPeriod } from '@/features/periods/selected-period'
|
||
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 { periodPath } from '@/lib/period-select'
|
||
import { useClosePeriod, 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 dönem artık seçilemez (açık dönem kalmaz); sonuç bu yüzden sayfanın kendi
|
||
* durumunda tutulur. `?donem=` ile düzeltmedeki dönem yeniden kapatılır. 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 [result, setResult] = useState<PeriodCloseResult | null>(null)
|
||
const empty = (
|
||
<div className="space-y-4">
|
||
<p className="text-muted-foreground">{s.noOpen}</p>
|
||
<NoPeriodCard />
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<div className="mx-auto max-w-3xl">
|
||
<PageHeader title={s.title} description={s.description} />
|
||
{result ? (
|
||
<Result result={result} />
|
||
) : (
|
||
// Açık dönem ya da `?donem=` ile düzeltmedeki dönem (yeniden kapatma)
|
||
<SelectedPeriod empty={empty}>
|
||
{(p) =>
|
||
p.status === 'closed' ? (
|
||
<p className="text-muted-foreground">{s.alreadyClosed}</p>
|
||
) : (
|
||
<Wizard key={p.id} period={p} onClosed={setResult} />
|
||
)
|
||
}
|
||
</SelectedPeriod>
|
||
)}
|
||
</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={periodPath(paths.review, period)}
|
||
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>
|
||
)
|
||
}
|