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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user