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