feat(plan): plan ekranı — görüntüleme/düzenleme modu ve haftalık bütçe

- Salt okunur görünüm: özet kutucukları, gelir/gider, 4 grup + haftalık döküm
- Düzenle → taslak; Kaydet yalnızca değişenleri gönderir, Vazgeç geri alır
- Kaydedilmemiş değişiklikle çıkışta onay
- Hafta elle ayarlama / otomatiğe döndürme, canlı önizleme (backend ile aynı hesap)
- DistributionBar ve SuggestionPicker ortak bileşen; 9 yeni vitest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 08:47:00 +03:00
parent ca0d3a505c
commit 9004a7cc42
18 changed files with 1276 additions and 110 deletions
@@ -1,36 +1,33 @@
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 { DistributionBar } from '@/features/planning/distribution-bar'
import { SuggestionPicker } from '@/features/planning/suggestion-picker'
import { SummaryTiles } from '@/features/planning/summary-tiles'
import { ApiError } from '@/lib/api'
import { formatMoney, formatNumber, parseMoney } from '@/lib/format'
import { parseMoney } from '@/lib/format'
import { GROUPS } from '@/lib/groups'
import { toKurus } from '@/lib/money-schema'
import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods'
import { moneyToText } from '@/lib/plan-draft'
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),
needs: moneyToText(a.needs),
wants: moneyToText(a.wants),
culture: moneyToText(a.culture),
unexpected: moneyToText(a.unexpected),
}
}
@@ -84,9 +81,6 @@ function BudgetStepForm({
onDone: () => void
}) {
const setBudget = useSetBudget(period.id)
const [method, setMethod] = useState<SuggestMethod>('default')
const suggestion = useSuggestion(period.id, method)
const {
control,
handleSubmit,
@@ -103,7 +97,6 @@ function BudgetStepForm({
(sum, g) => sum + Math.max(parseMoney(values[g.code] ?? '') ?? 0, 0),
0,
)
const remaining = summary.spendable - distributed
const onSubmit = handleSubmit(async (v) => {
try {
@@ -124,45 +117,10 @@ function BudgetStepForm({
<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>
<div className="mt-6">
<SuggestionPicker periodId={period.id} onApply={(a) => reset(toForm(a))} />
</div>
{/* 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]}>
@@ -182,34 +140,8 @@ function BudgetStepForm({
))}
</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 className="mt-6">
<DistributionBar distributed={distributed} spendable={summary.spendable} />
</div>
<FormError message={errors.root?.message} />
@@ -187,7 +187,7 @@ function ItemRow({ item, periodId }: { item: PlanItem; periodId: string }) {
}
/** Önceki dönem varsa görünür; gelir ve giderleri birlikte kopyalar. */
function CopyPrevious({ period }: { period: Period }) {
export function CopyPrevious({ period }: { period: Period }) {
const periods = usePeriods()
const copy = useCopyPrevious(period.id)
const hasPrevious = periods.data?.some((p) => p.start_date < period.start_date)