feat(defter): hafta seçici ve hızlı harcama giriş formu

- /defter: önceki/sonraki/bu hafta, haftanın harcanan/bütçe toplamı
- Form: tutar, 4 grup düğmesi, kategori ve ödeme türü seçimi, tarih, açıklama
- Son seçimler bu cihazda hatırlanır; sunucu hataları ilgili alana düşer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 11:20:18 +03:00
parent 7143613ab2
commit f930f2fbcc
14 changed files with 696 additions and 5 deletions
+284
View File
@@ -0,0 +1,284 @@
import { useEffect } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2 } from 'lucide-react'
import { Controller, useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import { FormError, FormField } from '@/components/form-field'
import { MoneyInput } from '@/components/money-input'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useCreateEntry } from '@/lib/entries'
import { formatMoney } from '@/lib/format'
import { GROUPS, type GroupCode } from '@/lib/groups'
import { defaultEntryDate, loadLastChoice, saveLastChoice } from '@/lib/ledger'
import { toKurus } from '@/lib/money-schema'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { CatalogItem } from '@/types/CatalogItem'
import type { Period } from '@/types/Period'
import type { PeriodWeek } from '@/types/PeriodWeek'
import { entrySchema, fieldForError, MAX_DESCRIPTION, type EntryForm } from './schema'
const s = t.ledger
/** Formun ilk değerleri: son seçimler hâlâ aktifse onlar (bu cihazda hatırlanır) */
function initialValues(categories: CatalogItem[], methods: CatalogItem[], date: string): EntryForm {
const last = loadLastChoice()
const group = last.group
const cat = group && categories.find((c) => c.id === last.categoryByGroup[group])
const pm = methods.find((m) => m.id === last.paymentMethodId)
return {
amount: '',
group: (cat ? group : '') as GroupCode,
category_id: cat ? cat.id : '',
payment_method_id: pm ? pm.id : '',
date,
description: '',
}
}
/**
* Hızlı giriş: tutar → grup → kategori → (ödeme türü, tarih, açıklama) → Kaydet.
* Kayıttan sonra tutar ve açıklama temizlenir, diğer seçimler kalır (art arda giriş için).
*/
export function EntryForm({
period,
week,
today,
categories,
methods,
onSaved,
}: {
period: Period
week: PeriodWeek
today: string
/** Yalnızca aktifler */
categories: CatalogItem[]
methods: CatalogItem[]
/** Kaydedilen kaydın tarihi (o haftaya geçmek için) */
onSaved: (date: string) => void
}) {
const create = useCreateEntry(period.id)
const {
control,
register,
handleSubmit,
setValue,
getValues,
setError,
setFocus,
reset,
formState: { errors, isSubmitting },
} = useForm<EntryForm>({
resolver: zodResolver(entrySchema(period.start_date, period.last_date)),
mode: 'onTouched',
defaultValues: initialValues(categories, methods, defaultEntryDate(week, today)),
})
const group = useWatch({ control, name: 'group' })
const groupCategories = categories.filter((c) => c.group === group)
// Başka haftaya geçilince tarih o haftaya çekilir (seçili tarih zaten oradaysa dokunulmaz)
useEffect(() => {
const d = getValues('date')
if (!(d >= week.start_date && d <= week.last_date)) {
setValue('date', defaultEntryDate(week, today))
}
}, [week, today, getValues, setValue])
function pickGroup(code: GroupCode) {
if (code === getValues('group')) return
const inGroup = categories.filter((c) => c.group === code)
const remembered = inGroup.find((c) => c.id === loadLastChoice().categoryByGroup[code])
setValue('group', code, { shouldValidate: true })
setValue('category_id', remembered?.id ?? (inGroup.length === 1 ? inGroup[0].id : ''), {
shouldValidate: !!errors.category_id,
})
}
const onSubmit = handleSubmit(async (v) => {
try {
const e = await create.mutateAsync({
date: v.date,
amount: toKurus(v.amount),
category_id: v.category_id,
payment_method_id: v.payment_method_id || undefined,
description: v.description.trim() || undefined,
})
const last = loadLastChoice()
saveLastChoice({
group: v.group,
categoryByGroup: { ...last.categoryByGroup, [v.group]: v.category_id },
paymentMethodId: v.payment_method_id,
})
const name = categories.find((c) => c.id === v.category_id)?.name ?? ''
toast.success(s.saved(formatMoney(e.amount), name))
reset({ ...v, amount: '', description: '' })
onSaved(e.date)
setFocus('amount')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(fieldForError(message), { message })
}
})
return (
<form onSubmit={onSubmit} noValidate className="space-y-5">
<Controller
control={control}
name="amount"
render={({ field }) => (
<FormField label={s.amount} error={errors.amount}>
{(f) => (
<MoneyInput
{...f}
ref={field.ref}
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="0,00"
className="h-14 text-2xl font-semibold md:text-2xl"
/>
)}
</FormField>
)}
/>
<fieldset>
<legend className="mb-2 text-sm font-medium">{s.group}</legend>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{GROUPS.map((g) => {
const selected = group === g.code
return (
<button
key={g.code}
type="button"
aria-pressed={selected}
onClick={() => pickGroup(g.code)}
className={cn(
'flex min-h-14 min-w-0 items-center gap-1.5 rounded-xl border-2 px-2.5 py-2 text-left text-sm font-medium transition-colors sm:gap-2 sm:px-3 sm:text-base',
selected
? cn(g.className.soft, g.className.text, 'border-current')
: 'border-border hover:bg-muted',
)}
>
<span aria-hidden className="text-lg sm:text-xl">
{g.emoji}
</span>
{g.label}
</button>
)
})}
</div>
{errors.group && <p className="mt-2 text-sm text-expense">{errors.group.message}</p>}
</fieldset>
{group && (
<Controller
control={control}
name="category_id"
render={({ field }) => (
<Chips
legend={s.category}
value={field.value}
onChange={field.onChange}
options={groupCategories.map((c) => ({ value: c.id, label: label(c) }))}
empty={s.noCategory}
error={errors.category_id?.message}
/>
)}
/>
)}
{methods.length > 0 && (
<Controller
control={control}
name="payment_method_id"
render={({ field }) => (
<Chips
legend={s.paymentMethod}
value={field.value}
onChange={field.onChange}
options={[
{ value: '', label: s.noPaymentMethod },
...methods.map((m) => ({ value: m.id, label: label(m) })),
]}
error={errors.payment_method_id?.message}
/>
)}
/>
)}
<div className="grid gap-4 sm:grid-cols-[12rem_1fr]">
<FormField label={s.date} error={errors.date}>
{(f) => (
<Input
{...f}
type="date"
min={period.start_date}
max={period.last_date}
{...register('date')}
/>
)}
</FormField>
<FormField label={s.description} error={errors.description} hint={s.descriptionHint}>
{(f) => <Input {...f} maxLength={MAX_DESCRIPTION * 2} {...register('description')} />}
</FormField>
</div>
<FormError message={errors.root?.message} />
<Button type="submit" size="lg" className="w-full sm:w-auto" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
{isSubmitting ? s.saving : s.save}
</Button>
</form>
)
}
const label = (c: CatalogItem) => (c.emoji ? `${c.emoji} ${c.name}` : c.name)
/** Tek seçimlik düğme listesi (kategori, ödeme türü) */
function Chips({
legend,
value,
onChange,
options,
empty,
error,
}: {
legend: string
value: string
onChange: (value: string) => void
options: { value: string; label: string }[]
empty?: string
error?: string
}) {
return (
<fieldset>
<legend className="mb-2 text-sm font-medium">{legend}</legend>
{options.length === 0 ? (
<p className="text-sm text-muted-foreground">{empty}</p>
) : (
<div className="flex flex-wrap gap-2">
{options.map((o) => (
<button
key={o.value}
type="button"
aria-pressed={value === o.value}
onClick={() => onChange(o.value)}
className={cn(
'min-h-11 rounded-full border px-4 text-sm transition-colors',
value === o.value
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-muted',
)}
>
{o.label}
</button>
))}
</div>
)}
{error && <p className="mt-2 text-sm text-expense">{error}</p>}
</fieldset>
)
}
+69 -2
View File
@@ -1,11 +1,78 @@
import { ComingSoon, PageHeader } from '@/components/page'
import { useState } from 'react'
import { useSearchParams } from 'react-router'
import { PageError, PageHeader, PageLoading } from '@/components/page'
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
import { Section } from '@/features/planning/plan-read'
import { useCategories, usePaymentMethods } from '@/lib/catalog'
import { toIsoDate } from '@/lib/format'
import { parseWeekParam, weekIndexFor } from '@/lib/ledger'
import { useBudget, useCurrentPeriod } from '@/lib/periods'
import { t } from '@/lib/strings'
import type { Period } from '@/types/Period'
import { EntryForm } from './entry-form'
import { WeekPicker } from './week-picker'
export function LedgerPage() {
const current = useCurrentPeriod()
return (
<>
<PageHeader title={t.pages.ledger.title} description={t.pages.ledger.description} />
<ComingSoon when="Faz 5'te" />
{current.isPending ? (
<PageLoading />
) : current.isError ? (
<PageError message={current.error.message} onRetry={current.refetch} />
) : current.data ? (
<LedgerView period={current.data} />
) : (
<NoPeriodCard />
)}
</>
)
}
/** Hafta adreste tutulur (`?hafta=2`): yenileyince / geri gelince aynı hafta açılır. */
function LedgerView({ period }: { period: Period }) {
const [params, setParams] = useSearchParams()
const [today] = useState(() => toIsoDate(new Date()))
const inPeriod = today >= period.start_date && today <= period.last_date
const todayIndex = inPeriod ? weekIndexFor(period.weeks, today) : null
const index =
parseWeekParam(params.get('hafta'), period.weeks.length) ?? weekIndexFor(period.weeks, today)
const week = period.weeks[index - 1]
const budget = useBudget(period.id)
const categories = useCategories()
const methods = usePaymentMethods()
const goTo = (n: number) =>
setParams(n === todayIndex ? {} : { hafta: String(n) }, { replace: true })
const failed = [categories, methods].find((q) => q.isError)
return (
<div className="mx-auto max-w-3xl space-y-4">
<WeekPicker
weeks={period.weeks}
index={index}
todayIndex={todayIndex}
budget={budget.data}
onChange={goTo}
/>
<Section title={t.ledger.formTitle}>
{failed?.error ? (
<PageError message={failed.error.message} onRetry={failed.refetch} />
) : !categories.data || !methods.data ? (
<PageLoading />
) : (
<EntryForm
period={period}
week={week}
today={today}
categories={categories.data}
methods={methods.data}
onSaved={(date) => goTo(weekIndexFor(period.weeks, date))}
/>
)}
</Section>
</div>
)
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { entrySchema, fieldForError } from './schema'
const schema = entrySchema('2026-12-01', '2026-12-31')
const ok = {
amount: '1.250,50',
group: 'needs',
category_id: 'c1',
payment_method_id: '',
date: '2026-12-31',
description: '',
}
const errorsOf = (v: object) =>
schema.safeParse({ ...ok, ...v }).error?.issues.map((i) => [i.path[0], i.message]) ?? []
describe('entry form', () => {
it('accepts a valid entry', () => {
expect(schema.safeParse(ok).success).toBe(true)
})
it('rejects missing and out-of-range values with Turkish messages', () => {
expect(errorsOf({ amount: '' })).toEqual([['amount', 'Tutarı yazın']])
expect(errorsOf({ amount: '0' })).toEqual([['amount', 'Tutar sıfırdan büyük olmalı']])
expect(errorsOf({ group: '' })).toEqual([['group', 'Ana grubu seçin']])
expect(errorsOf({ category_id: '' })).toEqual([['category_id', 'Kategori seçin']])
expect(errorsOf({ date: '2027-01-01' })).toEqual([['date', 'Tarih dönem içinde olmalı']])
expect(errorsOf({ date: '' })).toEqual([['date', 'Tarih geçersiz']])
})
it('counts description characters like the backend', () => {
expect(errorsOf({ description: '🛒'.repeat(200) })).toEqual([])
expect(errorsOf({ description: 'a'.repeat(201) })[0][0]).toBe('description')
})
it('maps server messages to fields', () => {
expect(fieldForError('Tarih dönem içinde olmalı')).toBe('date')
expect(fieldForError('Bu kategori pasif; başka bir kategori seçin')).toBe('category_id')
expect(fieldForError('Bu ödeme türü pasif; başka birini seçin')).toBe('payment_method_id')
expect(fieldForError('Bağlantı kurulamadı')).toBe('root')
})
})
+38
View File
@@ -0,0 +1,38 @@
import { z } from 'zod'
import { isIsoDate } from '@/lib/period-dates'
import { moneyText } from '@/lib/money-schema'
import { t } from '@/lib/strings'
const s = t.ledger
/** Backend ile aynı: `services/entry.rs` → `MAX_DESCRIPTION` (Unicode karakter) */
export const MAX_DESCRIPTION = 200
/** Hızlı giriş formu. `start`–`last`: dönemin ilk ve son günü (dahil). */
export function entrySchema(start: string, last: string) {
return z.object({
amount: moneyText('positive'),
group: z.enum(['needs', 'wants', 'culture', 'unexpected'], { error: s.groupRequired }),
category_id: z.string().min(1, s.categoryRequired),
/** `''` → belirtilmedi */
payment_method_id: z.string(),
date: z
.string()
.refine(isIsoDate, s.dateInvalid)
.refine((d) => !isIsoDate(d) || (d >= start && d <= last), s.dateOutside),
description: z
.string()
.refine((v) => [...v.trim()].length <= MAX_DESCRIPTION, s.descriptionTooLong),
})
}
export type EntryForm = z.infer<ReturnType<typeof entrySchema>>
/** Sunucu hatası hangi alana ait? (mesajlar `services/entry.rs`'den, Türkçe) */
export function fieldForError(message: string): keyof EntryForm | 'root' {
if (message.startsWith('Tutar')) return 'amount'
if (message.startsWith('Tarih')) return 'date'
if (/kategori/i.test(message)) return 'category_id'
if (message.startsWith('Ödeme') || message.startsWith('Bu ödeme')) return 'payment_method_id'
if (message.startsWith('Açıklama')) return 'description'
return 'root'
}
@@ -0,0 +1,75 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { formatDayMonth, formatMoney } from '@/lib/format'
import { t } from '@/lib/strings'
import type { GroupBudget } from '@/types/GroupBudget'
import type { PeriodWeek } from '@/types/PeriodWeek'
const s = t.ledger
/** ◀ önceki | 2. hafta / 5 · 07.12 – 13.12 | sonraki ▶ (+ "Bu hafta", haftanın toplamı) */
export function WeekPicker({
weeks,
index,
todayIndex,
budget,
onChange,
}: {
weeks: readonly PeriodWeek[]
index: number
/** Bugünün haftası; bugün dönem dışındaysa `null` */
todayIndex: number | null
budget?: GroupBudget[]
onChange: (index: number) => void
}) {
const week = weeks[index - 1]
const spent = budget?.reduce((a, g) => a + (g.weeks[index - 1]?.spent ?? 0), 0)
const planned = budget?.reduce((a, g) => a + (g.weeks[index - 1]?.amount ?? 0), 0)
return (
<div className="rounded-2xl border bg-card p-2 sm:p-3">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
aria-label={s.prevWeek}
disabled={index <= 1}
onClick={() => onChange(index - 1)}
>
<ChevronLeft aria-hidden />
</Button>
<div className="min-w-0 flex-1 text-center" aria-live="polite">
<p className="font-semibold">{s.week(index, weeks.length)}</p>
<p className="text-sm text-muted-foreground">
{formatDayMonth(week.start_date)} – {formatDayMonth(week.last_date)}
</p>
</div>
<Button
variant="ghost"
size="icon"
aria-label={s.nextWeek}
disabled={index >= weeks.length}
onClick={() => onChange(index + 1)}
>
<ChevronRight aria-hidden />
</Button>
</div>
<div className="mt-1 flex flex-wrap items-center justify-between gap-2 border-t px-2 pt-2 text-sm">
{spent !== undefined && planned !== undefined ? (
<p className="text-muted-foreground">
{s.weekSpent}:{' '}
<span className="money font-medium text-foreground">{formatMoney(spent)}</span> /{' '}
{s.weekBudget} <span className="money">{formatMoney(planned)}</span>
</p>
) : (
<span />
)}
{todayIndex !== null && todayIndex !== index && (
<Button variant="outline" size="sm" onClick={() => onChange(todayIndex)}>
{s.thisWeek}
</Button>
)}
</div>
</div>
)
}