Files
kakeibo/frontend/src/features/review/close-page.tsx
T
suphicakir 861bfba85a feat(değerlendirme): geçmiş dönemler ve düzeltme için yeniden açma
- 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>
2026-11-12 23:11:04 +03:00

377 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}