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
@@ -1,36 +1,33 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2, Sparkles } from 'lucide-react'
import { Controller, useForm, useWatch } from 'react-hook-form'
import { FormError, FormField } from '@/components/form-field'
import { MoneyInput } from '@/components/money-input'
import { PageError, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { DistributionBar } from '@/features/planning/distribution-bar'
import { SuggestionPicker } from '@/features/planning/suggestion-picker'
import { SummaryTiles } from '@/features/planning/summary-tiles'
import { ApiError } from '@/lib/api'
import { formatMoney, formatNumber, parseMoney } from '@/lib/format'
import { parseMoney } from '@/lib/format'
import { GROUPS } from '@/lib/groups'
import { toKurus } from '@/lib/money-schema'
import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods'
import { moneyToText } from '@/lib/plan-draft'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { GroupAmounts } from '@/types/GroupAmounts'
import type { Period } from '@/types/Period'
import type { PeriodSummary } from '@/types/PeriodSummary'
import type { SuggestMethod } from '@/types/SuggestMethod'
import { budgetSchema, type BudgetForm } from './schemas'
import { StepActions, StepIntro } from './wizard-ui'
const s = t.wizard.budget
const METHODS: SuggestMethod[] = ['default', 'previous', 'avg3']
function toForm(a: GroupAmounts): BudgetForm {
const f = (k: number) => (k ? formatNumber(k / 100) : '')
return {
needs: f(a.needs),
wants: f(a.wants),
culture: f(a.culture),
unexpected: f(a.unexpected),
needs: moneyToText(a.needs),
wants: moneyToText(a.wants),
culture: moneyToText(a.culture),
unexpected: moneyToText(a.unexpected),
}
}
@@ -84,9 +81,6 @@ function BudgetStepForm({
onDone: () => void
}) {
const setBudget = useSetBudget(period.id)
const [method, setMethod] = useState<SuggestMethod>('default')
const suggestion = useSuggestion(period.id, method)
const {
control,
handleSubmit,
@@ -103,7 +97,6 @@ function BudgetStepForm({
(sum, g) => sum + Math.max(parseMoney(values[g.code] ?? '') ?? 0, 0),
0,
)
const remaining = summary.spendable - distributed
const onSubmit = handleSubmit(async (v) => {
try {
@@ -124,45 +117,10 @@ function BudgetStepForm({
<StepIntro title={s.title} hint={s.hint} />
<SummaryTiles summary={summary} />
{/* Öneri seçimi */}
<fieldset className="mt-6 space-y-3 rounded-xl border p-4">
<legend className="px-1 text-sm font-medium">{s.method}</legend>
<div className="flex flex-wrap gap-2">
{METHODS.map((m) => (
<Button
key={m}
type="button"
size="sm"
variant={method === m ? 'default' : 'outline'}
aria-pressed={method === m}
onClick={() => setMethod(m)}
>
{s.methods[m]}
</Button>
))}
</div>
{suggestion.isPending ? (
<Loader2
className="size-5 animate-spin text-muted-foreground"
aria-label={t.common.loading}
/>
) : suggestion.data?.available ? (
<Button
type="button"
variant="secondary"
onClick={() => reset(toForm(suggestion.data.amounts))}
>
<Sparkles aria-hidden />
{s.apply}
</Button>
) : (
<p className="text-sm text-muted-foreground">
{suggestion.data?.message ?? suggestion.error?.message}
</p>
)}
</fieldset>
<div className="mt-6">
<SuggestionPicker periodId={period.id} onApply={(a) => reset(toForm(a))} />
</div>
{/* 4 grup */}
<div className="mt-6 grid gap-4 sm:grid-cols-2">
{GROUPS.map((g) => (
<FormField key={g.code} label={`${g.emoji} ${g.label}`} error={errors[g.code]}>
@@ -182,34 +140,8 @@ function BudgetStepForm({
))}
</div>
{/* Dağıtım durumu */}
<div className="mt-6 space-y-2 rounded-xl border p-4" aria-live="polite">
<div className="flex justify-between text-sm">
<span>{s.distributed}</span>
<span className="money font-medium">
{formatMoney(distributed)} / {formatMoney(summary.spendable)}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted" aria-hidden>
<div
className={cn('h-full rounded-full', remaining < 0 ? 'bg-warning' : 'bg-primary')}
style={{
width: `${summary.spendable > 0 ? Math.min(100, (distributed / summary.spendable) * 100) : 0}%`,
}}
/>
</div>
<p
className={cn(
'text-sm',
remaining < 0 ? 'font-medium text-warning' : 'text-muted-foreground',
)}
>
{remaining < 0
? s.over(formatMoney(-remaining))
: remaining > 0
? s.unallocated(formatMoney(remaining))
: s.balanced}
</p>
<div className="mt-6">
<DistributionBar distributed={distributed} spendable={summary.spendable} />
</div>
<FormError message={errors.root?.message} />
@@ -187,7 +187,7 @@ function ItemRow({ item, periodId }: { item: PlanItem; periodId: string }) {
}
/** Önceki dönem varsa görünür; gelir ve giderleri birlikte kopyalar. */
function CopyPrevious({ period }: { period: Period }) {
export function CopyPrevious({ period }: { period: Period }) {
const periods = usePeriods()
const copy = useCopyPrevious(period.id)
const hasPrevious = periods.data?.some((p) => p.start_date < period.start_date)
@@ -0,0 +1,45 @@
import { formatMoney } from '@/lib/format'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
const s = t.wizard.budget
/** Dağıtılan / harcanabilir çubuğu + "dağıtılmadı / aşıldı / tamamı dağıtıldı" durumu. */
export function DistributionBar({
distributed,
spendable,
}: {
distributed: number
spendable: number
}) {
const remaining = spendable - distributed
const pct = spendable > 0 ? Math.min(100, (distributed / spendable) * 100) : 0
return (
<div className="space-y-2 rounded-xl border p-4" aria-live="polite">
<div className="flex flex-wrap justify-between gap-x-3 text-sm">
<span>{s.distributed}</span>
<span className="money font-medium">
{formatMoney(distributed)} / {formatMoney(spendable)}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted" aria-hidden>
<div
className={cn('h-full rounded-full', remaining < 0 ? 'bg-warning' : 'bg-primary')}
style={{ width: `${pct}%` }}
/>
</div>
<p
className={cn(
'text-sm',
remaining < 0 ? 'font-medium text-warning' : 'text-muted-foreground',
)}
>
{remaining < 0
? s.over(formatMoney(-remaining))
: remaining > 0
? s.unallocated(formatMoney(remaining))
: s.balanced}
</p>
</div>
)
}
+50 -24
View File
@@ -1,13 +1,14 @@
import { useState } from 'react'
import { Pencil } from 'lucide-react'
import { Link } from 'react-router'
import { paths } from '@/components/layout/nav-items'
import { ComingSoon, PageError, PageHeader, PageLoading } from '@/components/page'
import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { CopyPrevious } from '@/features/period-wizard/step-items'
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
import { formatDate } from '@/lib/format'
import { useCurrentPeriod, useSummary } from '@/lib/periods'
import { useBudget, useCurrentPeriod, usePlanItems, useSummary } from '@/lib/periods'
import { t } from '@/lib/strings'
import type { Period } from '@/types/Period'
import { PlanEdit } from './plan-edit'
import { BudgetRead, ItemsList, PeriodHeading } from './plan-read'
import { SummaryTiles } from './summary-tiles'
export function PlanningPage() {
@@ -20,7 +21,7 @@ export function PlanningPage() {
) : current.isError ? (
<PageError message={current.error.message} onRetry={current.refetch} />
) : current.data ? (
<OpenPeriod period={current.data} />
<PlanView period={current.data} />
) : (
<NoPeriodCard />
)}
@@ -28,28 +29,53 @@ export function PlanningPage() {
)
}
/** Geçici görünüm: ayrıntılı Plan ekranı Faz 4 – Adım 5'te. */
function OpenPeriod({ period }: { period: Period }) {
/** Varsayılan salt okunur; "Düzenle" → düzenleme modu (Kaydet / Vazgeç). */
function PlanView({ period }: { period: Period }) {
const items = usePlanItems(period.id)
const summary = useSummary(period.id)
const budget = useBudget(period.id)
const [editing, setEditing] = useState(false)
const failed = [items, summary, budget].find((q) => q.isError)
if (failed?.error) return <PageError message={failed.error.message} onRetry={failed.refetch} />
if (!items.data || !summary.data || !budget.data) return <PageLoading />
if (editing) {
return (
<PlanEdit
period={period}
items={items.data}
budget={budget.data}
summary={summary.data}
onClose={() => {
setEditing(false)
window.scrollTo({ top: 0 })
}}
/>
)
}
const editable = period.status === 'open'
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">{period.name}</h2>
<p className="text-sm text-muted-foreground">
{formatDate(period.start_date)} – {formatDate(period.last_date)} · {period.days} gün ·{' '}
{period.weeks.length} hafta
</p>
</div>
<Button asChild variant="outline">
<Link to={`${paths.periodWizard}?adim=2`}>
<Pencil aria-hidden />
Sihirbazla düzenle
</Link>
</Button>
<PeriodHeading
period={period}
actions={
editable && (
<Button onClick={() => setEditing(true)}>
<Pencil aria-hidden />
{t.plan.edit}
</Button>
)
}
/>
<SummaryTiles summary={summary.data} />
<div className="grid gap-4 lg:grid-cols-2">
<ItemsList kind="income" list={items.data.filter((i) => i.kind === 'income')} />
<ItemsList kind="expense" list={items.data.filter((i) => i.kind === 'expense')} />
</div>
{summary.data && <SummaryTiles summary={summary.data} />}
<ComingSoon when="bir sonraki adımda" />
{editable && <CopyPrevious period={period} />}
<BudgetRead budget={budget.data} summary={summary.data} />
</div>
)
}
@@ -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>
)
}
@@ -0,0 +1,139 @@
import type { ReactNode } from 'react'
import { formatDate, formatDayMonth, formatMoney } from '@/lib/format'
import { group, GROUPS } from '@/lib/groups'
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'
const s = t.plan
const items = t.wizard.items
/** Dönem adı, tarihleri + sağda eylemler (Düzenle / Kaydet…) */
export function PeriodHeading({ period, actions }: { period: Period; actions?: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">{period.name}</h2>
<p className="text-sm text-muted-foreground">
{formatDate(period.start_date)} – {formatDate(period.last_date)} ·{' '}
{s.periodInfo(period.days, period.weeks.length)}
</p>
</div>
{actions && <div className="flex flex-wrap gap-2">{actions}</div>}
</div>
)
}
/** Bölüm kutusu: başlık + (isteğe bağlı) sağ üst eylem */
export function Section({
title,
action,
children,
className,
}: {
title: string
action?: ReactNode
children: ReactNode
className?: string
}) {
return (
<section className={cn('rounded-2xl border bg-card p-4 sm:p-5', className)}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold">{title}</h3>
{action}
</div>
{children}
</section>
)
}
/** Salt okunur gelir / gider listesi */
export function ItemsList({ kind, list }: { kind: PlanItemKind; list: PlanItem[] }) {
const text = items[kind]
const total = list.reduce((a, i) => a + i.amount, 0)
return (
<Section title={text.title}>
{list.length === 0 ? (
<p className="text-sm text-muted-foreground">{text.empty}</p>
) : (
<ul className="divide-y">
{list.map((i) => (
<li key={i.id} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<p className="truncate">{i.name}</p>
{i.date && <p className="text-xs text-muted-foreground">{formatDate(i.date)}</p>}
</div>
<span className="money">{formatMoney(i.amount)}</span>
</li>
))}
</ul>
)}
<div className="mt-2 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>
)
}
/** Salt okunur bütçe: dağıtım çubuğu + 4 grup (aylık + haftalık döküm) */
export function BudgetRead({ budget, summary }: { budget: GroupBudget[]; summary: PeriodSummary }) {
return (
<Section title={s.budget}>
<div className="space-y-4">
<DistributionBar distributed={summary.budget_total} spendable={summary.spendable} />
{summary.budget_total === 0 && (
<p className="text-sm text-muted-foreground">{s.emptyBudget}</p>
)}
<div className="grid gap-4 md:grid-cols-2">
{GROUPS.map((g) => {
const b = budget.find((x) => x.group === g.code)
return b ? <GroupBudgetCard key={g.code} budget={b} /> : null
})}
</div>
</div>
</Section>
)
}
function GroupBudgetCard({ budget }: { budget: GroupBudget }) {
const g = group(budget.group)
return (
<div className="overflow-hidden rounded-xl border">
<div className={cn('flex items-center justify-between gap-2 px-4 py-3', g.className.soft)}>
<span className={cn('font-medium', g.className.text)}>
<span aria-hidden>{g.emoji} </span>
{g.label}
</span>
<span className="money font-semibold">{formatMoney(budget.monthly)}</span>
</div>
<ul className="divide-y text-sm" aria-label={`${g.label} · ${s.weekly}`}>
{budget.weeks.map((w) => (
<li key={w.start_date} className="flex items-center gap-2 px-4 py-2">
<span className="flex-1 text-muted-foreground">
{s.week(w.index)} · {formatDayMonth(w.start_date)}–{formatDayMonth(w.last_date)}
</span>
{w.is_manual && (
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{s.manual}
</span>
)}
<span className="money">{formatMoney(w.amount)}</span>
</li>
))}
</ul>
{budget.weekly_over && (
<p role="status" className="border-t px-4 py-2 text-sm text-warning">
{s.weeklyOver}
</p>
)}
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod'
import { itemSchema } from '@/features/period-wizard/schemas'
import { moneyText } from '@/lib/money-schema'
/** Plan ekranı düzenleme formu (`lib/plan-draft.ts` → `PlanDraft` ile aynı biçim). */
export function planSchema(start: string, last: string) {
const item = itemSchema(start, last).extend({
id: z.string().optional(),
version: z.number().optional(),
})
const money = moneyText('optional')
return z.object({
incomes: z.array(item),
expenses: z.array(item),
savings: money,
budget: z.object({ needs: money, wants: money, culture: money, unexpected: money }),
weeks: z.object({
needs: z.array(money),
wants: z.array(money),
culture: z.array(money),
unexpected: z.array(money),
}),
})
}
export type PlanForm = z.infer<ReturnType<typeof planSchema>>
@@ -0,0 +1,60 @@
import { useState } from 'react'
import { Loader2, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useSuggestion } from '@/lib/periods'
import { t } from '@/lib/strings'
import type { GroupAmounts } from '@/types/GroupAmounts'
import type { SuggestMethod } from '@/types/SuggestMethod'
const s = t.wizard.budget
const METHODS: SuggestMethod[] = ['default', 'previous', 'avg3']
/** Bütçe önerisi: yöntem seç → "Öneriyi uygula" (veri yoksa nedeni yazılır). */
export function SuggestionPicker({
periodId,
onApply,
hint,
}: {
periodId: string
onApply: (amounts: GroupAmounts) => void
hint?: string
}) {
const [method, setMethod] = useState<SuggestMethod>('default')
const suggestion = useSuggestion(periodId, method)
return (
<fieldset className="space-y-3 rounded-xl border p-4">
<legend className="px-1 text-sm font-medium">{s.method}</legend>
<div className="flex flex-wrap gap-2">
{METHODS.map((m) => (
<Button
key={m}
type="button"
size="sm"
variant={method === m ? 'default' : 'outline'}
aria-pressed={method === m}
onClick={() => setMethod(m)}
>
{s.methods[m]}
</Button>
))}
</div>
{suggestion.isPending ? (
<Loader2
className="size-5 animate-spin text-muted-foreground"
aria-label={t.common.loading}
/>
) : suggestion.data?.available ? (
<Button type="button" variant="secondary" onClick={() => onApply(suggestion.data.amounts)}>
<Sparkles aria-hidden />
{s.apply}
</Button>
) : (
<p className="text-sm text-muted-foreground">
{suggestion.data?.message ?? suggestion.error?.message}
</p>
)}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</fieldset>
)
}