From 16535ad7f3d7509429e5808a45b1fec056d1feef Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 12 Nov 2026 11:29:33 +0300 Subject: [PATCH] =?UTF-8?q?feat(defter):=20haftan=C4=B1n=20giri=C5=9Fleri,?= =?UTF-8?q?=20d=C3=BCzenle/sil=20+=20geri=20al,=20haftal=C4=B1k=20=C3=B6ze?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Girişler gün gün, gün toplamıyla; satırda kategori, grup, ödeme türü, açıklama - Düzenleme aynı formda (sürüm kontrolü, pasif kategori korunur) - Silmede "Geri al" bildirimi; grup bazlı bütçe / harcanan / kalan özeti Co-Authored-By: Claude Opus 5.5 --- docs/MEMORY.md | 3 +- docs/PHASES.md | 4 +- frontend/src/features/ledger/entry-form.tsx | 101 +++++++++--- frontend/src/features/ledger/entry-list.tsx | 147 ++++++++++++++++++ frontend/src/features/ledger/page.tsx | 116 ++++++++++---- frontend/src/features/ledger/week-summary.tsx | 55 +++++++ frontend/src/lib/catalog.ts | 14 +- frontend/src/lib/entries.ts | 28 ++++ frontend/src/lib/format.test.ts | 9 ++ frontend/src/lib/format.ts | 8 + frontend/src/lib/ledger.test.ts | 16 +- frontend/src/lib/ledger.ts | 15 ++ frontend/src/lib/strings.ts | 18 +++ 13 files changed, 472 insertions(+), 62 deletions(-) create mode 100644 frontend/src/features/ledger/entry-list.tsx create mode 100644 frontend/src/features/ledger/week-summary.tsx diff --git a/docs/MEMORY.md b/docs/MEMORY.md index e9db6c3..824f426 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**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. +**Faz 5 – Adım 5:** Canlı yedekleme (gerçek veri girişinden önce): Dokploy'da günlük `mongodump --archive --gzip`, 14 gün sakla (eskisini sil), yedeği sunucu dışına da kopyala; boş bir DB'ye `mongorestore` ile geri yükleme denemesi yap ve `docs/DEPLOY.md`'ye adımları yaz. `scripts/backup` yerel karşılığı. Bitince Faz 5 ✅ → Faz 6 (ay sonu değerlendirmesi + dönem kapatma). ## 📌 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`). @@ -102,3 +102,4 @@ - **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. +- **Adım 4:** Defter listesi ve özet: `entry-list.tsx` (gün gün `groupByDay`, gün adı `formatWeekday` = Intl tr-TR UTC; satır: kategori emojisi grup renginde, grup · ödeme · açıklama; düzenle/sil 44 px), sil → sonner `action` "↩️ Geri al" (8 sn) → `useRestoreEntry`. Düzenleme aynı `EntryForm` (`entry` + `key`, Güncelle/Vazgeç; `version` gönderilir, 409 Türkçe root hatası; `''` → ödeme/açıklama kaldır). Katalog **pasifler dahil** okunur (eski kayıtların adı; seçenekte yalnız aktifler + kaydın mevcut değeri). `week-summary.tsx` grup bazlı bütçe/harcanan/kalan + çubuk, aşımda "Aşıldı" kırmızı. Masaüstü iki sütun (form solda yapışkan); dar sütunda taşmaya karşı **container query** (`@container`, `@lg:grid-cols-4`). Frontend 81 test; e2e 29 kontrol (+ Adım 3'ün 31'i yeniden geçti). **Not:** e2e betikleri aynı DB'yi paylaşırsa birbirini bozar → her biri temiz DB ile. diff --git a/docs/PHASES.md b/docs/PHASES.md index 37ae02b..85d0d9f 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -57,5 +57,5 @@ - ✅ 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 -- ⏳ 5. Canlı yedekleme (mongodump günlük, 14 gün) + geri yükleme denemesi +- ✅ 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 index b450579..01338cd 100644 --- a/frontend/src/features/ledger/entry-form.tsx +++ b/frontend/src/features/ledger/entry-form.tsx @@ -7,26 +7,43 @@ 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 { useCreateEntry, useUpdateEntry } 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 { moneyToText } from '@/lib/plan-draft' import { t } from '@/lib/strings' import { cn } from '@/lib/utils' import type { CatalogItem } from '@/types/CatalogItem' +import type { Entry } from '@/types/Entry' 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 { +/** Formun ilk değerleri: düzenlemede kaydın kendisi, yoksa son seçimler (hâlâ aktifse) */ +function initialValues( + categories: CatalogItem[], + methods: CatalogItem[], + date: string, + entry?: Entry, +): EntryForm { + if (entry) { + return { + amount: moneyToText(entry.amount), + group: entry.group, + category_id: entry.category_id, + payment_method_id: entry.payment_method_id ?? '', + date: entry.date, + description: entry.description ?? '', + } + } 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) + const cat = group && categories.find((c) => c.is_active && c.id === last.categoryByGroup[group]) + const pm = methods.find((m) => m.is_active && m.id === last.paymentMethodId) return { amount: '', group: (cat ? group : '') as GroupCode, @@ -37,9 +54,15 @@ function initialValues(categories: CatalogItem[], methods: CatalogItem[], date: } } +/** Seçenek olarak sunulabilir mi: aktif ya da kaydın zaten kullandığı (pasifleşmiş olabilir) */ +const usable = (c: CatalogItem, current?: string | null) => c.is_active || c.id === current + /** * 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). + * + * `entry` verilirse düzenleme modu (Güncelle / Vazgeç). Başka bir kaydı düzenlemek için + * bileşen `key` ile yeniden oluşturulur. */ export function EntryForm({ period, @@ -47,18 +70,24 @@ export function EntryForm({ today, categories, methods, + entry, onSaved, + onCancel, }: { period: Period week: PeriodWeek today: string - /** Yalnızca aktifler */ + /** Pasifler dahil (eski kaydın değeri için); yeni seçimde yalnızca aktifler sunulur */ categories: CatalogItem[] methods: CatalogItem[] + /** Düzenlenen kayıt */ + entry?: Entry /** Kaydedilen kaydın tarihi (o haftaya geçmek için) */ onSaved: (date: string) => void + onCancel?: () => void }) { const create = useCreateEntry(period.id) + const update = useUpdateEntry(period.id) const { control, register, @@ -72,22 +101,31 @@ export function EntryForm({ } = useForm({ resolver: zodResolver(entrySchema(period.start_date, period.last_date)), mode: 'onTouched', - defaultValues: initialValues(categories, methods, defaultEntryDate(week, today)), + defaultValues: initialValues(categories, methods, defaultEntryDate(week, today), entry), }) const group = useWatch({ control, name: 'group' }) - const groupCategories = categories.filter((c) => c.group === group) + const groupCategories = categories.filter( + (c) => c.group === group && usable(c, entry?.category_id), + ) + const methodOptions = methods.filter((m) => usable(m, entry?.payment_method_id)) // Başka haftaya geçilince tarih o haftaya çekilir (seçili tarih zaten oradaysa dokunulmaz) useEffect(() => { + if (entry) return const d = getValues('date') if (!(d >= week.start_date && d <= week.last_date)) { setValue('date', defaultEntryDate(week, today)) } - }, [week, today, getValues, setValue]) + }, [entry, week, today, getValues, setValue]) + + // Düzenlemeye geçince form görünür olsun ve tutara odaklansın + useEffect(() => { + if (entry) setFocus('amount') + }, [entry, setFocus]) function pickGroup(code: GroupCode) { if (code === getValues('group')) return - const inGroup = categories.filter((c) => c.group === code) + const inGroup = categories.filter((c) => c.group === code && usable(c, entry?.category_id)) 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 : ''), { @@ -97,6 +135,20 @@ export function EntryForm({ const onSubmit = handleSubmit(async (v) => { try { + if (entry) { + const e = await update.mutateAsync({ + id: entry.id, + version: entry.version, + date: v.date, + amount: toKurus(v.amount), + category_id: v.category_id, + payment_method_id: v.payment_method_id, // '' → kaldır + description: v.description.trim(), // '' → kaldır + }) + toast.success(s.updated) + onSaved(e.date) + return + } const e = await create.mutateAsync({ date: v.date, amount: toKurus(v.amount), @@ -122,7 +174,7 @@ export function EntryForm({ }) return ( -
+ {s.group} -
+
{GROUPS.map((g) => { const selected = group === g.code return ( @@ -156,13 +208,13 @@ export function EntryForm({ 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', + '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 @lg:gap-2 @lg:px-3 @lg:text-base', selected ? cn(g.className.soft, g.className.text, 'border-current') : 'border-border hover:bg-muted', )} > - + {g.emoji} {g.label} @@ -190,7 +242,7 @@ export function EntryForm({ /> )} - {methods.length > 0 && ( + {methodOptions.length > 0 && ( ({ value: m.id, label: label(m) })), + ...methodOptions.map((m) => ({ value: m.id, label: label(m) })), ]} error={errors.payment_method_id?.message} /> @@ -209,7 +261,7 @@ export function EntryForm({ /> )} -
+
{(f) => ( - +
+ {entry && ( + + )} + +
) } diff --git a/frontend/src/features/ledger/entry-list.tsx b/frontend/src/features/ledger/entry-list.tsx new file mode 100644 index 0000000..07b3992 --- /dev/null +++ b/frontend/src/features/ledger/entry-list.tsx @@ -0,0 +1,147 @@ +import { Pencil, Trash2 } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { useDeleteEntry, useRestoreEntry } from '@/lib/entries' +import { formatDate, formatMoney, formatWeekday } from '@/lib/format' +import { group } from '@/lib/groups' +import { groupByDay } from '@/lib/ledger' +import { t } from '@/lib/strings' +import { cn } from '@/lib/utils' +import type { CatalogItem } from '@/types/CatalogItem' +import type { Entry } from '@/types/Entry' + +const s = t.ledger + +/** + * Haftanın girişleri, gün gün. Sil → bildirimde "Geri al" (sunucuda soft delete + restore). + * Kategori ve ödeme türü adları pasifler dahil listeden bulunur (eski kayıtlar da adıyla görünür). + */ +export function EntryList({ + periodId, + entries, + categories, + methods, + editingId, + onEdit, + onDeleted, +}: { + periodId: string + entries: Entry[] + categories: CatalogItem[] + methods: CatalogItem[] + editingId?: string + onEdit: (e: Entry) => void + onDeleted: (id: string) => void +}) { + const del = useDeleteEntry(periodId) + const restore = useRestoreEntry(periodId) + const catName = (id: string) => categories.find((c) => c.id === id) + const pmName = (id: string | null) => (id ? methods.find((m) => m.id === id)?.name : undefined) + + function remove(e: Entry, name: string) { + del.mutate(e.id, { + onSuccess: () => { + onDeleted(e.id) + toast(s.deleted, { + description: `${formatMoney(e.amount)} · ${name}`, + duration: 8000, + action: { + label: s.undo, + onClick: () => + restore.mutate(e.id, { + onSuccess: () => toast.success(s.restored), + onError: (err) => toast.error(err.message), + }), + }, + }) + }, + onError: (err) => toast.error(err.message), + }) + } + + if (entries.length === 0) { + return

{s.empty}

+ } + + return ( +
+ {groupByDay(entries).map((day) => ( +
+
+

+ {formatWeekday(day.date)}{' '} + {formatDate(day.date)} +

+ + {formatMoney(day.total)} + +
+
    + {day.entries.map((e) => { + const g = group(e.group) + const cat = catName(e.category_id) + const name = cat?.name ?? '—' + const editing = e.id === editingId + const details = [g.label, pmName(e.payment_method_id), e.description].filter(Boolean) + return ( +
  • + + {cat?.emoji ?? g.emoji} + +
    +

    + {name} + {editing && ( + + · {s.editing} + + )} +

    +

    + {details[0]} + {details.slice(1).map((d) => ` · ${d}`)} +

    +
    + {formatMoney(e.amount)} +
    + + +
    +
  • + ) + })} +
+
+ ))} +
+ ) +} diff --git a/frontend/src/features/ledger/page.tsx b/frontend/src/features/ledger/page.tsx index 481338d..cf44e46 100644 --- a/frontend/src/features/ledger/page.tsx +++ b/frontend/src/features/ledger/page.tsx @@ -1,16 +1,20 @@ -import { useState } from 'react' +import { useRef, 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 { useEntries } from '@/lib/entries' import { toIsoDate } from '@/lib/format' import { parseWeekParam, weekIndexFor } from '@/lib/ledger' import { useBudget, useCurrentPeriod } from '@/lib/periods' import { t } from '@/lib/strings' +import type { Entry } from '@/types/Entry' import type { Period } from '@/types/Period' import { EntryForm } from './entry-form' +import { EntryList } from './entry-list' import { WeekPicker } from './week-picker' +import { WeekSummary } from './week-summary' export function LedgerPage() { const current = useCurrentPeriod() @@ -30,10 +34,15 @@ export function LedgerPage() { ) } -/** Hafta adreste tutulur (`?hafta=2`): yenileyince / geri gelince aynı hafta açılır. */ +/** + * Hafta adreste tutulur (`?hafta=2`): yenileyince / geri gelince aynı hafta açılır. + * Düzen: hafta seçici → form → haftalık özet → girişler (masaüstünde form solda, diğerleri sağda). + */ function LedgerView({ period }: { period: Period }) { const [params, setParams] = useSearchParams() const [today] = useState(() => toIsoDate(new Date())) + const [editing, setEditing] = useState(null) + const formRef = useRef(null) const inPeriod = today >= period.start_date && today <= period.last_date const todayIndex = inPeriod ? weekIndexFor(period.weeks, today) : null const index = @@ -41,38 +50,85 @@ function LedgerView({ period }: { period: Period }) { const week = period.weeks[index - 1] const budget = useBudget(period.id) - const categories = useCategories() - const methods = usePaymentMethods() + const categories = useCategories(true) + const methods = usePaymentMethods(true) + const entries = useEntries(period.id, { from: week.start_date, to: week.last_date }) - const goTo = (n: number) => + const goTo = (n: number) => { + setEditing(null) setParams(n === todayIndex ? {} : { hafta: String(n) }, { replace: true }) + } + + function edit(e: Entry) { + setEditing(e) + formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + const catalogFailed = [categories, methods].find((q) => q.isError) + const catalog = categories.data && methods.data ? { c: categories.data, m: methods.data } : null - const failed = [categories, methods].find((q) => q.isError) return ( -
- -
- {failed?.error ? ( - - ) : !categories.data || !methods.data ? ( - - ) : ( - goTo(weekIndexFor(period.weeks, date))} - /> - )} -
+
+
+ +
+
+
+
+ {catalogFailed?.error ? ( + + ) : !catalog ? ( + + ) : ( + goTo(weekIndexFor(period.weeks, date))} + onCancel={() => setEditing(null)} + /> + )} +
+
+
+
+ {budget.isError ? ( + + ) : budget.data ? ( + + ) : ( + + )} +
+
+ {entries.isError ? ( + + ) : entries.data && catalog ? ( + editing?.id === id && setEditing(null)} + /> + ) : ( + + )} +
+
+
) } diff --git a/frontend/src/features/ledger/week-summary.tsx b/frontend/src/features/ledger/week-summary.tsx new file mode 100644 index 0000000..9522196 --- /dev/null +++ b/frontend/src/features/ledger/week-summary.tsx @@ -0,0 +1,55 @@ +import { formatMoney } from '@/lib/format' +import { group } from '@/lib/groups' +import { t } from '@/lib/strings' +import { cn } from '@/lib/utils' +import type { GroupBudget } from '@/types/GroupBudget' + +const s = t.ledger + +/** Seçili haftada grup bazlı bütçe / harcanan / kalan (+ ilerleme çubuğu) */ +export function WeekSummary({ budget, index }: { budget: GroupBudget[]; index: number }) { + return ( +
+
    + {budget.map((b) => { + const g = group(b.group) + const w = b.weeks[index - 1] + const planned = w?.amount ?? 0 + const spent = w?.spent ?? 0 + const left = planned - spent + const over = left < 0 + const ratio = planned > 0 ? Math.min(spent / planned, 1) : spent > 0 ? 1 : 0 + return ( +
  • +
    + + {g.emoji} {g.label} + + + {over ? `${s.over} ${formatMoney(-left)}` : `${s.left} ${formatMoney(left)}`} + +
    +
    +
    +
    +

    + {s.spent} {formatMoney(spent)} / {s.budget}{' '} + {formatMoney(planned)} +

    +
  • + ) + })} +
+
+ ) +} diff --git a/frontend/src/lib/catalog.ts b/frontend/src/lib/catalog.ts index 95ea9c7..0f8596e 100644 --- a/frontend/src/lib/catalog.ts +++ b/frontend/src/lib/catalog.ts @@ -1,6 +1,6 @@ /** * Kategoriler ve ödeme türleri (sunucu verisi). Yönetim ekranı Ayarlar'da gelecek; - * şimdilik Defter formu yalnızca **aktif** olanları okur. + * Defter pasifler dahil okur: eski kayıtların adı gösterilir, yeni seçimde yalnızca aktifler sunulur. */ import { useQuery } from '@tanstack/react-query' import type { CatalogItem } from '@/types/CatalogItem' @@ -10,18 +10,18 @@ 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() { +export function useCategories(includeInactive = false) { return useQuery({ - queryKey: queryKeys.categories(), - queryFn: () => api.get('/categories'), + queryKey: queryKeys.categories(includeInactive), + queryFn: () => api.get(`/categories?include_inactive=${includeInactive}`), staleTime: STALE, }) } -export function usePaymentMethods() { +export function usePaymentMethods(includeInactive = false) { return useQuery({ - queryKey: queryKeys.paymentMethods(), - queryFn: () => api.get('/payment-methods'), + queryKey: queryKeys.paymentMethods(includeInactive), + queryFn: () => api.get(`/payment-methods?include_inactive=${includeInactive}`), staleTime: STALE, }) } diff --git a/frontend/src/lib/entries.ts b/frontend/src/lib/entries.ts index e147dc2..8983187 100644 --- a/frontend/src/lib/entries.ts +++ b/frontend/src/lib/entries.ts @@ -7,6 +7,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { Entry } from '@/types/Entry' import type { EntryCreateRequest } from '@/types/EntryCreateRequest' +import type { EntryUpdateRequest } from '@/types/EntryUpdateRequest' import { api } from './api' import { queryKeys } from './query' @@ -28,3 +29,30 @@ export function useCreateEntry(periodId: string) { onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }), }) } + +/** Düzenle (`version` ile: başkası değiştirdiyse 409). Hata da olsa liste yenilenir. */ +export function useUpdateEntry(periodId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, ...body }: EntryUpdateRequest & { id: string }) => + api.patch(`/entries/${id}`, body), + onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }), + }) +} + +/** Sil (soft) → `useRestoreEntry` ile geri alınabilir */ +export function useDeleteEntry(periodId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`/entries/${id}`), + onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }), + }) +} + +export function useRestoreEntry(periodId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.post(`/entries/${id}/restore`), + onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }), + }) +} diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts index 5d8361f..1e04459 100644 --- a/frontend/src/lib/format.test.ts +++ b/frontend/src/lib/format.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + formatWeekday, formatDate, formatMoney, formatNumber, @@ -67,3 +68,11 @@ describe('parseMoney', () => { expect(parseMoney('19,99')).toBe(1999) }) }) + +describe('formatWeekday', () => { + it('Türkçe gün adı, saat diliminden bağımsız', () => { + expect(formatWeekday('2026-11-12')).toBe('Perşembe') + expect(formatWeekday('2026-11-01')).toBe('Pazar') + expect(formatWeekday('2026-11-02')).toBe('Pazartesi') + }) +}) diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index d6aaa5b..8ad7dce 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -121,3 +121,11 @@ export function formatDayMonth(iso: string): string { const m = /^\d{4}-(\d{2})-(\d{2})$/.exec(iso) return m ? `${m[2]}.${m[1]}` : iso } + +const weekdayFormat = new Intl.DateTimeFormat('tr-TR', { weekday: 'long', timeZone: 'UTC' }) + +/** `"2026-11-12"` → `Perşembe` (UTC üzerinden: saat dilimi günü kaydırmaz) */ +export function formatWeekday(iso: string): string { + const [y, m, d] = iso.split('-').map(Number) + return weekdayFormat.format(new Date(Date.UTC(y, m - 1, d))) +} diff --git a/frontend/src/lib/ledger.test.ts b/frontend/src/lib/ledger.test.ts index e6e6498..b05eecb 100644 --- a/frontend/src/lib/ledger.test.ts +++ b/frontend/src/lib/ledger.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { PeriodWeek } from '@/types/PeriodWeek' -import { defaultEntryDate, parseWeekParam, weekIndexFor } from './ledger' +import type { Entry } from '@/types/Entry' +import { defaultEntryDate, groupByDay, parseWeekParam, weekIndexFor } from './ledger' // Aralık 2026: 1–6 · 7–13 · 14–20 · 21–27 · 28–31 (backend testleriyle aynı) const weeks: PeriodWeek[] = [ @@ -33,4 +34,17 @@ describe('ledger', () => { expect(parseWeekParam('2a', 5)).toBeNull() expect(parseWeekParam(null, 5)).toBeNull() }) + + it('groups entries by day, newest day first', () => { + const e = (id: string, date: string, amount: number) => ({ id, date, amount }) as Entry + const days = groupByDay([ + e('a', '2026-12-09', 100), + e('b', '2026-12-07', 50), + e('c', '2026-12-09', 25), + ]) + expect(days.map((d) => [d.date, d.total, d.entries.map((x) => x.id).join('')])).toEqual([ + ['2026-12-09', 125, 'ac'], + ['2026-12-07', 50, 'b'], + ]) + }) }) diff --git a/frontend/src/lib/ledger.ts b/frontend/src/lib/ledger.ts index a31c3a2..87637fa 100644 --- a/frontend/src/lib/ledger.ts +++ b/frontend/src/lib/ledger.ts @@ -3,6 +3,7 @@ * Tarihler `"YYYY-MM-DD"` metni → metin karşılaştırması tarih sırasıyla aynıdır. */ import type { GroupCode } from './groups' +import type { Entry } from '@/types/Entry' import type { PeriodWeek } from '@/types/PeriodWeek' /** Tarihin düştüğü hafta (1'den başlar); dönem dışındaysa en yakın uç hafta */ @@ -27,6 +28,20 @@ export function parseWeekParam(value: string | null, count: number): number | nu return n >= 1 && n <= count ? n : null } +export type EntryDay = { date: string; total: number; entries: Entry[] } + +/** Kayıtları gün gün toplar; en yeni gün başta, gün içinde sunucu sırası korunur */ +export function groupByDay(entries: readonly Entry[]): EntryDay[] { + const days = new Map() + for (const e of entries) { + let day = days.get(e.date) + if (!day) days.set(e.date, (day = { date: e.date, total: 0, entries: [] })) + day.total += e.amount + day.entries.push(e) + } + return [...days.values()].sort((a, b) => b.date.localeCompare(a.date)) +} + // ---------- Son seçimler (bu cihazda hatırlanır) ---------- export type LastChoice = { diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index 4f93c4a..5963e11 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -274,6 +274,24 @@ export const t = { dateInvalid: 'Tarih geçersiz', dateOutside: 'Tarih dönem içinde olmalı', descriptionTooLong: 'Açıklama en fazla 200 karakter olabilir', + editTitle: 'Harcamayı düzenle', + update: 'Güncelle', + cancel: 'Vazgeç', + updated: 'Harcama güncellendi', + listTitle: 'Haftanın girişleri', + empty: 'Bu hafta henüz harcama yok.', + dayTotal: 'Gün toplamı', + edit: (what: string) => `${what} harcamasını düzenle`, + remove: (what: string) => `${what} harcamasını sil`, + deleted: 'Harcama silindi', + undo: '↩️ Geri al', + restored: 'Harcama geri alındı', + editing: 'Düzenleniyor', + summaryTitle: 'Haftalık özet', + budget: 'Bütçe', + spent: 'Harcanan', + left: 'Kalan', + over: 'Aşıldı', }, common: {