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:
2026-11-12 08:47:00 +03:00
parent ca0d3a505c
commit 9004a7cc42
18 changed files with 1276 additions and 110 deletions
@@ -0,0 +1,476 @@
import { useEffect } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { CalendarCog, Loader2, Plus, RotateCcw, Save, Trash2, X } from 'lucide-react'
import {
Controller,
useFieldArray,
useForm,
useWatch,
type Control,
type FieldErrors,
type UseFormRegister,
type UseFormSetValue,
} from 'react-hook-form'
import { Link, useBlocker } from 'react-router'
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
import { paths } from '@/components/layout/nav-items'
import { MoneyInput } from '@/components/money-input'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { previewWeeks } from '@/lib/budget-preview'
import { formatDayMonth, formatMoney, formatNumber, parseMoney } from '@/lib/format'
import { group, GROUPS } from '@/lib/groups'
import { useSavePlan } from '@/lib/periods'
import { diffPlan, GROUP_CODES, hasChanges, moneyToText, toDraft } from '@/lib/plan-draft'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { GroupBudget } from '@/types/GroupBudget'
import type { Period } from '@/types/Period'
import type { PeriodSummary } from '@/types/PeriodSummary'
import type { PlanItem } from '@/types/PlanItem'
import type { PlanItemKind } from '@/types/PlanItemKind'
import { DistributionBar } from './distribution-bar'
import { PeriodHeading, Section } from './plan-read'
import { planSchema, type PlanForm } from './schema'
import { SuggestionPicker } from './suggestion-picker'
import { SummaryTiles } from './summary-tiles'
const s = t.plan
const itemText = t.wizard.items
/** Metin → kuruş (geçersiz / boş / eksi → 0): önizleme hesapları için */
const k = (text: string | undefined) => Math.max(parseMoney(text ?? '') ?? 0, 0)
/**
* Düzenleme modu. Hiçbir şey anında kaydedilmez: "Kaydet" yalnızca değişenleri gönderir,
* "Vazgeç" taslağı atar. Kaydedilmemiş değişiklik varken sayfadan çıkılırsa onay istenir.
*/
export function PlanEdit({
period,
items,
budget,
summary,
onClose,
}: {
period: Period
items: PlanItem[]
budget: GroupBudget[]
summary: PeriodSummary
onClose: () => void
}) {
const save = useSavePlan(period)
const {
control,
register,
handleSubmit,
setValue,
formState: { errors, isDirty, isSubmitting },
} = useForm<PlanForm>({
resolver: zodResolver(planSchema(period.start_date, period.last_date)),
mode: 'onTouched',
defaultValues: toDraft(period, items, budget),
})
const v = useWatch({ control })
// ---- Canlı önizleme: kaydedilmiş özet + taslaktaki farklar (formül backend'de kalır)
const incomeTotal = (v.incomes ?? []).reduce((a, r) => a + k(r?.amount), 0)
const fixedTotal = (v.expenses ?? []).reduce((a, r) => a + k(r?.amount), 0)
const savings = k(v.savings)
const spendable =
summary.spendable +
(incomeTotal - summary.income_total) -
(fixedTotal - summary.fixed_expense_total) -
(savings - summary.savings_goal)
const budgetTotal = GROUP_CODES.reduce((a, g) => a + k(v.budget?.[g]), 0)
const preview: PeriodSummary = {
...summary,
income_total: incomeTotal,
fixed_expense_total: fixedTotal,
savings_goal: savings,
spendable,
budget_total: budgetTotal,
unallocated: spendable - budgetTotal,
}
// ---- Kaydedilmemiş değişiklik koruması
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
isDirty && !isSubmitting && currentLocation.pathname !== nextLocation.pathname,
)
useEffect(() => {
if (blocker.state !== 'blocked') return
if (window.confirm(s.leaveConfirm)) blocker.proceed()
else blocker.reset()
}, [blocker])
useEffect(() => {
if (!isDirty) return
const warn = (e: BeforeUnloadEvent) => e.preventDefault()
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [isDirty])
const onSubmit = handleSubmit(async (draft) => {
const ops = diffPlan(period, items, budget, draft)
if (!hasChanges(ops)) {
toast(s.noChanges)
return onClose()
}
try {
await save.mutateAsync(ops)
toast.success(s.saved)
} catch (e) {
toast.error(s.saveFailed(e instanceof Error ? e.message : String(e)))
}
onClose()
})
const actions = (
<>
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
<X aria-hidden />
{s.cancel}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="animate-spin" aria-hidden /> : <Save aria-hidden />}
{isSubmitting ? s.saving : s.save}
</Button>
</>
)
return (
<form onSubmit={onSubmit} noValidate className="space-y-6">
<div className="rounded-xl border border-primary/40 bg-primary/5 px-4 py-3 text-sm">
<span className="font-medium">✏️ {s.editing}</span>
<span className="text-muted-foreground"> · {s.editingHint}</span>
</div>
<PeriodHeading period={period} actions={actions} />
<SummaryTiles summary={preview} />
<div className="grid gap-4">
<ItemsEdit
kind="income"
period={period}
control={control}
register={register}
errors={errors}
/>
<ItemsEdit
kind="expense"
period={period}
control={control}
register={register}
errors={errors}
/>
</div>
<Section
title={s.savings}
action={
<Button asChild variant="ghost" size="sm">
<Link to={`${paths.periodWizard}?adim=1`}>
<CalendarCog aria-hidden />
{s.changeDates}
</Link>
</Button>
}
>
<FormField label={s.savings} error={errors.savings} className="max-w-xs">
{(f) => (
<Controller
control={control}
name="savings"
render={({ field }) => <MoneyInput {...f} {...field} placeholder="0,00" />}
/>
)}
</FormField>
</Section>
<BudgetEdit
period={period}
control={control}
setValue={setValue}
errors={errors}
values={v}
spendable={spendable}
budgetTotal={budgetTotal}
/>
<div className="flex justify-end gap-2 border-t pt-5">{actions}</div>
</form>
)
}
// ---------- Gelir / gider satırları ----------
function ItemsEdit({
kind,
period,
control,
register,
errors,
}: {
kind: PlanItemKind
period: Period
control: Control<PlanForm>
register: UseFormRegister<PlanForm>
errors: FieldErrors<PlanForm>
}) {
const name = kind === 'income' ? 'incomes' : 'expenses'
// keyName: kalemlerin kendi `id` alanı var, RHF'nin iç anahtarı onu ezmesin
const { fields, append, remove } = useFieldArray({ control, name, keyName: 'key' })
const rows = useWatch({ control, name })
const total = (rows ?? []).reduce((a, r) => a + k(r?.amount), 0)
const text = itemText[kind]
return (
<Section title={text.title}>
{fields.length === 0 && <p className="mb-3 text-sm text-muted-foreground">{text.empty}</p>}
<ul className="space-y-3">
{fields.map((field, i) => {
const e = errors[name]?.[i]
const messages = [e?.name?.message, e?.amount?.message, e?.date?.message].filter(Boolean)
const label = rows?.[i]?.name || text.placeholder
return (
<li
key={field.key}
className="grid grid-cols-2 gap-2 sm:grid-cols-[minmax(0,1fr)_10rem_10.5rem_auto]"
>
{/* Mobilde ad + sil aynı satırda; geniş ekranda `contents` → ızgaraya katılır */}
<div className="col-span-2 flex gap-2 sm:contents">
<Input
className="flex-1"
placeholder={text.placeholder}
aria-label={itemText.name}
aria-invalid={!!e?.name}
maxLength={60}
{...register(`${name}.${i}.name`)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="sm:order-last"
aria-label={itemText.remove(label)}
onClick={() => remove(i)}
>
<Trash2 className="text-muted-foreground" aria-hidden />
</Button>
</div>
<Controller
control={control}
name={`${name}.${i}.amount`}
render={({ field: f }) => (
<MoneyInput
{...f}
aria-label={itemText.amount}
aria-invalid={!!e?.amount}
placeholder="0,00"
/>
)}
/>
<Input
type="date"
min={period.start_date}
max={period.last_date}
aria-label={itemText.date}
aria-invalid={!!e?.date}
{...register(`${name}.${i}.date`)}
/>
{messages.length > 0 && (
<p className="col-span-2 text-sm text-expense sm:order-last sm:col-span-4">
{messages.join(' · ')}
</p>
)}
</li>
)
})}
</ul>
<Button
type="button"
variant="secondary"
className="mt-3"
onClick={() => append({ name: '', amount: '', date: '' }, { shouldFocus: true })}
>
<Plus aria-hidden />
{s.add[kind]}
</Button>
<div className="mt-3 flex justify-between border-t pt-3 font-medium">
<span>{text.total}</span>
<span className={cn('money', kind === 'income' ? 'text-income' : 'text-expense')}>
{formatMoney(total)}
</span>
</div>
</Section>
)
}
// ---------- Bütçe: aylık + haftalık ----------
function BudgetEdit({
period,
control,
setValue,
errors,
values,
spendable,
budgetTotal,
}: {
period: Period
control: Control<PlanForm>
setValue: UseFormSetValue<PlanForm>
errors: FieldErrors<PlanForm>
values: ReturnType<typeof useWatch<PlanForm>>
spendable: number
budgetTotal: number
}) {
return (
<Section title={s.budget}>
<div className="space-y-4">
<SuggestionPicker
periodId={period.id}
hint={s.suggestionHint}
onApply={(a) =>
GROUP_CODES.forEach((g) =>
setValue(`budget.${g}`, moneyToText(a[g]), {
shouldDirty: true,
shouldValidate: true,
}),
)
}
/>
<DistributionBar distributed={budgetTotal} spendable={spendable} />
<div className="grid items-start gap-4 md:grid-cols-2">
{GROUPS.map((g) => {
const weekTexts = values.weeks?.[g.code] ?? []
const pv = previewWeeks(
k(values.budget?.[g.code]),
period.weeks.map((w, i) => {
const txt = weekTexts[i] ?? ''
return { days: w.days, manual: txt.trim() === '' ? null : k(txt) }
}),
)
return (
<div key={g.code} className="overflow-hidden rounded-xl border">
<div className={cn('space-y-2 px-4 py-3', g.className.soft)}>
<FormField label={`${g.emoji} ${g.label}`} error={errors.budget?.[g.code]}>
{(f) => (
<Controller
control={control}
name={`budget.${g.code}`}
render={({ field }) => (
<MoneyInput
{...f}
{...field}
placeholder="0,00"
className="bg-background"
/>
)}
/>
)}
</FormField>
</div>
<details open={pv.weeks.some((w) => w.isManual)} className="group/weeks">
<summary className="cursor-pointer px-4 py-3 text-sm font-medium select-none">
{s.weekly}
</summary>
<p className="px-4 pb-2 text-xs text-muted-foreground">{s.weekHint}</p>
<ul className="divide-y text-sm">
{period.weeks.map((w, i) => (
<WeekRow
key={w.start_date}
control={control}
setValue={setValue}
groupCode={g.code}
index={i}
label={s.week(w.index)}
range={`${formatDayMonth(w.start_date)}–${formatDayMonth(w.last_date)}`}
days={w.days}
auto={pv.weeks[i]?.amount ?? 0}
manual={pv.weeks[i]?.isManual ?? false}
error={errors.weeks?.[g.code]?.[i]?.message}
/>
))}
</ul>
</details>
{pv.weeklyOver && (
<p role="status" className="border-t px-4 py-2 text-sm text-warning">
{s.weeklyOver}
</p>
)}
</div>
)
})}
</div>
</div>
</Section>
)
}
function WeekRow({
control,
setValue,
groupCode,
index,
label,
range,
days,
auto,
manual,
error,
}: {
control: Control<PlanForm>
setValue: UseFormSetValue<PlanForm>
groupCode: (typeof GROUP_CODES)[number]
index: number
label: string
range: string
days: number
auto: number
manual: boolean
error?: string
}) {
const fieldName = `weeks.${groupCode}.${index}` as const
return (
<li className="flex flex-wrap items-center gap-2 px-4 py-2">
<span className="min-w-0 flex-1">
<span className="block">{label}</span>
<span className="text-xs text-muted-foreground">
{range} · {s.weekDays(days)}
{manual && ` · ${s.manual}`}
</span>
</span>
<div className="w-36">
<Controller
control={control}
name={fieldName}
render={({ field }) => (
<MoneyInput
{...field}
aria-label={`${group(groupCode).label} · ${label} (${range})`}
aria-invalid={!!error}
// Boşsa otomatik tutar silik görünür; yazınca elle girilmiş olur
placeholder={formatNumber(auto / 100)}
/>
)}
/>
</div>
{manual ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={s.resetWeek(index + 1)}
onClick={() => setValue(fieldName, '', { shouldDirty: true, shouldValidate: true })}
>
<RotateCcw className="text-muted-foreground" aria-hidden />
</Button>
) : (
<span className="size-11" aria-hidden />
)}
{error && <p className="w-full text-sm text-expense">{error}</p>}
</li>
)
}