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:
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ 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
|
## 📌 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`).
|
- **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 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 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 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
@@ -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
|
- ✅ 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
|
- ✅ 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)
|
- ✅ 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
|
- ✅ 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
|
- 🔄 5. Canlı yedekleme (mongodump günlük, 14 gün) + geri yükleme denemesi
|
||||||
|
|||||||
@@ -7,26 +7,43 @@ import { FormError, FormField } from '@/components/form-field'
|
|||||||
import { MoneyInput } from '@/components/money-input'
|
import { MoneyInput } from '@/components/money-input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { useCreateEntry } from '@/lib/entries'
|
import { useCreateEntry, useUpdateEntry } from '@/lib/entries'
|
||||||
import { formatMoney } from '@/lib/format'
|
import { formatMoney } from '@/lib/format'
|
||||||
import { GROUPS, type GroupCode } from '@/lib/groups'
|
import { GROUPS, type GroupCode } from '@/lib/groups'
|
||||||
import { defaultEntryDate, loadLastChoice, saveLastChoice } from '@/lib/ledger'
|
import { defaultEntryDate, loadLastChoice, saveLastChoice } from '@/lib/ledger'
|
||||||
import { toKurus } from '@/lib/money-schema'
|
import { toKurus } from '@/lib/money-schema'
|
||||||
|
import { moneyToText } from '@/lib/plan-draft'
|
||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { CatalogItem } from '@/types/CatalogItem'
|
import type { CatalogItem } from '@/types/CatalogItem'
|
||||||
|
import type { Entry } from '@/types/Entry'
|
||||||
import type { Period } from '@/types/Period'
|
import type { Period } from '@/types/Period'
|
||||||
import type { PeriodWeek } from '@/types/PeriodWeek'
|
import type { PeriodWeek } from '@/types/PeriodWeek'
|
||||||
import { entrySchema, fieldForError, MAX_DESCRIPTION, type EntryForm } from './schema'
|
import { entrySchema, fieldForError, MAX_DESCRIPTION, type EntryForm } from './schema'
|
||||||
|
|
||||||
const s = t.ledger
|
const s = t.ledger
|
||||||
|
|
||||||
/** Formun ilk değerleri: son seçimler hâlâ aktifse onlar (bu cihazda hatırlanır) */
|
/** Formun ilk değerleri: düzenlemede kaydın kendisi, yoksa son seçimler (hâlâ aktifse) */
|
||||||
function initialValues(categories: CatalogItem[], methods: CatalogItem[], date: string): EntryForm {
|
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 last = loadLastChoice()
|
||||||
const group = last.group
|
const group = last.group
|
||||||
const cat = group && categories.find((c) => c.id === last.categoryByGroup[group])
|
const cat = group && categories.find((c) => c.is_active && c.id === last.categoryByGroup[group])
|
||||||
const pm = methods.find((m) => m.id === last.paymentMethodId)
|
const pm = methods.find((m) => m.is_active && m.id === last.paymentMethodId)
|
||||||
return {
|
return {
|
||||||
amount: '',
|
amount: '',
|
||||||
group: (cat ? group : '') as GroupCode,
|
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.
|
* 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).
|
* 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({
|
export function EntryForm({
|
||||||
period,
|
period,
|
||||||
@@ -47,18 +70,24 @@ export function EntryForm({
|
|||||||
today,
|
today,
|
||||||
categories,
|
categories,
|
||||||
methods,
|
methods,
|
||||||
|
entry,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
period: Period
|
period: Period
|
||||||
week: PeriodWeek
|
week: PeriodWeek
|
||||||
today: string
|
today: string
|
||||||
/** Yalnızca aktifler */
|
/** Pasifler dahil (eski kaydın değeri için); yeni seçimde yalnızca aktifler sunulur */
|
||||||
categories: CatalogItem[]
|
categories: CatalogItem[]
|
||||||
methods: CatalogItem[]
|
methods: CatalogItem[]
|
||||||
|
/** Düzenlenen kayıt */
|
||||||
|
entry?: Entry
|
||||||
/** Kaydedilen kaydın tarihi (o haftaya geçmek için) */
|
/** Kaydedilen kaydın tarihi (o haftaya geçmek için) */
|
||||||
onSaved: (date: string) => void
|
onSaved: (date: string) => void
|
||||||
|
onCancel?: () => void
|
||||||
}) {
|
}) {
|
||||||
const create = useCreateEntry(period.id)
|
const create = useCreateEntry(period.id)
|
||||||
|
const update = useUpdateEntry(period.id)
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
register,
|
register,
|
||||||
@@ -72,22 +101,31 @@ export function EntryForm({
|
|||||||
} = useForm<EntryForm>({
|
} = useForm<EntryForm>({
|
||||||
resolver: zodResolver(entrySchema(period.start_date, period.last_date)),
|
resolver: zodResolver(entrySchema(period.start_date, period.last_date)),
|
||||||
mode: 'onTouched',
|
mode: 'onTouched',
|
||||||
defaultValues: initialValues(categories, methods, defaultEntryDate(week, today)),
|
defaultValues: initialValues(categories, methods, defaultEntryDate(week, today), entry),
|
||||||
})
|
})
|
||||||
const group = useWatch({ control, name: 'group' })
|
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)
|
// Başka haftaya geçilince tarih o haftaya çekilir (seçili tarih zaten oradaysa dokunulmaz)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (entry) return
|
||||||
const d = getValues('date')
|
const d = getValues('date')
|
||||||
if (!(d >= week.start_date && d <= week.last_date)) {
|
if (!(d >= week.start_date && d <= week.last_date)) {
|
||||||
setValue('date', defaultEntryDate(week, today))
|
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) {
|
function pickGroup(code: GroupCode) {
|
||||||
if (code === getValues('group')) return
|
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])
|
const remembered = inGroup.find((c) => c.id === loadLastChoice().categoryByGroup[code])
|
||||||
setValue('group', code, { shouldValidate: true })
|
setValue('group', code, { shouldValidate: true })
|
||||||
setValue('category_id', remembered?.id ?? (inGroup.length === 1 ? inGroup[0].id : ''), {
|
setValue('category_id', remembered?.id ?? (inGroup.length === 1 ? inGroup[0].id : ''), {
|
||||||
@@ -97,6 +135,20 @@ export function EntryForm({
|
|||||||
|
|
||||||
const onSubmit = handleSubmit(async (v) => {
|
const onSubmit = handleSubmit(async (v) => {
|
||||||
try {
|
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({
|
const e = await create.mutateAsync({
|
||||||
date: v.date,
|
date: v.date,
|
||||||
amount: toKurus(v.amount),
|
amount: toKurus(v.amount),
|
||||||
@@ -122,7 +174,7 @@ export function EntryForm({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit} noValidate className="space-y-5">
|
<form onSubmit={onSubmit} noValidate className="@container space-y-5">
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="amount"
|
name="amount"
|
||||||
@@ -146,7 +198,7 @@ export function EntryForm({
|
|||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend className="mb-2 text-sm font-medium">{s.group}</legend>
|
<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) => {
|
{GROUPS.map((g) => {
|
||||||
const selected = group === g.code
|
const selected = group === g.code
|
||||||
return (
|
return (
|
||||||
@@ -156,13 +208,13 @@ export function EntryForm({
|
|||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
onClick={() => pickGroup(g.code)}
|
onClick={() => pickGroup(g.code)}
|
||||||
className={cn(
|
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
|
selected
|
||||||
? cn(g.className.soft, g.className.text, 'border-current')
|
? cn(g.className.soft, g.className.text, 'border-current')
|
||||||
: 'border-border hover:bg-muted',
|
: '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}
|
{g.emoji}
|
||||||
</span>
|
</span>
|
||||||
{g.label}
|
{g.label}
|
||||||
@@ -190,7 +242,7 @@ export function EntryForm({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{methods.length > 0 && (
|
{methodOptions.length > 0 && (
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="payment_method_id"
|
name="payment_method_id"
|
||||||
@@ -201,7 +253,7 @@ export function EntryForm({
|
|||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: s.noPaymentMethod },
|
{ 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}
|
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}>
|
<FormField label={s.date} error={errors.date}>
|
||||||
{(f) => (
|
{(f) => (
|
||||||
<Input
|
<Input
|
||||||
@@ -227,10 +279,17 @@ export function EntryForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormError message={errors.root?.message} />
|
<FormError message={errors.root?.message} />
|
||||||
<Button type="submit" size="lg" className="w-full sm:w-auto" disabled={isSubmitting}>
|
<div className="flex flex-col-reverse gap-2 sm:flex-row">
|
||||||
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
|
{entry && (
|
||||||
{isSubmitting ? s.saving : s.save}
|
<Button type="button" variant="outline" size="lg" onClick={onCancel}>
|
||||||
</Button>
|
{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>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router'
|
import { useSearchParams } from 'react-router'
|
||||||
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||||
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
||||||
import { Section } from '@/features/planning/plan-read'
|
import { Section } from '@/features/planning/plan-read'
|
||||||
import { useCategories, usePaymentMethods } from '@/lib/catalog'
|
import { useCategories, usePaymentMethods } from '@/lib/catalog'
|
||||||
|
import { useEntries } from '@/lib/entries'
|
||||||
import { toIsoDate } from '@/lib/format'
|
import { toIsoDate } from '@/lib/format'
|
||||||
import { parseWeekParam, weekIndexFor } from '@/lib/ledger'
|
import { parseWeekParam, weekIndexFor } from '@/lib/ledger'
|
||||||
import { useBudget, useCurrentPeriod } from '@/lib/periods'
|
import { useBudget, useCurrentPeriod } from '@/lib/periods'
|
||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
|
import type { Entry } from '@/types/Entry'
|
||||||
import type { Period } from '@/types/Period'
|
import type { Period } from '@/types/Period'
|
||||||
import { EntryForm } from './entry-form'
|
import { EntryForm } from './entry-form'
|
||||||
|
import { EntryList } from './entry-list'
|
||||||
import { WeekPicker } from './week-picker'
|
import { WeekPicker } from './week-picker'
|
||||||
|
import { WeekSummary } from './week-summary'
|
||||||
|
|
||||||
export function LedgerPage() {
|
export function LedgerPage() {
|
||||||
const current = useCurrentPeriod()
|
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 }) {
|
function LedgerView({ period }: { period: Period }) {
|
||||||
const [params, setParams] = useSearchParams()
|
const [params, setParams] = useSearchParams()
|
||||||
const [today] = useState(() => toIsoDate(new Date()))
|
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 inPeriod = today >= period.start_date && today <= period.last_date
|
||||||
const todayIndex = inPeriod ? weekIndexFor(period.weeks, today) : null
|
const todayIndex = inPeriod ? weekIndexFor(period.weeks, today) : null
|
||||||
const index =
|
const index =
|
||||||
@@ -41,38 +50,85 @@ function LedgerView({ period }: { period: Period }) {
|
|||||||
const week = period.weeks[index - 1]
|
const week = period.weeks[index - 1]
|
||||||
|
|
||||||
const budget = useBudget(period.id)
|
const budget = useBudget(period.id)
|
||||||
const categories = useCategories()
|
const categories = useCategories(true)
|
||||||
const methods = usePaymentMethods()
|
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 })
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-3xl space-y-4">
|
<div className="space-y-4">
|
||||||
<WeekPicker
|
<div className="mx-auto max-w-3xl lg:max-w-none">
|
||||||
weeks={period.weeks}
|
<WeekPicker
|
||||||
index={index}
|
weeks={period.weeks}
|
||||||
todayIndex={todayIndex}
|
index={index}
|
||||||
budget={budget.data}
|
todayIndex={todayIndex}
|
||||||
onChange={goTo}
|
budget={budget.data}
|
||||||
/>
|
onChange={goTo}
|
||||||
<Section title={t.ledger.formTitle}>
|
/>
|
||||||
{failed?.error ? (
|
</div>
|
||||||
<PageError message={failed.error.message} onRetry={failed.refetch} />
|
<div className="grid items-start gap-4 lg:grid-cols-2">
|
||||||
) : !categories.data || !methods.data ? (
|
<div ref={formRef} className="scroll-mt-4 lg:sticky lg:top-4">
|
||||||
<PageLoading />
|
<Section title={editing ? t.ledger.editTitle : t.ledger.formTitle}>
|
||||||
) : (
|
{catalogFailed?.error ? (
|
||||||
<EntryForm
|
<PageError message={catalogFailed.error.message} onRetry={catalogFailed.refetch} />
|
||||||
period={period}
|
) : !catalog ? (
|
||||||
week={week}
|
<PageLoading />
|
||||||
today={today}
|
) : (
|
||||||
categories={categories.data}
|
<EntryForm
|
||||||
methods={methods.data}
|
key={editing?.id ?? 'new'}
|
||||||
onSaved={(date) => goTo(weekIndexFor(period.weeks, date))}
|
period={period}
|
||||||
/>
|
week={week}
|
||||||
)}
|
today={today}
|
||||||
</Section>
|
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>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Kategoriler ve ödeme türleri (sunucu verisi). Yönetim ekranı Ayarlar'da gelecek;
|
* 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 { useQuery } from '@tanstack/react-query'
|
||||||
import type { CatalogItem } from '@/types/CatalogItem'
|
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 */
|
/** Kategori ve ödeme türleri seyrek değişir → 5 dk taze sayılır */
|
||||||
const STALE = 5 * 60_000
|
const STALE = 5 * 60_000
|
||||||
|
|
||||||
export function useCategories() {
|
export function useCategories(includeInactive = false) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.categories(),
|
queryKey: queryKeys.categories(includeInactive),
|
||||||
queryFn: () => api.get<CatalogItem[]>('/categories'),
|
queryFn: () => api.get<CatalogItem[]>(`/categories?include_inactive=${includeInactive}`),
|
||||||
staleTime: STALE,
|
staleTime: STALE,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePaymentMethods() {
|
export function usePaymentMethods(includeInactive = false) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.paymentMethods(),
|
queryKey: queryKeys.paymentMethods(includeInactive),
|
||||||
queryFn: () => api.get<CatalogItem[]>('/payment-methods'),
|
queryFn: () => api.get<CatalogItem[]>(`/payment-methods?include_inactive=${includeInactive}`),
|
||||||
staleTime: STALE,
|
staleTime: STALE,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import type { Entry } from '@/types/Entry'
|
import type { Entry } from '@/types/Entry'
|
||||||
import type { EntryCreateRequest } from '@/types/EntryCreateRequest'
|
import type { EntryCreateRequest } from '@/types/EntryCreateRequest'
|
||||||
|
import type { EntryUpdateRequest } from '@/types/EntryUpdateRequest'
|
||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import { queryKeys } from './query'
|
import { queryKeys } from './query'
|
||||||
|
|
||||||
@@ -28,3 +29,30 @@ export function useCreateEntry(periodId: string) {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
|
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) }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
formatWeekday,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
@@ -67,3 +68,11 @@ describe('parseMoney', () => {
|
|||||||
expect(parseMoney('19,99')).toBe(1999)
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -121,3 +121,11 @@ export function formatDayMonth(iso: string): string {
|
|||||||
const m = /^\d{4}-(\d{2})-(\d{2})$/.exec(iso)
|
const m = /^\d{4}-(\d{2})-(\d{2})$/.exec(iso)
|
||||||
return m ? `${m[2]}.${m[1]}` : 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)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { PeriodWeek } from '@/types/PeriodWeek'
|
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ı)
|
// Aralık 2026: 1–6 · 7–13 · 14–20 · 21–27 · 28–31 (backend testleriyle aynı)
|
||||||
const weeks: PeriodWeek[] = [
|
const weeks: PeriodWeek[] = [
|
||||||
@@ -33,4 +34,17 @@ describe('ledger', () => {
|
|||||||
expect(parseWeekParam('2a', 5)).toBeNull()
|
expect(parseWeekParam('2a', 5)).toBeNull()
|
||||||
expect(parseWeekParam(null, 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'],
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Tarihler `"YYYY-MM-DD"` metni → metin karşılaştırması tarih sırasıyla aynıdır.
|
* Tarihler `"YYYY-MM-DD"` metni → metin karşılaştırması tarih sırasıyla aynıdır.
|
||||||
*/
|
*/
|
||||||
import type { GroupCode } from './groups'
|
import type { GroupCode } from './groups'
|
||||||
|
import type { Entry } from '@/types/Entry'
|
||||||
import type { PeriodWeek } from '@/types/PeriodWeek'
|
import type { PeriodWeek } from '@/types/PeriodWeek'
|
||||||
|
|
||||||
/** Tarihin düştüğü hafta (1'den başlar); dönem dışındaysa en yakın uç hafta */
|
/** 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
|
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) ----------
|
// ---------- Son seçimler (bu cihazda hatırlanır) ----------
|
||||||
|
|
||||||
export type LastChoice = {
|
export type LastChoice = {
|
||||||
|
|||||||
@@ -274,6 +274,24 @@ export const t = {
|
|||||||
dateInvalid: 'Tarih geçersiz',
|
dateInvalid: 'Tarih geçersiz',
|
||||||
dateOutside: 'Tarih dönem içinde olmalı',
|
dateOutside: 'Tarih dönem içinde olmalı',
|
||||||
descriptionTooLong: 'Açıklama en fazla 200 karakter olabilir',
|
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: {
|
common: {
|
||||||
|
|||||||
Reference in New Issue
Block a user