2c16cf4163
- /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>
157 lines
5.3 KiB
TypeScript
157 lines
5.3 KiB
TypeScript
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>
|
||
)
|
||
}
|