Files
kakeibo/frontend/src/features/period-wizard/step-dates.tsx
T
suphicakir 2c16cf4163 feat(değerlendirme): dönem kapatma sihirbazı
- /degerlendirme/kapat: Özet → Birikim → Onay → Sonuç
- Birikime aktarılacak tutar (varsayılan tamamı), kalan artı/açık devri önizlemesi
- Değerlendirme sayfasına "Dönemi Kapat" bölümü
- Yeni dönem önerisi son dönemin bitişinden başlar (erken kapatılan ay önerilmez)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 22:59:38 +03:00

157 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { PageError, PageLoading } from '@/components/page'
import { formatDate } from '@/lib/format'
import { daysInclusive, isIsoDate, lastToEnd, suggestRanges } from '@/lib/period-dates'
import { useCreatePeriod, usePeriods, 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' })
/** Hızlı seçim etiketi: takvim ayıysa "Aralık 2026", değilse "15.11.2026 – 14.12.2026" */
function rangeLabel(r: { start: string; last: string }) {
const [y, m, d] = r.start.split('-').map(Number)
const monthEnd = new Date(Date.UTC(y, m, 0)).toISOString().slice(0, 10)
return d === 1 && r.last === monthEnd
? monthName.format(new Date(y, m - 1, 1))
: `${formatDate(r.start)} – ${formatDate(r.last)}`
}
/**
* 1. adım: dönem yoksa açar, varsa (geri gelindiyse) günceller.
* Öneriler son dönemin bitişinden başlar (erken kapatılan ay tekrar önerilmez).
*/
export function StepDates({ period, onDone }: { period: Period | null; onDone: () => void }) {
const periods = usePeriods()
if (periods.isPending) return <PageLoading />
if (periods.isError)
return <PageError message={periods.error.message} onRetry={periods.refetch} />
// Liste en yeni başta; sihirbazda açık dönem varsa (geri gelindi) o hariç tutulur
const previous = periods.data.find((p) => p.id !== period?.id)
return <DatesStepForm period={period} prevEnd={previous?.end_date} onDone={onDone} />
}
function DatesStepForm({
period,
prevEnd,
onDone,
}: {
period: Period | null
prevEnd?: string
onDone: () => void
}) {
const create = useCreatePeriod()
const update = useUpdatePeriod()
const [today] = useState(() => new Date()) // render başına değil, bir kez
const presets = suggestRanges(today, prevEnd).map((range) => ({
label: rangeLabel(range),
range,
}))
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>
)
}