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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user