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:
@@ -71,6 +71,7 @@ npm run icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonl
|
||||
- Geliştirmede tasarım önizlemesi: `/dev/tasarim`.
|
||||
- Formlar: react-hook-form + zod (`mode: 'onTouched'`), alanlar `components/form-field.tsx` (`FormField`, `PasswordInput`, `FormError`); sunucu hatası `setError` ile ilgili alana. Bildirim: `toast` (sonner, `components/toaster.tsx`).
|
||||
- Dönem verisi: `lib/periods.ts` (Query kancaları), tarih `lib/period-dates.ts` (arayüz son günü dahil gösterir, backend `end_date` hariç), tutar alanı `MoneyInput` + `moneyText`/`toKurus` (`lib/money-schema.ts`). İlk dönem sihirbazı `/plan/donem-ac?adim=`. Plan düzenleme: taslak → `diffPlan` → `useSavePlan`; haftalık önizleme `lib/budget-preview.ts` (backend hesabıyla aynı tutulmalı).
|
||||
- Defter: `/defter?hafta=N`, `features/ledger/` (hafta seçici, hızlı giriş), saf hesaplar `lib/ledger.ts`, kancalar `lib/entries.ts` + `lib/catalog.ts`. Kayıt değişince `queryKeys.period(id)` yenilenir (özet/bütçe `spent` dahil).
|
||||
- Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`.
|
||||
- API örnekleri: `backend/api.http`
|
||||
- Canlı: `Dockerfile` (tek imaj), `docs/DEPLOY.md` (ortam değişkenleri, Dokploy, MongoDB, yedek). `routes::app()` = API + `STATIC_DIR` varsa SPA (`routes/spa.rs`) + sıkıştırma + TraceLayer. Testler `routes::router()` kullanır.
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
# 🧠 Proje Hafızası
|
||||
|
||||
## ▶️ Sıradaki Adım
|
||||
**Faz 5 – Adım 3:** Frontend Defter (`/defter`): hafta seçici (◀ önceki | Bu hafta | sonraki ▶, dönem haftaları `GET /periods/{id}` → `weeks`), hızlı giriş formu (büyük tutar, 4 renkli grup düğmesi → o grubun aktif kategorileri, ödeme türü, tarih varsayılan bugün/dönem içi, açıklama), son kategori/ödeme türü localStorage'da hatırlanır. `lib/entries.ts` kancaları; kayıttan sonra `period(id)` önekiyle geçersiz kıl (özet + bütçe `spent` yenilenir).
|
||||
**Faz 5 – Adım 4:** Defter: haftanın girişleri gün gün (`useEntries(pid, {from,to})` hafta aralığıyla), satırda grup rengi + kategori + ödeme türü + açıklama + tutar; düzenle (aynı form, `version` ile, 409 → Türkçe uyarı) / sil → sonner toast'ında "↩️ Geri al" (`POST /entries/{id}/restore`). `lib/entries.ts`'e `useUpdateEntry`, `useDeleteEntry`, `useRestoreEntry`. Haftalık özet: grup bazlı bütçe / harcanan / kalan (`useBudget` → `weeks[i]`). Sonra Adım 5: canlı yedekleme.
|
||||
|
||||
## 📌 Alınan Kararlar
|
||||
- **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`).
|
||||
@@ -101,3 +101,4 @@
|
||||
### 2026-11-12 — Faz 5
|
||||
- **Adım 1:** Harcamalar backend: `models/entry.rs`, `services/entry.rs` (list + `from`/`to` filtresi, sıra `date` ↓ `_id` ↓; create/update/delete soft/`restore`), `routes/entry.rs` (5 uç nokta), `catalog::find`. Grup **kategoriden türetilir** (istemci göndermez). Pasif kategori/ödeme türü yeni seçimde reddedilir, kaydın mevcut değeri olarak kalabilir. Ödeme türü isteğe bağlı; düzenlemede `""` → kaldır (projedeki "boş metin = kaldır" kuralı; ts-rs `deserialize_with` desteklemediği için çift-Option yerine). `tests/entries.rs` (6) — `previous` önerisi artık gerçek kayıtla test edildi. Mutasyon: grup türetme bozulunca yakalandı; restore'da hane filtresi tek başına kaldırılınca `get_open` ikinci katman olarak korudu, ikisi birden kaldırılınca 2 test yakaladı. Backend 97 test.
|
||||
- **Adım 2:** Gerçekleşen harcama (saklanmaz, her istekte aggregation): `GET /summary` → `spent_total`, `remaining` (= harcanabilir − harcanan, negatifse aşılmış). `GET /budget` → grupta `spent` (dönem), haftada `spent`. `spent_by_group_and_date` (grup×gün toplanır, Rust'ta haftalara dağıtılır; tarih metin karşılaştırması). Silinen kayıt sayılmaz, kategori değişince harcama yeni gruba geçer. Test `actual_spending_in_summary_and_budget` (hafta sınırları 6/7 Aralık, dönem son günü, önceki dönem hariç, geri al). Mutasyon: hafta sınırı `<=`→`<` ve silinenleri sayma → ikisi de yakalandı. Backend 98 test.
|
||||
- **Adım 3:** Defter ekranı (`features/ledger/`): `WeekPicker` (◀ ▶, "Bu hafta", haftanın harcanan/bütçe toplamı), `EntryForm` (büyük `MoneyInput`, 4 renkli grup düğmesi, kategori/ödeme türü çipleri, tarih, açıklama; kayıttan sonra tutar+açıklama temizlenir, odak tutara döner), `schema.ts` (`entrySchema(start,last)`, `fieldForError`: sunucu mesajı → alan). Hafta adreste: `/defter?hafta=N` (bugünün haftasında parametre yok); başka haftaya tarihli kayıt → o haftaya geçer; hafta değişince tarih o haftaya çekilir. `lib/ledger.ts` (`weekIndexFor`, `defaultEntryDate`, `parseWeekParam`, son seçim localStorage `kakeibo.ledger.last`: grup + **her grubun son kategorisi** + ödeme türü; pasifleşen seçim düşer). `lib/catalog.ts` (`useCategories`, `usePaymentMethods`, 5 dk staleTime), `lib/entries.ts` (`useEntries`, `useCreateEntry`), `queryKeys.entries`. Frontend 79 test; e2e 31 kontrol (ayrı 8091 sunucu, DB silindi). Mobilde "Beklenmeyen" taşması düzeltildi.
|
||||
|
||||
+2
-2
@@ -56,6 +56,6 @@
|
||||
## Faz 5 Adımları (taslak)
|
||||
- ✅ 1. Backend: harcama kayıtları (ekle/düzenle/sil + geri al, hafta/dönem filtresi, kategori–grup tutarlılığı, yalnızca açık dönem), testler
|
||||
- ✅ 2. Backend: haftalık/aylık gerçekleşen (grup bazlı bütçe–harcanan–kalan), özet ve önerilere gerçekleşen verisi
|
||||
- 🔄 3. Frontend: Defter — hafta seçici + hızlı giriş formu (tutar, 4 grup düğmesi, kategori, ödeme türü, tarih, açıklama; son seçim hatırlanır)
|
||||
- ⏳ 4. Frontend: haftanın girişleri (gün gün), düzenle/sil + "Geri al", haftalık özet
|
||||
- ✅ 3. Frontend: Defter — hafta seçici + hızlı giriş formu (tutar, 4 grup düğmesi, kategori, ödeme türü, tarih, açıklama; son seçim hatırlanır)
|
||||
- 🔄 4. Frontend: haftanın girişleri (gün gün), düzenle/sil + "Geri al", haftalık özet
|
||||
- ⏳ 5. Canlı yedekleme (mongodump günlük, 14 gün) + geri yükleme denemesi
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Kategoriler ve ödeme türleri (sunucu verisi). Yönetim ekranı Ayarlar'da gelecek;
|
||||
* şimdilik Defter formu yalnızca **aktif** olanları okur.
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { CatalogItem } from '@/types/CatalogItem'
|
||||
import { api } from './api'
|
||||
import { queryKeys } from './query'
|
||||
|
||||
/** Kategori ve ödeme türleri seyrek değişir → 5 dk taze sayılır */
|
||||
const STALE = 5 * 60_000
|
||||
|
||||
export function useCategories() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.categories(),
|
||||
queryFn: () => api.get<CatalogItem[]>('/categories'),
|
||||
staleTime: STALE,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePaymentMethods() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.paymentMethods(),
|
||||
queryFn: () => api.get<CatalogItem[]>('/payment-methods'),
|
||||
staleTime: STALE,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Günlük defter: harcama kayıtları (sunucu verisi kancaları).
|
||||
*
|
||||
* Kayıt değişince dönemin tüm sorguları yenilenir (`queryKeys.period(id)` öneki):
|
||||
* liste, özet (`spent_total`, `remaining`) ve bütçe (`spent`) birlikte güncellenir.
|
||||
*/
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { Entry } from '@/types/Entry'
|
||||
import type { EntryCreateRequest } from '@/types/EntryCreateRequest'
|
||||
import { api } from './api'
|
||||
import { queryKeys } from './query'
|
||||
|
||||
/** Bir tarih aralığının kayıtları (ikisi de dahil); en yeni başta */
|
||||
export function useEntries(periodId: string, range: { from: string; to: string }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.entries(periodId, range),
|
||||
queryFn: () =>
|
||||
api.get<Entry[]>(`/periods/${periodId}/entries?from=${range.from}&to=${range.to}`),
|
||||
// hafta değiştirirken liste boşalıp titremesin
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateEntry(periodId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: EntryCreateRequest) => api.post<Entry>(`/periods/${periodId}/entries`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PeriodWeek } from '@/types/PeriodWeek'
|
||||
import { defaultEntryDate, parseWeekParam, weekIndexFor } from './ledger'
|
||||
|
||||
// Aralık 2026: 1–6 · 7–13 · 14–20 · 21–27 · 28–31 (backend testleriyle aynı)
|
||||
const weeks: PeriodWeek[] = [
|
||||
['2026-12-01', '2026-12-06'],
|
||||
['2026-12-07', '2026-12-13'],
|
||||
['2026-12-14', '2026-12-20'],
|
||||
['2026-12-21', '2026-12-27'],
|
||||
['2026-12-28', '2026-12-31'],
|
||||
].map(([start_date, last_date], i) => ({ index: i + 1, start_date, last_date, days: 0 }))
|
||||
|
||||
describe('ledger', () => {
|
||||
it('finds the week of a date, clamping outside the period', () => {
|
||||
expect(weekIndexFor(weeks, '2026-12-06')).toBe(1)
|
||||
expect(weekIndexFor(weeks, '2026-12-07')).toBe(2)
|
||||
expect(weekIndexFor(weeks, '2026-12-31')).toBe(5)
|
||||
expect(weekIndexFor(weeks, '2026-11-12')).toBe(1)
|
||||
expect(weekIndexFor(weeks, '2027-01-03')).toBe(5)
|
||||
})
|
||||
|
||||
it('defaults the entry date to today, or the nearest day of the week', () => {
|
||||
expect(defaultEntryDate(weeks[1], '2026-12-09')).toBe('2026-12-09')
|
||||
expect(defaultEntryDate(weeks[1], '2026-12-20')).toBe('2026-12-13') // geçmiş hafta → son gün
|
||||
expect(defaultEntryDate(weeks[1], '2026-12-02')).toBe('2026-12-07') // gelecek hafta → ilk gün
|
||||
})
|
||||
|
||||
it('accepts only valid week numbers from the address', () => {
|
||||
expect(parseWeekParam('3', 5)).toBe(3)
|
||||
expect(parseWeekParam('0', 5)).toBeNull()
|
||||
expect(parseWeekParam('6', 5)).toBeNull()
|
||||
expect(parseWeekParam('2a', 5)).toBeNull()
|
||||
expect(parseWeekParam(null, 5)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Defter ekranının saf hesapları (test edilebilir, React'ten bağımsız).
|
||||
* Tarihler `"YYYY-MM-DD"` metni → metin karşılaştırması tarih sırasıyla aynıdır.
|
||||
*/
|
||||
import type { GroupCode } from './groups'
|
||||
import type { PeriodWeek } from '@/types/PeriodWeek'
|
||||
|
||||
/** Tarihin düştüğü hafta (1'den başlar); dönem dışındaysa en yakın uç hafta */
|
||||
export function weekIndexFor(weeks: readonly PeriodWeek[], date: string): number {
|
||||
if (weeks.length === 0) return 1
|
||||
if (date < weeks[0].start_date) return weeks[0].index
|
||||
const w = weeks.find((w) => date >= w.start_date && date <= w.last_date)
|
||||
return w ? w.index : weeks[weeks.length - 1].index
|
||||
}
|
||||
|
||||
/** Formun varsayılan tarihi: bugün bu haftadaysa bugün; hafta geçmişse son günü, gelecekse ilk günü */
|
||||
export function defaultEntryDate(week: PeriodWeek, today: string): string {
|
||||
if (today < week.start_date) return week.start_date
|
||||
if (today > week.last_date) return week.last_date
|
||||
return today
|
||||
}
|
||||
|
||||
/** Adresteki `?hafta=` değeri → geçerli hafta numarası ya da `null` */
|
||||
export function parseWeekParam(value: string | null, count: number): number | null {
|
||||
if (!value || !/^\d+$/.test(value)) return null
|
||||
const n = Number(value)
|
||||
return n >= 1 && n <= count ? n : null
|
||||
}
|
||||
|
||||
// ---------- Son seçimler (bu cihazda hatırlanır) ----------
|
||||
|
||||
export type LastChoice = {
|
||||
group?: GroupCode
|
||||
/** Her grupta en son seçilen kategori */
|
||||
categoryByGroup: Partial<Record<GroupCode, string>>
|
||||
/** `''` → ödeme türü belirtilmedi */
|
||||
paymentMethodId?: string
|
||||
}
|
||||
|
||||
const KEY = 'kakeibo.ledger.last'
|
||||
const EMPTY: LastChoice = { categoryByGroup: {} }
|
||||
|
||||
/** Tarayıcı depolaması kapalı/özel pencere olabilir → hata yutulur, boş seçim döner */
|
||||
export function loadLastChoice(): LastChoice {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return EMPTY
|
||||
const v = JSON.parse(raw) as Partial<LastChoice>
|
||||
return { ...v, categoryByGroup: v.categoryByGroup ?? {} }
|
||||
} catch {
|
||||
return EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
export function saveLastChoice(choice: LastChoice) {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(choice))
|
||||
} catch {
|
||||
// depolama yoksa hatırlama olmaz; kayıt yine de çalışır
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export const queryKeys = {
|
||||
budget: (id: string) => ['period', id, 'budget'] as const,
|
||||
suggestion: (id: string, method: string) => ['period', id, 'suggestion', method] as const,
|
||||
notes: (id: string, context: string) => ['period', id, 'notes', context] as const,
|
||||
/** Harcamalar; `range` verilmezse tüm dönem (`entries(id)` → tüm aralıkların öneki) */
|
||||
entries: (id: string, range?: { from: string; to: string }) =>
|
||||
range ? (['period', id, 'entries', range] as const) : (['period', id, 'entries'] as const),
|
||||
}
|
||||
|
||||
/** 4xx hataları tekrar denemenin anlamı yok (yanlış istek / yetki); ağ ve 5xx için 2 kez dene. */
|
||||
|
||||
@@ -249,6 +249,33 @@ export const t = {
|
||||
spendable: 'Harcanabilir',
|
||||
},
|
||||
|
||||
ledger: {
|
||||
week: (n: number, count: number) => `${n}. hafta / ${count}`,
|
||||
prevWeek: 'Önceki hafta',
|
||||
nextWeek: 'Sonraki hafta',
|
||||
thisWeek: 'Bu hafta',
|
||||
weekSpent: 'Bu hafta harcanan',
|
||||
weekBudget: 'bütçe',
|
||||
formTitle: 'Harcama ekle',
|
||||
amount: 'Tutar',
|
||||
group: 'Ana grup',
|
||||
category: 'Kategori',
|
||||
noCategory: 'Bu grupta aktif kategori yok.',
|
||||
paymentMethod: 'Ödeme türü',
|
||||
noPaymentMethod: 'Belirtme',
|
||||
date: 'Tarih',
|
||||
description: 'Açıklama',
|
||||
descriptionHint: 'İsteğe bağlı (ör. "Haftalık market")',
|
||||
save: 'Kaydet',
|
||||
saving: 'Kaydediliyor…',
|
||||
saved: (amount: string, category: string) => `Eklendi: ${amount} · ${category}`,
|
||||
groupRequired: 'Ana grubu seçin',
|
||||
categoryRequired: 'Kategori seçin',
|
||||
dateInvalid: 'Tarih geçersiz',
|
||||
dateOutside: 'Tarih dönem içinde olmalı',
|
||||
descriptionTooLong: 'Açıklama en fazla 200 karakter olabilir',
|
||||
},
|
||||
|
||||
common: {
|
||||
loading: 'Yükleniyor…',
|
||||
retry: 'Tekrar dene',
|
||||
|
||||
Reference in New Issue
Block a user