feat(plan): ilk dönem sihirbazı (dönem, gelir, gider, birikim, bütçe)

- /plan/donem-ac?adim=1..5: her adım hemen kaydedilir, adım adreste
- Panel ve Plan'da açık dönem yoksa "Dönemi başlat" kartı
- MoneyInput (sayısal klavye, Türkçe biçim), moneyText/toKurus, period-dates
- lib/periods.ts: dönem, plan kalemi, özet, bütçe ve öneri kancaları
- SummaryTiles, PageLoading/PageError; 13 yeni vitest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 08:16:32 +03:00
parent 95231cd4d6
commit ca0d3a505c
26 changed files with 1594 additions and 7 deletions
@@ -0,0 +1,219 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2, Sparkles } from 'lucide-react'
import { Controller, useForm, useWatch } from 'react-hook-form'
import { FormError, FormField } from '@/components/form-field'
import { MoneyInput } from '@/components/money-input'
import { PageError, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { SummaryTiles } from '@/features/planning/summary-tiles'
import { ApiError } from '@/lib/api'
import { formatMoney, formatNumber, parseMoney } from '@/lib/format'
import { GROUPS } from '@/lib/groups'
import { toKurus } from '@/lib/money-schema'
import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { GroupAmounts } from '@/types/GroupAmounts'
import type { Period } from '@/types/Period'
import type { PeriodSummary } from '@/types/PeriodSummary'
import type { SuggestMethod } from '@/types/SuggestMethod'
import { budgetSchema, type BudgetForm } from './schemas'
import { StepActions, StepIntro } from './wizard-ui'
const s = t.wizard.budget
const METHODS: SuggestMethod[] = ['default', 'previous', 'avg3']
function toForm(a: GroupAmounts): BudgetForm {
const f = (k: number) => (k ? formatNumber(k / 100) : '')
return {
needs: f(a.needs),
wants: f(a.wants),
culture: f(a.culture),
unexpected: f(a.unexpected),
}
}
/**
* 5. adım: harcanabilir tutarı 4 gruba dağıt.
* Daha önce bütçe girilmişse o gösterilir; yoksa varsayılan öneriyle doldurulur.
*/
export function StepBudget({
period,
onBack,
onDone,
}: {
period: Period
onBack: () => void
onDone: () => void
}) {
const summary = useSummary(period.id)
const budget = useBudget(period.id)
const suggestion = useSuggestion(period.id, 'default')
const queries = [summary, budget, suggestion]
const failed = queries.find((q) => q.isError)
if (failed?.error) return <PageError message={failed.error.message} onRetry={failed.refetch} />
if (!summary.data || !budget.data || !suggestion.data) return <PageLoading />
const saved = Object.fromEntries(budget.data.map((g) => [g.group, g.monthly])) as GroupAmounts
const hasSaved = budget.data.some((g) => g.monthly > 0)
return (
<BudgetStepForm
period={period}
summary={summary.data}
initial={toForm(hasSaved ? saved : suggestion.data.amounts)}
onBack={onBack}
onDone={onDone}
/>
)
}
function BudgetStepForm({
period,
summary,
initial,
onBack,
onDone,
}: {
period: Period
summary: PeriodSummary
initial: BudgetForm
onBack: () => void
onDone: () => void
}) {
const setBudget = useSetBudget(period.id)
const [method, setMethod] = useState<SuggestMethod>('default')
const suggestion = useSuggestion(period.id, method)
const {
control,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<BudgetForm>({
resolver: zodResolver(budgetSchema),
mode: 'onTouched',
defaultValues: initial,
})
const values = useWatch({ control })
const distributed = GROUPS.reduce(
(sum, g) => sum + Math.max(parseMoney(values[g.code] ?? '') ?? 0, 0),
0,
)
const remaining = summary.spendable - distributed
const onSubmit = handleSubmit(async (v) => {
try {
await setBudget.mutateAsync({
needs: toKurus(v.needs),
wants: toKurus(v.wants),
culture: toKurus(v.culture),
unexpected: toKurus(v.unexpected),
})
onDone()
} catch (e) {
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
}
})
return (
<form onSubmit={onSubmit} noValidate>
<StepIntro title={s.title} hint={s.hint} />
<SummaryTiles summary={summary} />
{/* Öneri seçimi */}
<fieldset className="mt-6 space-y-3 rounded-xl border p-4">
<legend className="px-1 text-sm font-medium">{s.method}</legend>
<div className="flex flex-wrap gap-2">
{METHODS.map((m) => (
<Button
key={m}
type="button"
size="sm"
variant={method === m ? 'default' : 'outline'}
aria-pressed={method === m}
onClick={() => setMethod(m)}
>
{s.methods[m]}
</Button>
))}
</div>
{suggestion.isPending ? (
<Loader2
className="size-5 animate-spin text-muted-foreground"
aria-label={t.common.loading}
/>
) : suggestion.data?.available ? (
<Button
type="button"
variant="secondary"
onClick={() => reset(toForm(suggestion.data.amounts))}
>
<Sparkles aria-hidden />
{s.apply}
</Button>
) : (
<p className="text-sm text-muted-foreground">
{suggestion.data?.message ?? suggestion.error?.message}
</p>
)}
</fieldset>
{/* 4 grup */}
<div className="mt-6 grid gap-4 sm:grid-cols-2">
{GROUPS.map((g) => (
<FormField key={g.code} label={`${g.emoji} ${g.label}`} error={errors[g.code]}>
{(f) => (
<div className={cn('rounded-xl p-3', g.className.soft)}>
<Controller
control={control}
name={g.code}
render={({ field }) => (
<MoneyInput {...f} {...field} placeholder="0,00" className="bg-background" />
)}
/>
<p className="mt-1.5 text-xs text-muted-foreground">{g.description}</p>
</div>
)}
</FormField>
))}
</div>
{/* Dağıtım durumu */}
<div className="mt-6 space-y-2 rounded-xl border p-4" aria-live="polite">
<div className="flex justify-between text-sm">
<span>{s.distributed}</span>
<span className="money font-medium">
{formatMoney(distributed)} / {formatMoney(summary.spendable)}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted" aria-hidden>
<div
className={cn('h-full rounded-full', remaining < 0 ? 'bg-warning' : 'bg-primary')}
style={{
width: `${summary.spendable > 0 ? Math.min(100, (distributed / summary.spendable) * 100) : 0}%`,
}}
/>
</div>
<p
className={cn(
'text-sm',
remaining < 0 ? 'font-medium text-warning' : 'text-muted-foreground',
)}
>
{remaining < 0
? s.over(formatMoney(-remaining))
: remaining > 0
? s.unallocated(formatMoney(remaining))
: s.balanced}
</p>
</div>
<FormError message={errors.root?.message} />
<StepActions onBack={onBack} pending={isSubmitting} nextLabel={s.finish} />
</form>
)
}