From f930f2fbcc05d4d72635f33f3eb280fe9323eb53 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 12 Nov 2026 11:20:18 +0300 Subject: [PATCH] =?UTF-8?q?feat(defter):=20hafta=20se=C3=A7ici=20ve=20h?= =?UTF-8?q?=C4=B1zl=C4=B1=20harcama=20giri=C5=9F=20formu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- CLAUDE.md | 1 + docs/MEMORY.md | 3 +- docs/PHASES.md | 4 +- frontend/src/features/ledger/entry-form.tsx | 284 +++++++++++++++++++ frontend/src/features/ledger/page.tsx | 71 ++++- frontend/src/features/ledger/schema.test.ts | 41 +++ frontend/src/features/ledger/schema.ts | 38 +++ frontend/src/features/ledger/week-picker.tsx | 75 +++++ frontend/src/lib/catalog.ts | 27 ++ frontend/src/lib/entries.ts | 30 ++ frontend/src/lib/ledger.test.ts | 36 +++ frontend/src/lib/ledger.ts | 61 ++++ frontend/src/lib/query.ts | 3 + frontend/src/lib/strings.ts | 27 ++ 14 files changed, 696 insertions(+), 5 deletions(-) create mode 100644 frontend/src/features/ledger/entry-form.tsx create mode 100644 frontend/src/features/ledger/schema.test.ts create mode 100644 frontend/src/features/ledger/schema.ts create mode 100644 frontend/src/features/ledger/week-picker.tsx create mode 100644 frontend/src/lib/catalog.ts create mode 100644 frontend/src/lib/entries.ts create mode 100644 frontend/src/lib/ledger.test.ts create mode 100644 frontend/src/lib/ledger.ts diff --git a/CLAUDE.md b/CLAUDE.md index a7d184e..db055aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 885fe9a..e9db6c3 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -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. diff --git a/docs/PHASES.md b/docs/PHASES.md index 64941a5..37ae02b 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -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 diff --git a/frontend/src/features/ledger/entry-form.tsx b/frontend/src/features/ledger/entry-form.tsx new file mode 100644 index 0000000..b450579 --- /dev/null +++ b/frontend/src/features/ledger/entry-form.tsx @@ -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({ + 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 ( +
+ ( + + {(f) => ( + + )} + + )} + /> + +
+ {s.group} +
+ {GROUPS.map((g) => { + const selected = group === g.code + return ( + + ) + })} +
+ {errors.group &&

{errors.group.message}

} +
+ + {group && ( + ( + ({ value: c.id, label: label(c) }))} + empty={s.noCategory} + error={errors.category_id?.message} + /> + )} + /> + )} + + {methods.length > 0 && ( + ( + ({ value: m.id, label: label(m) })), + ]} + error={errors.payment_method_id?.message} + /> + )} + /> + )} + +
+ + {(f) => ( + + )} + + + {(f) => } + +
+ + + + + ) +} + +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 ( +
+ {legend} + {options.length === 0 ? ( +

{empty}

+ ) : ( +
+ {options.map((o) => ( + + ))} +
+ )} + {error &&

{error}

} +
+ ) +} diff --git a/frontend/src/features/ledger/page.tsx b/frontend/src/features/ledger/page.tsx index 75111ce..481338d 100644 --- a/frontend/src/features/ledger/page.tsx +++ b/frontend/src/features/ledger/page.tsx @@ -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 ( <> - + {current.isPending ? ( + + ) : current.isError ? ( + + ) : current.data ? ( + + ) : ( + + )} ) } + +/** 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 ( +
+ +
+ {failed?.error ? ( + + ) : !categories.data || !methods.data ? ( + + ) : ( + goTo(weekIndexFor(period.weeks, date))} + /> + )} +
+
+ ) +} diff --git a/frontend/src/features/ledger/schema.test.ts b/frontend/src/features/ledger/schema.test.ts new file mode 100644 index 0000000..54a92de --- /dev/null +++ b/frontend/src/features/ledger/schema.test.ts @@ -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') + }) +}) diff --git a/frontend/src/features/ledger/schema.ts b/frontend/src/features/ledger/schema.ts new file mode 100644 index 0000000..ea13e6d --- /dev/null +++ b/frontend/src/features/ledger/schema.ts @@ -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> + +/** 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' +} diff --git a/frontend/src/features/ledger/week-picker.tsx b/frontend/src/features/ledger/week-picker.tsx new file mode 100644 index 0000000..80a37c0 --- /dev/null +++ b/frontend/src/features/ledger/week-picker.tsx @@ -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 ( +
+
+ +
+

{s.week(index, weeks.length)}

+

+ {formatDayMonth(week.start_date)} – {formatDayMonth(week.last_date)} +

+
+ +
+
+ {spent !== undefined && planned !== undefined ? ( +

+ {s.weekSpent}:{' '} + {formatMoney(spent)} /{' '} + {s.weekBudget} {formatMoney(planned)} +

+ ) : ( + + )} + {todayIndex !== null && todayIndex !== index && ( + + )} +
+
+ ) +} diff --git a/frontend/src/lib/catalog.ts b/frontend/src/lib/catalog.ts new file mode 100644 index 0000000..95ea9c7 --- /dev/null +++ b/frontend/src/lib/catalog.ts @@ -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('/categories'), + staleTime: STALE, + }) +} + +export function usePaymentMethods() { + return useQuery({ + queryKey: queryKeys.paymentMethods(), + queryFn: () => api.get('/payment-methods'), + staleTime: STALE, + }) +} diff --git a/frontend/src/lib/entries.ts b/frontend/src/lib/entries.ts new file mode 100644 index 0000000..e147dc2 --- /dev/null +++ b/frontend/src/lib/entries.ts @@ -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(`/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(`/periods/${periodId}/entries`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }), + }) +} diff --git a/frontend/src/lib/ledger.test.ts b/frontend/src/lib/ledger.test.ts new file mode 100644 index 0000000..e6e6498 --- /dev/null +++ b/frontend/src/lib/ledger.test.ts @@ -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() + }) +}) diff --git a/frontend/src/lib/ledger.ts b/frontend/src/lib/ledger.ts new file mode 100644 index 0000000..a31c3a2 --- /dev/null +++ b/frontend/src/lib/ledger.ts @@ -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> + /** `''` → ö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 + 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 + } +} diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts index 836628a..5058dd2 100644 --- a/frontend/src/lib/query.ts +++ b/frontend/src/lib/query.ts @@ -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. */ diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index 0d1fbc5..4f93c4a 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -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',