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