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:
2026-11-12 08:16:32 +03:00
parent 95231cd4d6
commit ca0d3a505c
26 changed files with 1594 additions and 7 deletions
@@ -0,0 +1,124 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import { FormError, FormField } from '@/components/form-field'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ApiError } from '@/lib/api'
import { daysInclusive, isIsoDate, lastToEnd, monthRange } from '@/lib/period-dates'
import { useCreatePeriod, useUpdatePeriod } from '@/lib/periods'
import { t } from '@/lib/strings'
import type { Period } from '@/types/Period'
import { datesSchema, type DatesForm } from './schemas'
import { StepActions, StepIntro } from './wizard-ui'
const s = t.wizard.dates
const monthName = new Intl.DateTimeFormat('tr-TR', { month: 'long', year: 'numeric' })
/** 1. adım: dönem yoksa açar, varsa (geri gelindiyse) günceller. */
export function StepDates({ period, onDone }: { period: Period | null; onDone: () => void }) {
const create = useCreatePeriod()
const update = useUpdatePeriod()
const [today] = useState(() => new Date()) // render başına değil, bir kez
const presets = [0, 1].map((offset) => ({
label: monthName.format(new Date(today.getFullYear(), today.getMonth() + offset, 1)),
range: monthRange(today, offset),
}))
const {
register,
control,
handleSubmit,
setValue,
setError,
formState: { errors, isSubmitting },
} = useForm<DatesForm>({
resolver: zodResolver(datesSchema),
mode: 'onTouched',
defaultValues: period
? { start: period.start_date, last: period.last_date, name: period.name }
: { ...presets[0].range, name: '' },
})
const [start, last] = useWatch({ control, name: ['start', 'last'] })
const days =
isIsoDate(start) && isIsoDate(last) && last >= start ? daysInclusive(start, last) : null
const onSubmit = handleSubmit(async (v) => {
const end = lastToEnd(v.last)
try {
if (!period) {
await create.mutateAsync({ start_date: v.start, end_date: end, name: v.name || undefined })
toast.success(s.created)
} else if (
v.start !== period.start_date ||
end !== period.end_date ||
v.name !== period.name
) {
await update.mutateAsync({
id: period.id,
version: period.version,
start_date: v.start,
end_date: end,
name: v.name, // boş → backend tarihlere göre otomatik ad verir
})
}
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} />
<div className="space-y-5">
<div role="group" aria-label={s.presetsLabel} className="flex flex-wrap gap-2">
{presets.map((p) => {
const selected = start === p.range.start && last === p.range.last
return (
<Button
key={p.label}
type="button"
variant={selected ? 'default' : 'outline'}
aria-pressed={selected}
onClick={() => {
setValue('start', p.range.start, { shouldValidate: true })
setValue('last', p.range.last, { shouldValidate: true })
}}
>
{p.label}
</Button>
)
})}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<FormField label={s.start} error={errors.start}>
{(f) => <Input {...f} type="date" {...register('start')} />}
</FormField>
<FormField label={s.last} error={errors.last}>
{(f) => <Input {...f} type="date" min={start || undefined} {...register('last')} />}
</FormField>
</div>
{days !== null && (
<p className="text-sm text-muted-foreground" aria-live="polite">
{s.days(days)}
</p>
)}
<FormField label={s.name} error={errors.name} hint={s.nameHint}>
{(f) => <Input {...f} maxLength={60} {...register('name')} />}
</FormField>
<FormError message={errors.root?.message} />
</div>
<StepActions pending={isSubmitting} />
</form>
)
}