ca0d3a505c
- /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>
220 lines
6.9 KiB
TypeScript
220 lines
6.9 KiB
TypeScript
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>
|
||
)
|
||
}
|