Files
kakeibo/frontend/src/features/period-wizard/step-budget.tsx
T
suphicakir ca0d3a505c 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>
2026-11-12 08:16:32 +03:00

220 lines
6.9 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 } 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>
)
}