feat(defter): haftanın girişleri, düzenle/sil + geri al, haftalık özet

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 11:29:33 +03:00
parent f930f2fbcc
commit 16535ad7f3
13 changed files with 472 additions and 62 deletions
+2 -1
View File
@@ -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.
+2 -2
View File
@@ -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
+80 -21
View File
@@ -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<EntryForm>({
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 (
<form onSubmit={onSubmit} noValidate className="space-y-5">
<form onSubmit={onSubmit} noValidate className="@container space-y-5">
<Controller
control={control}
name="amount"
@@ -146,7 +198,7 @@ export function EntryForm({
<fieldset>
<legend className="mb-2 text-sm font-medium">{s.group}</legend>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div className="grid grid-cols-2 gap-2 @lg:grid-cols-4">
{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',
)}
>
<span aria-hidden className="text-lg sm:text-xl">
<span aria-hidden className="text-lg @lg:text-xl">
{g.emoji}
</span>
{g.label}
@@ -190,7 +242,7 @@ export function EntryForm({
/>
)}
{methods.length > 0 && (
{methodOptions.length > 0 && (
<Controller
control={control}
name="payment_method_id"
@@ -201,7 +253,7 @@ export function EntryForm({
onChange={field.onChange}
options={[
{ value: '', label: s.noPaymentMethod },
...methods.map((m) => ({ 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({
/>
)}
<div className="grid gap-4 sm:grid-cols-[12rem_1fr]">
<div className="grid gap-4 @md:grid-cols-[12rem_1fr]">
<FormField label={s.date} error={errors.date}>
{(f) => (
<Input
@@ -227,10 +279,17 @@ export function EntryForm({
</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>
<div className="flex flex-col-reverse gap-2 sm:flex-row">
{entry && (
<Button type="button" variant="outline" size="lg" onClick={onCancel}>
{s.cancel}
</Button>
)}
<Button type="submit" size="lg" className="w-full sm:w-auto" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
{isSubmitting ? s.saving : entry ? s.update : s.save}
</Button>
</div>
</form>
)
}
+147
View File
@@ -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 <p className="text-sm text-muted-foreground">{s.empty}</p>
}
return (
<div className="space-y-4">
{groupByDay(entries).map((day) => (
<section key={day.date} aria-label={`${formatWeekday(day.date)} ${formatDate(day.date)}`}>
<div className="mb-1 flex items-baseline justify-between gap-2 border-b pb-1 text-sm">
<h4 className="font-medium">
{formatWeekday(day.date)}{' '}
<span className="text-muted-foreground">{formatDate(day.date)}</span>
</h4>
<span className="money text-muted-foreground" title={s.dayTotal}>
{formatMoney(day.total)}
</span>
</div>
<ul className="divide-y">
{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 (
<li
key={e.id}
className={cn(
'flex items-center gap-2 py-2',
editing && '-mx-2 rounded-lg bg-muted px-2',
)}
>
<span
aria-hidden
className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-full text-lg',
g.className.soft,
)}
>
{cat?.emoji ?? g.emoji}
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{name}
{editing && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
· {s.editing}
</span>
)}
</p>
<p className="truncate text-xs text-muted-foreground">
<span className={g.className.text}>{details[0]}</span>
{details.slice(1).map((d) => ` · ${d}`)}
</p>
</div>
<span className="money shrink-0 font-medium">{formatMoney(e.amount)}</span>
<div className="flex shrink-0">
<Button
variant="ghost"
size="icon"
aria-label={s.edit(name)}
aria-pressed={editing}
onClick={() => onEdit(e)}
>
<Pencil aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={s.remove(name)}
className="text-muted-foreground hover:text-expense"
disabled={del.isPending && del.variables === e.id}
onClick={() => remove(e, name)}
>
<Trash2 aria-hidden />
</Button>
</div>
</li>
)
})}
</ul>
</section>
))}
</div>
)
}
+86 -30
View File
@@ -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<Entry | null>(null)
const formRef = useRef<HTMLDivElement>(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 (
<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 className="space-y-4">
<div className="mx-auto max-w-3xl lg:max-w-none">
<WeekPicker
weeks={period.weeks}
index={index}
todayIndex={todayIndex}
budget={budget.data}
onChange={goTo}
/>
</div>
<div className="grid items-start gap-4 lg:grid-cols-2">
<div ref={formRef} className="scroll-mt-4 lg:sticky lg:top-4">
<Section title={editing ? t.ledger.editTitle : t.ledger.formTitle}>
{catalogFailed?.error ? (
<PageError message={catalogFailed.error.message} onRetry={catalogFailed.refetch} />
) : !catalog ? (
<PageLoading />
) : (
<EntryForm
key={editing?.id ?? 'new'}
period={period}
week={week}
today={today}
categories={catalog.c}
methods={catalog.m}
entry={editing ?? undefined}
onSaved={(date) => goTo(weekIndexFor(period.weeks, date))}
onCancel={() => setEditing(null)}
/>
)}
</Section>
</div>
<div className="space-y-4">
<Section title={t.ledger.summaryTitle}>
{budget.isError ? (
<PageError message={budget.error.message} onRetry={budget.refetch} />
) : budget.data ? (
<WeekSummary budget={budget.data} index={index} />
) : (
<PageLoading />
)}
</Section>
<Section title={t.ledger.listTitle}>
{entries.isError ? (
<PageError message={entries.error.message} onRetry={entries.refetch} />
) : entries.data && catalog ? (
<EntryList
periodId={period.id}
entries={entries.data}
categories={catalog.c}
methods={catalog.m}
editingId={editing?.id}
onEdit={edit}
onDeleted={(id) => editing?.id === id && setEditing(null)}
/>
) : (
<PageLoading />
)}
</Section>
</div>
</div>
</div>
)
}
@@ -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 (
<div className="@container">
<ul className="grid gap-3 @lg:grid-cols-2">
{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 (
<li key={b.group} className="rounded-xl border p-3">
<div className="flex flex-wrap items-center justify-between gap-x-2">
<span className={cn('font-medium whitespace-nowrap', g.className.text)}>
<span aria-hidden>{g.emoji}</span> {g.label}
</span>
<span className={cn('money text-sm font-semibold', over && 'text-expense')}>
{over ? `${s.over} ${formatMoney(-left)}` : `${s.left} ${formatMoney(left)}`}
</span>
</div>
<div
className="my-2 h-2 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-label={`${g.label}: ${s.spent}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(ratio * 100)}
>
<div
className={cn('h-full rounded-full', over ? 'bg-expense' : g.className.fill)}
style={{ width: `${ratio * 100}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{s.spent} <span className="money">{formatMoney(spent)}</span> / {s.budget}{' '}
<span className="money">{formatMoney(planned)}</span>
</p>
</li>
)
})}
</ul>
</div>
)
}
+7 -7
View File
@@ -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<CatalogItem[]>('/categories'),
queryKey: queryKeys.categories(includeInactive),
queryFn: () => api.get<CatalogItem[]>(`/categories?include_inactive=${includeInactive}`),
staleTime: STALE,
})
}
export function usePaymentMethods() {
export function usePaymentMethods(includeInactive = false) {
return useQuery({
queryKey: queryKeys.paymentMethods(),
queryFn: () => api.get<CatalogItem[]>('/payment-methods'),
queryKey: queryKeys.paymentMethods(includeInactive),
queryFn: () => api.get<CatalogItem[]>(`/payment-methods?include_inactive=${includeInactive}`),
staleTime: STALE,
})
}
+28
View File
@@ -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<Entry>(`/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<Entry>(`/entries/${id}/restore`),
onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
})
}
+9
View File
@@ -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')
})
})
+8
View File
@@ -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)))
}
+15 -1
View File
@@ -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'],
])
})
})
+15
View File
@@ -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<string, EntryDay>()
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 = {
+18
View File
@@ -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: {