Files
kakeibo/frontend/src/features/planning/plan-edit.tsx
T
suphicakir 9004a7cc42 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>
2026-11-12 08:47:00 +03:00

477 lines
15 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 { 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>
)
}