9004a7cc42
- 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>
477 lines
15 KiB
TypeScript
477 lines
15 KiB
TypeScript
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>
|
||
)
|
||
}
|