feat(plan): ilk dönem sihirbazı (dönem, gelir, gider, birikim, bütçe)

- /plan/donem-ac?adim=1..5: her adım hemen kaydedilir, adım adreste
- Panel ve Plan'da açık dönem yoksa "Dönemi başlat" kartı
- MoneyInput (sayısal klavye, Türkçe biçim), moneyText/toKurus, period-dates
- lib/periods.ts: dönem, plan kalemi, özet, bütçe ve öneri kancaları
- SummaryTiles, PageLoading/PageError; 13 yeni vitest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 08:16:32 +03:00
parent 95231cd4d6
commit ca0d3a505c
26 changed files with 1594 additions and 7 deletions
+4 -1
View File
@@ -1,11 +1,14 @@
import { ComingSoon, PageHeader } from '@/components/page'
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
import { useCurrentPeriod } from '@/lib/periods'
import { t } from '@/lib/strings'
export function DashboardPage() {
const current = useCurrentPeriod()
return (
<>
<PageHeader title={t.pages.dashboard.title} description={t.pages.dashboard.description} />
<ComingSoon when="Faz 7'de" />
{current.data === null ? <NoPeriodCard /> : <ComingSoon when="Faz 7'de" />}
</>
)
}
@@ -0,0 +1,25 @@
import { CalendarPlus } from 'lucide-react'
import { Link } from 'react-router'
import { paths } from '@/components/layout/nav-items'
import { Button } from '@/components/ui/button'
import { t } from '@/lib/strings'
const s = t.noPeriod
/** Açık dönem yokken Panel ve Plan'da görünür → sihirbaza götürür. */
export function NoPeriodCard() {
return (
<section className="flex flex-col items-center gap-4 rounded-2xl border bg-card p-8 text-center">
<div className="grid size-14 place-items-center rounded-full bg-primary/10 text-primary">
<CalendarPlus className="size-7" aria-hidden />
</div>
<div className="max-w-md space-y-2">
<h2 className="text-lg font-semibold">{s.title}</h2>
<p className="text-muted-foreground">{s.description}</p>
</div>
<Button asChild size="lg">
<Link to={paths.periodWizard}>{s.action}</Link>
</Button>
</section>
)
}
@@ -0,0 +1,85 @@
import { useNavigate, useSearchParams } from 'react-router'
import { toast } from 'sonner'
import { paths } from '@/components/layout/nav-items'
import { PageError, PageHeader, PageLoading } from '@/components/page'
import { useCurrentPeriod } from '@/lib/periods'
import { t } from '@/lib/strings'
import { StepBudget } from './step-budget'
import { StepDates } from './step-dates'
import { StepItems } from './step-items'
import { StepSavings } from './step-savings'
import { Stepper } from './wizard-ui'
const s = t.wizard
const LAST_STEP = s.steps.length
/**
* İlk dönem sihirbazı: Dönem → Gelirler → Giderler → Birikim → Bütçe.
*
* Dönem 1. adımda açılır; sonraki her adım hemen kaydedilir. Adım adreste tutulur
* (`?adim=3`): sayfa yenilense ya da yarıda bırakılsa da kalınan yerden devam edilir,
* tarayıcının geri tuşu bir önceki adıma döner.
*/
export function PeriodWizardPage() {
const current = useCurrentPeriod()
const [params, setParams] = useSearchParams()
const navigate = useNavigate()
const header = <PageHeader title={s.title} description={s.description} />
if (current.isPending)
return (
<>
{header}
<PageLoading />
</>
)
if (current.isError) {
return (
<>
{header}
<PageError message={current.error.message} onRetry={current.refetch} />
</>
)
}
const period = current.data
const requested = Number(params.get('adim'))
// Dönem yoksa yalnızca 1. adım; varsa adres yoksa 2. adımdan devam
const step = !period
? 1
: Number.isInteger(requested) && requested >= 1 && requested <= LAST_STEP
? requested
: 2
const go = (n: number) => {
setParams({ adim: String(n) })
window.scrollTo({ top: 0 })
}
return (
<div className="mx-auto max-w-3xl">
{header}
<Stepper current={step} />
{step === 1 && <StepDates period={period} onDone={() => go(2)} />}
{period && step === 2 && (
<StepItems period={period} kind="income" onBack={() => go(1)} onNext={() => go(3)} />
)}
{period && step === 3 && (
<StepItems period={period} kind="expense" onBack={() => go(2)} onNext={() => go(4)} />
)}
{period && step === 4 && (
<StepSavings period={period} onBack={() => go(3)} onNext={() => go(5)} />
)}
{period && step === 5 && (
<StepBudget
period={period}
onBack={() => go(4)}
onDone={() => {
toast.success(s.budget.done)
navigate(paths.planning)
}}
/>
)}
</div>
)
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { t } from '@/lib/strings'
import { datesSchema, itemSchema } from './schemas'
const s = t.wizard
function datesError(start: string, last: string, name = '') {
const r = datesSchema.safeParse({ start, last, name })
return r.success ? null : r.error.issues[0].message
}
describe('datesSchema', () => {
it('accepts a calendar month and a salary-day period', () => {
expect(datesError('2026-12-01', '2026-12-31')).toBeNull()
expect(datesError('2026-12-15', '2027-01-14', 'Maaş dönemi')).toBeNull()
})
it('rejects missing, reversed and too long ranges', () => {
expect(datesError('', '2026-12-31')).toBe(s.dates.startRequired)
expect(datesError('2026-12-01', '')).toBe(s.dates.lastRequired)
expect(datesError('2026-12-10', '2026-12-09')).toBe(s.dates.lastBeforeStart)
expect(datesError('2026-12-01', '2027-02-01')).toBe(s.dates.tooLong(62))
expect(datesError('2026-12-01', '2027-01-31')).toBeNull() // tam 62 gün
expect(datesError('2026-12-01', '2026-12-31', 'x'.repeat(61))).toBe(s.dates.nameTooLong)
})
})
describe('itemSchema', () => {
const schema = itemSchema('2026-12-01', '2026-12-31')
const err = (v: { name: string; amount: string; date: string }) => {
const r = schema.safeParse(v)
return r.success ? null : r.error.issues[0].message
}
it('accepts items with or without a date', () => {
expect(err({ name: 'Maaş', amount: '45.000', date: '' })).toBeNull()
expect(err({ name: 'Kira', amount: '15000,50', date: '2026-12-05' })).toBeNull()
})
it('rejects empty names and dates outside the period', () => {
expect(err({ name: ' ', amount: '10', date: '' })).toBe(s.items.nameRequired)
expect(err({ name: 'Kira', amount: '10', date: '2027-01-01' })).toBe(s.items.dateOutside)
})
})
@@ -0,0 +1,55 @@
import { z } from 'zod'
import { moneyText } from '@/lib/money-schema'
import { daysInclusive, isIsoDate, MAX_PERIOD_DAYS } from '@/lib/period-dates'
import { t } from '@/lib/strings'
const s = t.wizard
/** 1. adım: dönem tarihleri. `last` = son gün (dahil). */
export const datesSchema = z
.object({
start: z.string(),
last: z.string(),
name: z.string().trim().max(60, s.dates.nameTooLong),
})
.superRefine((v, ctx) => {
if (!isIsoDate(v.start)) {
ctx.addIssue({ code: 'custom', path: ['start'], message: s.dates.startRequired })
}
if (!isIsoDate(v.last)) {
ctx.addIssue({ code: 'custom', path: ['last'], message: s.dates.lastRequired })
return
}
if (!isIsoDate(v.start)) return
if (v.last < v.start) {
ctx.addIssue({ code: 'custom', path: ['last'], message: s.dates.lastBeforeStart })
} else if (daysInclusive(v.start, v.last) > MAX_PERIOD_DAYS) {
ctx.addIssue({ code: 'custom', path: ['last'], message: s.dates.tooLong(MAX_PERIOD_DAYS) })
}
})
export type DatesForm = z.infer<typeof datesSchema>
/** 2–3. adım: gelir / gider kalemi. Tarih boş olabilir; doluysa dönem içinde olmalı. */
export function itemSchema(start: string, last: string) {
return z.object({
name: z.string().trim().min(1, s.items.nameRequired).max(60, s.items.nameTooLong),
amount: moneyText('positive'),
date: z
.string()
.refine((d) => d === '' || (isIsoDate(d) && d >= start && d <= last), s.items.dateOutside),
})
}
export type ItemForm = z.infer<ReturnType<typeof itemSchema>>
/** 4. adım */
export const savingsSchema = z.object({ goal: moneyText('optional') })
export type SavingsForm = z.infer<typeof savingsSchema>
/** 5. adım: 4 grubun aylık bütçesi */
export const budgetSchema = z.object({
needs: moneyText('optional'),
wants: moneyText('optional'),
culture: moneyText('optional'),
unexpected: moneyText('optional'),
})
export type BudgetForm = z.infer<typeof budgetSchema>
@@ -0,0 +1,219 @@
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 { SummaryTiles } from '@/features/planning/summary-tiles'
import { ApiError } from '@/lib/api'
import { formatMoney, formatNumber, parseMoney } from '@/lib/format'
import { GROUPS } from '@/lib/groups'
import { toKurus } from '@/lib/money-schema'
import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods'
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),
}
}
/**
* 5. adım: harcanabilir tutarı 4 gruba dağıt.
* Daha önce bütçe girilmişse o gösterilir; yoksa varsayılan öneriyle doldurulur.
*/
export function StepBudget({
period,
onBack,
onDone,
}: {
period: Period
onBack: () => void
onDone: () => void
}) {
const summary = useSummary(period.id)
const budget = useBudget(period.id)
const suggestion = useSuggestion(period.id, 'default')
const queries = [summary, budget, suggestion]
const failed = queries.find((q) => q.isError)
if (failed?.error) return <PageError message={failed.error.message} onRetry={failed.refetch} />
if (!summary.data || !budget.data || !suggestion.data) return <PageLoading />
const saved = Object.fromEntries(budget.data.map((g) => [g.group, g.monthly])) as GroupAmounts
const hasSaved = budget.data.some((g) => g.monthly > 0)
return (
<BudgetStepForm
period={period}
summary={summary.data}
initial={toForm(hasSaved ? saved : suggestion.data.amounts)}
onBack={onBack}
onDone={onDone}
/>
)
}
function BudgetStepForm({
period,
summary,
initial,
onBack,
onDone,
}: {
period: Period
summary: PeriodSummary
initial: BudgetForm
onBack: () => void
onDone: () => void
}) {
const setBudget = useSetBudget(period.id)
const [method, setMethod] = useState<SuggestMethod>('default')
const suggestion = useSuggestion(period.id, method)
const {
control,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<BudgetForm>({
resolver: zodResolver(budgetSchema),
mode: 'onTouched',
defaultValues: initial,
})
const values = useWatch({ control })
const distributed = GROUPS.reduce(
(sum, g) => sum + Math.max(parseMoney(values[g.code] ?? '') ?? 0, 0),
0,
)
const remaining = summary.spendable - distributed
const onSubmit = handleSubmit(async (v) => {
try {
await setBudget.mutateAsync({
needs: toKurus(v.needs),
wants: toKurus(v.wants),
culture: toKurus(v.culture),
unexpected: toKurus(v.unexpected),
})
onDone()
} catch (e) {
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
}
})
return (
<form onSubmit={onSubmit} noValidate>
<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>
{/* 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]}>
{(f) => (
<div className={cn('rounded-xl p-3', g.className.soft)}>
<Controller
control={control}
name={g.code}
render={({ field }) => (
<MoneyInput {...f} {...field} placeholder="0,00" className="bg-background" />
)}
/>
<p className="mt-1.5 text-xs text-muted-foreground">{g.description}</p>
</div>
)}
</FormField>
))}
</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>
<FormError message={errors.root?.message} />
<StepActions onBack={onBack} pending={isSubmitting} nextLabel={s.finish} />
</form>
)
}
@@ -0,0 +1,124 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import { FormError, FormField } from '@/components/form-field'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ApiError } from '@/lib/api'
import { daysInclusive, isIsoDate, lastToEnd, monthRange } from '@/lib/period-dates'
import { useCreatePeriod, useUpdatePeriod } from '@/lib/periods'
import { t } from '@/lib/strings'
import type { Period } from '@/types/Period'
import { datesSchema, type DatesForm } from './schemas'
import { StepActions, StepIntro } from './wizard-ui'
const s = t.wizard.dates
const monthName = new Intl.DateTimeFormat('tr-TR', { month: 'long', year: 'numeric' })
/** 1. adım: dönem yoksa açar, varsa (geri gelindiyse) günceller. */
export function StepDates({ period, onDone }: { period: Period | null; onDone: () => void }) {
const create = useCreatePeriod()
const update = useUpdatePeriod()
const [today] = useState(() => new Date()) // render başına değil, bir kez
const presets = [0, 1].map((offset) => ({
label: monthName.format(new Date(today.getFullYear(), today.getMonth() + offset, 1)),
range: monthRange(today, offset),
}))
const {
register,
control,
handleSubmit,
setValue,
setError,
formState: { errors, isSubmitting },
} = useForm<DatesForm>({
resolver: zodResolver(datesSchema),
mode: 'onTouched',
defaultValues: period
? { start: period.start_date, last: period.last_date, name: period.name }
: { ...presets[0].range, name: '' },
})
const [start, last] = useWatch({ control, name: ['start', 'last'] })
const days =
isIsoDate(start) && isIsoDate(last) && last >= start ? daysInclusive(start, last) : null
const onSubmit = handleSubmit(async (v) => {
const end = lastToEnd(v.last)
try {
if (!period) {
await create.mutateAsync({ start_date: v.start, end_date: end, name: v.name || undefined })
toast.success(s.created)
} else if (
v.start !== period.start_date ||
end !== period.end_date ||
v.name !== period.name
) {
await update.mutateAsync({
id: period.id,
version: period.version,
start_date: v.start,
end_date: end,
name: v.name, // boş → backend tarihlere göre otomatik ad verir
})
}
onDone()
} catch (e) {
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
}
})
return (
<form onSubmit={onSubmit} noValidate>
<StepIntro title={s.title} hint={s.hint} />
<div className="space-y-5">
<div role="group" aria-label={s.presetsLabel} className="flex flex-wrap gap-2">
{presets.map((p) => {
const selected = start === p.range.start && last === p.range.last
return (
<Button
key={p.label}
type="button"
variant={selected ? 'default' : 'outline'}
aria-pressed={selected}
onClick={() => {
setValue('start', p.range.start, { shouldValidate: true })
setValue('last', p.range.last, { shouldValidate: true })
}}
>
{p.label}
</Button>
)
})}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<FormField label={s.start} error={errors.start}>
{(f) => <Input {...f} type="date" {...register('start')} />}
</FormField>
<FormField label={s.last} error={errors.last}>
{(f) => <Input {...f} type="date" min={start || undefined} {...register('last')} />}
</FormField>
</div>
{days !== null && (
<p className="text-sm text-muted-foreground" aria-live="polite">
{s.days(days)}
</p>
)}
<FormField label={s.name} error={errors.name} hint={s.nameHint}>
{(f) => <Input {...f} maxLength={60} {...register('name')} />}
</FormField>
<FormError message={errors.root?.message} />
</div>
<StepActions pending={isSubmitting} />
</form>
)
}
@@ -0,0 +1,215 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { Copy, Loader2, Plus, Trash2 } from 'lucide-react'
import { Controller, useForm } from 'react-hook-form'
import { toast } from 'sonner'
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 { Input } from '@/components/ui/input'
import { ApiError } from '@/lib/api'
import { formatDate, formatMoney } from '@/lib/format'
import { toKurus } from '@/lib/money-schema'
import {
useCopyPrevious,
useCreatePlanItem,
useDeletePlanItem,
usePeriods,
usePlanItems,
} from '@/lib/periods'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { Period } from '@/types/Period'
import type { PlanItem } from '@/types/PlanItem'
import type { PlanItemKind } from '@/types/PlanItemKind'
import { itemSchema, type ItemForm } from './schemas'
import { StepActions, StepIntro } from './wizard-ui'
const s = t.wizard.items
/** 2–3. adım: sabit gelir / gider kalemleri. Her ekleme ve silme hemen kaydedilir. */
export function StepItems({
period,
kind,
onBack,
onNext,
}: {
period: Period
kind: PlanItemKind
onBack: () => void
onNext: () => void
}) {
const items = usePlanItems(period.id)
const text = s[kind]
if (items.isPending) return <PageLoading />
if (items.isError) return <PageError message={items.error.message} onRetry={items.refetch} />
const list = items.data.filter((i) => i.kind === kind)
const total = list.reduce((sum, i) => sum + i.amount, 0)
return (
<div>
<StepIntro title={text.title} hint={text.hint} />
<CopyPrevious period={period} />
<AddItemForm period={period} kind={kind} />
<section aria-label={text.title} className="mt-6">
{list.length === 0 ? (
<p className="rounded-xl border border-dashed p-6 text-center text-muted-foreground">
{text.empty}
</p>
) : (
<ul className="divide-y rounded-xl border">
{list.map((item) => (
<ItemRow key={item.id} item={item} periodId={period.id} />
))}
</ul>
)}
<div className="mt-3 flex justify-between px-1 font-medium">
<span>{text.total}</span>
<span className={cn('money', kind === 'income' ? 'text-income' : 'text-expense')}>
{formatMoney(total)}
</span>
</div>
</section>
<StepActions onBack={onBack} onNext={onNext} />
</div>
)
}
function AddItemForm({ period, kind }: { period: Period; kind: PlanItemKind }) {
const create = useCreatePlanItem(period.id)
const {
register,
control,
handleSubmit,
reset,
setFocus,
setError,
formState: { errors, isSubmitting },
} = useForm<ItemForm>({
resolver: zodResolver(itemSchema(period.start_date, period.last_date)),
mode: 'onTouched',
defaultValues: { name: '', amount: '', date: '' },
})
const onSubmit = handleSubmit(async (v) => {
try {
await create.mutateAsync({
kind,
name: v.name,
amount: toKurus(v.amount),
date: v.date || undefined,
})
reset()
setFocus('name') // art arda hızlı giriş
} catch (e) {
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
}
})
return (
<form onSubmit={onSubmit} noValidate className="space-y-4 rounded-xl border bg-card p-4">
<FormField label={s.name} error={errors.name}>
{(f) => (
<Input {...f} maxLength={60} placeholder={s[kind].placeholder} {...register('name')} />
)}
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label={s.amount} error={errors.amount}>
{(f) => (
<Controller
control={control}
name="amount"
render={({ field }) => <MoneyInput {...f} {...field} placeholder="0,00" />}
/>
)}
</FormField>
<FormField label={s.date} error={errors.date}>
{(f) => (
<Input
{...f}
type="date"
min={period.start_date}
max={period.last_date}
{...register('date')}
/>
)}
</FormField>
</div>
<FormError message={errors.root?.message} />
<Button
type="submit"
variant="secondary"
className="w-full sm:w-auto"
disabled={isSubmitting}
>
{isSubmitting ? <Loader2 className="animate-spin" aria-hidden /> : <Plus aria-hidden />}
{s.add}
</Button>
</form>
)
}
function ItemRow({ item, periodId }: { item: PlanItem; periodId: string }) {
const remove = useDeletePlanItem(periodId)
return (
<li className="flex items-center gap-3 py-2 pr-1 pl-4">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{item.name}</p>
{item.date && <p className="text-sm text-muted-foreground">{formatDate(item.date)}</p>}
</div>
<span className="money font-medium">{formatMoney(item.amount)}</span>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={s.remove(item.name)}
disabled={remove.isPending}
onClick={() =>
remove.mutate(item.id, {
onSuccess: () => toast.success(s.removed(item.name)),
onError: (e) => toast.error(e.message),
})
}
>
{remove.isPending ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<Trash2 className="text-muted-foreground" aria-hidden />
)}
</Button>
</li>
)
}
/** Önceki dönem varsa görünür; gelir ve giderleri birlikte kopyalar. */
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)
if (!hasPrevious) return null
return (
<div className="mb-5 flex flex-wrap items-center gap-3 rounded-xl bg-muted/50 p-3">
<p className="flex-1 text-sm text-muted-foreground">{s.copyHint}</p>
<Button
type="button"
variant="outline"
disabled={copy.isPending}
onClick={() =>
copy.mutate(undefined, {
onSuccess: (r) => toast.success(s.copied(r.added)),
onError: (e) => toast.error(e.message),
})
}
>
{copy.isPending ? <Loader2 className="animate-spin" aria-hidden /> : <Copy aria-hidden />}
{s.copy}
</Button>
</div>
)
}
@@ -0,0 +1,110 @@
import { zodResolver } from '@hookform/resolvers/zod'
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 { ApiError } from '@/lib/api'
import { formatMoney, formatNumber, parseMoney } from '@/lib/format'
import { toKurus } from '@/lib/money-schema'
import { useSummary, useUpdatePeriod } from '@/lib/periods'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { Period } from '@/types/Period'
import { savingsSchema, type SavingsForm } from './schemas'
import { StepActions, StepIntro } from './wizard-ui'
const s = t.wizard.savings
/** 4. adım: birikim hedefi (harcamadan önce ayrılır). */
export function StepSavings({
period,
onBack,
onNext,
}: {
period: Period
onBack: () => void
onNext: () => void
}) {
const summary = useSummary(period.id)
const update = useUpdatePeriod()
const {
control,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<SavingsForm>({
resolver: zodResolver(savingsSchema),
mode: 'onTouched',
defaultValues: { goal: period.savings_goal ? formatNumber(period.savings_goal / 100) : '' },
})
const goalText = useWatch({ control, name: 'goal' })
if (summary.isPending) return <PageLoading />
if (summary.isError)
return <PageError message={summary.error.message} onRetry={summary.refetch} />
// Birikimden önceki kalan = harcanabilir + (kayıtlı) birikim hedefi → formül yalnızca backend'de
const available = summary.data.spendable + summary.data.savings_goal
const goal = parseMoney(goalText) ?? 0
const after = available - Math.max(goal, 0)
const onSubmit = handleSubmit(async (v) => {
const kurus = toKurus(v.goal)
try {
if (kurus !== period.savings_goal) {
await update.mutateAsync({ id: period.id, version: period.version, savings_goal: kurus })
}
onNext()
} catch (e) {
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
}
})
return (
<form onSubmit={onSubmit} noValidate>
<StepIntro title={s.title} hint={s.hint} />
<div className="space-y-5">
<Row label={s.available} amount={available} />
<FormField label={s.goal} error={errors.goal}>
{(f) => (
<Controller
control={control}
name="goal"
render={({ field }) => (
<MoneyInput {...f} {...field} placeholder="0,00" className="h-12 text-lg" />
)}
/>
)}
</FormField>
<Row label={s.after} amount={after} strong />
{after < 0 && (
<p role="status" className="text-sm text-warning">
{s.negative}
</p>
)}
<FormError message={errors.root?.message} />
</div>
<StepActions onBack={onBack} pending={isSubmitting} />
</form>
)
}
function Row({ label, amount, strong }: { label: string; amount: number; strong?: boolean }) {
return (
<div
className={cn(
'flex items-center justify-between rounded-xl border px-4 py-3',
strong && 'border-primary/40 bg-primary/5',
)}
aria-live={strong ? 'polite' : undefined}
>
<span className={cn(strong ? 'font-medium' : 'text-muted-foreground')}>{label}</span>
<span className={cn('money', strong && 'text-lg font-bold', amount < 0 && 'text-expense')}>
{formatMoney(amount)}
</span>
</div>
)
}
@@ -0,0 +1,116 @@
import type { ReactNode } from 'react'
import { ArrowLeft, ArrowRight, Check, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
const s = t.wizard
/** Adım göstergesi: mobilde "Adım 2/5 · Gelirler" + çubuk, geniş ekranda numaralı liste. */
export function Stepper({ current }: { current: number }) {
const total = s.steps.length
return (
<nav aria-label={s.title} className="mb-6">
<p className="mb-2 text-sm font-medium sm:hidden">
{s.stepOf(current, total)} · {s.steps[current - 1]}
</p>
<div className="h-1.5 overflow-hidden rounded-full bg-muted sm:hidden" aria-hidden>
<div
className="h-full rounded-full bg-primary transition-[width]"
style={{ width: `${(current / total) * 100}%` }}
/>
</div>
<ol className="hidden gap-2 sm:flex">
{s.steps.map((label, i) => {
const n = i + 1
const done = n < current
const active = n === current
return (
<li
key={label}
aria-current={active ? 'step' : undefined}
className={cn(
'flex flex-1 items-center gap-2 rounded-lg border px-3 py-2 text-sm',
active && 'border-primary bg-primary/5 font-medium',
!active && !done && 'text-muted-foreground',
)}
>
<span
className={cn(
'grid size-6 shrink-0 place-items-center rounded-full text-xs',
done || active ? 'bg-primary text-primary-foreground' : 'bg-muted',
)}
>
{done ? <Check className="size-3.5" aria-hidden /> : n}
</span>
{label}
</li>
)
})}
</ol>
</nav>
)
}
/** Adım başlığı + açıklama */
export function StepIntro({ title, hint }: { title: string; hint: string }) {
return (
<div className="mb-5 space-y-1">
<h2 className="text-xl font-semibold">{title}</h2>
<p className="text-muted-foreground">{hint}</p>
</div>
)
}
/**
* Alt düğmeler. `onNext` verilmezse İleri düğmesi `type="submit"` olur (formu gönderir).
* Mobilde düğmeler tam genişlik, İleri sağda (başparmak erişimi).
*/
export function StepActions({
onBack,
onNext,
pending,
nextLabel = s.next,
form,
extra,
}: {
onBack?: () => void
onNext?: () => void
pending?: boolean
nextLabel?: string
/** Düğme form dışındaysa formun `id`'si */
form?: string
extra?: ReactNode
}) {
return (
<div className="mt-8 flex items-center gap-3 border-t pt-5">
{onBack && (
<Button type="button" variant="outline" size="lg" onClick={onBack} disabled={pending}>
<ArrowLeft aria-hidden />
{s.back}
</Button>
)}
{extra}
<Button
type={onNext ? 'button' : 'submit'}
form={form}
size="lg"
className="ml-auto min-w-32 flex-1 sm:flex-none"
onClick={onNext}
disabled={pending}
>
{pending ? (
<>
<Loader2 className="animate-spin" aria-hidden />
{s.saving}
</>
) : (
<>
{nextLabel}
<ArrowRight aria-hidden />
</>
)}
</Button>
</div>
)
}
+46 -2
View File
@@ -1,11 +1,55 @@
import { ComingSoon, PageHeader } from '@/components/page'
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 { Button } from '@/components/ui/button'
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
import { formatDate } from '@/lib/format'
import { useCurrentPeriod, useSummary } from '@/lib/periods'
import { t } from '@/lib/strings'
import type { Period } from '@/types/Period'
import { SummaryTiles } from './summary-tiles'
export function PlanningPage() {
const current = useCurrentPeriod()
return (
<>
<PageHeader title={t.pages.planning.title} description={t.pages.planning.description} />
<ComingSoon when="Faz 4'te" />
{current.isPending ? (
<PageLoading />
) : current.isError ? (
<PageError message={current.error.message} onRetry={current.refetch} />
) : current.data ? (
<OpenPeriod period={current.data} />
) : (
<NoPeriodCard />
)}
</>
)
}
/** Geçici görünüm: ayrıntılı Plan ekranı Faz 4 – Adım 5'te. */
function OpenPeriod({ period }: { period: Period }) {
const summary = useSummary(period.id)
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>
</div>
{summary.data && <SummaryTiles summary={summary.data} />}
<ComingSoon when="bir sonraki adımda" />
</div>
)
}
@@ -0,0 +1,59 @@
import { formatMoney } from '@/lib/format'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { PeriodSummary } from '@/types/PeriodSummary'
const s = t.summary
/**
* Plan özeti: Gelir − Sabit giderler − Birikim − Devreden borç (+ Devreden artı) = Harcanabilir.
* Mobilde 2 sütun, geniş ekranda tek satır. Devir kutucukları yalnızca tutar varsa görünür.
*/
export function SummaryTiles({ summary }: { summary: PeriodSummary }) {
const tiles: { label: string; amount: number; sign?: '−' | '+'; tone?: string }[] = [
{ label: s.income, amount: summary.income_total, tone: 'text-income' },
{ label: s.fixed, amount: summary.fixed_expense_total, sign: '−' },
{ label: s.savings, amount: summary.savings_goal, sign: '−' },
]
if (summary.carryover_debt > 0) {
tiles.push({ label: s.debt, amount: summary.carryover_debt, sign: '−', tone: 'text-expense' })
}
if (summary.carryover_surplus > 0) {
tiles.push({ label: s.surplus, amount: summary.carryover_surplus, sign: '+' })
}
return (
<dl className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
{tiles.map((tile) => (
<div key={tile.label} className="rounded-xl border bg-card p-3 sm:min-w-32 sm:flex-1">
<dt className="text-xs text-muted-foreground">
{tile.sign && <span aria-hidden>{tile.sign} </span>}
{tile.label}
</dt>
<dd className={cn('money mt-1 font-semibold', tile.tone)}>{formatMoney(tile.amount)}</dd>
</div>
))}
<div
className={cn(
'col-span-2 rounded-xl border-2 p-3 sm:min-w-40 sm:flex-[1.4]',
summary.spendable < 0
? 'border-expense/50 bg-expense/5'
: 'border-primary/40 bg-primary/5',
)}
>
<dt className="text-xs font-medium text-muted-foreground">
<span aria-hidden>= </span>
{s.spendable}
</dt>
<dd
className={cn(
'money mt-1 text-xl font-bold',
summary.spendable < 0 ? 'text-expense' : 'text-foreground',
)}
>
{formatMoney(summary.spendable)}
</dd>
</div>
</dl>
)
}