95 Commits

Author SHA1 Message Date
suphicakir 42561b570a feat: Faz 14b - cari, birikim, alışveriş, birim fiyat ve fire raporları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:53:39 +03:00
suphicakir 4b5def9452 chore(release): 0.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:53:02 +03:00
suphicakir c3c3c43bd1 feat(ui): cari, birikim, alışveriş, birim fiyat ve fire rapor sayfaları
- dönem seçici, tablolar, birim fiyat grafiği ve CSV indirme
- rapor ana sayfasında yeni kartlar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:53:02 +03:00
suphicakir 8623c5a02e feat(reports): cari, birikim, alışveriş, birim fiyat ve fire rapor servisleri
- mağaza adı eşleşmesi ve aylık birim fiyat özeti (saf, birim testli)
- tamamlanan listelerin plan/gerçekleşen özeti, mağaza bazlı harcama
- ürün tipi fiyat geçmişi yükleyicisi; fire oranı ve tahmini maliyet; testler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:53:02 +03:00
suphicakir d9a22551e1 feat: Faz 14a - dashboard ana grafiği ve finans raporları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:30:34 +03:00
suphicakir 0c421a2d34 docs(changelog): Faz 14a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:29:25 +03:00
suphicakir 29d7b29cfa feat(ui): dashboard ana grafiği ve rapor sayfaları
- dashboard: periyot seçici ve kümülatif/dönemsel görünüm, tablo; günlük plan kartı sadeleşti
- /raporlar özet ve kategori, trend, kişi/hesap, bütçe sayfaları; ay seçici ve CSV indirme

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:29:25 +03:00
suphicakir bc2a8a3bde feat(reports): rapor ve dashboard grafiği motorları
- periyot aralıkları (haftalık, aylık, altı aylık, yıllık) ve kümülatif dönüşüm
- kategori payı, alt kategori kırılımı, önceki aya göre değişim, tasarruf oranı
- kategori, trend, kişi/hesap ve bütçe raporu servisleri; gizli hesap kuralları; testler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:29:25 +03:00
suphicakir d77a2efcb8 refactor(budgets): bütçe, gelir planı ve günlük planı bağlam alan yardımcılara ayır
budgetFor, incomePlan, expensesByDay, incomeByDay ve monthDailyPlan raporlarda ve
dashboard grafiğinde birden çok ay için bağlamı yeniden yüklemeden kullanılır.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:29:25 +03:00
suphicakir 47396cba52 feat: Faz 13b - alışveriş sonrası giriş, karşılaştırma ve alışverişi tamamla
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:58:31 +03:00
suphicakir 6ec29aa030 chore(release): 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:57:50 +03:00
suphicakir c8678da7b5 feat(ui): alışveriş sonrası giriş ekranı ve karşılaştırma raporu
- /alisveris/:id/tamamla: alınan ürün/muadil, paket, tutar, mağaza başına hesap ve fiş, alınmayanlar
- tamamlanan listede planlanan vs. gerçekleşen raporu ve mağaza giderleri
- liste sayfası ve alışveriş modunda "Alışverişi tamamla"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:57:50 +03:00
suphicakir bd5a20ce64 feat(shopping): alışverişi tamamla transaction'ı ve karşılaştırma motoru
- tek transaction: stok alımı, fiş kaynaklı fiyat, mağaza başına bölünmüş gider, fiş ekleri, kalanların taşınması
- muadil, markasız ürün tipi ve plansız ekstra satırları; stok → gider kategorisi eşlemesi ve yedek kategori
- compareShopping saf fonksiyonu: aynı/muadil/alınmadı/ekstra, farkın nedenlere tam ayrışması
- tamamlanan listede rapor görüntüsü; testler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:57:50 +03:00
suphicakir 3dd9834a3d feat: Faz 13a - alışveriş listeleri, akıllı mağaza önerisi ve alışveriş modu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:29:29 +03:00
suphicakir 55f2269f82 docs(changelog): Faz 13a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:28:54 +03:00
suphicakir fa70168622 feat(ui): alışveriş listeleri, akıllı öneri paneli ve alışveriş modu
- /alisveris, /alisveris/:id ve /alisveris/:id/mod sayfaları; odak ve 30 sn yenileme
- stok önerileri paneli, kalem ekle/düzenle/taşı, mağazaya gruplu kontrol listesi
- dashboard Alışveriş kartı, kenar menü, ürün tipi formunda hacimli işareti

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:28:54 +03:00
suphicakir 8ab2244755 feat(shopping): alışveriş listeleri, stok eşiği önerileri ve akıllı mağaza önerisi motoru
- shoppingLists koleksiyonu (ürün tipi / marka / serbest metin kalemleri), migration 015
- planShopping saf fonksiyonu: paket yuvarlama, zahmet ve hacimli cezası, teslimat, yemek kartı önceliği, ek mağaza eşiği
- eşik önerileri onay/red (red stok yenilenene kadar), ürün tipine hacimli işareti
- fiyat servisinde ortak loadTypeOffers; seed listeleri ve testler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:28:54 +03:00
suphicakir 0b7de17b5c feat: Faz 12b - fiyat kayıtları ve birim fiyat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:27:51 +03:00
suphicakir d46869c644 chore(release): 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:27:51 +03:00
suphicakir 9e32f2988d feat(ui): ürün sayfası, fiyat geçmişi grafiği ve birim fiyat karşılaştırması
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:27:51 +03:00
suphicakir e67a1d5411 feat(prices): fiyat kayıtları, birim fiyat motoru ve analizde aylık maliyet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:27:51 +03:00
suphicakir 78503b1787 feat: Faz 12a - mağazalar ve ürünler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:48:04 +03:00
suphicakir d52f41ab0c feat(ui): mağazalar sayfası, ürün kataloğu ve ürün tipi detayında ürünler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:48:04 +03:00
suphicakir f0b570284e feat(shopping): mağazalar, zahmet skoru ve ürünler (marka/paket, dönüşüm, görseller)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:48:04 +03:00
suphicakir 8e7e374778 feat: Faz 11b - periyodik sayım, fire ayarlı öneri ve tüketim analizi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:58:18 +03:00
suphicakir 6cc698f70b chore(release): 0.11.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:58:12 +03:00
suphicakir 07ba531d31 feat(ui): sayım ekranı, tüketim analizi ve sayım hatırlatmaları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:58:12 +03:00
suphicakir 814bdbbc2a feat(stock): sayım oturumları, fire ayarlı öneri motoru ve tüketim analizi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:58:12 +03:00
suphicakir 5e0f1b5901 feat: Faz 11a - ev stoğu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:10:10 +03:00
suphicakir 868bb23fd9 feat(ui): ev stoğu sayfaları ve dashboard azalan stoklar kartı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:10:10 +03:00
suphicakir d9de29c18d feat(stock): stok kategorileri, ürün tipleri, hareketler ve FIFO SKT
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:10:10 +03:00
suphicakir abf2ea6dd7 feat: Faz 10b - gelir planı ve günlük plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 01:22:38 +03:00
suphicakir 0c5d959058 chore(release): 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 01:22:38 +03:00
suphicakir d8f442c1aa feat(ui): günlük plan kartı, gelir planı bölümü ve dashboard özet kartları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 01:22:38 +03:00
suphicakir 39aa19d168 feat(budget): gelir planı ve günlük plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 01:22:38 +03:00
suphicakir 21b3715d35 fix(budget): sabit gider ayrımı ve taban hızlı ay sonu tahmini
$ne ile eksik alan yolu her işlemi sabit sayıyordu; $ifNull ile düzeltildi.
Tahmin, bu ayın hızını önceki dönemin değişken hızıyla karıştırır.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 01:22:38 +03:00
suphicakir b5078d9251 feat: Faz 10a - aylık bütçe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:58:51 +03:00
suphicakir a274d29728 feat(seed): örnek bütçeler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:58:51 +03:00
suphicakir 62023ef35d feat(ui): bütçe sayfası, limit düzenleyici ve dashboard uyarısı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:58:51 +03:00
suphicakir 3fa1e949e2 feat(budget): aylık bütçe motoru, limitler ve ay sonu tahmini
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:58:51 +03:00
suphicakir 6de6d07c66 docs: Faz 9 kararları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:19:18 +03:00
suphicakir 660c6d5f2d feat: Faz 9b - dönemsel rapor ve net varlık
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:19:11 +03:00
suphicakir c53fe80241 chore(release): 0.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:19:05 +03:00
suphicakir b022c1c3b0 feat(ui): değer grafiği, net varlık kartı, dashboard kartları ve ödeme diyaloğunda varlık bağı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:19:05 +03:00
suphicakir 9a60085d14 feat(assets): dönemsel rapor, net varlık ve aynî kayıt bağlantısı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:19:05 +03:00
suphicakir 20e5a90df4 feat: Faz 9a - varlık lotları ve kâr/zarar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:17:44 +03:00
suphicakir b8306b3b5d feat(seed): örnek varlıklar ve hareketler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:17:44 +03:00
suphicakir a818c110cf feat(ui): birikim sayfası, varlık detayı ve hareket diyalogları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:17:44 +03:00
suphicakir 890ba9f521 feat(assets): varlık lotları, ortalama maliyet ve kâr/zarar servisi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:17:44 +03:00
suphicakir baec70d324 style: test dosyası biçimi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:37:57 +03:00
suphicakir 7a6414df7a feat: Faz 8b - aynî borç/alacak ve fiyat tablosu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:37:48 +03:00
suphicakir 571bf7690d chore(release): 0.8.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:37:48 +03:00
suphicakir 380c9b3647 feat(seed): örnek fiyat geçmişi ve aynî kayıtlar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:37:48 +03:00
suphicakir 3cd8d98324 feat(ui): aynî kayıt kartları, ödeme ve birikim diyalogları, fiyat tablosu sayfası
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:37:48 +03:00
suphicakir 9781cd9ed2 feat(in-kind): varlık türleri, fiyat tablosu, aynî borç/alacak, birikim planı ve ödeme akışı, 008 migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:37:48 +03:00
suphicakir 1062b2d96c feat: Faz 8a - cari hesaplar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:58:37 +03:00
suphicakir 015906392c chore: CHANGELOG (Faz 8a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:58:37 +03:00
suphicakir 5648eafd7c feat(seed): örnek cariler ve hareketleri
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:58:37 +03:00
suphicakir b6c6be5c97 feat(ui): cari hesaplar sayfaları, hareket formu ve vade uyarısı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:58:37 +03:00
suphicakir 5d370a062e feat(contacts): cari kişiler, hesap ↔ cari transferleri, cari ekstresi ve vade uyarısı, 007 migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:58:37 +03:00
suphicakir dfb83d6fd4 docs: Faz 8 kararlarını kaydet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:58:37 +03:00
suphicakir b18f918baf feat: Faz 7b - kart ve KMH dönemleri, taksitler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:19:17 +03:00
suphicakir c711eb8d28 chore(release): 0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:19:17 +03:00
suphicakir 6ae98a1cb4 fix(seed): çakışan transfer seedRef'leri; örnek taksitli alışverişler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:19:17 +03:00
suphicakir 0ad52757ad feat(ui): kart özeti, KMH paneli, taksit seçimi ve kalan taksit iptali
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:19:17 +03:00
suphicakir cc624af69f feat(cards): kart/KMH dönem hesabı, taksitli harcama, kart ve KMH özeti servisleri, 006 migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:19:17 +03:00
suphicakir 57067b0fd0 feat: Faz 7a - planlı ve düzenli işlemler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:55:05 +03:00
suphicakir 8c4771908c chore: CHANGELOG (Faz 7a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:55:05 +03:00
suphicakir 5bd2c372bf feat(seed): örnek düzenli işlemler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:55:05 +03:00
suphicakir cfc2b2c974 feat(ui): planlı işlemler sayfası, kural formu ve vadesi geçmiş uyarısı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:55:05 +03:00
suphicakir 04a17d2775 feat(recurring): düzenli işlem kuralları, planlı kayıt üretimi, onay/atla/ertele ve seri düzenleme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:55:05 +03:00
suphicakir eaafe5dd9b docs: Faz 7 kararlarını kaydet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:55:05 +03:00
suphicakir 2d3a9e3a68 feat: Faz 6c - ek dosyalar, carousel ve mutabakat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:55:11 +03:00
suphicakir 43053898b4 chore(release): 0.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:55:04 +03:00
suphicakir 8a9f5ddc7d feat(ui): ek ekleme alanı, carousel görüntüleyici, ekstre sayfası ve mutabakat penceresi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:55:04 +03:00
suphicakir 2e2e33233f feat(transactions): ek bağlama, hesap ekstresi ve mutabakat servisi, 004 migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:55:04 +03:00
suphicakir 2e1f4d7929 feat(attachments): depolama soyutlaması, içerikten tür tespiti, sharp/HEIC dönüşümü ve yetkili dosya servisi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:55:04 +03:00
suphicakir 99fc9dfea6 feat: Faz 6b - işlemler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:23:30 +03:00
suphicakir 00f8809823 chore: typecheck route tipleri, CHANGELOG (Faz 6b)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:23:30 +03:00
suphicakir 3acd71fd59 feat(seed): son 6 ayın örnek işlemleri ve 003 migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:23:30 +03:00
suphicakir dcfa2f8c8d feat(transactions): işlem formu, bölme, liste, filtreler, ayrıntı ve CSV
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:23:30 +03:00
suphicakir 1208e2d1eb feat(transactions): işlem servisi, bakiye hesaplama, satıcı kuralları ve izolasyon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:23:30 +03:00
suphicakir d18c8a0d76 feat(shared): işlem şeması, bakiye etkisi, etiket ve CSV yardımcıları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:23:30 +03:00
suphicakir a00c953dc0 feat: Faz 6a - para, hesaplar ve kategoriler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:57:58 +03:00
suphicakir 904a969b18 docs: CHANGELOG (Faz 6a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:57:58 +03:00
suphicakir 0558d92203 feat(seed): varsayılan kategoriler, örnek hesaplar ve 002 migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:57:58 +03:00
suphicakir d2aa9d8ae2 feat(ui): renk paleti, ikonlar, MoneyInput ve navigasyon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:57:58 +03:00
suphicakir 8dd5fd42e3 feat(accounts): hesaplar (KMH, kredi kartı, yemek kartı, yalnızca ben)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:57:58 +03:00
suphicakir 380ddaf303 feat(categories): kategori ağacı, varsayılan Türkçe set ve yönetim ekranı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:57:58 +03:00
suphicakir eaf80c6bb4 feat(shared): para, miktar ve İstanbul günü yardımcıları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:57:58 +03:00
suphicakir 7fda139199 feat: Faz 5 - haneler, roller ve veri izolasyonu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
suphicakir be9f2a5c3a docs: CHANGELOG, README ve PROMPT (0.5.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
suphicakir b79951601c feat(db): migration mekanizması ve ilk index migration'ı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
suphicakir 3644e081b1 feat(households): hane seçici, ayarlar, admin hane yönetimi ve hane-yok sayfası
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
suphicakir de91f2505a feat(households): hane rolleri, servis, DAL hane kapsamı ve denetim kaydı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
349 changed files with 53125 additions and 161 deletions
+351 -1
View File
@@ -6,6 +6,348 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
## [Yayınlanmamış] ## [Yayınlanmamış]
## [0.14.0] - 2026-11-07
Faz 14: dashboard ana grafiği ve raporlar (14a ve 14b alt fazları).
### Eklendi — Faz 14b: cari, birikim, alışveriş, birim fiyat ve fire raporları
- Cari ve aynî borçlar (`/raporlar/cari`): bakiyesi olan cariler (bana borçlu / ben borçluyum, vade durumu), açık aynî borç ve alacaklar kalan miktar ve güncel TL değeriyle; toplamlar.
- Birikim kâr/zarar (`/raporlar/birikim`): varlık başına miktar, maliyet, güncel değer, gerçekleşmemiş ve gerçekleşen K/Z; son 3/6/12 ay veya tümü için dönem özeti (başlangıç değeri, yatırılan, satıştan alınan, bitiş değeri, dönem kârı).
- Alışveriş (`/raporlar/alisveris`): tamamlanan listelerde planlanan / gerçekleşen / fark, eşleşme sayıları ve farkın nedenleri; mağaza bazlı harcama (alıcısı mağaza adıyla başlayan giderler, şubeler birlikte, "Alışverişi tamamla"dan gelen kısım ayrıca).
- Birim fiyat geçmişi (`/raporlar/birim-fiyat`): ürün tipi seçimi, son 12 bütçe ayında aylık en düşük ve ortalama birim fiyat (grafik ve tablo), mağazalarda dönemin en düşük birim fiyatı.
- Fire / zarar (`/raporlar/fire`): ürün tipi başına fire, alım, fire/alım oranı, nedenlere göre dağılım ve tahmini maliyet (fire × son bilinen birim fiyat).
- Hepsinde CSV dışa aktarma; rapor ana sayfasında yeni rapor kartları.
- Testler: mağaza adı eşleşmesi ve aylık birim fiyat özeti (birim); cari/aynî, birikim, alışveriş (tamamlanan liste dahil), birim fiyat ve fire raporları (entegrasyon).
### Eklendi — Faz 14a: dashboard ana grafiği ve finans raporları
- Dashboard ana çizgi grafiği: Planlanan gelir (yeşil), Planlanan gider / bütçe (mavi, kesikli), Gerçekleşen gider (kırmızı). Periyotlar: Haftalık (bu hafta, günlük), Aylık (bütçe ayı, günlük), Altı aylık ve Yıllık (bütçe ayları). Kümülatif / dönemsel görünüm; seçim URL'de (`?periyot=…&gorunum=…`). Bütçesi olmayan dönemde bütçe çizgisi boş, gelecek günlerde gerçekleşen çizilmez; grafiğin altında tablo görünümü.
- Raporlar (`/raporlar`): bu bütçe ayının özeti (gelir, gider, net, tasarruf oranı, en çok harcanan kategoriler) ve rapor sayfaları; her raporda ay seçici ve CSV dışa aktarma (Türkçe Excel uyumlu).
- Kategori dağılımı: halka grafik ve tablo; üst kategori toplamları ve alt kategori kırılımı, toplam gidere oran (%14,3 gibi), önceki aya göre tutar ve yüzde değişim. Dilim renkleri kategorinin kendi rengi; 7'den fazla kategori "Diğer"de toplanır.
- Aylık trend: son 12 bütçe ayı gelir ve gider sütunları, ayrı grafikte tasarruf oranı (devam eden ay grafikte gösterilmez), net ve 12 ay toplamı.
- Kişi ve hesap bazlı: gideri kaydeden üyeye göre harcama ve pay; hesap başına gelir, gider, gelen/giden transfer ve net değişim. Başka üyelerin "yalnızca ben" hesapları hesap tablosunda hiç görünmez.
- Bütçe ve gerçekleşen: kategori bazında limit, gerçekleşen, kalan ve kullanım; son 6 ayın limit/gerçekleşen karşılaştırması.
- Kurallar bütçeyle aynı: bütçe ayı, gerçekleşmiş işlemler, bölünmüş işlemler kalem bazında, taksit modu ve gizli hesap giderleri hane ayarına göre.
- Testler: periyot aralıkları ve kümülatif dönüşüm, kategori payı/değişim/tasarruf oranı ve alt kategori kırılımı (birim); rapor toplamlarının bütçeyle tutarlılığı, gizli hesap görünürlüğü, dashboard periyotları (entegrasyon).
### Değişti — Faz 14a
- Dashboard'daki günlük plan kartı istatistik ve tabloya indi; kümülatif ay grafiği ana grafiğin "Aylık" görünümüne taşındı.
- Bütçe, gelir planı ve günlük plan hesapları bağlam alan yardımcılara ayrıldı (raporlarda birden çok ay için yeniden kullanılır).
## [0.13.0] - 2026-11-06
Faz 13: alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu ve alışveriş sonrası giriş (13a ve 13b alt fazları).
### Eklendi — Faz 13b: alışveriş sonrası giriş, karşılaştırma ve "Alışverişi tamamla"
- Alışveriş sonrası giriş (`/alisveris/:id/tamamla`): liste kalemleri tek tek işaretlenir; her biri için alınan ürün (aynı ya da aynı ürün tipinden muadil; ürün tipi kaleminde marka belirtmeden de), mağaza, paket adedi ve ödenen tutar. Tutar, bilinen paket fiyatıyla ön doldurulur. Listede olmayan ekstra ürünler eklenebilir. Mağaza başına ödeme hesabı (yemek kartı geçen mağazada uygun yemek kartı önseçili) ve fiş görselleri.
- "Alışverişi tamamla" tek MongoDB transaction'ı: stok alım hareketleri, fiyat kayıtları (kaynak `receipt`; ürün × mağaza başına, paket fiyatı = tutar / paket), mağaza başına bölünmüş gider işlemi (kalemler stok kategorisinin eşlendiği gider kategorisine; eşlenmemiş ve serbest metin kalemleri seçilen yedek kategoriye), fiş görselleri işlemin eki, alınmayanların taşınması ve listenin kapanması. Herhangi bir adım başarısızsa hiçbir şey yazılmaz.
- Alınmayanlar: başka aktif listeye taşı, yeni liste aç ya da bırak.
- Karşılaştırma raporu (tamamlanan listede): planlanan (tamamlama anındaki akıllı öneri) vs. gerçekleşen tutar ve fark; farkın nedenleri (aynı üründe fiyat farkı, miktar farkı, muadil, alınmayanlar, plansız ekstralar, planda fiyatı olmayanlar; toplamları farka eşit); kalem bazında Aynı / Muadil / Alınmadı / Plansız ekstra; mağaza giderleri ve kullanılan hesaplar. Etiketler tamamlama anındaki hâliyle saklanır.
- Liste sayfasında ve alışveriş modunda "Alışverişi tamamla" düğmesi.
- Stok hareketleri ve işlemlerde `shoppingListId` (kaynağı izlemek için).
- Testler: eşleşme ve fark ayrıştırması (birim); tamamlama ekranı verisi, doğrulamalar (muadil tipi, gelecek tarih, izleyici), ek dosya hatasında geri alma, stok/fiyat/mağaza başına bölünmüş gider, markasız kalem, kalanların taşınması ve rapor (entegrasyon).
### Eklendi — Faz 13a: alışveriş listeleri, stok önerileri, akıllı mağaza önerisi, alışveriş modu
- Alışveriş listeleri (`shoppingLists`, `/alisveris`): birden çok aktif liste; kalem olarak ürün tipi (genel), belirli ürün (marka/paket) ya da serbest metin. Miktar ürün tipinde tipin biriminde, üründe paket adedi; boşsa en az bir paket. Aynı ürün listede iki kez olamaz; kalem düzenleme, çıkarma ve başka aktif listeye taşıma. Liste silme soft delete ve denetim kaydı. Aile üyeleri aynı listeyi kullanır: sayfa odaklanınca ve 30 saniyede bir yenilenir.
- Stok eşiği önerileri: eşiğin altındaki ürün tipleri hiçbir aktif listede yoksa liste sayfasında onayla/reddet önerisi olarak gelir (fire ayarlı önerilen miktar ve nedeni). Onaylanan önerilen miktarla eklenir ("Stok önerisi" rozeti), "Tümünü ekle" ile toplu. Reddedilen öneri, ürün için yeni alım girilene (stok yenilenene) kadar gösterilmez.
- Akıllı mağaza önerisi (saf fonksiyon, birim testli): son bilinen fiyatlarla her kalem için mağaza ve marka; ihtiyacı karşılayan paket sayısı × paket fiyatı. Fiziksel mağaza ziyareti ve hacimli kalemler için zahmet skoruna bağlı maliyet cezası, online teslimat ücreti ve ücretsiz teslimat alt limiti, yemek kartı önceliği (kartın geçtiği mağazada kartla ödenebilecek tutar avantajlı sayılır; bakiye mağazalar arasında bir kez harcanır). En az mağazalı eksiksiz plandan başlanır; ek mağaza ancak tasarruf hane ayarındaki eşiği aşarsa eklenir. Çıktı: "Tek mağaza en iyi seçenek: X" ya da "Önerilen dağılım: A (7 ürün), B (3 ürün): Y (Z tasarruf)"; önerilen ve tek mağaza dağılımları, fiyatı bilinmeyen kalemler.
- Alışveriş modu (`/alisveris/:id/mod`): plana göre mağazaya gruplanmış, büyük dokunma alanlı kontrol listesi; "sepete alındı" işareti anında görünür ve kaydedilir, ilerleme çubuğu; önerilen / tek mağaza planı arasında geçiş.
- Ürün tipine "Ağır / hacimli" işareti (akıllı öneride taşıma cezası).
- Dashboard'da "Alışveriş" kartı (aktif listeler ve bekleyen öneri sayısı); stok sayfasındaki "Alınması önerilenler" alışveriş listelerine bağlanır. Kenar menüde "Alışveriş".
- Migration `015-shopping-lists`. Seed: "Haftalık market" (9 kalem: tip, marka, serbest metin, fiyatı olmayan domates) ve "Ay sonu toptan" listeleri; deterjan, yumuşatıcı, çamaşır suyu ve tuvalet kağıdı hacimli.
- Testler: planlayıcı (paket yuvarlama, marka kısıtı, eşik, eksik kapsama, zahmet ve hacimli cezası, teslimat, yemek kartı), miktar ve öneri reddi kuralları (birim); liste/kalem işlemleri, yetki, öneri onay/red, plan çıktısı, silme ve denetim (entegrasyon).
### Değişti — Faz 13a
- Fiyat servisinde ürün tipi teklifleri ortak yükleyicide (`loadTypeOffers`); tüketim analizindeki son birim fiyat da bunu kullanır.
## [0.12.0] - 2026-11-06
Faz 12: mağazalar, ürünler, fiyatlar ve birim fiyat (12a ve 12b alt fazları).
### Eklendi — Faz 12b: fiyat kayıtları ve birim fiyat
- Fiyat kayıtları (`productPrices`): ürün × mağaza × tarih × paket fiyatı; kampanya/indirim işareti ve geçerlilik sonu; kaynak `manual` | `receipt` | `provider:<ad>` (şimdilik elle giriş). Gelecek tarihli fiyat ve arşivli ürün/mağazaya fiyat girilemez; kayıt silme denetim kaydına yazılır.
- Birim fiyat: her fiyat ürün tipinin birimine çevrilir (TL/kg, TL/lt, TL/adet, TL/yıkama; g ve ml gösterimde kg ve lt). Yuvarlama yalnızca gösterimde. Geçerli fiyat mağaza başına son kayıt; süresi biten kampanyada önceki normal fiyata dönülür ("Kampanya bitti, normal fiyat"), 90 günden eski fiyat işaretlenir.
- Ürün tipi detayında "Birim fiyat karşılaştırması": tüm marka/paket ve mağazalar birim fiyata göre sıralı, en uygun vurgulu, diğerleri "%X pahalı"; buradan fiyat girişi (birim fiyat anlık önizlemesiyle).
- Ürün sayfası (`/stok/urunler/:id`): görseller, mağazalardaki geçerli fiyatlar, mağaza başına fiyat geçmişi grafiği (basamaklı çizgi, bugüne uzanır) ve fiyat kayıtları listesi. Mağaza renkleri sabit kategorik paletten (mağazanın ekleniş sırası; açık/koyu tema için doğrulanmış `--series-1…8`).
- Tüketim analizinde aylık ortalama maliyet: aylık ortalama tüketim × ürün tipinin son bilinen birim fiyatı; fiyatı bilinen ürünlerin toplamı.
- Migration `014-product-prices`. Seed: 22 ürün için son 6 ayın 265 fiyat kaydı (mağaza farkları, aylık artış, süren ve biten kampanya, eski fiyat örneği).
- Testler: birim fiyat ve gösterim birimi, geçerli fiyat (biten kampanya, eski fiyat), sıralama ve eşitlik, renk yuvası (birim); karşılaştırma sırası, kampanya dönüşü, fiyat girişi kuralları, silme ve denetim, analiz maliyeti (entegrasyon).
### Değişti — Faz 12b
- Sayfa başlığı açıklaması bağlantı içerebilir; ek dosya seçicisine özel düğme metni ve ipucu.
### Eklendi — Faz 12a: mağazalar ve ürünler (marka/paket)
- Mağazalar (`stores`, `/magazalar`): ad, tür (fiziksel market / online platform / pazar), şube, eve uzaklık (km), taşıma güçlüğü (1–5), online ise teslimat ücreti ve ücretsiz teslimat alt limiti, yemek kartı geçerliliği ve geçerli sağlayıcılar, not. Zahmet skoru (0–100; uzaklık 20 km'ye kadar ve taşıma güçlüğü eşit ağırlıkla, online mağazada uzaklık sayılmaz) ve düzeyi; "ada göre / en yakın / en uzak / en zahmetli" sıralama. Aynı ad + şube tekil; arşivleme denetim kaydına yazılır. Kenar menüde "Mağazalar".
- Ürünler (`products`): ürün tipine bağlı marka, tanım, isteğe bağlı barkod (8–14 rakam, hanede tekil), paket miktarı ve birimi. Paket birimi ürün tipinin birimine kendiliğinden çevrilebiliyorsa (aynı birim, kg↔g, lt↔ml) dönüşüm otomatik; çevrilemiyorsa dönüşüm zorunlu ("5 kg = 33 yıkama", "1 paket = 50 tablet", "75 ml = 1 adet"). Ürün tipinin birimi sonradan değişirse "Dönüşüm gerekli" uyarısı.
- Ürün görselleri: birden çok görsel, mevcut ek dosya altyapısıyla (WebP'ye dönüştürme, küçük resim, yetkili okuma; hane üyeleri görür). Liste küçük resmi ve görüntüleyici.
- Ürün tipi detayında "Ürünler (marka / paket)" bölümü; `/stok/urunler` ürün kataloğu (ürün tipine göre gruplu, marka/tanım/barkodla arama, arşiv).
- Migration `013-stores-products` (index'ler). Seed: Çakır Ailesi için 8 mağaza ve 22 ürün.
- Testler: zahmet skoru, sıralama, uzaklık gösterimi, paket dönüşümü ve açıklaması (birim); mağaza şeması ve kuralları, ürün dönüşüm zorunluluğu, barkod tekilliği, görsel bağlama/sıralama/çıkarma ve okuma yetkisi, arşiv (entegrasyon).
## [0.11.0] - 2026-11-06
Faz 11: ev stoğu (11a ve 11b alt fazları).
### Eklendi — Faz 11b: periyodik sayım, fire ayarlı öneri, tüketim analizi
- Sayım oturumları (`stockCounts`): her sayımda ürün başına beklenen, sayılan ve fireye ayrılan miktar; fark sıfır olsa da kayıt tutulur ve oluşan hareketler oturuma bağlanır (`countId`). Ürün detayındaki tek ürün sayımı da oturum oluşturur.
- Periyodik sayım ekranı (`/stok/sayim?kategori=…`): kategori (alt ağacıyla) veya tüm stok; liste hâlinde beklenen → sayılan, anlık fark; eksiğin bir kısmı nedenle fire olarak işaretlenebilir (fire, eksikten fazla olamaz), kalan fark sayım düzeltmesi olur. "Yalnızca sayımı gelenler", "Beklenenle doldur", tek transaction'da kayıt.
- Sayım takvimi: sıklık ürün tipinden, yoksa stok kategorisinden (üstten devralınır); son sayımdan (hiç sayılmadıysa ilk hareketten) n gün sonra sayım gelir. `/stok`'ta "Sayımı gelenler" kartı (kategori, ürün sayısı, gecikme) ve dashboard hatırlatması.
- Önerilen alım miktarı motoru: hedef − mevcut; son 3 alımda fire varsa (fireler FIFO ile hangi alımdan düştüyse ona yazılır, ≥ %5) fire oranı kadar düşürülür; haftalık tüketim adımdan büyükse haftalık tüketimin katına, değilse adıma yuvarlanır; hedefi aşmaz. Gerekçe gösterilir ("Son 2 alımda %30 fire, miktar azaltıldı.", "Haftalık tüketime (0,3 kg) göre yuvarlandı.", "Mevcutla birlikte yaklaşık 46 gün yeter.").
- Tüketim hızı (son 90 gün; tüketim + sayımda çıkan eksik, fire hariç) ve periyodik sayım durumu ürün detayında.
- Tüketim analizi (`/stok/analiz`): ürün tipi başına son 6 ayın tüketim/fire çubukları, aylık ortalama tüketim ve fire, fire oranı (≥ %15 vurgulu), bu ayın tüketimi. Maliyet ürün fiyatlarıyla (Faz 12) eklenecek.
- Migration `012-stock-counts`: index'ler; Faz 11a'da oturumsuz kaydedilen sayım düzeltmeleri, beklenen miktar yeniden hesaplanarak sayım oturumlarına bağlanır.
- Seed: Çakır Ailesi'nde Mutfak 15, Genel Temizlik 30 günde bir sayılır; sayım oturumları ve ara sıra sayım farkı; Mutfak'ın sayımı gecikmiş.
- Testler: FIFO fire dağılımı, son alımların fire oranı, öneri (fire indirimi, tüketim hızına yuvarlama, hedef sınırı), sayım takvimi, aylık kullanım (birim); seed sayım takvimi, toplu sayım (fire + düzeltme, oturum, sıfır fark, yetki), tüketim analizi (entegrasyon).
### Değişti — Faz 11b
- Dashboard stok kartı "Stok uyarıları" oldu: azalan ürünler, SKT yaklaşan partiler ve sayımı gelen kategoriler.
- Seed'deki sayım farkları artık periyodik sayım oturumlarında oluşur.
### Eklendi — Faz 11a: ev stoğu
- Stok kategorileri (`stockCategories`): gider kategorilerinden ayrı, sınırsız derinlikte ağaç; varsayılan set (Mutfak › Manav, Şarküteri, Bakliyat; Genel Temizlik › Bulaşık, Çamaşır, Genel; Kişisel Temizlik › Şampuan, Tıraş, Kişisel Bakım) yeni hanede ve migration ile mevcut hanelere yüklenir. Her kategori bir gider kategorisine eşlenebilir (alt kategoriler üstten devralır; Faz 13'te alışveriş gideri için) ve sayım sıklığı tutar. Taşıma, arşiv (içinde aktif ürün yoksa) ve arşivden çıkarma; `/stok/kategoriler`.
- Ürün tipleri (`productTypes`): kategori, temel ölçü birimi (kg, lt, adet … veya özel birim: "yıkama", "tablet"), hedef stok, kritik eşik yüzdesi (varsayılan %20), saklama yeri, "1 azalt" miktarı, not; ilk kurulumda evdeki başlangıç miktarı.
- Stok hareketleri (`stockMovements`): alım (isteğe bağlı SKT), tüketim, fire/zarar (nedenli) ve sayım düzeltmesi (gerçek miktar girilir, fark işlenir). Mevcut miktar hareketlerden türetilir; stokta olandan fazla tüketim/fire reddedilir. Hareket silme denetim kaydına yazılır.
- SKT takibi FIFO partileriyle: çıkışlar en eski girişten düşer; evde kalan miktarın SKT'si 7 gün içinde dolan veya geçmiş partiler uyarılır.
- `/stok`: özet, "Alınması önerilenler" (eşiğin altındakiler ve hedefe tamamlayan miktar), SKT uyarıları, kategoriye göre ürün listesi (mevcut/hedef çubuğu ve eşik çizgisi, arama, "yalnızca azalanlar"), tek dokunuşla "1 azalt". `/stok/:id`: partiler ve hareket geçmişi.
- Dashboard "Azalan stoklar" kartı.
- Seed: Çakır Ailesi için 19 ürün tipi ve son 6 ayın haftalık alım/tüketim/fire/sayım simülasyonu.
- Testler: mevcut miktar, FIFO partileri, SKT durumu, eşik ve öneri (birim); görünüm, izolasyon, hareket kuralları, sayım, kategori kuralları (entegrasyon).
## [0.10.0] - 2026-11-06
Faz 10: aylık bütçe, gelir planı ve günlük plan (10a ve 10b alt fazları).
### Eklendi — Faz 10b: gelir planı ve günlük plan
- Gelir planı (`/butce` altında): gerçekleşen gelirler, henüz gerçekleşmemiş planlı (düzenli) gelirler ve elle eklenen beklenen gelirler (`budgets.incomeItems`, ör. prim); beklenen toplam. "Plana göre elde kalacak" (beklenen gelir − bütçe) ve "Bu tempoda elde kalacak" (beklenen gelir − ay sonu tahmini gider).
- Günlük plan (dashboard): ayın bütçesi günlere yayılır; tarihe bağlı giderler (düzenli işlemler, taksitler) kendi günlerine, kalan değişken bütçe günlere eşit veya hane ayarına göre önceki ayın günlük örüntüsüyle (kuruşu kuruşuna toplam korunur). "Bugün ayın %X'i geçti, bütçenin %Y'si harcandı", bugün için harcanabilir tutar (önceki günlerden kalan/aşan dahil), bugüne kadar planlanan ve harcanan, ay sonunda elde kalacak.
- Kümülatif planlanan / gerçekleşen gider grafiği, beklenen gelir referans çizgisi ve gün tablosu; sonraki ayın beklenen geliri ile bütçesi karşılaştırması.
- Dashboard özet kartları: "Bu ay gelir", "Bu ay gider" ve "Kalan" gerçek veriyle (bütçe ayına göre).
- Testler: dağıtım ve kuruş yuvarlama, örüntü uyarlama, günlük plan ve bugünkü pay, taban hızlı tahmin (birim); gelir planı, gizli hesap ayarı, elle kalemler, yetki, günlük plan tutarlılığı ve örüntü modu (entegrasyon).
### Değişti — Faz 10b
- Ay sonu tahmini: değişken giderin günlük hızı, bu ayın hızı ile önceki dönemin değişken harcama hızının karışımı (ay ilerledikçe bu ayın ağırlığı artar); ay başındaki tek bir büyük harcama artık tüm aya yayılmıyor. Geçmişi olmayan hanede yalnızca bu ayın hızı.
### Düzeltildi — Faz 10b
- Bütçede "sabit gider" (düzenli işlem / taksit) ayrımı her işlemi sabit sayıyordu (`installment` boşken alan yolu eksik değer verir ve `null`'a eşit sayılmaz); ay sonu tahmini değişken gideri hiç yansıtmıyordu. `$ifNull` ile düzeltildi, regresyon testi eklendi.
### Eklendi — Faz 10a: aylık bütçe
- Bütçe sayfası (`/butce?ay=YYYY-MM`): ay gezintisi, dönem sınırları hane ayarı `budgetMonthStartDay`'e göre (ör. 15'i → 15 Kasım – 14 Aralık).
- Kategori limitleri (`budgets`): her gider kategorisine limit; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır ve toplam bütçede iki kez sayılmaz. Limitsiz alt kategoriler "Üst kategori limitinde" olarak gösterilir; hiçbir limitin kapsamadığı harcama "Bütçesiz harcama" toplamında.
- Gerçekleşen / limit / kalan, yüzde çubuğu (ayın geçen kısmı işaretli), %80'de uyarı, %100'de "Limit doldu", aşımda "Limit aşıldı".
- Ay sonu tahmini: sabit giderler (düzenli işlem, taksit) olduğu gibi + kalan planlı giderler + değişken giderin günlük hızıyla ay sonuna yansıtılması; tahmin limiti aşıyorsa işaret.
- Bölünmüş işlemler kalem bazında; taksitler hane ayarına göre her biri kendi ayına (varsayılan) veya tamamı alış ayına. Başkasının "yalnızca ben" hesabından giderler hane ayarına göre yalnızca tutar olarak sayılır.
- "Önceki aydan kopyala" ve "Önceki ayın harcamalarından oluştur" (ana kategori bazında, 100 TL'ye yukarı yuvarlanır); mevcut limitler varsa onay istenir.
- Limit düzenleyici: tüm etkin gider kategorileri ağaç sırasıyla, anlık toplam bütçe.
- Dashboard: %80'i geçilen veya aşılan kategori sayısıyla uygulama içi bütçe uyarısı.
- Seed: Çakır Ailesi'nde önceki ve bu ay için harcamalardan oluşturulmuş bütçeler (bu ay Market limiti düşük, Elektrik alt limiti).
- `010` migration'ı: `budgets` index'i (hane × ay benzersiz).
- Testler: dönem sınırları, eşikler, tahmin, üst/alt limit toplama, bütçesiz harcama, sıra (birim); limitler, bölünmüş işlem, planlı, gizli hesap ayarı, taksit modları, başlangıç günü, kopyala/oluştur, yetki (entegrasyon).
## [0.9.0] - 2026-11-06
Faz 9: birikim ve varlık takibi, fiyat tablosu (elle), kâr/zarar, net varlık (9a ve 9b alt fazları).
### Eklendi — Faz 9b: dönemsel rapor ve net varlık
- Dönemsel rapor (`/birikim` toplam, `/birikim/:id` varlık bazında): 3 ay / 6 ay / 12 ay / tümü (`?donem=`); dönem başı değer, yatırılan TL, dönem sonu değer, satış geliri, gelen/çıkan hediye ve dönem kâr/zararı (TL ve %, dönemde bağlanan sermayeye oranla). Formül sayfada yazılıdır.
- Aylık değer grafiği (shadcn charts / Recharts): güncel değer (düz çizgi) ve maliyet (kesikli, basamaklı), lejant, ipucu ve tablo görünümü. Fiyatı olmayan varlık toplam rapora katılmaz ve belirtilir.
- Net varlık (`/birikim` üstünde): hesap bakiyeleri + birikim (alış fiyatı) + cari alacaklar + aynî alacaklar (alış) − kart/KMH borçları − cari borçlar − aynî borçlar (satış). Gizli varlık ve cariler yalnızca sahibinin net varlığına girer; gizli hesap bakiyeleri hane ayarına göre.
- Dashboard: "Toplam hesap bakiyesi", "Kart borçları" ve "Net varlık" kartları gerçek veriyle (gelir/gider kartları Faz 14).
- Aynî kayıt ↔ varlık bağlantısı: "Ödeme gerçekleşti → Aynı varlıkla" diyaloğunda isteğe bağlı kutucuk. Borç ödemesi seçilen varlıktan ortalama maliyetle düşer; alacak tahsilatı varlığa borç tarihindeki birim fiyatla eklenir. Ödeme ve varlık hareketi tek transaction'da; varlık yetersizse hiçbir şey yazılmaz. Bağlı hareket Birikim'den tek başına silinemez; aynî kayıt silinince bağ çözülür.
- Fiyat okuma yardımcıları ayrı modülde (`features/in-kind/prices.ts`).
- Testler: dönem noktaları, örnek senaryo ("10 gr'ı 25.000'e aldım, her ay ekledim"), başlangıç değeri, fiyat yokluğu, toplam (birim); dönem raporu, gizli varlık ve fiyatsız varlık, net varlık formülü ve değişmezleri (makas, cari borç), aynî borç/alacak bağlantısı, yetersiz varlıkta geri alma, tür uyuşmazlığı (entegrasyon).
### Değişti — Faz 9b
- "Aynı varlıkla" alacak tahsilatında anlamsız olan "varlığı almak için harcanan" tutar alanı kaldırıldı (yalnızca borç ödemesinde gösterilir).
- `recharts` bağımlılığı ve `components/ui/chart.tsx` eklendi.
- CHANGELOG sürüm karşılaştırma bağlantıları 0.6.0–0.9.0 için tamamlandı.
### Eklendi — Faz 9a: varlık lotları ve kâr/zarar
- Birikim sayfası (`/birikim`): altın, döviz ve ziynet varlıkları (`assets`); toplam güncel değer, toplam maliyet, gerçekleşmemiş ve gerçekleşmiş kâr/zarar; elde kalmayan varlıklar ayrı grupta. Fiyatı olmayan varlık toplam değere katılmaz ve belirtilir.
- Varlık hareketleri (`assetMovements`): alım, gelen hediye/miras, satış, hediye/çıkış; tarih, miktar, tutar veya birim fiyat (ikisi de boşsa fiyat tablosundan: alımda satış, satış ve hediyede alış fiyatı), hesap, saklama yeri, not. Geçmiş tarihli satış/çıkış o tarihteki miktarla sınırlıdır.
- Hesap bağlantısı: alım hesaptan varlığa, satış varlıktan hesaba transfer olarak kaydedilir (bakiye değişir, gelir/gider raporlarına girmez). İşlem listesinde varlık adıyla görünür; bağlı işlem tek başına düzenlenemez/silinemez, hareket silinince birlikte silinir.
- Hesap motoru (`shared/portfolio.ts`): ağırlıklı ortalama maliyet, satışta ortalama maliyetle gerçekleşmiş K/Z, çıkışta K/Z yazılmaz, gelen hediyenin maliyeti o günkü değer (yatırım sayılmaz); güncel değer alış fiyatıyla (Faz 0), gerçekleşmemiş K/Z TL ve %.
- `/birikim/:id`: özet (eldeki miktar, ortalama maliyet, maliyet, güncel değer, K/Z, yatırılan, satış geliri), hareket geçmişi (kalan miktar, satış K/Z'si), hareket silme (sonraki çıkışı eksiye düşürecekse reddedilir).
- "Yalnızca ben" varlık: varlık, hareketleri ve bağlı hesap işlemleri yalnızca sahibine görünür.
- Seed: aylık alımlı ve bir satışlı gram altın birikimi, düğün ziynetleri (hediye giriş ve çıkış), Emre'nin "yalnızca ben" dolar varlığı, Annemler'de fiyatsız bilezikler.
- `009` migration'ı: `assets`, `assetMovements` index'leri, işlemlerde `assetId`. Denetim kaydı: `asset.delete`, `asset.movement.delete`.
- Testler: ortalama maliyet, satış/çıkış, hediye, eksi miktar, oran biçimi (birim); alım/satış transferleri ve bakiye, fiyat tablosundan tutar, gerçekleşmiş K/Z, kilitli işlem, hareket silme, gizli varlık, izleyici ve başka hane (entegrasyon).
## [0.8.0] - 2026-11-05
Faz 8: cari hesaplar ve aynî borç/alacak (8a ve 8b alt fazları).
### Eklendi — Faz 8b: aynî borç/alacak ve fiyat tablosu
- Varlık türleri: gram altın, çeyrek, yarım, tam, Cumhuriyet altını, 22 ayar bilezik, gümüş, USD, EUR, GBP (birim: gr / adet / birim; miktarlar binde bir hassasiyetle).
- Fiyat tablosu (`/fiyatlar`, `assetPrices`): varlık × tarih × alış/satış, elle giriş; tek fiyat girilirse ikisine de yazılır; gün başına tek kayıt; son fiyatlar ve geçmiş. Faz 9 varlık takibi aynı tabloyu kullanacak.
- Aynî borç/alacak (`inKindDebts`), cari sayfasında: cari, yön (ben borçluyum / bana borçlu), varlık, miktar, borç tarihi; o tarihteki birim fiyat ve TL karşılığı not olarak saklanır (boşsa fiyat tablosundan). Değerleme: borç satış, alacak alış fiyatıyla.
- Borca bağlı birikim planı: birikim hesabı ve aylık tutar; "Birikim ekle" kaynak hesaptan birikim hesabına transfer oluşturur, borç kapanmaz. Kartta güncel değer, biriken, kalan ihtiyaç ve tahmini kapanış ayı.
- "Ödeme gerçekleşti": aynı varlıkla veya TL karşılığıyla; tarih, miktar/tutar, hesap. TL ödemede miktar boşsa güncel fiyattan hesaplanır; kısmi ödeme miktarı düşürür, kalanı aşan ödeme kalana sınırlanır, tam ödemede kayıt kapanır. Borç ödemesi hesaptan çıkış, alacak tahsilatı hesaba giriş işlemi olarak aynı transaction'da oluşturulur; denetim kaydı (`inkind.payment`, `inkind.close`).
- Cari listesinde açık aynî kayıtlar rozet olarak.
- Aynî borç hareketleri cari TL bakiyesine ve gelir/gider toplamlarına girmez; "yalnızca ben" carinin aynî kayıtları ve hareketleri başkasına görünmez (hesap ekstresinde ad ve açıklama gizlenir). Ödeme işlemi tek başına silinemez.
- Seed: son 6 ayın örnek altın/döviz fiyatları (gerçek piyasa verisi değildir), birikim planlı 5 gr altın borcu (iki katkıyla), USD alacağı, Annemler'de çeyrek altın alacağı.
- `008` migration'ı: `assetPrices`, `inKindDebts` index'leri, işlemlerde aynî borç alanları.
- Testler: değer/miktar karşılığı, değerleme fiyatı, ilerleme ve tahmini kapanış (birim); fiyat tablosu, tarih fiyatı, birikim katkısı, kısmi/tam ödeme, kapanış, alacak tahsilatı, cari bakiye ve gelir/gider ayrımı, gizli cari ve yetki (entegrasyon).
### Eklendi — Faz 8a: cari hesaplar
- Cari kişiler (`contacts`): ad, yakınlık, telefon, not, açılış bakiyesi (+ bana borçlu / − ben borçluyum), vade tarihi ve hatırlatma günü; arşivleme; aynı adda iki aktif cari engellenir (Türkçe normalize).
- "Yalnızca ben" cari: kişi ve hareketleri yalnızca sahibine görünür; ortak hesabın ekstresinde bu hareketler görünür ama kişi adı "Gizli cari" olarak maskelenir.
- Cari hareketleri: "Borç verdim", "Borç ödedim" (hesaptan cariye), "Borç aldım", "Tahsilat" (cariden hesaba). Hepsi transferdir: hesap bakiyesi değişir, gelir/gider toplamlarına girmez. Kısmi ödeme bakiyeyle doğal olarak desteklenir.
- `/cari`: bana borçlu / ben borçluyum / net özeti, cari listesi (bakiye, son hareket, vade rozeti), yeni cari, hareket ekle, düzenle, arşivle.
- `/cari/:id`: cari ekstresi (tarih sıralı hareketler ve yürüyen bakiye, açılış satırı); hareketi düzenleme ve geri alınabilir silme.
- Vade uyarısı (uygulama içi): vade geçtiyse veya hatırlatma penceresindeyse cari listesinde rozet ve dashboard'da uyarı.
- İşlem listesi ve ayrıntısında cari hareketleri kişi adı ve hareket türüyle; ayrıntıdan cariye bağlantı. CSV'de kategori sütununda "Cari: Ad (tür)".
- Seed: 5 örnek cari (vadesi yaklaşan alacak, kısmen ödenen borç, vadesi geçmiş alacak, "yalnızca ben" cari, Annemler hanesinde bir borç).
- `007` migration'ı: `contacts` index'leri, işlemlerde cari alanları. Denetim kaydı: cari arşivleme.
- Testler: yön/bakiye ve vade durumu (birim); defter, bakiye ve gelir/gider ayrımı, açılış bakiyesi, ekstre, tür değiştirme, vade uyarısı, çift ad, arşiv, yetki, başka hanenin hesabı ve gizli cari izolasyonu (entegrasyon).
### Değişti — Faz 8a
- İşlemlerde kaynak hesap (`accountId`) artık boş olabilir: cariden girişte para yalnızca hedef hesaba (`toAccountId`) girer. Bakiye, ekstre, kart ve KMH hesapları ek kural olmadan doğru çalışır.
## [0.7.0] - 2026-11-05
Faz 7: planlı/düzenli işlemler, kredi kartı ve KMH dönemleri, taksitler (7a ve 7b alt fazları).
### Eklendi — Faz 7b: kart ve KMH dönemleri, taksitler
- Dönem hesabı (`shared/card-periods.ts`): kesim günü dahil "Hesap Kesim Dönemi", sonrası "Güncel Dönem"; kesim günü ayda yoksa ayın son günü; kesim günü bitmeden dönem kapanmış sayılmaz. Son ödeme: kesimden N gün sonra veya ayın sabit günü.
- Kart özeti (`/hesaplar/:id`, kredi kartı): toplam borç, kullanılabilir limit (gelecek taksitler dahil düşülür) ve kullanım çubuğu; hesap kesim dönemi borcu, asgari ödeme, ödenen, kalan, son ödeme tarihi ve kalan gün; güncel dönem harcaması; dönem dönem yaklaşan taksitler; planlı harcamalar; son 6 ekstre.
- Asgari ödeme: limit 50.000 TL altı %20, üstü %40; kart bazında oran girilebilir (hesap formu).
- "Borcu öde": ödeme hesabından karta transfer formu kalan dönem borcuyla dolu açılır; ödemeler dönem borcundan düşer (kısmi ödeme desteklenir).
- Taksitli harcama: kredi kartı giderinde taksit sayısı (2–36). Her taksit ayrı işlem ("Hepsiburada 4/9"); toplam kuruş kaybı olmadan bölünür; vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler planlıdır ve bakiyeyi etkilemez. Ayrıntıdan "Kalan taksitleri iptal et".
- KMH özeti (KMH'li banka hesabı): limit, kullanılan, kullanılabilir; geçen ve bu dönem için gün sonu eksi bakiyelerden tahmini faiz; "Faiz kaydı ekle" kesim tarihli, "Banka › Faiz" kategorili gider formunu doldurur (vergiler hariç not).
- `statements` koleksiyonu: kart/KMH dönem kayıtları (borç, asgari, ödenen, harcama, tahmini faiz); kart özeti okunurken işlemlerden yeniden hesaplanır.
- İşlem formuna ön doldurma desteği (kart ödemesi, faiz kaydı).
- Seed: iki taksitli kart alışverişi.
- `006` migration'ı: `statements` index'leri, taksit planı index'i.
- Testler: dönem/son ödeme/asgari/taksit planı/KMH faizi (birim); kart özeti tutarlılığı (bağımsız bakiye hesabıyla), ödeme etkisi, taksit üretimi ve otomatik gerçekleşme, taksit iptali, taksit kuralları, KMH faiz tahmini ve yetki (entegrasyon).
### Düzeltildi — Faz 7b
- Seed: aynı aydaki satıcısız/kategorisiz transferler aynı `seedRef`'i aldığı için kart borcu ödemesi ve aylık birikim transferleri hiç oluşmuyordu; artık oluşuyor (mevcut kayıtlar kopyalanmaz). Kart ödemesi son ödeme gününden önceye alındı.
### Eklendi — Faz 7a: planlı ve düzenli işlemler
- Düzenli işlem kuralları (`recurringRules`): gelir, gider, transfer; sabit veya tahmini tutar; günlük/haftalık/aylık/yıllık, "her N birimde bir", ayın N. günü (ayda yoksa son gün) veya ayın son günü; bitiş yok, tekrar sayısı (ör. 12) veya bitiş tarihi. Formda Türkçe tekrar açıklaması ve ilk tarihlerin önizlemesi.
- Her tekrar için `planned` durumunda planlı kayıt; bitiş tarihsiz kurallarda 12 ay ileriye kadar. Ufuk sayfa okunurken ilerletilir (zamanlanmış görev gerekmez); üretim idempotent (`recurringRuleId + occurrenceIndex` benzersiz). Planlı kayıtlar bakiyeyi etkilemez.
- `/planli`: vadesi geçmiş, yaklaşan (60 gün) ve kurallar sekmeleri; "Onayla" (tutar ve tarih düzeltilebilir), "Ertele", "Atla"; seri pozisyonu ("3/12") ve "Tahmini" rozetleri.
- Seri düzenleme: "Yalnızca bu" (kayıt seriden ayrılır), "Bu ve sonrakiler" (seri bölünür), "Tümü" (onaylanmamış planlı kayıtlar yeniden üretilir; gerçekleşen, atlanan ve ayrılmış kayıtlar korunur). Silmede "bu ve sonrakiler" / "tüm seri"; gerçekleşmiş kayıtlar silinmez.
- Dashboard'da vadesi geçmiş planlı işlem uyarısı.
- Seed: 11 örnek düzenli işlem (maaş, kira, aidat, tahmini faturalar, abonelikler, emekli maaşı; vadesi geçmiş örnek olarak 12 taksitli spor salonu).
- `005` migration'ı: `recurringRules` index'leri, işlemlerde tekrar alanları. Denetim kaydı: `recurring.delete`.
- Testler: tekrar tarihi hesabı (ay sonu, artık yıl, aralık, sayı/bitiş/ufuk) ve form şeması (birim); üretim, onay/atla/ertele, seri düzenleme ve silme, bakiye etkisizliği, yetki ve gizli hesap izolasyonu (entegrasyon).
### Değişti — Faz 7a
- İşlem listesi varsayılan olarak planlı kayıtları göstermez (durum filtresiyle görülebilir); özet toplamları seçili duruma göre hesaplanır.
## [0.6.0] - 2026-11-05
Faz 6: hesaplar, kategoriler, işlemler, ek dosyalar ve mutabakat (6a, 6b, 6c alt fazları).
### Eklendi — Faz 6c: ek dosyalar, carousel, ekstre ve mutabakat
- Depolama soyutlaması `lib/storage/` (`put`, `get`, `delete`); yerel disk sürücüsü (`UPLOAD_DIR`), atomik yazma, rastgele anahtarlar, yol geçişine karşı anahtar doğrulaması.
- Ek dosya yükleme `POST /api/files`: tür dosya içeriğinden (imza) tespit edilir — JPEG, PNG, WebP, HEIC, PDF; uzantıya güvenilmez. Dosya başına `MAX_UPLOAD_MB` (10 MB), kayıt başına 10 dosya. Origin kontrolü.
- Görseller sharp ile EXIF yönüne göre döndürülür, en fazla 2000 px'e küçültülür, WebP'ye çevrilir; EXIF/GPS dahil tüm meta veri silinir; 320 px küçük resim üretilir. iPhone HEIC dosyaları libheif (WASM, `heic-decode`) ile çözülür. PDF'ler yapı kontrolünden sonra olduğu gibi saklanır.
- Yetkili servis `GET /api/files/:id` (`?v=thumb`, `&indir=1`): oturum + hane üyeliği; bekleyen ek yalnızca yükleyene; silinmiş işlemin veya başkasının gizli hesabındaki işlemin eki görünmez (404). Dosyalara özel sıkı CSP, `nosniff`, `private` önbellek.
- `attachments` koleksiyonu (`ownerType`, `ownerId`, tür, boyut, yükleyen, sıra). Ekler işlemle aynı MongoDB transaction'ında bağlanır; hata olursa işlem de oluşmaz. Gider: yalnızca görsel; gelir ve transfer: görsel veya PDF.
- İşlem formunda küçük "Ek ekle" alanı (çoklu seçim, yükleme göstergesi, küçük önizleme, kaldırma, sayaç); yüklemeler bitmeden kaydedilemez. Kopyada ekler taşınmaz.
- Görüntüleyici: shadcn Carousel (Embla) — oklar, kaydırma, klavye okları, tam ekran, "2 / 5" göstergesi, indirme; PDF'ler ikon + "Aç" düğmesiyle. Ayrıntıda en fazla 4 küçük resim + "+N".
- Temizlik `npm run cleanup`: 24 saatten eski bağlanmamış ekler, 30 günden önce kayıttan çıkarılmış ekler ve 30 günden önce silinmiş işlemlerin ekleri (dosyalarıyla) silinir.
- Hesap ekstresi `/hesaplar/:id?ay=YYYY-MM`: ay başı/ay sonu bakiyesi, giriş/çıkış, yürüyen bakiye, mutabakat işaretleri; işlem ayrıntısı ekstreden açılır. Başkasının gizli hesabına transfer ekstrede görünür ama karşı hesap adı gizlenir.
- Mutabakat: ekstre tarihi ve bakiyesi girilir, uygulama bakiyesiyle karşılaştırılır (kredi kartında borç pozitif girilir). Fark varsa onayla "Mutabakat düzeltmesi" işlemi oluşturulur; o tarihe kadarki işlemler hesap bazında işaretlenir; `reconciliations` kaydı ve denetim kaydı (`account.reconcile`) tek transaction'da.
- `004` migration'ı: `attachments` ve `reconciliations` index'leri; işlemlerde `reconciledAccountIds` ve `isAdjustment`.
- Testler: dosya türü tespiti, depolama, ekstre yardımcıları (birim); yükleme/dönüştürme (EXIF silme, yön, boyut, HEIC), bağlama kuralları, erişim izolasyonu, temizlik, ekstre tutarlılığı, mutabakat (entegrasyon).
### Değişti — Faz 6c
- Hesap adı artık hesap ekstresini açar (gizli hesaplarda bağlantı yok).
- Bakiyeyi etkileyen alanı (tutar, tarih, tür, hesap) değiştirilen işlemin mutabakat işareti kalkar. Mutabakat düzeltmesi düzenlenemez, yalnızca silinebilir.
- İşlemlerdeki kullanılmayan `reconciledAt` alanı, hesap bazında `reconciledAccountIds` ile değiştirildi.
### Düzeltildi
- `seed:reset` işlemleri ve satıcıları silmiyordu; artık işlemler, satıcılar, ekler (diskteki dosyalarıyla) ve mutabakatlar da temizlenir.
### Eklendi — Faz 6b: işlemler
- İşlemler (`/islemler`): gelir, gider ve transfer (hesap ↔ hesap). Tutar her zaman pozitif; işaret türden gelir. Hesap bakiyeleri artık açılış bakiyesi + gerçekleşmiş işlemlerden hesaplanır (tek doğruluk kaynağı: işlemler).
- Bölünmüş işlem: en az 2 kalem, kalemler toplamı = tutar (istemci ve sunucuda aynı zod şeması); kalan tutar göstergesi, "kalanı son kaleme ekle"; listede tek satır, ayrıntıda kalemler.
- Akıllı giriş: satıcı önerileri (Türkçe normalize arama, kullanım sıklığı) ve satıcının son kullanılan kategori/hesabı; işlemi kopyalama.
- Liste: güne göre gruplama ve günlük net, gelir/gider/net özetleri, sayfalama; filtreler (tarih aralığı İstanbul gününe göre, tür, durum, hesap — transfer hedefi dahil, kategori — alt kategoriler ve bölünmüş kalemler dahil, ekleyen kişi, tutar aralığı, etiket, metin arama); filtreler URL'de.
- İşlem ayrıntısı, düzenleme ve yumuşak silme; silme bildiriminde "Geri al".
- CSV dışa aktarma (`/islemler/disa-aktar`): listedeki filtrelerle, bölünmüş işlem kalem başına satır; Türkçe Excel uyumlu (noktalı virgül, ondalık virgül, UTF-8 BOM), formül enjeksiyonu koruması.
- Hızlı ekle her ekrandan doğrudan işlem formunu açar (gider/gelir/transfer sekmeleri).
- Gizlilik: başkasının "yalnızca ben" hesabına dokunan işlemler listede, ayrıntıda ve CSV'de görünmez; o hesaba yalnızca sahibi işlem girer.
- Seed: son 6 ayın 221 gerçekçi işlemi (faker.seed(42); maaş, kira, faturalar — kışın doğalgaz yüksek, market — bir kısmı bölünmüş, yakıt, kafe, kart ödemesi, birikim, nakit çekme), satıcı kuralları; idempotent (`seedRef`).
- `003` migration'ı: `transactions` ve `payees` index'leri. Denetim kaydı: işlem silme/geri alma.
- Testler: bakiye tutarlılığı (bağımsız doğrulama dahil), izolasyon, gizli hesap, arşivli kategori, filtreler, satıcı kuralları, CSV.
### Değişti
- `npm run typecheck` önce route tiplerini üretir (`next typegen`).
### Eklendi — Faz 6a: para, hesaplar ve kategoriler
- `shared/money.ts`: Türkçe tutar ayrıştırma (`"1.250,50"` → 125050 kuruş; binlik/ondalık kuralları belgeli), `formatMoneyInput`, `sumMoney`, kuruş kaybı olmadan oransal bölme (`allocate`, `splitEvenly`).
- `shared/quantity.ts`: miktarlar birimin binde biri tamsayı (`1,5 kg` → 1500); ayrıştırma, biçimlendirme, kg↔g / lt↔ml dönüşümü.
- `shared/dates.ts`: İstanbul günü yardımcıları (gün başlangıcı, UTC → yerel gün).
- Hesaplar (`/hesaplar`): nakit, vadesiz banka (KMH limiti, aylık faiz, kesim günü), birikim, kredi kartı (limit, kesim günü, son ödeme kuralı, ödeme hesabı), yemek kartı (sağlayıcı); hesap sahibi kişi ve "yalnızca ben" (bakiyeyi yalnızca hesap sahibi görür, toplamlar hane ayarına uyar); renk/ikon, sıralama, arşiv. Varlık / kart borcu / net özet.
- Kategoriler (`/kategoriler`): gelir ve gider ağaçları (5 seviyeye kadar), ekleme, düzenleme, taşıma (alt ağaçla, transaction), sıralama, arşivleme (alt ağaçla); kardeşler arasında ad tekrarı engeli (Türkçe normalize).
- Varsayılan Türkçe kategori seti (44 kategori): yeni hanede aynı transaction'da, mevcut hanelere `002` migration'ıyla, idempotent (`defaultKey`).
- Kategori ağacı saf fonksiyonları (`buildTree`, `toOptions`, `checkMove`, `rollupTotals` …) ve birim testleri.
- Renk paleti (13 renk, açık/koyu tema değişkenleri) ve ikon kayıt tablosu; ikon/renk seçiciler, `MoneyInput`.
- Seed: varsayılan kategoriler ve 7 örnek hesap (KMH'li banka, kredi kartı, birikim, "yalnızca ben" yemek kartı).
- Denetim kaydı: hesap oluşturma/düzenleme/arşiv, kategori taşıma/arşiv.
- Entegrasyon testleri: kategori ve hesap izolasyonu, roller, gizli bakiye, kart kuralları, ağaç kuralları.
## [0.5.0] - 2026-11-05
Faz 5: Haneler, hane içi roller ve veri izolasyonu.
### Eklendi
- Hane içi roller (`viewer < member < owner`) ve kuralları: son sahip düşürülemez veya çıkarılamaz.
- DAL: `getHouseholdContext` (istek başına tek okuma), `requireActiveHousehold(minRole)`, `getHouseholdActionContext(minRole)`, `requireHouseholdMember(householdId, minRole)`. Sistem admini için hane verisinde ayrıcalık yok.
- Hane servisi (`features/households/service.ts`, framework'ten bağımsız): her hane kapsamlı fonksiyon üyeliği kendisi de doğrular; hane oluşturma + ilk sahip + denetim kaydı tek MongoDB transaction'ında.
- Aktif hane seçici (üst bar): seçim sunucuda doğrulanır, `butcem.hh` cookie'sine yazılır; geçersiz/uydurma cookie yok sayılır. Tercih yoksa en yüksek rolün olduğu hane açılır.
- `/ayarlar`: hane adı, bütçe ayı başlangıç günü, taksit modu, günlük plan dağıtımı, "yalnızca ben" hesaplarının toplamlara dahil edilmesi, ek mağaza tasarruf eşiği; üye rolleri ve üye çıkarma (yalnızca sahip; diğer roller salt okunur).
- `/admin/haneler`: hane oluşturma (ilk sahip seçerek), kullanıcıyı haneye ekleme, hane içi rol değiştirme, üye çıkarma.
- `/hane-yok`: hiçbir haneye üye olmayan kullanıcılar için yönlendirme sayfası.
- Denetim kaydı (`auditLogs`): hane ayarları, üye ekleme/rol/çıkarma, hane oluşturma ve tüm admin kullanıcı işlemleri (oluşturma, düzenleme, rol, şifre sıfırlama, askı, silme).
- Migration mekanizması: `npm run migrate`, `scripts/migrations/NNN-*.ts`, uygulananlar `migrations` koleksiyonunda; `001-initial-indexes`. Seed önce migration'ları çalıştırır.
- Veri izolasyonu entegrasyon testleri (başka hanenin üyeliği/ayrıntısı/ayarları, rol yetersizliği, uydurma ve enjeksiyon denemesi kimlikler, hanesi olmayan admin, çapraz hane üyelik değişikliği, son sahip, transaction).
### Değişti
- İzleyici (viewer) rolünde Hızlı ekle gizlenir/pasifleşir.
- Kullanıcı silindiğinde tüm hane üyelikleri de temizlenir.
### Kaldırıldı
- Hane mock verisi.
## [0.4.0] - 2026-11-05 ## [0.4.0] - 2026-11-05
Faz 4: Örnek test verisi. Faz 4: Örnek test verisi.
@@ -94,7 +436,15 @@ Faz 1: Framework, klasör yapısı ve altyapı.
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri. - `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`. - `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...HEAD [Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.12.0...HEAD
[0.12.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.11.0...v0.12.0
[0.11.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.10.0...v0.11.0
[0.10.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.9.0...v0.10.0
[0.9.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.8.0...v0.9.0
[0.8.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.7.0...v0.8.0
[0.7.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.6.0...v0.7.0
[0.6.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.5.0...v0.6.0
[0.5.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...v0.5.0
[0.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0 [0.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0
[0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0 [0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0
[0.2.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.1.0...v0.2.0 [0.2.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.1.0...v0.2.0
+19 -10
View File
@@ -52,16 +52,25 @@ Giriş kullanıcı adı **veya** e-posta ile yapılır. Kayıt kapalıdır; kull
## Scriptler ## Scriptler
| Komut | Açıklama | | Komut | Açıklama |
| ----------------------------- | -------------------------------- | | ----------------------------- | --------------------------------- |
| `npm run dev` | Geliştirme sunucusu | | `npm run dev` | Geliştirme sunucusu |
| `npm run build` / `start` | Üretim build'i / sunucusu | | `npm run build` / `start` | Üretim build'i / sunucusu |
| `npm run lint` | ESLint | | `npm run lint` | ESLint |
| `npm run typecheck` | `tsc --noEmit` | | `npm run typecheck` | `tsc --noEmit` |
| `npm run test` | Vitest birim testleri | | `npm run test` | Vitest birim testleri |
| `npm run format` | Prettier | | `npm run format` | Prettier |
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur | | `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) | | `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
| `npm run test:integration` | Entegrasyon testleri (MongoDB) |
| `npm run migrate` | Bekleyen migration'lar |
| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil |
## Haneler ve yetki
- Tüm alan verisi bir **haneye** (`householdId`) aittir; kullanıcı yalnızca üyesi olduğu hanelerin verisini görür. Sistem admini de bu kurala tabidir.
- Hane içi roller: **sahip** (ayarlar, üyeler), **üye** (veri girer), **izleyici** (yalnızca görüntüler).
- Her sayfa, Server Action ve API, hane yetkisini `src/lib/dal.ts` üzerinden sunucuda doğrular; servisler de üyeliği ayrıca kontrol eder.
## Marka dosyaları ## Marka dosyaları
+9
View File
@@ -317,6 +317,15 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
- **Değerleme:** varlıklar ve aynî alacaklar **alış** fiyatıyla, aynî borçlar **satış** fiyatıyla değerlenir. Tek fiyat girilirse ikisine de yazılır. - **Değerleme:** varlıklar ve aynî alacaklar **alış** fiyatıyla, aynî borçlar **satış** fiyatıyla değerlenir. Tek fiyat girilirse ikisine de yazılır.
- **URL'ler:** ekran yolları Türkçe ASCII (`/islemler`, `/butce`); kod, koleksiyon adları ve `/api/v1` İngilizce. - **URL'ler:** ekran yolları Türkçe ASCII (`/islemler`, `/butce`); kod, koleksiyon adları ve `/api/v1` İngilizce.
- **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı). - **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı).
- **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır.
- **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir.
- **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir.
- **Faz 9 kararları (2026-11-05):** 9a varlık lotları ve kâr/zarar, 9b dönemsel rapor ve net varlık; `v0.9.0` 9b sonunda. Varlıklar hane ortaktır; "yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür. Varlık alım/satışı hesaba bağlıysa transfer olarak kaydedilir (gelir/gider raporlarına girmez). Aynî kayıt "aynı varlıkla" kapanırken isteğe bağlı olarak varlıktan düşülür / varlığa eklenir.
- **Faz 10 kararları (2026-11-06):** 10a aylık bütçe, 10b gelir planı ve günlük plan; `v0.10.0` 10b sonunda. Limit her gider kategorisine verilebilir; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır (toplamda iki kez sayılmaz). Başkasının "yalnızca ben" hesabından yapılan harcama hane ayarına (gizli hesapları toplamlara kat) göre yalnızca tutar olarak bütçeye girer.
- **Faz 11 kararları (2026-11-06):** 11a stok kategorileri, ürün tipleri, hareketler, SKT ve %20 eşik; 11b periyodik sayım, fire ayarlı öneri ve tüketim analizi; `v0.11.0` 11b sonunda. Eşiğin altındaki ürünler Faz 11'de "Alınması önerilenler" listesinde gösterilir (koleksiyon yok); Faz 13'te aktif alışveriş listesine onayla/reddet önerisi olarak bağlanır. SKT alım hareketinde (isteğe bağlı) girilir; tüketim ve fire FIFO ile en eski alımdan düşülür.
- **Faz 12 kararları (2026-11-06):** 12a mağazalar ve ürünler (marka/paket, görseller), 12b fiyat kayıtları, birim fiyat ve fiyat geçmişi; `v0.12.0` 12b sonunda. Mağazalar, ürünler ve fiyatlar hane bazlıdır (hane içinde ortak, başka hane görmez). Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa ürün kaydında dönüşüm zorunludur ("5 kg = 33 yıkama"); her fiyat ürün tipinin biriminde karşılaştırılır.
- **Faz 13 kararları (2026-11-06):** 13a alışveriş listeleri, stok eşiği önerileri (onayla/reddet), akıllı mağaza önerisi ve alışveriş modu; 13b alışveriş sonrası giriş, karşılaştırma raporu ve "Alışverişi tamamla"; `v0.13.0` 13b sonunda. Birden çok mağazadan alışverişte mağaza başına ayrı bölünmüş gider (her mağaza için ayrı hesap ve fiş). Tamamlanınca liste kapanır ve rapor sabitlenir; alınmayanlar başka aktif listeye taşınır, yeni listeye alınır ya da bırakılır. Reddedilen öneri yeni alım girilene kadar gösterilmez. Markası belirtilmeyen ürün tipi kalemi (ör. pazardan domates) tipin biriminde stoğa işlenir, fiyat kaydı oluşmaz.
- **Faz 14 kararları (2026-11-07):** 14a dashboard ana grafiği (Haftalık/Aylık/Altı aylık/Yıllık, kümülatif/dönemsel) ve finans raporları (kategori dağılımı, aylık trend, kişi ve hesap bazlı, bütçe vs. gerçekleşen); 14b cari ve aynî borçlar, birikim kâr/zarar, alışveriş (liste vs. gerçekleşen, mağaza bazlı harcama), birim fiyat geçmişi, fire/zarar; `v0.14.0` 14b sonunda. Raporlar `/raporlar` altında ayrı sayfalar; dönem ve filtreler URL'de, her raporda CSV. "Ay" bütçe ayıdır; gider kuralları bütçeyle aynı (taksit modu, gizli hesap ayarı). Kişi bazlı harcama gideri kaydeden üyeye göre; mağaza bazlı harcama gider alıcısının mağaza adıyla başlamasına göre (şubeler birlikte).
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir. - **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
--- ---
+24 -1
View File
@@ -42,10 +42,24 @@ const securityHeaders = [
[{ key: "Strict-Transport-Security", value: "max-age=31536000" }]), [{ key: "Strict-Transport-Security", value: "max-age=31536000" }]),
]; ];
/**
* /api/files: kullanıcı dosyaları (WebP görsel, PDF). Sayfa CSP'si yerine daha sıkı bir politika:
* betik yok; PDF'in tarayıcı görüntüleyicisinde açılabilmesi için object-src 'self'.
*/
const fileCsp = [
"default-src 'none'",
"img-src 'self'",
"style-src 'unsafe-inline'",
"object-src 'self'",
"frame-ancestors 'self'",
].join("; ");
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Docker imajı için yalnızca gerekli dosyaları .next/standalone altına kopyala. // Docker imajı için yalnızca gerekli dosyaları .next/standalone altına kopyala.
output: "standalone", output: "standalone",
poweredByHeader: false, poweredByHeader: false,
// HEIC çözücü (libheif WASM) bundle'a girmesin; Node require ile yüklensin.
serverExternalPackages: ["heic-decode", "libheif-js"],
experimental: { experimental: {
agentFeedback: true, agentFeedback: true,
}, },
@@ -60,7 +74,16 @@ const nextConfig: NextConfig = {
}, },
}, },
async headers() { async headers() {
return [{ source: "/:path*", headers: securityHeaders }]; return [
{ source: "/:path((?!api/files/).*)", headers: securityHeaders },
{
source: "/api/files/:path*",
headers: [
...securityHeaders.filter((h) => h.key !== "Content-Security-Policy"),
{ key: "Content-Security-Policy", value: fileCsp },
],
},
];
}, },
}; };
+423 -3
View File
@@ -1,18 +1,20 @@
{ {
"name": "butcem", "name": "butcem",
"version": "0.1.0", "version": "0.14.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "butcem", "name": "butcem",
"version": "0.1.0", "version": "0.14.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^7.0.2", "@hookform/resolvers": "^7.0.2",
"@tanstack/react-table": "^9.3.1", "@tanstack/react-table": "^9.3.1",
"better-auth": "^1.7.17", "better-auth": "^1.7.17",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cn": "^0.5.2", "cn": "^0.5.2",
"embla-carousel-react": "^8.6.0",
"heic-decode": "^2.1.0",
"lucide-react": "^1.68.0", "lucide-react": "^1.68.0",
"mongodb": "~7.7", "mongodb": "~7.7",
"mongoose": "^9.11.4", "mongoose": "^9.11.4",
@@ -22,6 +24,7 @@
"react": "^19.3.1", "react": "^19.3.1",
"react-dom": "^19.3.1", "react-dom": "^19.3.1",
"react-hook-form": "^7.91.0", "react-hook-form": "^7.91.0",
"recharts": "^3.8.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"shadcn": "^4.23.0", "shadcn": "^4.23.0",
"sharp": "^0.35.5", "sharp": "^0.35.5",
@@ -4049,6 +4052,46 @@
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.14.0.tgz",
"integrity": "sha512-m/PxMwRe0xLL6XUsAgy0YjInklHcfi9uMwFhJk+QINqjxqPvyc89T5cMjszA5bDmTE0gwGc7zr9LRO4gL+mhaw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.2.9",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.2.9",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.2.9.tgz",
"integrity": "sha512-IQ4UEtXjZjyefyWjtLEDXUSrF6T2hwjKPi6BIJl3bY/2XXW24d6cNGdXJSBOA/+KNnuiXkZY6DR0C3efolhitA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": { "node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.16", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
@@ -4962,6 +5005,69 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -5020,6 +5126,12 @@
"@types/react": "^19.3.0" "@types/react": "^19.3.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/validate-npm-package-name": { "node_modules/@types/validate-npm-package-name": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
@@ -6571,6 +6683,127 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.5.tgz",
"integrity": "sha512-Th8qGXoBalmWsstpkuhmBuTsBQIFqYOJtjlBvNVvBfJ8E3uYusBUwUR8HU20U0qKKQkgCvKyR1YjiozWadk5QA==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.1.tgz",
"integrity": "sha512-lfab8x0Bj0UO7SNsY8dZSbNIA6ipbkE3X0NG9nqjCu6CfCHZseuDnOSJY7hidKrjpP++i1M2qT+FlAdPKYRilA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-B5znKqRmKROAN4oHWQ50W/qisVjA9GXh2WMsVKdFVp4DxWmcrOsweqgUOUVsv5th96kwaZlokU6SlV4J3gak1w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.3.tgz",
"integrity": "sha512-+F+/49swqmGuQJmSKnVMIDH/QkEFV5li9QRj0xm/KhyAUBv099O5PrvpQFxBp24PYph17Mnvt9mjKQV0ii0nMA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.2.tgz",
"integrity": "sha512-IRzGU62DtxhvsnSTWMQIdnoZ6gA+BCWwBon1EJI6SjoN595799b6z/QRBzXfFhE8iupUPp3042yBNo0STPqnzQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-jsbgV8zIT38ReB9O7X7gcLndTQRONSqNNd+oGIaTBdUug4r5jFmp0N6RY9R5GWbdUDucn0qoTGyfjapT4tDhcg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.3.tgz",
"integrity": "sha512-66cWobCNzcMvlfoaDHCxuIv9TBUWG1BLOWdv9kHHFuCr1JdqAWPvTmsaEYDD5NRMyWZ5+7u0GqiCDfMXMqh3hg==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.1.tgz",
"integrity": "sha512-dm2o+lbKdzg9tY7GEag/dB9hcbodLfwkE/DkvqbELJXaMz0yKzWWhZqhbI7CKubukA7HnRyz0avdtvi7EPxPvw==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.1.tgz",
"integrity": "sha512-Ze7rjrhMzdeulh0/hjcfFj7XLAHOfowtEMfsoG6DU866HPynQNW7Wfen6hzXcqKTv8XzxvF6Hzr5XD6bMHr2ug==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.1.tgz",
"integrity": "sha512-22XaqjyCjpqgda20erhsOjGmC3vvYwTNYniKAd45W5UWLDF43moeOuV9qYpRqobIlsnK0UhnKZjUUJm3VgORUA==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-1Il1aliroVSErrv5aulRBxAY+7+DnaoLIWGd1C6SsBTWDeobWiP/V69vwb8vT6abR1kKwaNe1196pmaQ1fK90A==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -6664,6 +6897,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -6861,6 +7100,34 @@
"integrity": "sha512-LnPnRZ0esKB9NjsSIaC+mffW+5dAtBui6kbK5f9Qykblj4Q33cn6yIhuCcTVjwpvL3YJPqqQ4IlsNyriGNpyFQ==", "integrity": "sha512-LnPnRZ0esKB9NjsSIaC+mffW+5dAtBui6kbK5f9Qykblj4Q33cn6yIhuCcTVjwpvL3YJPqqQ4IlsNyriGNpyFQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/embla-carousel": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT"
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
"license": "MIT",
"dependencies": {
"embla-carousel": "8.6.0",
"embla-carousel-reactive-utils": "8.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/embla-carousel-reactive-utils": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
"license": "MIT",
"peerDependencies": {
"embla-carousel": "8.6.0"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -7110,6 +7377,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.53.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.1.tgz",
"integrity": "sha512-VhMc6IXmexu8iYFfL2aaAww6IvJTdHlqBLruLoEUs33YD4IcP5DdZcLOybMsMmmLsiy1c8PsiLsrM0DdIuuRUA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types",
"tests/browser-compat"
]
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -7628,6 +7907,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/execa": { "node_modules/execa": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
@@ -8205,6 +8490,18 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/heic-decode": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
"license": "ISC",
"dependencies": {
"libheif-js": "^1.19.8"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/hermes-estree": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -8240,6 +8537,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -8282,6 +8589,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.7.6", "version": "10.7.6",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz",
@@ -9076,6 +9392,15 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/libheif-js": {
"version": "1.23.5",
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.5.tgz",
"integrity": "sha512-umXZPthnWZtF3iG/5mQG+AZ2V6i0UeYpo3z2CkggTEgL8dKmIIwJT6TDuAMuabXa1yTW8uJ1ZAgR+CxNTklYYw==",
"license": "LGPL-3.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -10664,9 +10989,31 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-redux": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.1.tgz",
"integrity": "sha512-vxMLT0lalUjbZPkoTepWyKAZbjIIFssNx/CEL9lTfwQBU6XpkGSmCNdcCIrkOBUgB68uftZMnbXVb6DwGnhRKQ==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -10752,6 +11099,51 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/recharts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -10805,6 +11197,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "2.0.0-next.7", "version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -12354,6 +12752,28 @@
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
} }
}, },
"node_modules/victory-vendor": {
"version": "37.4.1",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.4.1.tgz",
"integrity": "sha512-geV4p2GVfVM9lgm9ZRS3pvsbAPS+BUGhM9W9nPzPHTKi+/+KjNrFXdgP6KkG+XspNZuvcXmchcrtklfuQ4gniw==",
"license": "MIT AND ISC AND BSD-3-Clause",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.3.5", "version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
+8 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "butcem", "name": "butcem",
"version": "0.4.0", "version": "0.14.0",
"private": true, "private": true,
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
"engines": { "engines": {
@@ -11,7 +11,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"typecheck": "tsc --noEmit", "typecheck": "next typegen && tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"format": "prettier --write .", "format": "prettier --write .",
@@ -21,7 +21,9 @@
"seed": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts", "seed": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts",
"seed:reset": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts --reset", "seed:reset": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts --reset",
"icons": "tsx scripts/generate-icons.ts", "icons": "tsx scripts/generate-icons.ts",
"test:integration": "vitest run --config vitest.integration.config.mts" "test:integration": "vitest run --config vitest.integration.config.mts",
"migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts",
"cleanup": "tsx --conditions=react-server --env-file=.env.local scripts/cleanup.ts"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^7.0.2", "@hookform/resolvers": "^7.0.2",
@@ -29,6 +31,8 @@
"better-auth": "^1.7.17", "better-auth": "^1.7.17",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cn": "^0.5.2", "cn": "^0.5.2",
"embla-carousel-react": "^8.6.0",
"heic-decode": "^2.1.0",
"lucide-react": "^1.68.0", "lucide-react": "^1.68.0",
"mongodb": "~7.7", "mongodb": "~7.7",
"mongoose": "^9.11.4", "mongoose": "^9.11.4",
@@ -38,6 +42,7 @@
"react": "^19.3.1", "react": "^19.3.1",
"react-dom": "^19.3.1", "react-dom": "^19.3.1",
"react-hook-form": "^7.91.0", "react-hook-form": "^7.91.0",
"recharts": "^3.8.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"shadcn": "^4.23.0", "shadcn": "^4.23.0",
"sharp": "^0.35.5", "sharp": "^0.35.5",
+18
View File
@@ -0,0 +1,18 @@
/**
* Ek dosya temizliği: npm run cleanup (canlıda günlük zamanlanmış görev; bkz. Faz 15).
* 24 saatten eski bağlanmamış ekler ve 30 günden önce silinmiş kayıtların ekleri fiziksel olarak silinir.
*/
import mongoose from "mongoose";
import { purgeAttachments } from "@/features/attachments/service";
import { getMongoClient } from "@/lib/db/mongo";
purgeAttachments()
.then(({ removed }) => console.log(`✓ Temizlenen ek dosya: ${removed}`))
.catch((err: unknown) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
})
.finally(async () => {
await mongoose.disconnect();
await getMongoClient().close();
});
+17
View File
@@ -0,0 +1,17 @@
/**
* Migration'ları uygular: npm run migrate (canlıda deploy sonrası; bkz. docs/DEPLOY.md, Faz 15).
* `server-only` modülleri Next dışında yükleyebilmek için --conditions=react-server ile çalışır.
*/
import mongoose from "mongoose";
import { getMongoClient } from "@/lib/db/mongo";
import { runMigrations } from "./migrations";
runMigrations()
.catch((err: unknown) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
})
.finally(async () => {
await mongoose.disconnect();
await getMongoClient().close();
});
View File
+15
View File
@@ -0,0 +1,15 @@
import { AuditLogModel } from "@/models/audit-log";
import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import type { Migration } from "./types";
/** Hane, üyelik ve denetim kaydı index'leri (Mongoose şemalarındaki tanımlarla). */
export const migration: Migration = {
name: "001-initial-indexes",
description: "households, householdMembers ve auditLogs index'leri",
async up() {
await HouseholdModel.createIndexes();
await HouseholdMemberModel.createIndexes();
await AuditLogModel.createIndexes();
},
};
@@ -0,0 +1,19 @@
import { Types } from "mongoose";
import { installDefaultCategories } from "@/features/categories/service";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { HouseholdModel } from "@/models/household";
import type { Migration } from "./types";
/** Hesap ve kategori index'leri + mevcut hanelere varsayılan kategori seti (idempotent). */
export const migration: Migration = {
name: "002-accounts-categories",
description: "accounts/categories index'leri ve mevcut hanelere varsayılan kategoriler",
async up() {
await AccountModel.createIndexes();
await CategoryModel.createIndexes();
for (const h of await HouseholdModel.find().select({ _id: 1, createdBy: 1 }).lean()) {
await installDefaultCategories(h._id as Types.ObjectId, h.createdBy as Types.ObjectId);
}
},
};
+12
View File
@@ -0,0 +1,12 @@
import { PayeeModel } from "@/models/payee";
import { TransactionModel } from "@/models/transaction";
import type { Migration } from "./types";
export const migration: Migration = {
name: "003-transactions",
description: "transactions ve payees index'leri",
async up() {
await TransactionModel.createIndexes();
await PayeeModel.createIndexes();
},
};
@@ -0,0 +1,20 @@
import { AttachmentModel } from "@/models/attachment";
import { ReconciliationModel } from "@/models/reconciliation";
import type { Migration } from "./types";
export const migration: Migration = {
name: "004-attachments-reconciliation",
description: "attachments ve reconciliations index'leri; işlemlerde mutabakat alanları",
async up(db) {
await AttachmentModel.createIndexes();
await ReconciliationModel.createIndexes();
const tx = db.collection("transactions");
// reconciledAt (hiç kullanılmadı) → hesap bazında reconciledAccountIds.
await tx.updateMany({ reconciledAt: { $exists: true } }, { $unset: { reconciledAt: "" } });
await tx.updateMany(
{ reconciledAccountIds: { $exists: false } },
{ $set: { reconciledAccountIds: [] } },
);
await tx.updateMany({ isAdjustment: { $exists: false } }, { $set: { isAdjustment: false } });
},
};
+17
View File
@@ -0,0 +1,17 @@
import { RecurringRuleModel } from "@/models/recurring-rule";
import { TransactionModel } from "@/models/transaction";
import type { Migration } from "./types";
export const migration: Migration = {
name: "005-recurring",
description: "recurringRules index'leri; işlemlerde tekrar alanları ve benzersiz tekrar index'i",
async up(db) {
const tx = db.collection("transactions");
await tx.updateMany(
{ occurrenceIndex: { $exists: false } },
{ $set: { occurrenceIndex: null, detached: false, isEstimate: false } },
);
await RecurringRuleModel.createIndexes();
await TransactionModel.createIndexes();
},
};
+12
View File
@@ -0,0 +1,12 @@
import { CardStatementModel } from "@/models/card-statement";
import { TransactionModel } from "@/models/transaction";
import type { Migration } from "./types";
export const migration: Migration = {
name: "006-card-statements",
description: "statements (kart/KMH dönemleri) index'leri; taksit planı index'i",
async up() {
await CardStatementModel.createIndexes();
await TransactionModel.createIndexes();
},
};
+15
View File
@@ -0,0 +1,15 @@
import { ContactModel } from "@/models/contact";
import { TransactionModel } from "@/models/transaction";
import type { Migration } from "./types";
export const migration: Migration = {
name: "007-contacts",
description: "contacts index'leri; işlemlerde cari alanları ve cari index'i",
async up(db) {
await db
.collection("transactions")
.updateMany({ contactKind: { $exists: false } }, { $set: { contactKind: null } });
await ContactModel.createIndexes();
await TransactionModel.createIndexes();
},
};
+18
View File
@@ -0,0 +1,18 @@
import { AssetPriceModel } from "@/models/asset-price";
import { InKindDebtModel } from "@/models/in-kind-debt";
import type { Migration } from "./types";
export const migration: Migration = {
name: "008-in-kind",
description: "assetPrices ve inKindDebts index'leri; işlemlerde aynî borç alanları",
async up(db) {
await db
.collection("transactions")
.updateMany(
{ inKindDebtId: { $exists: false } },
{ $set: { inKindDebtId: null, inKindRole: null } },
);
await AssetPriceModel.createIndexes();
await InKindDebtModel.createIndexes();
},
};
+15
View File
@@ -0,0 +1,15 @@
import { AssetModel } from "@/models/asset";
import { AssetMovementModel } from "@/models/asset-movement";
import type { Migration } from "./types";
export const migration: Migration = {
name: "009-assets",
description: "assets ve assetMovements index'leri; işlemlerde varlık alanları",
async up(db) {
await db
.collection("transactions")
.updateMany({ assetId: { $exists: false } }, { $set: { assetId: null } });
await AssetModel.createIndexes();
await AssetMovementModel.createIndexes();
},
};
+10
View File
@@ -0,0 +1,10 @@
import { BudgetModel } from "@/models/budget";
import type { Migration } from "./types";
export const migration: Migration = {
name: "010-budgets",
description: "budgets index'i (hane × ay benzersiz)",
async up() {
await BudgetModel.createIndexes();
},
};
+20
View File
@@ -0,0 +1,20 @@
import type { Types } from "mongoose";
import { installDefaultStockCategories } from "@/features/stock/service";
import { HouseholdModel } from "@/models/household";
import { ProductTypeModel } from "@/models/product-type";
import { StockCategoryModel } from "@/models/stock-category";
import { StockMovementModel } from "@/models/stock-movement";
import type { Migration } from "./types";
export const migration: Migration = {
name: "011-stock",
description: "stok koleksiyonları index'leri; mevcut hanelere varsayılan stok kategorileri",
async up() {
await StockCategoryModel.createIndexes();
await ProductTypeModel.createIndexes();
await StockMovementModel.createIndexes();
const households = await HouseholdModel.find({}).select({ createdBy: 1 }).lean();
for (const h of households)
await installDefaultStockCategories(h._id as Types.ObjectId, h.createdBy as Types.ObjectId);
},
};
+92
View File
@@ -0,0 +1,92 @@
import { Types } from "mongoose";
import { StockCountModel } from "@/models/stock-count";
import { StockMovementModel } from "@/models/stock-movement";
import { signedDelta, type StockMovementKind } from "@/shared/stock";
import type { Migration } from "./types";
type Move = {
_id: Types.ObjectId;
householdId: Types.ObjectId;
productTypeId: Types.ObjectId;
kind: StockMovementKind;
date: string;
quantity: number;
initial: boolean;
countId: Types.ObjectId | null;
createdBy: Types.ObjectId;
createdAt: Date;
};
/**
* Faz 11b: sayım oturumları. Faz 11a'da tek ürün sayımıyla oluşan (oturumsuz) sayım
* düzeltmeleri, o ana kadarki kayıtlardan beklenen miktar hesaplanarak hane + tarih + kişi
* başına bir sayım oturumuna bağlanır. Başlangıç miktarları sayım sayılmaz.
*/
export const migration: Migration = {
name: "012-stock-counts",
description: "stockCounts index'leri; oturumsuz sayım düzeltmelerini sayım oturumuna bağla",
async up() {
await StockCountModel.createIndexes();
const orphans = await StockMovementModel.find({
kind: "count_adjust",
initial: { $ne: true },
countId: null,
deletedAt: null,
}).lean<Move[]>();
if (orphans.length === 0) return;
const products = [...new Set(orphans.map((o) => String(o.productTypeId)))];
const all = await StockMovementModel.find({
productTypeId: { $in: products.map((id) => new Types.ObjectId(id)) },
deletedAt: null,
}).lean<Move[]>();
const byProduct = new Map<string, Move[]>();
for (const m of all) {
const list = byProduct.get(String(m.productTypeId)) ?? [];
list.push(m);
byProduct.set(String(m.productTypeId), list);
}
// Her düzeltme için beklenen = önceki hareketlerin toplamı (tarih, oluşturulma sırası).
const expectedOf = new Map<string, number>();
for (const list of byProduct.values()) {
list.sort((a, b) =>
a.date === b.date
? a.createdAt.getTime() - b.createdAt.getTime()
: a.date < b.date
? -1
: 1,
);
let running = 0;
for (const m of list) {
expectedOf.set(String(m._id), running);
running += signedDelta(m);
}
}
const sessions = new Map<string, Move[]>();
for (const o of orphans) {
const key = `${o.householdId}:${o.date}:${o.createdBy}`;
sessions.set(key, [...(sessions.get(key) ?? []), o]);
}
for (const moves of sessions.values()) {
const first = moves[0]!;
const count = await StockCountModel.create({
householdId: first.householdId,
date: first.date,
categoryId: null,
items: moves.map((m) => {
const expected = expectedOf.get(String(m._id)) ?? 0;
return {
productTypeId: m.productTypeId,
expected,
actual: expected + m.quantity,
wasted: 0,
};
}),
createdBy: first.createdBy,
});
await StockMovementModel.updateMany(
{ _id: { $in: moves.map((m) => m._id) } },
{ $set: { countId: count._id } },
);
}
},
};
+12
View File
@@ -0,0 +1,12 @@
import { ProductModel } from "@/models/product";
import { StoreModel } from "@/models/store";
import type { Migration } from "./types";
export const migration: Migration = {
name: "013-stores-products",
description: "mağaza ve ürün (marka/paket) koleksiyonlarının index'leri",
async up() {
await StoreModel.createIndexes();
await ProductModel.createIndexes();
},
};
+10
View File
@@ -0,0 +1,10 @@
import { ProductPriceModel } from "@/models/product-price";
import type { Migration } from "./types";
export const migration: Migration = {
name: "014-product-prices",
description: "ürün fiyat kayıtları (productPrices) index'leri",
async up() {
await ProductPriceModel.createIndexes();
},
};
+10
View File
@@ -0,0 +1,10 @@
import { ShoppingListModel } from "@/models/shopping-list";
import type { Migration } from "./types";
export const migration: Migration = {
name: "015-shopping-lists",
description: "alışveriş listeleri (shoppingLists) index'leri",
async up() {
await ShoppingListModel.createIndexes();
},
};
+65
View File
@@ -0,0 +1,65 @@
import { getDb } from "@/lib/db/mongo";
import { connectMongoose } from "@/lib/db/mongoose";
import { migration as m001 } from "./001-initial-indexes";
import { migration as m002 } from "./002-accounts-categories";
import { migration as m003 } from "./003-transactions";
import { migration as m004 } from "./004-attachments-reconciliation";
import { migration as m005 } from "./005-recurring";
import { migration as m006 } from "./006-card-statements";
import { migration as m007 } from "./007-contacts";
import { migration as m008 } from "./008-in-kind";
import { migration as m009 } from "./009-assets";
import { migration as m010 } from "./010-budgets";
import { migration as m011 } from "./011-stock";
import { migration as m012 } from "./012-stock-counts";
import { migration as m013 } from "./013-stores-products";
import { migration as m014 } from "./014-product-prices";
import { migration as m015 } from "./015-shopping-lists";
import type { Migration } from "./types";
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
export const MIGRATIONS: Migration[] = [
m001,
m002,
m003,
m004,
m005,
m006,
m007,
m008,
m009,
m010,
m011,
m012,
m013,
m014,
m015,
];
const COLLECTION = "migrations";
/**
* Uygulanmamış migration'ları sırayla çalıştırır; her biri bir kez (`migrations` koleksiyonu).
* Aynı anda iki çalıştırma olursa benzersiz `name` index'i ikinci kaydı reddeder.
*/
export async function runMigrations(log: (line: string) => void = console.log): Promise<string[]> {
const db = getDb();
await connectMongoose();
const col = db.collection<{ name: string; appliedAt: Date; durationMs: number }>(COLLECTION);
await col.createIndex({ name: 1 }, { unique: true });
const applied = new Set(
(await col.find({}, { projection: { name: 1 } }).toArray()).map((d) => d.name),
);
const ran: string[] = [];
for (const m of MIGRATIONS) {
if (applied.has(m.name)) continue;
const started = Date.now();
await m.up(db);
await col.insertOne({ name: m.name, appliedAt: new Date(), durationMs: Date.now() - started });
ran.push(m.name);
log(`✓ Migration: ${m.name} — ${m.description}`);
}
if (!ran.length) log("• Bekleyen migration yok.");
return ran;
}
+8
View File
@@ -0,0 +1,8 @@
import type { Db } from "mongodb";
/** Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). Geri alma yok; ileri yönlü. */
export type Migration = {
name: string;
description: string;
up: (db: Db) => Promise<void>;
};
+4 -2
View File
@@ -11,8 +11,10 @@ import { closeSeedConnections, runSeed } from "./seed/run";
async function main() { async function main() {
const summary = await runSeed({ reset: process.argv.includes("--reset") }); const summary = await runSeed({ reset: process.argv.includes("--reset") });
const { users, households, memberships } = summary.created; const { users, households, memberships, categories, accounts, transactions } = summary.created;
console.log(`\nOluşturulan: ${users} kullanıcı, ${households} hane, ${memberships} üyelik.`); console.log(
`\nOluşturulan: ${users} kullanıcı, ${households} hane, ${memberships} üyelik, ${categories} kategori, ${accounts} hesap, ${transactions} işlem.`,
);
// Hesap tablosu yalnızca geliştirme ortamında gösterilir. // Hesap tablosu yalnızca geliştirme ortamında gösterilir.
if (env().NODE_ENV !== "production" && summary.accounts.length) { if (env().NODE_ENV !== "production" && summary.accounts.length) {
+162
View File
@@ -0,0 +1,162 @@
import type { Types } from "mongoose";
import { addMovement, createAsset } from "@/features/assets/service";
import { AccountModel } from "@/models/account";
import { AssetModel } from "@/models/asset";
import { AssetMovementModel } from "@/models/asset-movement";
import { TransactionModel } from "@/models/transaction";
import type { AssetKey } from "@/shared/assets";
import { todayIstanbul } from "@/shared/dates";
import type { MovementKind } from "@/shared/portfolio";
import { addDays } from "@/shared/recurrence";
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
/**
* Örnek varlıklar (Faz 9a). Tutarı boş alımlar fiyat tablosundaki örnek fiyatlardan (seedInKind
* önce çalışır) hesaplanır. İdempotent: varlık `seedRef` ile bir kez oluşturulur.
*/
type SeedMovement = {
kind: MovementKind;
daysAgo: number;
quantity: string;
amount?: string;
account?: string;
storage?: string;
note?: string;
};
type SeedAsset = {
ref: string;
household: string;
by: string;
assetKey: AssetKey;
name: string;
isPrivate?: boolean;
note?: string;
movements: SeedMovement[];
};
const monthly = (from: number, to: number): SeedMovement[] =>
Array.from({ length: to - from + 1 }, (_, i) => ({
kind: "buy" as const,
daysAgo: (to - i) * 30 + 2,
quantity: "1",
account: "Ziraat Maaş",
storage: "Ev kasası",
}));
const ITEMS: SeedAsset[] = [
{
ref: "gram-altin",
household: "Çakır Ailesi",
by: "ayse",
assetKey: "gram_gold",
name: "Gram altın birikimi",
note: "Her ay maaştan 1 gr",
movements: [
{
kind: "buy",
daysAgo: 178,
quantity: "4",
account: "Birikim (Vadeli)",
storage: "Ev kasası",
note: "İlk alım",
},
...monthly(1, 5),
{ kind: "sell", daysAgo: 12, quantity: "2", account: "Birikim (Vadeli)", note: "Tatil için" },
],
},
{
ref: "ziynet",
household: "Çakır Ailesi",
by: "admin",
assetKey: "quarter_gold",
name: "Düğün ziynetleri",
movements: [
{
kind: "gift_in",
daysAgo: 175,
quantity: "6",
storage: "Banka kiralık kasa",
note: "Düğün takıları",
},
{ kind: "gift_out", daysAgo: 40, quantity: "1", note: "Yeğenin düğünü" },
],
},
{
ref: "emre-dolar",
household: "Çakır Ailesi",
by: "emre",
assetKey: "usd",
name: "Dolar",
isPrivate: true,
movements: [
{ kind: "buy", daysAgo: 120, quantity: "200", account: "Ziraat Maaş", storage: "Cüzdan" },
],
},
{
ref: "bilezik",
household: "Annemler",
by: "fatma",
assetKey: "bracelet_22k",
name: "Bilezikler",
note: "Fiyat tablosunda bilezik fiyatı yok (örnek: fiyatsız varlık)",
movements: [{ kind: "gift_in", daysAgo: 160, quantity: "40", amount: "120.000" }],
},
];
export async function seedAssets(
householdIds: Map<string, Types.ObjectId>,
userIds: Map<string, string>,
): Promise<number> {
const today = todayIstanbul();
let created = 0;
for (const x of ITEMS) {
const householdId = householdIds.get(x.household)!;
const seedRef = `asset:${x.ref}`;
if (await AssetModel.exists({ householdId, seedRef })) continue;
const by = userIds.get(x.by)!;
const hh = String(householdId);
const r = await createAsset(
by,
hh,
assetFormSchema.parse({
assetKey: x.assetKey,
name: x.name,
note: x.note ?? "",
isPrivate: x.isPrivate ?? false,
}),
);
if (!r.ok) throw new Error(`Seed varlığı oluşturulamadı (${x.ref}): ${r.error}`);
await AssetModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
for (const [i, m] of x.movements.entries()) {
const account = m.account
? String((await AccountModel.findOne({ householdId, name: m.account }).lean())!._id)
: "";
const res = await addMovement(
by,
hh,
r.data.id,
movementFormSchema.parse({
kind: m.kind,
date: addDays(today, -m.daysAgo),
quantity: m.quantity,
amount: m.amount ?? "",
accountId: account,
storage: m.storage ?? "",
note: m.note ?? "",
}),
);
if (!res.ok) throw new Error(`Seed varlık hareketi eklenemedi (${x.ref}): ${res.error}`);
const ref = `${seedRef}:${i}`;
const mv = await AssetMovementModel.findOneAndUpdate(
{ _id: res.data.id },
{ $set: { seedRef: ref } },
).lean();
if (mv?.transactionId)
await TransactionModel.updateOne({ _id: mv.transactionId }, { $set: { seedRef: ref } });
}
created++;
}
return created;
}
+50
View File
@@ -0,0 +1,50 @@
import type { Types } from "mongoose";
import { budgetFromActuals, saveBudget } from "@/features/budgets/service";
import { BudgetModel } from "@/models/budget";
import { CategoryModel } from "@/models/category";
import { HouseholdModel } from "@/models/household";
import { periodOfDate, shiftMonth } from "@/shared/budget";
import { todayIstanbul } from "@/shared/dates";
import { budgetLimitsSchema } from "@/shared/schemas/budget";
import { formatMoneyInput } from "@/shared/money";
/**
* Örnek bütçeler (Faz 10a): Çakır Ailesi'nde önceki ve bu ay, önceki ayların harcamalarından
* oluşturulur. Bu ay "Market" limiti biraz düşürülür ve "Elektrik" alt limiti eklenir; böylece
* uyarı ve alt limit örnekleri görünür. İdempotent: bütçesi olan aya dokunulmaz.
*/
export async function seedBudgets(
householdIds: Map<string, Types.ObjectId>,
userIds: Map<string, string>,
): Promise<number> {
const householdId = householdIds.get("Çakır Ailesi")!;
const by = userIds.get("ayse")!;
const hh = String(householdId);
const h = await HouseholdModel.findById(householdId).select({ settings: 1 }).lean();
const current = periodOfDate(todayIstanbul(), h?.settings?.budgetMonthStartDay ?? 1);
let created = 0;
for (const month of [shiftMonth(current, -1), current]) {
if (await BudgetModel.exists({ householdId, month })) continue;
const r = await budgetFromActuals(by, hh, month);
if (!r.ok) continue;
created++;
}
if (created && (await BudgetModel.exists({ householdId, month: current }))) {
const doc = (await BudgetModel.findOne({ householdId, month: current }).lean())!;
const cats = await CategoryModel.find({
householdId,
defaultKey: { $in: ["market", "faturalar.elektrik"] },
}).lean();
const id = (key: string) => String(cats.find((c) => c.defaultKey === key)?._id ?? "");
const limits = doc.limits.map((l) => ({
categoryId: String(l.categoryId),
amount: formatMoneyInput(
String(l.categoryId) === id("market") ? Math.round(l.amountMinor * 0.7) : l.amountMinor,
),
}));
if (id("faturalar.elektrik"))
limits.push({ categoryId: id("faturalar.elektrik"), amount: "900" });
await saveBudget(by, hh, budgetLimitsSchema.parse({ month: current, limits }));
}
return created;
}
+158
View File
@@ -0,0 +1,158 @@
import type { Types } from "mongoose";
import { createContact, createContactEntry } from "@/features/contacts/service";
import { AccountModel } from "@/models/account";
import { ContactModel } from "@/models/contact";
import type { ContactEntryKind, ContactRelation } from "@/shared/contacts";
import { todayIstanbul } from "@/shared/dates";
import { formatMoneyInput } from "@/shared/money";
import { addDays } from "@/shared/recurrence";
import { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact";
/**
* Örnek cariler (Faz 8a): vadesi yaklaşan alacak, kısmen ödenen borç, vadesi geçmiş küçük alacak
* ve "yalnızca ben" cari. İdempotent: cari `seedRef` ile; hareketler yalnızca cari ilk
* oluşturulduğunda eklenir.
*/
type SeedContact = {
ref: string;
household: string;
by: string;
name: string;
relation: ContactRelation;
phone?: string;
isPrivate?: boolean;
dueInDays?: number;
reminderDays?: number;
entries: {
kind: ContactEntryKind;
account: string;
amountMinor: number;
daysAgo: number;
description?: string;
}[];
};
const CONTACTS: SeedContact[] = [
{
ref: "ahmet",
household: "Çakır Ailesi",
by: "admin",
name: "Ahmet Yılmaz",
relation: "friend",
phone: "0532 000 00 01",
dueInDays: 5,
reminderDays: 7,
entries: [
{
kind: "lend",
account: "Ziraat Maaş",
amountMinor: 5_000_00,
daysAgo: 70,
description: "Araba tamiri için",
},
{ kind: "collect", account: "Cüzdan", amountMinor: 2_000_00, daysAgo: 30 },
],
},
{
ref: "hasan",
household: "Çakır Ailesi",
by: "admin",
name: "Hasan Çakır",
relation: "family",
dueInDays: 60,
entries: [
{
kind: "borrow",
account: "Ziraat Maaş",
amountMinor: 20_000_00,
daysAgo: 120,
description: "Ev eşyası için",
},
{ kind: "repay", account: "Ziraat Maaş", amountMinor: 5_000_00, daysAgo: 60 },
{ kind: "repay", account: "Ziraat Maaş", amountMinor: 5_000_00, daysAgo: 10 },
],
},
{
ref: "elif",
household: "Çakır Ailesi",
by: "ayse",
name: "Elif (komşu)",
relation: "neighbor",
dueInDays: -2,
entries: [
{
kind: "lend",
account: "Cüzdan",
amountMinor: 300_00,
daysAgo: 12,
description: "Pazar alışverişi",
},
],
},
{
ref: "selin",
household: "Çakır Ailesi",
by: "ayse",
name: "Selin (iş arkadaşı)",
relation: "work",
isPrivate: true,
entries: [{ kind: "lend", account: "Cüzdan", amountMinor: 1_000_00, daysAgo: 8 }],
},
{
ref: "ali",
household: "Annemler",
by: "fatma",
name: "Ali Demir",
relation: "family",
dueInDays: 45,
entries: [{ kind: "borrow", account: "Vakıfbank Emekli", amountMinor: 3_000_00, daysAgo: 25 }],
},
];
export async function seedContacts(
householdIds: Map<string, Types.ObjectId>,
userIds: Map<string, string>,
): Promise<number> {
const today = todayIstanbul();
let created = 0;
for (const c of CONTACTS) {
const householdId = householdIds.get(c.household)!;
const seedRef = `contact:${c.ref}`;
if (await ContactModel.exists({ householdId, seedRef })) continue;
const by = userIds.get(c.by)!;
const oldest = Math.max(...c.entries.map((e) => e.daysAgo));
const r = await createContact(
by,
String(householdId),
contactFormSchema.parse({
name: c.name,
relation: c.relation,
phone: c.phone ?? "",
openingDate: addDays(today, -oldest - 1),
isPrivate: c.isPrivate ?? false,
dueDate: c.dueInDays !== undefined ? addDays(today, c.dueInDays) : null,
reminderDays: c.reminderDays ?? 3,
}),
);
if (!r.ok) throw new Error(`Seed carisi oluşturulamadı (${c.name}): ${r.error}`);
await ContactModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
for (const e of c.entries) {
const account = await AccountModel.findOne({ householdId, name: e.account }).lean();
const x = await createContactEntry(
by,
String(householdId),
r.data.id,
contactEntrySchema.parse({
kind: e.kind,
accountId: String(account!._id),
amount: formatMoneyInput(e.amountMinor),
date: addDays(today, -e.daysAgo),
description: e.description ?? "",
}),
);
if (!x.ok) throw new Error(`Seed cari hareketi oluşturulamadı (${c.name}): ${x.error}`);
}
created++;
}
return created;
}
+96
View File
@@ -1,4 +1,6 @@
import type { HouseholdRole } from "@/features/households/types"; import type { HouseholdRole } from "@/features/households/types";
import type { AccountType, MealCardProvider } from "@/shared/accounts";
import type { IconKey, Swatch } from "@/shared/appearance";
import type { SystemRole } from "@/shared/schemas/common"; import type { SystemRole } from "@/shared/schemas/common";
/** Örnek (geliştirme) kullanıcıları. E-postası olmayanlar kullanıcı adıyla giriş yapar. */ /** Örnek (geliştirme) kullanıcıları. E-postası olmayanlar kullanıcı adıyla giriş yapar. */
@@ -56,3 +58,97 @@ export const SEED_HOUSEHOLDS: SeedHousehold[] = [
], ],
}, },
]; ];
/** Örnek hesaplar (tutarlar kuruş; kart borcu negatif). Açılış: 6 ay önce. */
export type SeedAccount = {
household: string;
name: string;
type: AccountType;
institution?: string;
openingBalanceMinor: number;
color: Swatch;
icon: IconKey;
owner?: string;
isPrivate?: boolean;
mealCardProvider?: MealCardProvider;
overdraft?: { limitMinor: number; monthlyRateBp: number; statementDay: number };
creditCard?: {
limitMinor: number;
statementDay: number;
dueDays: number;
paymentAccount: string;
};
};
export const SEED_ACCOUNTS: SeedAccount[] = [
{
household: "Çakır Ailesi",
name: "Cüzdan",
type: "cash",
openingBalanceMinor: 2_350_00,
color: "green",
icon: "wallet",
},
{
household: "Çakır Ailesi",
name: "Ziraat Maaş",
type: "bank",
institution: "Ziraat Bankası",
openingBalanceMinor: 18_420_50,
color: "red",
icon: "landmark",
overdraft: { limitMinor: 10_000_00, monthlyRateBp: 425, statementDay: 1 },
},
{
household: "Çakır Ailesi",
name: "Garanti Bonus",
type: "credit_card",
institution: "Garanti BBVA",
openingBalanceMinor: -6_480_75,
color: "teal",
icon: "credit-card",
creditCard: {
limitMinor: 60_000_00,
statementDay: 29,
dueDays: 10,
paymentAccount: "Ziraat Maaş",
},
},
{
household: "Çakır Ailesi",
name: "Birikim (Vadeli)",
type: "savings",
institution: "Ziraat Bankası",
openingBalanceMinor: 45_000_00,
color: "indigo",
icon: "piggy-bank",
},
{
household: "Çakır Ailesi",
name: "Ayşe — Pluxee",
type: "meal_card",
mealCardProvider: "pluxee",
openingBalanceMinor: 3_120_00,
color: "lime",
icon: "utensils",
owner: "ayse",
isPrivate: true,
},
{
household: "Annemler",
name: "Nakit",
type: "cash",
openingBalanceMinor: 1_500_00,
color: "green",
icon: "wallet",
},
{
household: "Annemler",
name: "Vakıfbank Emekli",
type: "bank",
institution: "VakıfBank",
openingBalanceMinor: 27_800_00,
color: "amber",
icon: "landmark",
},
];
+156
View File
@@ -0,0 +1,156 @@
import type { Types } from "mongoose";
import { addContribution, createInKind, savePrice } from "@/features/in-kind/service";
import { AccountModel } from "@/models/account";
import { AssetPriceModel } from "@/models/asset-price";
import { ContactModel } from "@/models/contact";
import { InKindDebtModel } from "@/models/in-kind-debt";
import type { AssetKey } from "@/shared/assets";
import { todayIstanbul } from "@/shared/dates";
import { formatMoneyInput } from "@/shared/money";
import { addDays, addMonths } from "@/shared/recurrence";
import {
assetPriceFormSchema,
contributionSchema,
inKindFormSchema,
} from "@/shared/schemas/in-kind";
/**
* Örnek fiyat tablosu ve aynî kayıtlar (Faz 8b). Fiyatlar örnek amaçlıdır (gerçek piyasa verisi
* değildir): son 6 ay, ayda bir kayıt, doğrusal artış. İdempotent.
*/
/** [6 ay önceki alış, bugünkü alış, satış farkı] — birim başına kuruş. */
const PRICE_PATH: Partial<Record<AssetKey, [number, number, number]>> = {
gram_gold: [4_900_00, 5_600_00, 100_00],
quarter_gold: [8_050_00, 9_200_00, 250_00],
usd: [40_20, 42_80, 30],
eur: [46_90, 49_50, 40],
};
async function seedPrices(householdId: Types.ObjectId, actorId: string, today: string) {
for (const [k, [from, to, spread]] of Object.entries(PRICE_PATH)) {
const key = k as AssetKey;
for (let i = 6; i >= 0; i--) {
const date = i === 0 ? today : addMonths(today, -i);
if (await AssetPriceModel.exists({ householdId, assetKey: key, date })) continue;
const buy = Math.round(to - ((to - from) * i) / 6);
await savePrice(
actorId,
String(householdId),
assetPriceFormSchema.parse({
assetKey: key,
date,
buy: formatMoneyInput(buy),
sell: formatMoneyInput(buy + spread),
}),
);
}
}
}
type SeedInKind = {
ref: string;
household: string;
contact: string;
by: string;
direction: "debt" | "receivable";
assetKey: AssetKey;
quantity: string;
daysAgo: number;
note?: string;
savings?: string;
monthlyPlanMinor?: number;
contributions?: { from: string; amountMinor: number; daysAgo: number }[];
};
const ITEMS: SeedInKind[] = [
{
ref: "hasan-altin",
household: "Çakır Ailesi",
contact: "contact:hasan",
by: "admin",
direction: "debt",
assetKey: "gram_gold",
quantity: "5",
daysAgo: 95,
note: "Düğün için ödünç alınan altın",
savings: "Birikim (Vadeli)",
monthlyPlanMinor: 4_000_00,
contributions: [
{ from: "Ziraat Maaş", amountMinor: 4_000_00, daysAgo: 60 },
{ from: "Ziraat Maaş", amountMinor: 4_000_00, daysAgo: 30 },
],
},
{
ref: "elif-usd",
household: "Çakır Ailesi",
contact: "contact:elif",
by: "ayse",
direction: "receivable",
assetKey: "usd",
quantity: "100",
daysAgo: 40,
},
{
ref: "ali-ceyrek",
household: "Annemler",
contact: "contact:ali",
by: "fatma",
direction: "receivable",
assetKey: "quarter_gold",
quantity: "2",
daysAgo: 150,
note: "Torun doğumunda verilen çeyrekler",
},
];
export async function seedInKind(
householdIds: Map<string, Types.ObjectId>,
userIds: Map<string, string>,
): Promise<number> {
const today = todayIstanbul();
await seedPrices(householdIds.get("Çakır Ailesi")!, userIds.get("admin")!, today);
await seedPrices(householdIds.get("Annemler")!, userIds.get("fatma")!, today);
let created = 0;
for (const x of ITEMS) {
const householdId = householdIds.get(x.household)!;
const seedRef = `inkind:${x.ref}`;
if (await InKindDebtModel.exists({ householdId, seedRef })) continue;
const contact = await ContactModel.findOne({ householdId, seedRef: x.contact }).lean();
if (!contact) continue;
const account = async (name: string) =>
String((await AccountModel.findOne({ householdId, name }).lean())!._id);
const by = userIds.get(x.by)!;
const r = await createInKind(
by,
String(householdId),
String(contact._id),
inKindFormSchema.parse({
direction: x.direction,
assetKey: x.assetKey,
quantity: x.quantity,
date: addDays(today, -x.daysAgo),
note: x.note ?? "",
savingsAccountId: x.savings ? await account(x.savings) : "",
monthlyPlan: x.monthlyPlanMinor ? formatMoneyInput(x.monthlyPlanMinor) : "",
}),
);
if (!r.ok) throw new Error(`Seed aynî kaydı oluşturulamadı (${x.ref}): ${r.error}`);
await InKindDebtModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
for (const c of x.contributions ?? []) {
const res = await addContribution(
by,
String(householdId),
r.data.id,
contributionSchema.parse({
fromAccountId: await account(c.from),
amount: formatMoneyInput(c.amountMinor),
date: addDays(today, -c.daysAgo),
}),
);
if (!res.ok) throw new Error(`Seed birikimi oluşturulamadı (${x.ref}): ${res.error}`);
}
created++;
}
return created;
}
+68
View File
@@ -0,0 +1,68 @@
import type { Types } from "mongoose";
import { createTransaction } from "@/features/transactions/service";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { TransactionModel } from "@/models/transaction";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
/**
* Örnek taksitli kart harcamaları (Faz 7b): bir kısmı gerçekleşmiş, kalanı planlı taksitler.
* İdempotent: aynı satıcıyla taksit planı varsa atlanır.
*/
const PURCHASES = [
{
payee: "Hepsiburada",
description: "Çamaşır makinesi",
amountMinor: 18_000_00,
count: 9,
daysAgo: 75,
category: "ev",
by: "admin",
},
{
payee: "Teknosa",
description: "Telefon",
amountMinor: 12_000_00,
count: 6,
daysAgo: 20,
category: "diger-gider",
by: "emre",
},
];
export async function seedInstallments(
householdId: Types.ObjectId,
userIds: Map<string, string>,
): Promise<number> {
const today = todayIstanbul();
const card = await AccountModel.findOne({ householdId, name: "Garanti Bonus" }).lean();
if (!card) return 0;
let created = 0;
for (const p of PURCHASES) {
const exists = await TransactionModel.exists({
householdId,
payeeName: p.payee,
"installment.planId": { $type: "objectId" },
});
if (exists) continue;
const category = await CategoryModel.findOne({ householdId, defaultKey: p.category }).lean();
const r = await createTransaction(userIds.get(p.by)!, String(householdId), {
type: "expense",
amount: p.amountMinor,
date: addDays(today, -p.daysAgo),
accountId: String(card._id),
toAccountId: null,
categoryId: String(category!._id),
splits: [],
payee: p.payee,
description: p.description,
tags: [],
installments: p.count,
attachmentIds: [],
});
if (!r.ok) throw new Error(`Seed taksiti oluşturulamadı (${p.payee}): ${r.error}`);
created++;
}
return created;
}
+201
View File
@@ -0,0 +1,201 @@
import type { Types } from "mongoose";
import { createRule } from "@/features/recurring/service";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { RecurringRuleModel } from "@/models/recurring-rule";
import { todayIstanbul } from "@/shared/dates";
import { addDays, addMonths } from "@/shared/recurrence";
import { recurringRuleFormSchema, type RecurringRuleFormInput } from "@/shared/schemas/recurring";
import { formatMoneyInput } from "@/shared/money";
/**
* Örnek düzenli işlemler (Faz 7a). Seed işlemleri bugüne kadarki günleri doldurur; kurallar bir
* sonraki gerçekleşme gününden başlar (çakışma yok). "Spor salonu" kasıtlı olarak 3 gün önce
* başlar: vadesi geçmiş planlı kayıt örneği. İdempotent (`seedRef`).
*/
type SeedRule = {
ref: string;
by: string;
type: "income" | "expense" | "transfer";
amountMinor: number;
account: string;
category?: string;
payee: string;
/** Ayın günü (bir sonraki gerçekleşme) veya bugüne göre gün farkı. */
day?: number;
offsetDays?: number;
isEstimate?: boolean;
count?: number;
};
const RULES: Record<string, SeedRule[]> = {
"Çakır Ailesi": [
{
ref: "maas",
by: "admin",
type: "income",
amountMinor: 52_000_00,
account: "Ziraat Maaş",
category: "maas",
payee: "Anadolu Yazılım A.Ş.",
day: 15,
},
{
ref: "kira",
by: "admin",
type: "expense",
amountMinor: 17_500_00,
account: "Ziraat Maaş",
category: "kira",
payee: "Ev sahibi",
day: 3,
},
{
ref: "aidat",
by: "admin",
type: "expense",
amountMinor: 1_250_00,
account: "Ziraat Maaş",
category: "aidat",
payee: "Site yönetimi",
day: 5,
},
{
ref: "elektrik",
by: "ayse",
type: "expense",
amountMinor: 1_200_00,
account: "Ziraat Maaş",
category: "faturalar.elektrik",
payee: "Enerjisa",
day: 10,
isEstimate: true,
},
{
ref: "su",
by: "ayse",
type: "expense",
amountMinor: 450_00,
account: "Ziraat Maaş",
category: "faturalar.su",
payee: "İSKİ",
day: 12,
isEstimate: true,
},
{
ref: "dogalgaz",
by: "ayse",
type: "expense",
amountMinor: 2_500_00,
account: "Ziraat Maaş",
category: "faturalar.dogalgaz",
payee: "İGDAŞ",
day: 14,
isEstimate: true,
},
{
ref: "internet",
by: "admin",
type: "expense",
amountMinor: 499_90,
account: "Garanti Bonus",
category: "faturalar.internet",
payee: "Türk Telekom",
day: 20,
},
{
ref: "netflix",
by: "admin",
type: "expense",
amountMinor: 229_99,
account: "Garanti Bonus",
category: "abonelikler",
payee: "Netflix",
day: 8,
},
{
ref: "spor",
by: "emre",
type: "expense",
amountMinor: 1_250_00,
account: "Garanti Bonus",
category: "eglence",
payee: "Spor salonu",
offsetDays: -3,
count: 12,
},
],
Annemler: [
{
ref: "emekli",
by: "fatma",
type: "income",
amountMinor: 24_350_00,
account: "Vakıfbank Emekli",
category: "maas",
payee: "SGK",
day: 17,
},
{
ref: "elektrik",
by: "fatma",
type: "expense",
amountMinor: 700_00,
account: "Vakıfbank Emekli",
category: "faturalar.elektrik",
payee: "Enerjisa",
day: 11,
isEstimate: true,
},
],
};
/** Bugünden sonraki ilk "ayın N. günü". */
function nextMonthDay(today: string, day: number): string {
const thisMonth = addMonths(today, 0, day);
return thisMonth > today ? thisMonth : addMonths(today, 1, day);
}
export async function seedRecurring(
householdIds: Map<string, Types.ObjectId>,
userIds: Map<string, string>,
): Promise<number> {
const today = todayIstanbul();
let created = 0;
for (const [household, rules] of Object.entries(RULES)) {
const householdId = householdIds.get(household)!;
const [accounts, categories] = await Promise.all([
AccountModel.find({ householdId }).select({ name: 1 }).lean(),
CategoryModel.find({ householdId, defaultKey: { $ne: null } })
.select({ defaultKey: 1 })
.lean(),
]);
for (const r of rules) {
const seedRef = `rule:${r.ref}`;
if (await RecurringRuleModel.exists({ householdId, seedRef })) continue;
const input: RecurringRuleFormInput = {
type: r.type,
amount: formatMoneyInput(r.amountMinor),
isEstimate: r.isEstimate ?? false,
accountId: String(accounts.find((a) => a.name === r.account)!._id),
categoryId: r.category
? String(categories.find((c) => c.defaultKey === r.category)!._id)
: null,
payee: r.payee,
startDate: r.day ? nextMonthDay(today, r.day) : addDays(today, r.offsetDays ?? 0),
frequency: "monthly",
endKind: r.count ? "count" : "never",
count: r.count ?? null,
};
const result = await createRule(
userIds.get(r.by)!,
String(householdId),
recurringRuleFormSchema.parse(input),
);
if (!result.ok) throw new Error(`Seed kuralı oluşturulamadı (${seedRef}): ${result.error}`);
await RecurringRuleModel.updateOne({ _id: result.data.id }, { $set: { seedRef } });
created++;
}
}
return created;
}
+44 -2
View File
@@ -24,22 +24,64 @@ describe("seed", () => {
it("ilk çalıştırmada admin, örnek kullanıcılar, haneler ve üyelikleri oluşturur", async () => { it("ilk çalıştırmada admin, örnek kullanıcılar, haneler ve üyelikleri oluşturur", async () => {
const summary = await runSeed({ log: silent }); const summary = await runSeed({ log: silent });
const memberCount = SEED_HOUSEHOLDS.reduce((n, h) => n + h.members.length, 0); const memberCount = SEED_HOUSEHOLDS.reduce((n, h) => n + h.members.length, 0);
expect(summary.created).toEqual({ expect(summary.created).toMatchObject({
users: SEED_USERS.length + 1, users: SEED_USERS.length + 1,
households: SEED_HOUSEHOLDS.length, households: SEED_HOUSEHOLDS.length,
memberships: memberCount, memberships: memberCount,
}); });
expect(summary.created.categories).toBeGreaterThan(40);
expect(summary.created.accounts).toBe(7);
expect(summary.created.transactions).toBeGreaterThan(150);
expect(summary.created.assets).toBe(4);
expect(summary.created.budgets).toBe(2);
expect(summary.created.stock).toBe(19);
expect(summary.created.stores).toBe(8);
expect(summary.created.products).toBe(22);
expect(summary.created.prices).toBeGreaterThan(200);
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount); expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
}); });
it("idempotenttir: ikinci çalıştırma kopya oluşturmaz", async () => { it("idempotenttir: ikinci çalıştırma kopya oluşturmaz", async () => {
const summary = await runSeed({ log: silent }); const summary = await runSeed({ log: silent });
expect(summary.created).toEqual({ users: 0, households: 0, memberships: 0 }); expect(summary.created).toEqual({
users: 0,
households: 0,
memberships: 0,
categories: 0,
accounts: 0,
transactions: 0,
recurringRules: 0,
installmentPlans: 0,
contacts: 0,
inKind: 0,
assets: 0,
budgets: 0,
stock: 0,
stores: 0,
products: 0,
prices: 0,
shoppingLists: 0,
});
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
}); });
it("aynı aydaki transferlerin hepsi oluşur (seedRef çakışması yok)", async () => {
const tx = getDb().collection("transactions");
const months = new Set(
(await tx.find({ description: "ATM" }).toArray()).map((t) => t.seedRef.split(":")[1]),
);
expect(months.size).toBeGreaterThanOrEqual(5);
// Her ay ATM'nin yanında kart ödemesi ve birikim de var (ayın 7'si geçmişse).
expect(await tx.countDocuments({ description: "Kart borcu ödemesi" })).toBeGreaterThanOrEqual(
months.size - 1,
);
expect(await tx.countDocuments({ description: "Aylık birikim" })).toBeGreaterThanOrEqual(
months.size - 1,
);
});
it("roller ve askı durumu doğru", async () => { it("roller ve askı durumu doğru", async () => {
const users = await getDb() const users = await getDb()
.collection("user") .collection("user")
+176 -8
View File
@@ -3,10 +3,29 @@ import { getAuth } from "@/lib/auth";
import { getDb } from "@/lib/db/mongo"; import { getDb } from "@/lib/db/mongo";
import { connectMongoose } from "@/lib/db/mongoose"; import { connectMongoose } from "@/lib/db/mongoose";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { getStorage } from "@/lib/storage";
import { installDefaultCategories } from "@/features/categories/service";
import { AccountModel } from "@/models/account";
import { HouseholdModel } from "@/models/household"; import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member"; import { HouseholdMemberModel } from "@/models/household-member";
import { storedEmail } from "@/shared/account-email"; import { storedEmail } from "@/shared/account-email";
import { ADMIN_USERNAME, SEED_HOUSEHOLDS, SEED_USERS, type SeedUser } from "./fixtures"; import { runMigrations } from "../migrations";
import { seedContacts } from "./contacts";
import { seedAssets } from "./assets";
import { seedBudgets } from "./budgets";
import { seedProductPrices, seedShopping, seedShoppingLists } from "./shopping";
import { seedStock } from "./stock";
import { seedInKind } from "./in-kind";
import { seedInstallments } from "./installments";
import { seedRecurring } from "./recurring";
import { seedTransactions } from "./transactions";
import {
ADMIN_USERNAME,
SEED_ACCOUNTS,
SEED_HOUSEHOLDS,
SEED_USERS,
type SeedUser,
} from "./fixtures";
/** /**
* İdempotent seed. Kullanıcılar Better Auth API'siyle oluşturulur (şifreler doğru hashlenir); * İdempotent seed. Kullanıcılar Better Auth API'siyle oluşturulur (şifreler doğru hashlenir);
@@ -25,12 +44,56 @@ export type SeedAccountRow = {
}; };
export type SeedSummary = { export type SeedSummary = {
created: { users: number; households: number; memberships: number }; created: {
users: number;
households: number;
memberships: number;
categories: number;
accounts: number;
transactions: number;
recurringRules: number;
installmentPlans: number;
contacts: number;
inKind: number;
assets: number;
budgets: number;
stock: number;
stores: number;
products: number;
prices: number;
shoppingLists: number;
};
accounts: SeedAccountRow[]; accounts: SeedAccountRow[];
}; };
const AUTH_COLLECTIONS = ["user", "account", "session", "verification", "rateLimit"]; const AUTH_COLLECTIONS = ["user", "account", "session", "verification", "rateLimit"];
const DOMAIN_COLLECTIONS = ["households", "householdMembers"]; const DOMAIN_COLLECTIONS = [
"households",
"householdMembers",
"auditLogs",
"categories",
"accounts",
"transactions",
"payees",
"attachments",
"reconciliations",
"recurringRules",
"statements",
"contacts",
"assetPrices",
"inKindDebts",
"assets",
"assetMovements",
"budgets",
"stockCategories",
"productTypes",
"stockMovements",
"stockCounts",
"stores",
"products",
"productPrices",
"shoppingLists",
];
type AuthApi = ReturnType<typeof getAuth>["api"]; type AuthApi = ReturnType<typeof getAuth>["api"];
@@ -72,20 +135,45 @@ export async function runSeed({
const db = getDb(); const db = getDb();
await connectMongoose(); await connectMongoose();
const summary: SeedSummary = { const summary: SeedSummary = {
created: { users: 0, households: 0, memberships: 0 }, created: {
users: 0,
households: 0,
memberships: 0,
categories: 0,
accounts: 0,
transactions: 0,
recurringRules: 0,
installmentPlans: 0,
contacts: 0,
inKind: 0,
assets: 0,
budgets: 0,
stock: 0,
stores: 0,
products: 0,
prices: 0,
shoppingLists: 0,
},
accounts: [], accounts: [],
}; };
if (reset) { if (reset) {
// Ek dosyaların diskteki kopyaları da silinsin (yetim dosya kalmasın).
const storage = getStorage();
for await (const a of db
.collection("attachments")
.find({}, { projection: { storageKey: 1, thumbKey: 1 } })) {
await storage.delete(String(a.storageKey));
if (a.thumbKey) await storage.delete(String(a.thumbKey));
}
for (const name of [...AUTH_COLLECTIONS, ...DOMAIN_COLLECTIONS]) { for (const name of [...AUTH_COLLECTIONS, ...DOMAIN_COLLECTIONS]) {
await db.collection(name).deleteMany({}); await db.collection(name).deleteMany({});
} }
log("• Kimlik doğrulama ve hane koleksiyonları temizlendi."); log("• Kimlik doğrulama ve hane koleksiyonları temizlendi.");
} }
// Index'ler (production'da Mongoose autoIndex kapalı olduğu için açıkça oluşturulur). // Index'ler ve şema değişiklikleri (production'da Mongoose autoIndex kapalı).
await HouseholdModel.createIndexes(); await runMigrations(log);
await HouseholdMemberModel.createIndexes();
// 1) Admin // 1) Admin
const adminEmail = e.SEED_ADMIN_EMAIL.toLowerCase(); const adminEmail = e.SEED_ADMIN_EMAIL.toLowerCase();
@@ -180,7 +268,87 @@ export async function runSeed({
} }
} }
// 5) Geliştirme hesapları tablosu // 5) Varsayılan kategoriler ve örnek hesaplar
const householdIds = new Map<string, Types.ObjectId>();
for (const h of SEED_HOUSEHOLDS) {
const doc = await HouseholdModel.findOne({ name: h.name }).select({ _id: 1 }).lean();
householdIds.set(h.name, doc!._id);
summary.created.categories += await installDefaultCategories(doc!._id, actor);
}
const openingDate = new Date(Date.now() - 183 * 24 * 60 * 60 * 1000);
for (const [order, a] of SEED_ACCOUNTS.entries()) {
const householdId = householdIds.get(a.household)!;
if (await AccountModel.exists({ householdId, name: a.name })) continue;
const payer = a.creditCard
? await AccountModel.findOne({ householdId, name: a.creditCard.paymentAccount })
.select({ _id: 1 })
.lean()
: null;
await AccountModel.create({
householdId,
name: a.name,
type: a.type,
institution: a.institution ?? "",
mealCardProvider: a.mealCardProvider ?? null,
openingBalanceMinor: a.openingBalanceMinor,
openingDate,
color: a.color,
icon: a.icon,
order,
ownerUserId: a.owner ? new Types.ObjectId(ids.get(a.owner)!) : null,
isPrivate: a.isPrivate ?? false,
overdraft: a.overdraft ? { enabled: true, ...a.overdraft } : null,
creditCard: a.creditCard
? {
limitMinor: a.creditCard.limitMinor,
statementDay: a.creditCard.statementDay,
dueRule: { kind: "daysAfter", value: a.creditCard.dueDays },
paymentAccountId: payer?._id ?? null,
}
: null,
createdBy: actor,
updatedBy: actor,
});
summary.created.accounts++;
}
if (summary.created.categories) log(`✓ Varsayılan kategoriler: ${summary.created.categories}`);
if (summary.created.accounts) log(`✓ Örnek hesaplar: ${summary.created.accounts}`);
// 6) Son 6 ayın örnek işlemleri (faker.seed(42), idempotent)
const userIdMap = new Map([...ids.entries()]);
summary.created.transactions = await seedTransactions(householdIds, userIdMap);
if (summary.created.transactions) log(`✓ Örnek işlemler: ${summary.created.transactions}`);
summary.created.recurringRules = await seedRecurring(householdIds, userIdMap);
if (summary.created.recurringRules)
log(`✓ Örnek düzenli işlemler: ${summary.created.recurringRules}`);
summary.created.installmentPlans = await seedInstallments(
householdIds.get("Çakır Ailesi")!,
userIdMap,
);
if (summary.created.installmentPlans)
log(`✓ Örnek taksitli alışverişler: ${summary.created.installmentPlans}`);
summary.created.contacts = await seedContacts(householdIds, userIdMap);
if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`);
summary.created.inKind = await seedInKind(householdIds, userIdMap);
if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`);
summary.created.assets = await seedAssets(householdIds, userIdMap);
if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`);
summary.created.budgets = await seedBudgets(householdIds, userIdMap);
if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`);
summary.created.stock = await seedStock(householdIds, userIdMap);
if (summary.created.stock) log(`✓ Örnek stok ürün tipleri: ${summary.created.stock}`);
const shopping = await seedShopping(householdIds, userIdMap);
summary.created.stores = shopping.stores;
summary.created.products = shopping.products;
if (shopping.stores || shopping.products)
log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`);
summary.created.prices = await seedProductPrices(householdIds, userIdMap);
if (summary.created.prices) log(`✓ Örnek ürün fiyatları: ${summary.created.prices}`);
summary.created.shoppingLists = await seedShoppingLists(householdIds, userIdMap);
if (summary.created.shoppingLists)
log(`✓ Örnek alışveriş listeleri: ${summary.created.shoppingLists}`);
// 7) Geliştirme hesapları tablosu
const all = [ const all = [
{ username: ADMIN_USERNAME, name: "Yönetici", email: adminEmail, role: "admin" as const }, { username: ADMIN_USERNAME, name: "Yönetici", email: adminEmail, role: "admin" as const },
...SEED_USERS, ...SEED_USERS,
+580
View File
@@ -0,0 +1,580 @@
import { Types } from "mongoose";
import { ProductModel } from "@/models/product";
import { ProductPriceModel } from "@/models/product-price";
import { ProductTypeModel } from "@/models/product-type";
import { ShoppingListModel } from "@/models/shopping-list";
import { StoreModel } from "@/models/store";
import type { MealCardProvider } from "@/shared/accounts";
import { autoConvertible } from "@/shared/products";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import { foldTr } from "@/shared/search";
import type { StoreKind } from "@/shared/stores";
/**
* Örnek mağazalar ve ürünler (Faz 12). Çakır Ailesi (Kadıköy) için marketler, pazar ve online
* platform; stok ürün tiplerine bağlı marka/paketler. Elle dönüşüm gereken örnekler: deterjan
* (kg → yıkama), tablet paketi (paket → tablet), diş macunu/şampuan (ml → adet). Görsel yok.
* Idempotent (`seedRef`). Miktarlar binde bir, tutarlar kuruş.
*/
type SeedStore = {
ref: string;
name: string;
kind: StoreKind;
branch?: string;
distanceM?: number;
carry: number;
deliveryFee?: number;
freeDeliveryMin?: number;
mealCards?: MealCardProvider[];
note?: string;
};
export const STORES: SeedStore[] = [
{
ref: "migros",
name: "Migros",
kind: "market",
branch: "Bahariye",
distanceM: 1_200,
carry: 2,
mealCards: ["pluxee", "multinet", "edenred", "setcard"],
},
{ ref: "a101", name: "A101", kind: "market", branch: "Moda", distanceM: 400, carry: 1 },
{ ref: "bim", name: "BİM", kind: "market", branch: "Yeldeğirmeni", distanceM: 650, carry: 2 },
{
ref: "sok",
name: "Şok",
kind: "market",
branch: "Rasimpaşa",
distanceM: 300,
carry: 1,
mealCards: ["multinet", "pluxee"],
},
{
ref: "carrefoursa",
name: "CarrefourSA",
kind: "market",
branch: "Acıbadem",
distanceM: 3_800,
carry: 4,
mealCards: ["pluxee", "multinet", "edenred", "setcard", "metropol"],
note: "Hafta sonu arabayla; büyük alışveriş.",
},
{
ref: "sali-pazari",
name: "Salı Pazarı",
kind: "bazaar",
branch: "Kadıköy",
distanceM: 1_900,
carry: 4,
note: "Salı ve cuma; sebze-meyve.",
},
{
ref: "getir-buyuk",
name: "Getir Büyük",
kind: "online",
carry: 1,
deliveryFee: 39_90,
freeDeliveryMin: 750_00,
mealCards: ["multinet"],
},
{
ref: "metro",
name: "Metro Toptancı",
kind: "market",
branch: "Kozyatağı",
distanceM: 9_500,
carry: 5,
},
];
type SeedProduct = {
ref: string;
/** Stok seed'indeki ürün tipi `seedRef`'i. */
type: string;
brand: string;
name?: string;
barcode?: string;
qty: number;
unit: string;
/** Elle dönüşüm (bir paket = bu kadar ürün tipi birimi). */
typeQty?: number;
};
const K = 1000;
export const PRODUCTS: SeedProduct[] = [
{
ref: "ariel-5kg",
type: "toz-deterjan",
brand: "Ariel",
name: "Matik Dağ Esintisi",
qty: 5 * K,
unit: "kg",
typeQty: 33 * K,
barcode: "8690000100015",
},
{
ref: "omo-6kg",
type: "toz-deterjan",
brand: "Omo",
name: "Matik Active Fresh",
qty: 6 * K,
unit: "kg",
typeQty: 40 * K,
},
{
ref: "bingo-4kg",
type: "toz-deterjan",
brand: "Bingo",
name: "Matik",
qty: 4 * K,
unit: "kg",
typeQty: 26 * K,
},
{
ref: "fairy-50",
type: "tablet-deterjan",
brand: "Fairy",
name: "Platinum Plus",
qty: 1 * K,
unit: "paket",
typeQty: 50 * K,
barcode: "8690000100022",
},
{
ref: "finish-40",
type: "tablet-deterjan",
brand: "Finish",
name: "Quantum",
qty: 40 * K,
unit: "tablet",
},
{
ref: "yayla-mercimek",
type: "kirmizi-mercimek",
brand: "Yayla",
name: "Kırmızı Mercimek",
qty: 1 * K,
unit: "kg",
},
{
ref: "reis-mercimek",
type: "kirmizi-mercimek",
brand: "Reis",
name: "Kırmızı Mercimek",
qty: 1 * K,
unit: "kg",
},
{
ref: "duru-mercimek",
type: "kirmizi-mercimek",
brand: "Duru",
name: "Kırmızı Mercimek",
qty: 2_500,
unit: "kg",
},
{ ref: "yayla-baldo", type: "pirinc", brand: "Yayla", name: "Baldo", qty: 2_500, unit: "kg" },
{ ref: "reis-baldo", type: "pirinc", brand: "Reis", name: "Baldo", qty: 1 * K, unit: "kg" },
{
ref: "pinar-sut",
type: "sut",
brand: "Pınar",
name: "Tam Yağlı Süt",
qty: 1 * K,
unit: "lt",
barcode: "8690000100039",
},
{ ref: "sutas-sut", type: "sut", brand: "Sütaş", name: "Tam Yağlı Süt", qty: 1 * K, unit: "lt" },
{
ref: "icim-sut-500",
type: "sut",
brand: "İçim",
name: "Yarım Yağlı Süt",
qty: 500 * K,
unit: "ml",
},
{
ref: "keskinoglu-30",
type: "yumurta",
brand: "Keskinoğlu",
name: "L Boy 30'lu",
qty: 30 * K,
unit: "adet",
},
{
ref: "koy-yumurta-15",
type: "yumurta",
brand: "Köy Yumurtası",
name: "15'li",
qty: 15 * K,
unit: "adet",
},
{
ref: "pinar-peynir",
type: "beyaz-peynir",
brand: "Pınar",
name: "Beyaz Peynir",
qty: 500 * K,
unit: "g",
},
{
ref: "yumos-1440",
type: "yumusatici",
brand: "Yumoş",
name: "Extra Lilyum",
qty: 1_440 * K,
unit: "ml",
},
{
ref: "selpak-32",
type: "tuvalet-kagidi",
brand: "Selpak",
name: "32'li",
qty: 32 * K,
unit: "rulo",
},
{
ref: "solo-24",
type: "tuvalet-kagidi",
brand: "Solo",
name: "24'lü",
qty: 24 * K,
unit: "rulo",
},
{
ref: "colgate-75",
type: "dis-macunu",
brand: "Colgate",
name: "Total 75 ml",
qty: 75 * K,
unit: "ml",
typeQty: 1 * K,
},
{
ref: "sensodyne-75",
type: "dis-macunu",
brand: "Sensodyne",
name: "Repair 75 ml",
qty: 75 * K,
unit: "ml",
typeQty: 1 * K,
},
{
ref: "elidor-500",
type: "sampuan",
brand: "Elidor",
name: "500 ml",
qty: 500 * K,
unit: "ml",
typeQty: 1 * K,
},
];
export async function seedShopping(
householdIds: Map<string, Types.ObjectId>,
users: Map<string, string>,
): Promise<{ stores: number; products: number }> {
const actor = new Types.ObjectId(users.get("ayse")!);
const householdId = householdIds.get("Çakır Ailesi")!;
let stores = 0;
for (const s of STORES) {
if (await StoreModel.exists({ householdId, seedRef: s.ref })) continue;
await StoreModel.create({
householdId,
name: s.name,
nameKey: foldTr(s.name),
kind: s.kind,
branch: s.branch ?? "",
distanceM: s.kind === "online" ? null : (s.distanceM ?? null),
carryDifficulty: s.carry,
deliveryFeeMinor: s.kind === "online" ? (s.deliveryFee ?? null) : null,
freeDeliveryMinMinor: s.kind === "online" ? (s.freeDeliveryMin ?? null) : null,
mealCardAccepted: Boolean(s.mealCards?.length),
mealCardProviders: s.mealCards ?? [],
note: s.note ?? "",
seedRef: s.ref,
createdBy: actor,
updatedBy: actor,
});
stores++;
}
const types = await ProductTypeModel.find({ householdId, seedRef: { $type: "string" } })
.select({ seedRef: 1, unit: 1 })
.lean<{ _id: Types.ObjectId; seedRef: string; unit: string }[]>();
const typeByRef = new Map(types.map((x) => [x.seedRef, x]));
let products = 0;
for (const p of PRODUCTS) {
const type = typeByRef.get(p.type);
if (!type || (await ProductModel.exists({ householdId, seedRef: p.ref }))) continue;
if (!autoConvertible(p.unit, type.unit) && !p.typeQty)
throw new Error(`Seed ürünü ${p.ref}: ${p.unit} → ${type.unit} dönüşümü eksik`);
await ProductModel.create({
householdId,
productTypeId: type._id,
brand: p.brand,
name: p.name ?? "",
searchKey: foldTr(`${p.brand} ${p.name ?? ""}`),
barcode: p.barcode ?? null,
packageQuantity: p.qty,
packageUnit: p.unit,
typeQuantity: autoConvertible(p.unit, type.unit) ? null : p.typeQty,
seedRef: p.ref,
createdBy: actor,
updatedBy: actor,
});
products++;
}
return { stores, products };
}
// --- Fiyatlar (Faz 12b) ---
/** Bugünkü paket fiyatı (kuruş, Kasım 2026 tahmini). */
const CURRENT_PRICE: Record<string, number> = {
"ariel-5kg": 519_90,
"omo-6kg": 559_90,
"bingo-4kg": 329_90,
"fairy-50": 479_90,
"finish-40": 419_90,
"yayla-mercimek": 84_90,
"reis-mercimek": 79_90,
"duru-mercimek": 189_90,
"yayla-baldo": 259_90,
"reis-baldo": 109_90,
"pinar-sut": 41_90,
"sutas-sut": 39_90,
"icim-sut-500": 23_90,
"keskinoglu-30": 174_90,
"koy-yumurta-15": 119_90,
"pinar-peynir": 209_90,
"yumos-1440": 139_90,
"selpak-32": 329_90,
"solo-24": 229_90,
"colgate-75": 84_90,
"sensodyne-75": 159_90,
"elidor-500": 119_90,
};
/** Mağaza fiyat çarpanı (indirim marketi ucuz, online pahalı). */
const STORE_FACTOR: Record<string, number> = {
migros: 1.06,
a101: 0.94,
bim: 0.91,
sok: 0.96,
carrefoursa: 1.0,
"getir-buyuk": 1.12,
metro: 0.88,
};
const PRICE_STORES = Object.keys(STORE_FACTOR);
/** Aylık fiyat artışı. */
const MONTHLY_INFLATION = 0.025;
/** x,90 biçimine yuvarla (ör. 87,34 → 86,90 / 87,90). */
const ninety = (minor: number) => Math.max(90, Math.round((minor - 90) / 100) * 100 + 90);
type SeedPrice = {
ref: string;
product: string;
store: string;
daysAgo: number;
priceMinor: number;
isPromo?: boolean;
promoUntilDaysAgo?: number;
};
/**
* Her ürün 3 mağazada (ürün sırasına göre dönüşümlü), son 6 ayda ~45 günde bir fiyat.
* Kampanyalar: Ariel @ Migros hâlâ süren (%20), Pınar süt @ A101 süresi bitmiş (önceki normal
* fiyata dönülür). Reis mercimek @ Metro yalnızca eski kayıtlarla ("eski fiyat").
*/
export function seedPrices(): SeedPrice[] {
const out: SeedPrice[] = [];
PRODUCTS.forEach((p, i) => {
const current = CURRENT_PRICE[p.ref]!;
const stores = [0, 2, 4].map((k) => PRICE_STORES[(i + k) % PRICE_STORES.length]!);
stores.forEach((store, si) => {
for (let step = 0; step < 4; step++) {
const daysAgo = 170 - step * 45 - si * 7 - (i % 5);
if (daysAgo < 0) continue;
if (p.ref === "reis-mercimek" && store === "metro" && daysAgo < 100) continue;
const months = daysAgo / 30;
const price = (current * STORE_FACTOR[store]!) / (1 + MONTHLY_INFLATION) ** months;
out.push({
ref: `${p.ref}:${store}:${step}`,
product: p.ref,
store,
daysAgo,
priceMinor: ninety(price),
});
}
});
});
out.push(
{
ref: "ariel-5kg:migros:promo",
product: "ariel-5kg",
store: "migros",
daysAgo: 4,
priceMinor: ninety(CURRENT_PRICE["ariel-5kg"]! * 1.06 * 0.8),
isPromo: true,
promoUntilDaysAgo: -6,
},
{
ref: "pinar-sut:a101:normal",
product: "pinar-sut",
store: "a101",
daysAgo: 30,
priceMinor: ninety(CURRENT_PRICE["pinar-sut"]! * 0.94),
},
{
ref: "pinar-sut:a101:promo",
product: "pinar-sut",
store: "a101",
daysAgo: 12,
priceMinor: ninety(CURRENT_PRICE["pinar-sut"]! * 0.75),
isPromo: true,
promoUntilDaysAgo: 3,
},
);
return out;
}
export async function seedProductPrices(
householdIds: Map<string, Types.ObjectId>,
users: Map<string, string>,
): Promise<number> {
const actor = new Types.ObjectId(users.get("ayse")!);
const householdId = householdIds.get("Çakır Ailesi")!;
const [products, stores] = await Promise.all([
ProductModel.find({ householdId, seedRef: { $type: "string" } })
.select({ seedRef: 1 })
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
StoreModel.find({ householdId, seedRef: { $type: "string" } })
.select({ seedRef: 1 })
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
]);
const productByRef = new Map(products.map((p) => [p.seedRef, p._id]));
const storeByRef = new Map(stores.map((x) => [x.seedRef, x._id]));
const existing = new Set(
(
await ProductPriceModel.find({ householdId, seedRef: { $type: "string" } })
.select({ seedRef: 1 })
.lean<{ seedRef: string }[]>()
).map((x) => x.seedRef),
);
const today = todayIstanbul();
const base = Date.now() - 200 * 86_400_000;
const docs = seedPrices()
.filter((x) => !existing.has(x.ref) && productByRef.has(x.product) && storeByRef.has(x.store))
.sort((a, b) => b.daysAgo - a.daysAgo)
.map((x, i) => {
const at = new Date(base + i * 1000);
return {
householdId,
productId: productByRef.get(x.product)!,
storeId: storeByRef.get(x.store)!,
date: addDays(today, -x.daysAgo),
priceMinor: x.priceMinor,
isPromo: x.isPromo ?? false,
promoUntil: x.promoUntilDaysAgo === undefined ? null : addDays(today, -x.promoUntilDaysAgo),
source: "manual",
note: "",
deletedAt: null,
seedRef: x.ref,
createdBy: actor,
createdAt: at,
updatedAt: at,
};
});
if (docs.length) await ProductPriceModel.collection.insertMany(docs);
return docs.length;
}
type SeedListItem =
| { type: string; qty?: number }
| { product: string; qty?: number }
| { text: string; qty?: number; unit?: string; note?: string };
/**
* Örnek alışveriş listeleri (Faz 13). Eşiğin altındaki ürünler (mercimek, tablet deterjan, diş
* macunu) listelere eklenmez; liste sayfasında öneri olarak görünür. Domatesin fiyatı yok:
* akıllı öneride "fiyatı bilinmeyen" örneği.
*/
const SHOPPING_LISTS: { ref: string; name: string; note?: string; items: SeedListItem[] }[] = [
{
ref: "haftalik",
name: "Haftalık market",
items: [
{ type: "sut", qty: 4 * K },
{ type: "yumurta", qty: 30 * K },
{ type: "pirinc", qty: 2_500 },
{ type: "beyaz-peynir", qty: 500 },
{ type: "domates", qty: 2 * K },
{ type: "toz-deterjan", qty: 40 * K },
{ type: "tuvalet-kagidi", qty: 32 * K },
{ product: "yumos-1440", qty: 1 * K },
{ text: "Doğum günü mumu", qty: 1 * K, unit: "paket", note: "Rakam mum: 7" },
],
},
{
ref: "ay-sonu",
name: "Ay sonu toptan",
note: "Metro'ya arabayla.",
items: [{ type: "camasir-suyu", qty: 4 * K }],
},
];
export async function seedShoppingLists(
householdIds: Map<string, Types.ObjectId>,
users: Map<string, string>,
): Promise<number> {
const actor = new Types.ObjectId(users.get("ayse")!);
const householdId = householdIds.get("Çakır Ailesi")!;
const [types, products] = await Promise.all([
ProductTypeModel.find({ householdId, seedRef: { $type: "string" } })
.select({ seedRef: 1 })
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
ProductModel.find({ householdId, seedRef: { $type: "string" } })
.select({ seedRef: 1 })
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
]);
const typeByRef = new Map(types.map((x) => [x.seedRef, x._id]));
const productByRef = new Map(products.map((x) => [x.seedRef, x._id]));
let created = 0;
for (const l of SHOPPING_LISTS) {
if (await ShoppingListModel.exists({ householdId, seedRef: l.ref })) continue;
const items = l.items.flatMap((i): Record<string, unknown>[] => {
const common = {
_id: new Types.ObjectId(),
quantity: i.qty ?? null,
source: "manual",
addedBy: actor,
addedAt: new Date(),
};
if ("type" in i) {
const id = typeByRef.get(i.type);
return id ? [{ ...common, kind: "type", productTypeId: id }] : [];
}
if ("product" in i) {
const id = productByRef.get(i.product);
return id ? [{ ...common, kind: "product", productId: id }] : [];
}
return [{ ...common, kind: "text", text: i.text, unit: i.unit ?? "", note: i.note ?? "" }];
});
await ShoppingListModel.create({
householdId,
name: l.name,
note: l.note ?? "",
items,
seedRef: l.ref,
createdBy: actor,
updatedBy: actor,
});
created++;
}
return created;
}
+443
View File
@@ -0,0 +1,443 @@
import { Types } from "mongoose";
import { installDefaultStockCategories } from "@/features/stock/service";
import { ProductTypeModel } from "@/models/product-type";
import { StockCategoryModel } from "@/models/stock-category";
import { StockCountModel } from "@/models/stock-count";
import { StockMovementModel } from "@/models/stock-movement";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import { foldTr } from "@/shared/search";
/**
* Örnek ev stoğu (Faz 11). Varsayılan stok kategorileri her haneye; Çakır Ailesi'ne ürün tipleri
* ve son 6 ayın haftalık tüketim simülasyonu (alım, tüketim, fire, periyodik sayım, SKT).
* Sayım: Mutfak 15 günde bir (son sayım 21 gün önce → gecikmiş), Genel Temizlik 30 günde bir
* (son sayım 14 gün önce). Deterministik; idempotent: ürün tipi `seedRef` ile bir kez
* oluşturulur, sayım oturumları `seedRef` ile paylaşılır. Miktarlar binde bir.
*/
type SeedProduct = {
ref: string;
category: string;
name: string;
unit: string;
target: number;
/** Alımda paket miktarı (alım = hedefe tamamlayan paket sayısı × paket). */
pack: number;
perDay: number;
location: string;
step?: number;
threshold?: number;
shelfDays?: number;
/** Her n. alımdan sonra fire (oran %). */
waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" };
/** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */
stopRefillDaysAgo?: number;
/** Ağır/hacimli (akıllı öneride taşıma cezası). */
bulky?: boolean;
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
countDrift?: { everyCounts: number; pct: number };
};
const K = 1000;
export const PRODUCTS: SeedProduct[] = [
// Mutfak › Bakliyat
{
ref: "kirmizi-mercimek",
category: "mutfak.bakliyat",
name: "Kırmızı Mercimek",
unit: "kg",
target: 2 * K,
pack: 1 * K,
perDay: 40,
location: "Kiler",
step: 100,
stopRefillDaysAgo: 40,
},
{
ref: "kuru-fasulye",
category: "mutfak.bakliyat",
name: "Kuru Fasulye",
unit: "kg",
target: 2 * K,
pack: 1 * K,
perDay: 25,
location: "Kiler",
step: 250,
},
{
ref: "nohut",
category: "mutfak.bakliyat",
name: "Nohut",
unit: "kg",
target: 2 * K,
pack: 1 * K,
perDay: 20,
location: "Kiler",
step: 250,
},
{
ref: "pirinc",
category: "mutfak.bakliyat",
name: "Baldo Pirinç",
unit: "kg",
target: 5 * K,
pack: 2_500,
perDay: 90,
location: "Kiler",
step: 500,
},
// Mutfak › Manav
{
ref: "domates",
category: "mutfak.manav",
name: "Domates",
unit: "kg",
target: 3 * K,
pack: 1 * K,
perDay: 300,
location: "Buzdolabı",
step: 500,
shelfDays: 8,
waste: { every: 2, pct: 30, reason: "spoiled" },
},
{
ref: "patates",
category: "mutfak.manav",
name: "Patates",
unit: "kg",
target: 5 * K,
pack: 1 * K,
perDay: 250,
location: "Kiler",
step: 500,
waste: { every: 4, pct: 10, reason: "spoiled" },
},
{
ref: "sogan",
category: "mutfak.manav",
name: "Kuru Soğan",
unit: "kg",
target: 3 * K,
pack: 1 * K,
perDay: 120,
location: "Kiler",
step: 500,
},
// Mutfak › Şarküteri
{
ref: "beyaz-peynir",
category: "mutfak.sarkuteri",
name: "Beyaz Peynir",
unit: "kg",
target: 1 * K,
pack: 500,
perDay: 30,
location: "Buzdolabı",
step: 100,
shelfDays: 45,
},
{
ref: "yumurta",
category: "mutfak.sarkuteri",
name: "Yumurta",
unit: "adet",
target: 30 * K,
pack: 15 * K,
perDay: 3 * K,
location: "Buzdolabı",
shelfDays: 25,
},
{
ref: "sut",
category: "mutfak.sarkuteri",
name: "Süt",
unit: "lt",
target: 6 * K,
pack: 1 * K,
perDay: 700,
location: "Buzdolabı",
shelfDays: 12,
waste: { every: 6, pct: 15, reason: "expired" },
},
// Genel Temizlik
{
ref: "tablet-deterjan",
category: "genel-temizlik.bulasik",
name: "Tablet Deterjan",
unit: "tablet",
target: 60 * K,
pack: 40 * K,
perDay: 720,
location: "Lavabo altı",
stopRefillDaysAgo: 40,
},
{
ref: "elde-yikama",
category: "genel-temizlik.bulasik",
name: "Elde Yıkama Deterjanı",
unit: "lt",
target: 1_500,
pack: 750,
perDay: 15,
location: "Lavabo altı",
step: 100,
},
{
ref: "toz-deterjan",
bulky: true,
category: "genel-temizlik.camasir",
name: "Toz Deterjan",
unit: "yıkama",
target: 80 * K,
pack: 50 * K,
perDay: 700,
location: "Banyo dolabı",
countDrift: { everyCounts: 2, pct: 4 },
},
{
ref: "yumusatici",
bulky: true,
category: "genel-temizlik.camasir",
name: "Yumuşatıcı",
unit: "lt",
target: 3 * K,
pack: 1_440,
perDay: 40,
location: "Banyo dolabı",
step: 100,
},
{
ref: "camasir-suyu",
bulky: true,
category: "genel-temizlik.genel",
name: "Çamaşır Suyu",
unit: "lt",
target: 3 * K,
pack: 1 * K,
perDay: 25,
location: "Balkon",
step: 250,
},
{
ref: "tuvalet-kagidi",
bulky: true,
category: "genel-temizlik.genel",
name: "Tuvalet Kağıdı",
unit: "rulo",
target: 32 * K,
pack: 16 * K,
perDay: 600,
location: "Banyo dolabı",
countDrift: { everyCounts: 3, pct: 6 },
},
// Kişisel Temizlik
{
ref: "sampuan",
category: "kisisel-temizlik.sampuan",
name: "Şampuan",
unit: "adet",
target: 3 * K,
pack: 1 * K,
perDay: 50,
location: "Banyo dolabı",
},
{
ref: "tiras-bicagi",
category: "kisisel-temizlik.tiras",
name: "Tıraş Bıçağı",
unit: "adet",
target: 8 * K,
pack: 4 * K,
perDay: 150,
location: "Banyo dolabı",
},
{
ref: "dis-macunu",
category: "kisisel-temizlik.kisisel-bakim",
name: "Diş Macunu",
unit: "adet",
target: 4 * K,
pack: 2 * K,
perDay: 70,
location: "Banyo dolabı",
stopRefillDaysAgo: 60,
},
];
const START_DAYS_AGO = 182;
/** Sayım sıklığı (gün) ve sayım günü kuralı (gün önce), ana stok kategorisine göre. */
const COUNT_PLANS: Record<string, { everyDays: number; on: (daysAgo: number) => boolean }> = {
mutfak: { everyDays: 15, on: (d) => d >= 21 && d % 14 === 7 },
"genel-temizlik": { everyDays: 30, on: (d) => d % 28 === 14 },
};
type Row = {
kind: "purchase" | "consume" | "waste" | "count_adjust";
daysAgo: number;
quantity: number;
expiryDate?: string | null;
wasteReason?: string | null;
note?: string;
initial?: boolean;
/** Bu satır hangi sayıma ait (gün önce). */
countDaysAgo?: number;
};
export type SeedCount = { daysAgo: number; expected: number; actual: number };
/**
* Haftalık simülasyon: tüketim, periyodik sayım (farkı nadiren), eşikte alım ve fire. Stok asla
* eksiye düşmez.
*/
export function simulate(p: SeedProduct, today: string): { rows: Row[]; counts: SeedCount[] } {
const rows: Row[] = [];
const counts: SeedCount[] = [];
const plan = COUNT_PLANS[p.category.split(".")[0]!];
let stock = p.target;
let purchases = 0;
rows.push({ kind: "count_adjust", daysAgo: START_DAYS_AGO, quantity: stock, initial: true });
for (let d = START_DAYS_AGO - 7; d >= 0; d -= 7) {
// Haftalık tüketim azaltma adımının katı (ör. tam tablet, 0,25 kg).
const step = p.step ?? K;
const consume = Math.min(stock, Math.max(step, Math.round((p.perDay * 7) / step) * step));
if (consume > 0) {
rows.push({ kind: "consume", daysAgo: d, quantity: consume });
stock -= consume;
}
if (plan?.on(d)) {
const expected = stock;
const nth = counts.length + 1;
if (p.countDrift && nth % p.countDrift.everyCounts === 0 && stock > 0) {
const drift = Math.min(stock, Math.round((p.target * p.countDrift.pct) / 100));
rows.push({
kind: "count_adjust",
daysAgo: d,
quantity: -drift,
note: "Sayım",
countDaysAgo: d,
});
stock -= drift;
}
counts.push({ daysAgo: d, expected, actual: stock });
}
const canRefill = p.stopRefillDaysAgo === undefined || d > p.stopRefillDaysAgo;
if (canRefill && stock < (p.target * 35) / 100) {
const packs = Math.max(1, Math.ceil((p.target - stock) / p.pack));
const quantity = packs * p.pack;
purchases++;
rows.push({
kind: "purchase",
daysAgo: d,
quantity,
expiryDate: p.shelfDays ? addDays(addDays(today, -d), p.shelfDays) : null,
});
stock += quantity;
if (p.waste && purchases % p.waste.every === 0 && d >= 3) {
const w = Math.min(stock, Math.round((quantity * p.waste.pct) / 100));
rows.push({ kind: "waste", daysAgo: d - 3, quantity: w, wasteReason: p.waste.reason });
stock -= w;
}
}
}
return { rows, counts };
}
export async function seedStock(
householdIds: Map<string, Types.ObjectId>,
users: Map<string, string>,
): Promise<number> {
const actor = new Types.ObjectId(users.get("ayse")!);
for (const [, id] of householdIds) await installDefaultStockCategories(id, actor);
const householdId = householdIds.get("Çakır Ailesi")!;
// Periyodik sayım sıklığı (kullanıcı ayarladıysa dokunulmaz).
for (const [key, plan] of Object.entries(COUNT_PLANS))
await StockCategoryModel.updateOne(
{ householdId, defaultKey: key, countEveryDays: null },
{ $set: { countEveryDays: plan.everyDays } },
);
const cats = await StockCategoryModel.find({ householdId, defaultKey: { $type: "string" } })
.select({ defaultKey: 1 })
.lean<{ _id: Types.ObjectId; defaultKey: string }[]>();
const catByKey = new Map(cats.map((c) => [c.defaultKey, c._id]));
const today = todayIstanbul();
let created = 0;
for (const p of PRODUCTS) {
if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) {
// Faz 13 öncesi seed'lenmiş veritabanında hacimli işaretini tamamla.
if (p.bulky)
await ProductTypeModel.updateOne(
{ householdId, seedRef: p.ref, bulky: { $exists: false } },
{ $set: { bulky: true } },
);
continue;
}
const doc = await ProductTypeModel.create({
householdId,
categoryId: catByKey.get(p.category)!,
name: p.name,
nameKey: foldTr(p.name),
unit: p.unit,
targetQuantity: p.target,
thresholdPct: p.threshold ?? 20,
location: p.location,
step: p.step ?? K,
bulky: p.bulky ?? false,
seedRef: p.ref,
createdBy: actor,
updatedBy: actor,
});
const base = Date.now() - START_DAYS_AGO * 86_400_000;
const { rows, counts } = simulate(p, today);
// Sayım oturumu kategori başına ortak: ürün satırı oturuma eklenir.
const root = p.category.split(".")[0]!;
const countIds = new Map<number, Types.ObjectId>();
for (const c of counts) {
const session = await StockCountModel.findOneAndUpdate(
{ householdId, seedRef: `${root}:${c.daysAgo}` },
{
$setOnInsert: {
householdId,
date: addDays(today, -c.daysAgo),
categoryId: catByKey.get(root) ?? null,
note: "",
seedRef: `${root}:${c.daysAgo}`,
createdBy: actor,
},
$push: {
items: { productTypeId: doc._id, expected: c.expected, actual: c.actual, wasted: 0 },
},
},
{ upsert: true, returnDocument: "after" },
).lean<{ _id: Types.ObjectId }>();
countIds.set(c.daysAgo, session!._id);
}
// Ham ekleme: aynı tarihli hareketlerin sırası (createdAt) korunur.
await StockMovementModel.collection.insertMany(
rows.map((r, i) => {
const at = new Date(base + i * 1000);
return {
householdId,
productTypeId: doc._id,
kind: r.kind,
date: addDays(today, -r.daysAgo),
quantity: r.quantity,
wasteReason: r.wasteReason ?? null,
expiryDate: r.expiryDate ?? null,
note: r.note ?? "",
initial: r.initial ?? false,
countId: r.countDaysAgo === undefined ? null : (countIds.get(r.countDaysAgo) ?? null),
deletedAt: null,
seedRef: `${p.ref}:${i}`,
createdBy: actor,
createdAt: at,
updatedAt: at,
};
}),
);
created++;
}
return created;
}
+504
View File
@@ -0,0 +1,504 @@
import { faker } from "@faker-js/faker";
import { Types } from "mongoose";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { PayeeModel } from "@/models/payee";
import { TransactionModel } from "@/models/transaction";
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
import { allocate } from "@/shared/money";
import { foldTr } from "@/shared/search";
import type { TransactionType } from "@/shared/transactions";
/**
* Son 6 ayın gerçekçi işlemleri (faker.seed(42) ile tekrarlanabilir).
* Her işlemin sabit `seedRef`'i vardır: tekrar çalıştırmada kopya oluşmaz.
*/
type Ctx = {
householdId: Types.ObjectId;
account: (name: string) => Types.ObjectId;
category: (key: string) => Types.ObjectId;
user: (username: string) => Types.ObjectId;
};
type SeedTx = {
ref: string;
type: TransactionType;
ymd: string;
amountMinor: number;
account: string;
toAccount?: string;
category?: string;
splits?: { category: string; weight: number; note?: string }[];
payee?: string;
description?: string;
tags?: string[];
by: string;
};
const tl = (min: number, max: number) => faker.number.int({ min: min * 100, max: max * 100 });
const pick = <T>(xs: readonly T[]) => faker.helpers.arrayElement(xs);
/** Ayın gün sayısına göre güvenli gün (31 → ayın son günü). */
function ymdOf(year: number, month: number, day: number) {
const last = new Date(Date.UTC(year, month, 0)).getUTCDate();
return `${year}-${String(month).padStart(2, "0")}-${String(Math.min(day, last)).padStart(2, "0")}`;
}
function months(count: number): { y: number; m: number; key: string }[] {
const [ty, tm] = todayIstanbul().split("-").map(Number) as [number, number];
return Array.from({ length: count }, (_, i) => {
const d = new Date(Date.UTC(ty, tm - 1 - (count - 1 - i), 1));
const y = d.getUTCFullYear();
const m = d.getUTCMonth() + 1;
return { y, m, key: `${y}-${String(m).padStart(2, "0")}` };
});
}
function cakirAiles(): SeedTx[] {
const out: SeedTx[] = [];
const marketPayees = ["Migros", "BİM", "A101", "Şok", "CarrefourSA"] as const;
for (const { y, m, key } of months(6)) {
const winter = [11, 12, 1, 2, 3].includes(m);
const d = (day: number) => ymdOf(y, m, day);
const add = (x: Omit<SeedTx, "ref">, n = 0) =>
out.push({ ...x, ref: `cakir:${key}:${x.payee ?? x.category ?? x.type}:${n}` });
add({
type: "income",
ymd: d(15),
amountMinor: 52_000_00,
account: "Ziraat Maaş",
category: "maas",
payee: "Anadolu Yazılım A.Ş.",
by: "admin",
});
add({
type: "income",
ymd: d(1),
amountMinor: 4_500_00,
account: "Ayşe — Pluxee",
category: "yemek-karti",
payee: "Pluxee",
by: "ayse",
});
add({
type: "expense",
ymd: d(3),
amountMinor: 17_500_00,
account: "Ziraat Maaş",
category: "kira",
payee: "Ev sahibi",
description: "Kira",
by: "admin",
});
add({
type: "expense",
ymd: d(5),
amountMinor: 1_250_00,
account: "Ziraat Maaş",
category: "aidat",
payee: "Site yönetimi",
by: "admin",
});
add({
type: "expense",
ymd: d(10),
amountMinor: tl(750, 1_600),
account: "Ziraat Maaş",
category: "faturalar.elektrik",
payee: "Enerjisa",
by: "ayse",
});
add({
type: "expense",
ymd: d(12),
amountMinor: tl(300, 620),
account: "Ziraat Maaş",
category: "faturalar.su",
payee: "İSKİ",
by: "ayse",
});
add({
type: "expense",
ymd: d(14),
amountMinor: winter ? tl(1_800, 3_400) : tl(350, 700),
account: "Ziraat Maaş",
category: "faturalar.dogalgaz",
payee: "İGDAŞ",
by: "ayse",
});
add({
type: "expense",
ymd: d(20),
amountMinor: 499_90,
account: "Garanti Bonus",
category: "faturalar.internet",
payee: "Türk Telekom",
by: "admin",
});
add({
type: "expense",
ymd: d(22),
amountMinor: 714_00,
account: "Garanti Bonus",
category: "faturalar.telefon",
payee: "Turkcell",
description: "2 hat",
by: "admin",
});
add({
type: "expense",
ymd: d(8),
amountMinor: 229_99,
account: "Garanti Bonus",
category: "abonelikler",
payee: "Netflix",
by: "admin",
});
add({
type: "expense",
ymd: d(9),
amountMinor: 99_99,
account: "Garanti Bonus",
category: "abonelikler",
payee: "Spotify",
by: "emre",
});
// Transferler: nakit çekme, kart ödemesi, birikim. Satıcı/kategori olmadığından seedRef
// türden üretilir; çakışmasın diye sıra numarası verilir (ATM 0 — mevcut veriyle uyumlu).
add({
type: "transfer",
ymd: d(2),
amountMinor: 2_000_00,
account: "Ziraat Maaş",
toAccount: "Cüzdan",
description: "ATM",
by: "admin",
});
add(
{
type: "transfer",
// Kesim 29, son ödeme ~8: ödeme son ödeme gününden önce.
ymd: d(7),
amountMinor: tl(11_000, 15_000),
account: "Ziraat Maaş",
toAccount: "Garanti Bonus",
description: "Kart borcu ödemesi",
by: "admin",
},
1,
);
add(
{
type: "transfer",
ymd: d(16),
amountMinor: 5_000_00,
account: "Ziraat Maaş",
toAccount: "Birikim (Vadeli)",
description: "Aylık birikim",
by: "ayse",
},
2,
);
// Market: haftada 1–2; bir kısmı bölünmüş.
const marketCount = faker.number.int({ min: 6, max: 8 });
for (let i = 0; i < marketCount; i++) {
const payee = pick(marketPayees);
const amountMinor = tl(280, 2_600);
const split = amountMinor > 1_200_00 && faker.datatype.boolean({ probability: 0.5 });
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor,
account: pick(["Garanti Bonus", "Garanti Bonus", "Cüzdan"]),
...(split
? {
splits: [
{ category: "market.gida", weight: faker.number.int({ min: 55, max: 75 }) },
{
category: "market.temizlik",
weight: faker.number.int({ min: 10, max: 25 }),
note: "Deterjan",
},
{
category: "market.kisisel-bakim",
weight: faker.number.int({ min: 5, max: 15 }),
},
],
}
: { category: "market.gida" }),
payee,
tags: faker.datatype.boolean({ probability: 0.15 }) ? ["indirim"] : [],
by: pick(["ayse", "ayse", "admin", "emre"]),
},
i,
);
}
for (let i = 0; i < faker.number.int({ min: 2, max: 3 }); i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(1_200, 2_300),
account: "Garanti Bonus",
category: "ulasim.yakit",
payee: pick(["Shell", "Opet", "BP"]),
by: "admin",
},
i,
);
}
for (let i = 0; i < faker.number.int({ min: 4, max: 6 }); i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(110, 340),
account: "Ayşe — Pluxee",
category: "eglence.kafe",
payee: pick(["Starbucks", "Kahve Dünyası", "Simit Sarayı"]),
by: "ayse",
},
i,
);
}
for (let i = 0; i < faker.number.int({ min: 2, max: 4 }); i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(450, 1_600),
account: pick(["Garanti Bonus", "Ayşe — Pluxee"]),
category: "eglence.disarida-yemek",
payee: pick(["Köfteci Yusuf", "Nusr-Et", "Big Chefs", "Yemeksepeti"]),
by: pick(["ayse", "admin"]),
},
i,
);
}
for (let i = 0; i < 4; i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(60, 220),
account: "Cüzdan",
category: "ulasim.toplu-tasima",
payee: "İstanbulkart",
by: "emre",
},
i,
);
}
if (faker.datatype.boolean({ probability: 0.6 })) {
add({
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(140, 650),
account: "Cüzdan",
category: "saglik.ilac",
payee: "Eczane",
by: "ayse",
});
}
if (faker.datatype.boolean({ probability: 0.5 })) {
add({
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(600, 3_200),
account: "Garanti Bonus",
category: "giyim",
payee: pick(["LC Waikiki", "Trendyol", "Koton"]),
by: "ayse",
});
}
}
return out;
}
function annemler(): SeedTx[] {
const out: SeedTx[] = [];
for (const { y, m, key } of months(6)) {
const winter = [11, 12, 1, 2, 3].includes(m);
const d = (day: number) => ymdOf(y, m, day);
const add = (x: Omit<SeedTx, "ref">, n = 0) =>
out.push({ ...x, ref: `annemler:${key}:${x.payee ?? x.category ?? x.type}:${n}` });
add({
type: "income",
ymd: d(17),
amountMinor: 24_350_00,
account: "Vakıfbank Emekli",
category: "maas",
payee: "SGK",
description: "Emekli maaşı",
by: "fatma",
});
add({
type: "expense",
ymd: d(11),
amountMinor: tl(450, 900),
account: "Vakıfbank Emekli",
category: "faturalar.elektrik",
payee: "Enerjisa",
by: "mehmet",
});
add({
type: "expense",
ymd: d(13),
amountMinor: winter ? tl(1_400, 2_600) : tl(250, 500),
account: "Vakıfbank Emekli",
category: "faturalar.dogalgaz",
payee: "İGDAŞ",
by: "mehmet",
});
add({
type: "transfer",
ymd: d(18),
amountMinor: 4_000_00,
account: "Vakıfbank Emekli",
toAccount: "Nakit",
description: "ATM",
by: "fatma",
});
for (let i = 0; i < 5; i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(200, 1_100),
account: pick(["Nakit", "Vakıfbank Emekli"]),
category: "market.gida",
payee: pick(["BİM", "Şok", "Pazar"]),
by: pick(["fatma", "mehmet"]),
},
i,
);
}
}
return out;
}
export async function seedTransactions(
householdIds: Map<string, Types.ObjectId>,
userIds: Map<string, string>,
): Promise<number> {
faker.seed(42);
let inserted = 0;
const plan: [string, SeedTx[]][] = [
["Çakır Ailesi", cakirAiles()],
["Annemler", annemler()],
];
const today = todayIstanbul();
for (const [householdName, txs] of plan) {
const householdId = householdIds.get(householdName)!;
const [accounts, categories] = await Promise.all([
AccountModel.find({ householdId }).select({ name: 1 }).lean(),
CategoryModel.find({ householdId, defaultKey: { $ne: null } })
.select({ defaultKey: 1 })
.lean(),
]);
const ctx: Ctx = {
householdId,
account: (name) => accounts.find((a) => a.name === name)!._id,
category: (key) => categories.find((c) => c.defaultKey === key)!._id,
user: (username) => new Types.ObjectId(userIds.get(username)!),
};
const payeeStats = new Map<
string,
{
name: string;
type: TransactionType;
account: Types.ObjectId;
category: Types.ObjectId | null;
count: number;
last: Date;
}
>();
for (const x of txs) {
if (x.ymd > today) continue; // Gelecek tarihli seed işlemi yok.
const date = istanbulDayStart(x.ymd)!;
const splits = x.splits
? allocate(
x.amountMinor,
x.splits.map((s) => s.weight),
).map((amountMinor, i) => ({
categoryId: ctx.category(x.splits![i]!.category),
amountMinor,
note: x.splits![i]!.note ?? "",
}))
: [];
const categoryId = x.category ? ctx.category(x.category) : null;
const accountId = ctx.account(x.account);
const by = ctx.user(x.by);
const doc = {
householdId,
type: x.type,
status: "posted",
date,
accountId,
toAccountId: x.toAccount ? ctx.account(x.toAccount) : null,
amountMinor: x.amountMinor,
categoryId: splits.length ? null : categoryId,
splits,
effectiveCategoryIds: splits.length
? [...new Set(splits.map((s) => String(s.categoryId)))].map(
(id) => new Types.ObjectId(id),
)
: categoryId
? [categoryId]
: [],
payeeName: x.payee ?? "",
description: x.description ?? "",
searchText: foldTr(
[x.payee ?? "", x.description ?? "", ...splits.map((s) => s.note)].join(" "),
),
tags: x.tags ?? [],
seedRef: x.ref,
createdBy: by,
updatedBy: by,
};
const res = await TransactionModel.updateOne(
{ householdId, seedRef: x.ref },
{ $setOnInsert: doc },
{ upsert: true },
);
if (res.upsertedCount) inserted++;
if (x.payee) {
const key = foldTr(x.payee);
const prev = payeeStats.get(key);
const latest = !prev || date >= prev.last;
payeeStats.set(key, {
name: x.payee,
type: latest ? x.type : prev!.type,
account: latest ? accountId : prev!.account,
category: latest ? (splits.length ? null : categoryId) : prev!.category,
count: (prev?.count ?? 0) + 1,
last: latest ? date : prev!.last,
});
}
}
for (const [nameKey, p] of payeeStats) {
await PayeeModel.updateOne(
{ householdId, nameKey },
{
$setOnInsert: { householdId, nameKey, name: p.name },
$set: {
lastType: p.type,
lastAccountId: p.account,
lastCategoryId: p.category,
useCount: p.count,
lastUsedAt: p.last,
},
},
{ upsert: true },
);
}
}
return inserted;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/page-header";
import { HouseholdsAdmin } from "@/features/admin-households/components/households-admin";
import { getAdminHouseholdsData } from "@/features/admin-households/queries";
import { tr } from "@/i18n/tr";
import { requireRole } from "@/lib/dal";
export const metadata: Metadata = { title: tr.adminHouseholds.title };
export default async function AdminHouseholdsPage() {
const [admin, data] = await Promise.all([requireRole("admin"), getAdminHouseholdsData()]);
return (
<>
<PageHeader title={tr.adminHouseholds.title} description={tr.adminHouseholds.description} />
<HouseholdsAdmin households={data.households} users={data.users} currentUserId={admin.id} />
</>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ShoppingModeView } from "@/features/shopping/components/shopping-mode";
import { getShoppingList } from "@/features/shopping/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.shopping.mode.title };
/** /alisveris/:id/mod — alışverişe çıkış: mağazaya göre gruplu kontrol listesi. */
export default async function ShoppingModePage({
params,
searchParams,
}: PageProps<"/alisveris/[id]/mod">) {
const { user, household } = await requireActiveHousehold("viewer");
const [{ id }, query] = await Promise.all([params, searchParams]);
const data = await getShoppingList(user.id, household.id, id);
if (!data) notFound();
return (
<ShoppingModeView data={data} choice={query.plan === "single" ? "single" : "recommended"} />
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ShoppingListDetailView } from "@/features/shopping/components/list-detail";
import { getShoppingList } from "@/features/shopping/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.shopping.title };
/** /alisveris/:id — liste kalemleri, stok önerileri ve akıllı mağaza önerisi. */
export default async function ShoppingListPage({ params }: PageProps<"/alisveris/[id]">) {
const { user, household } = await requireActiveHousehold("viewer");
const { id } = await params;
const data = await getShoppingList(user.id, household.id, id);
if (!data) notFound();
return <ShoppingListDetailView data={data} />;
}
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { CompletionForm } from "@/features/shopping/components/completion-form";
import { getCompletionSheet } from "@/features/shopping/completion";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.shopping.complete.title };
/** /alisveris/:id/tamamla — alışveriş sonrası giriş: alınanlar, ödeme, fiş, alınmayanlar. */
export default async function CompleteShoppingPage({
params,
}: PageProps<"/alisveris/[id]/tamamla">) {
const { user, household } = await requireActiveHousehold("member");
const { id } = await params;
const sheet = await getCompletionSheet(user.id, household.id, id);
if (!sheet) notFound();
return <CompletionForm sheet={sheet} />;
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { ShoppingListsView } from "@/features/shopping/components/lists-view";
import { listShoppingLists } from "@/features/shopping/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.shopping.title };
/** /alisveris — aktif ve tamamlanan alışveriş listeleri. */
export default async function ShoppingPage() {
const { user, household } = await requireActiveHousehold("viewer");
const data = await listShoppingLists(user.id, household.id);
if (!data) notFound();
return (
<>
<PageHeader title={tr.shopping.title} description={tr.shopping.description} />
{!data.canWrite ? (
<p className="text-sm text-muted-foreground">{tr.shopping.readOnly}</p>
) : null}
<ShoppingListsView
active={data.active}
completed={data.completed}
suggestions={data.suggestions}
canWrite={data.canWrite}
/>
</>
);
}
+50
View File
@@ -0,0 +1,50 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { HouseholdSettingsForm } from "@/features/households/components/household-settings-form";
import { SettingsMembers } from "@/features/households/components/settings-members";
import { getHouseholdDetail } from "@/features/households/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.settings.title };
export default async function SettingsPage() {
const { user, household } = await requireActiveHousehold("viewer");
const detail = await getHouseholdDetail(user.id, household.id);
if (!detail) notFound();
const isOwner = household.role === "owner";
const t = tr.settings;
return (
<>
<PageHeader title={t.title} description={t.description} />
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
<Card>
<CardHeader>
<CardTitle>{t.household}</CardTitle>
<CardDescription>{isOwner ? t.householdDescription : t.ownerOnly}</CardDescription>
</CardHeader>
<CardContent>
<HouseholdSettingsForm settings={detail.settings} canEdit={isOwner} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t.members}</CardTitle>
<CardDescription>{t.membersDescription}</CardDescription>
</CardHeader>
<CardContent>
<SettingsMembers
members={detail.members}
householdName={detail.name}
currentUserId={user.id}
canManage={isOwner}
/>
</CardContent>
</Card>
</div>
</>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { AssetDetailView } from "@/features/assets/components/asset-detail-view";
import { PeriodReportView } from "@/features/assets/components/period-report";
import {
getAsset,
getPeriodReport,
REPORT_RANGES,
type ReportRange,
} from "@/features/assets/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.assets.history };
/** /birikim/:id — varlık hareketleri ve dönemsel rapor (gizli varlık yalnızca sahibine; aksi 404). */
export default async function AssetPage({ params, searchParams }: PageProps<"/birikim/[id]">) {
const { user, household } = await requireActiveHousehold("viewer");
const { id } = await params;
const donem = (await searchParams).donem;
const range: ReportRange = REPORT_RANGES.includes(donem as ReportRange)
? (donem as ReportRange)
: "6";
const [data, report] = await Promise.all([
getAsset(user.id, household.id, id),
getPeriodReport(user.id, household.id, range, id),
]);
if (!data || !report) notFound();
return (
<AssetDetailView
key={id}
data={data}
report={<PeriodReportView report={report} range={range} basePath={`/birikim/${id}`} />}
/>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { AssetsView } from "@/features/assets/components/assets-view";
import { PeriodReportView } from "@/features/assets/components/period-report";
import {
getPeriodReport,
listAssets,
REPORT_RANGES,
type ReportRange,
} from "@/features/assets/service";
import { NetWorthCard } from "@/features/net-worth/components/net-worth-card";
import { getNetWorth } from "@/features/net-worth/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.assets.title };
function parseRange(value: string | string[] | undefined): ReportRange {
return REPORT_RANGES.includes(value as ReportRange) ? (value as ReportRange) : "6";
}
/** /birikim — net varlık, varlıklar (ortalama maliyet, K/Z) ve dönemsel rapor. */
export default async function AssetsPage({ searchParams }: PageProps<"/birikim">) {
const { user, household } = await requireActiveHousehold("viewer");
const range = parseRange((await searchParams).donem);
const [data, report, netWorth] = await Promise.all([
listAssets(user.id, household.id),
getPeriodReport(user.id, household.id, range),
getNetWorth(user.id, household.id),
]);
if (!data || !report || !netWorth) notFound();
return (
<>
<PageHeader title={tr.assets.title} description={tr.assets.description} />
<section aria-labelledby="net-worth-title" className="flex flex-col gap-2">
<h2 id="net-worth-title" className="text-base font-medium">
{tr.assets.netWorth.title}
</h2>
<NetWorthCard data={netWorth} />
</section>
<AssetsView items={data.items} totals={data.totals} canWrite={data.canWrite} />
<PeriodReportView report={report} range={range} basePath="/birikim" />
</>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { BudgetView } from "@/features/budgets/components/budget-view";
import { IncomePlanSection } from "@/features/budgets/components/income-plan";
import { getIncomePlan } from "@/features/budgets/plan";
import { getBudget } from "@/features/budgets/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { budgetMonthSchema } from "@/shared/schemas/budget";
export const metadata: Metadata = { title: tr.budget.title };
/** /butce?ay=YYYY-MM — aylık bütçe (varsayılan: içinde bulunulan bütçe ayı). */
export default async function BudgetPage({ searchParams }: PageProps<"/butce">) {
const { user, household } = await requireActiveHousehold("viewer");
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
const data = await getBudget(user.id, household.id, ay.success ? ay.data : undefined);
if (!data) notFound();
const income = await getIncomePlan(user.id, household.id, data.month);
if (!income) notFound();
const monthLabel = new Intl.DateTimeFormat("tr-TR", {
month: "long",
year: "numeric",
timeZone: "Europe/Istanbul",
}).format(new Date(`${data.month}-15T12:00:00+03:00`));
return (
<>
<PageHeader title={tr.budget.title} description={tr.budget.description} />
<BudgetView key={data.month} data={data} />
<IncomePlanSection
key={`income-${data.month}`}
month={data.month}
monthLabel={monthLabel}
income={income}
limitMinor={data.totals.limitMinor}
forecastMinor={data.totals.forecastMinor}
hasBudget={data.hasBudget}
canWrite={data.canWrite}
/>
</>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ContactStatementView } from "@/features/contacts/components/contact-statement-view";
import { getContactStatement } from "@/features/contacts/service";
import { listInKind } from "@/features/in-kind/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.contacts.statement };
/** /cari/:id — cari ekstresi (gizli cari yalnızca sahibine; aksi 404). */
export default async function ContactPage({ params }: PageProps<"/cari/[id]">) {
const { user, household } = await requireActiveHousehold("viewer");
const { id } = await params;
const [data, inKind] = await Promise.all([
getContactStatement(user.id, household.id, id),
listInKind(user.id, household.id, id),
]);
if (!data) notFound();
return <ContactStatementView key={id} data={data} inKind={inKind ?? []} />;
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { ContactsView } from "@/features/contacts/components/contacts-view";
import { listContacts } from "@/features/contacts/service";
import { openInKindByContact } from "@/features/in-kind/service";
import { hasHouseholdRole } from "@/features/households/roles";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.contacts.title };
export default async function ContactsPage() {
const { user, household } = await requireActiveHousehold("viewer");
const [data, inKind] = await Promise.all([
listContacts(user.id, household.id),
openInKindByContact(user.id, household.id),
]);
if (!data) notFound();
return (
<>
<PageHeader title={tr.contacts.title} description={tr.contacts.description} />
<ContactsView
data={data}
inKind={Object.fromEntries(inKind)}
canWrite={hasHouseholdRole(household.role, "member")}
/>
</>
);
}
+75 -17
View File
@@ -1,10 +1,22 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { OverviewCard } from "@/features/dashboard/components/overview-card";
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
import { DailyPlanCard } from "@/features/budgets/components/daily-plan-card";
import { BudgetWarningBanner } from "@/features/budgets/components/warning-banner";
import { getIncomePlan } from "@/features/budgets/plan";
import { getBudget } from "@/features/budgets/service";
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
import { getNetWorth } from "@/features/net-worth/service";
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
import { ShoppingCard } from "@/features/shopping/components/shopping-card";
import { LowStockCard } from "@/features/stock/components/low-stock-card";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { requireSession } from "@/lib/dal"; import { requireActiveHousehold } from "@/lib/dal";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format"; import { formatMoney } from "@/shared/format";
@@ -14,22 +26,37 @@ type SummaryCard = {
key: keyof typeof tr.dashboard.cards; key: keyof typeof tr.dashboard.cards;
tone?: string; tone?: string;
amountMinor: number | null; amountMinor: number | null;
href?: string;
}; };
/** FAZ 14: gerçek özet verisi (gelir, gider, bakiye, kart borcu, net varlık). */ /**
async function getSummary(): Promise<SummaryCard[]> { * Özet kartları (içinde bulunulan bütçe ayı): gerçekleşen gelir, gider ve farkı; hesap bakiyesi,
* kart borcu ve net varlık.
*/
async function getSummary(userId: string, householdId: string): Promise<SummaryCard[]> {
const [nw, budget] = await Promise.all([
getNetWorth(userId, householdId),
getBudget(userId, householdId),
]);
const income = budget ? await getIncomePlan(userId, householdId, budget.month) : null;
const inc = income?.actualMinor ?? null;
const exp = budget?.totals.actualMinor ?? null;
return [ return [
{ key: "monthIncome", tone: "text-income", amountMinor: null }, { key: "monthIncome", tone: "text-income", amountMinor: inc, href: "/butce" },
{ key: "monthExpense", tone: "text-expense", amountMinor: null }, { key: "monthExpense", tone: "text-expense", amountMinor: exp, href: "/butce" },
{ key: "monthRemaining", tone: "text-budget", amountMinor: null }, {
{ key: "totalBalance", amountMinor: null }, key: "monthRemaining",
{ key: "cardDebt", tone: "text-expense", amountMinor: null }, tone: "text-budget",
{ key: "netWorth", amountMinor: null }, amountMinor: inc !== null && exp !== null ? inc - exp : null,
},
{ key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" },
{ key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null },
{ key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" },
]; ];
} }
async function SummaryCards() { async function SummaryCards({ userId, householdId }: { userId: string; householdId: string }) {
const cards = await getSummary(); const cards = await getSummary(userId, householdId);
return ( return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
{cards.map((c) => ( {cards.map((c) => (
@@ -37,7 +64,15 @@ async function SummaryCards() {
<CardHeader> <CardHeader>
<CardDescription>{tr.dashboard.cards[c.key]}</CardDescription> <CardDescription>{tr.dashboard.cards[c.key]}</CardDescription>
<CardTitle className={cn("text-lg tabular-nums", c.tone)}> <CardTitle className={cn("text-lg tabular-nums", c.tone)}>
{c.amountMinor === null ? "—" : formatMoney(c.amountMinor)} {c.amountMinor === null ? (
"—"
) : c.href ? (
<Link href={c.href} className="hover:underline">
{formatMoney(c.amountMinor)}
</Link>
) : (
formatMoney(c.amountMinor)
)}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
{c.amountMinor === null ? ( {c.amountMinor === null ? (
@@ -51,17 +86,40 @@ async function SummaryCards() {
); );
} }
export default async function DashboardPage() { export default async function DashboardPage({ searchParams }: PageProps<"/dashboard">) {
const user = await requireSession(); const { user, household } = await requireActiveHousehold("viewer");
return ( return (
<> <>
<PageHeader <PageHeader
title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)} title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)}
description={tr.dashboard.welcomeDescription} description={`${household.name} · ${tr.dashboard.welcomeDescription}`}
/> />
<Suspense fallback={<SummaryCardsSkeleton />}> <Suspense fallback={null}>
<SummaryCards /> <OverdueBanner userId={user.id} householdId={household.id} />
</Suspense> </Suspense>
<Suspense fallback={null}>
<BudgetWarningBanner userId={user.id} householdId={household.id} />
</Suspense>
<Suspense fallback={null}>
<ContactsDueBanner userId={user.id} householdId={household.id} />
</Suspense>
<Suspense fallback={<SummaryCardsSkeleton />}>
<SummaryCards userId={user.id} householdId={household.id} />
</Suspense>
<Suspense fallback={<Skeleton className="h-96 rounded-xl" />}>
<OverviewCard userId={user.id} householdId={household.id} searchParams={searchParams} />
</Suspense>
<Suspense fallback={null}>
<DailyPlanCard userId={user.id} householdId={household.id} />
</Suspense>
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
<Suspense fallback={null}>
<LowStockCard userId={user.id} householdId={household.id} />
</Suspense>
<Suspense fallback={null}>
<ShoppingCard userId={user.id} householdId={household.id} />
</Suspense>
</div>
</> </>
); );
} }
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { PricesView } from "@/features/in-kind/components/prices-view";
import { listPrices } from "@/features/in-kind/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.prices.title };
/** /fiyatlar — altın ve döviz fiyat tablosu (elle; Faz 9 varlık takibi de kullanır). */
export default async function PricesPage() {
const { user, household } = await requireActiveHousehold("viewer");
const data = await listPrices(user.id, household.id);
if (!data) notFound();
return (
<>
<PageHeader title={tr.prices.title} description={tr.prices.description} />
<PricesView rows={data.rows} canWrite={data.canWrite} />
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { House } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { tr } from "@/i18n/tr";
import { getHouseholdContext } from "@/lib/dal";
export const metadata: Metadata = { title: tr.households.noHousehold.title };
/** Hiçbir haneye üye olmayan kullanıcılar (ör. hanesi olmayan admin) buraya yönlenir. */
export default async function NoHouseholdPage() {
const ctx = await getHouseholdContext();
if (!ctx) redirect("/login");
if (ctx.household) redirect("/dashboard");
const t = tr.households.noHousehold;
return (
<div className="flex flex-1 items-center justify-center">
<Card className="max-w-md">
<CardHeader>
<span className="mb-2 flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<House className="size-6" />
</span>
<CardTitle className="text-xl">{t.title}</CardTitle>
<CardDescription>{t.description}</CardDescription>
</CardHeader>
{ctx.user.role === "admin" ? (
<CardContent className="flex flex-col items-start gap-3">
<p className="text-sm text-muted-foreground">{t.adminHint}</p>
<Button asChild>
<Link href="/admin/haneler">{t.adminLink}</Link>
</Button>
</CardContent>
) : null}
</Card>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { hasHouseholdRole } from "@/features/households/roles";
import { StatementView } from "@/features/transactions/components/statement-view";
import { getCardSummary, getOverdraftSummary } from "@/features/transactions/card";
import { currentMonth, getAccountStatement } from "@/features/transactions/statement";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { MONTH_PATTERN } from "@/shared/statement";
export const metadata: Metadata = { title: tr.statement.title };
/** /hesaplar/:id?ay=2026-11 — aylık hesap ekstresi (yürüyen bakiye) ve mutabakat. */
export default async function AccountStatementPage({
params,
searchParams,
}: PageProps<"/hesaplar/[id]">) {
const { user, household } = await requireActiveHousehold("viewer");
const [{ id }, sp] = await Promise.all([params, searchParams]);
const now = currentMonth();
const requested = typeof sp.ay === "string" && MONTH_PATTERN.test(sp.ay) ? sp.ay : now;
const month = requested > now ? now : requested;
const statement = await getAccountStatement(user.id, household.id, id, month);
if (!statement) notFound();
const [card, overdraft] = await Promise.all([
statement.account.type === "credit_card"
? getCardSummary(user.id, household.id, id)
: Promise.resolve(null),
statement.account.type === "bank"
? getOverdraftSummary(user.id, household.id, id)
: Promise.resolve(null),
]);
return (
<StatementView
key={`${id}-${month}`}
statement={statement}
card={card}
overdraft={overdraft}
canWrite={hasHouseholdRole(household.role, "member")}
/>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { AccountsView } from "@/features/accounts/components/accounts-view";
import { listAccounts, listMemberOptions } from "@/features/accounts/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.accounts.title };
export default async function AccountsPage() {
const { user, household } = await requireActiveHousehold("viewer");
const [data, members] = await Promise.all([
listAccounts(user.id, household.id, { includeArchived: true }),
listMemberOptions(user.id, household.id),
]);
if (!data || !members) notFound();
return (
<>
<PageHeader title={tr.accounts.title} description={tr.accounts.description} />
<AccountsView
accounts={data.accounts}
totals={data.totals}
members={members}
canEdit={household.role === "owner"}
/>
</>
);
}
@@ -0,0 +1,44 @@
import { exportTransactions } from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import { getHouseholdContext } from "@/lib/dal";
import { csvMoney, toCsv } from "@/shared/csv";
import { todayIstanbul } from "@/shared/dates";
import { formatDate } from "@/shared/format";
import { transactionFilterSchema } from "@/shared/schemas/transaction";
/** GET /islemler/disa-aktar?…filtreler → CSV (listedeki filtrelerle, sayfalama yok). */
export async function GET(request: Request) {
const ctx = await getHouseholdContext();
if (!ctx?.household) return new Response(tr.common.unauthorized, { status: 401 });
const params = Object.fromEntries(new URL(request.url).searchParams);
const filters = transactionFilterSchema.parse(params);
const rows = await exportTransactions(ctx.user.id, ctx.household.id, filters);
if (!rows) return new Response(tr.common.unauthorized, { status: 403 });
const t = tr.transactions;
const csv = toCsv(
t.csv.header,
rows.map((r) => [
r.no,
formatDate(`${r.date}T12:00:00+03:00`),
t.types[r.type],
t.statuses[r.status],
r.account,
r.toAccount,
r.category,
r.payee,
r.description,
r.note,
r.tags,
csvMoney(r.amountMinor),
r.createdBy,
]),
);
return new Response(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${t.csv.filename(todayIstanbul())}"`,
"Cache-Control": "no-store",
},
});
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { listMemberOptions } from "@/features/accounts/service";
import { hasHouseholdRole } from "@/features/households/roles";
import { TransactionsView } from "@/features/transactions/components/transactions-view";
import { getTransactionFormOptions, listTransactions } from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { transactionFilterSchema } from "@/shared/schemas/transaction";
export const metadata: Metadata = { title: tr.transactions.title };
export default async function TransactionsPage({ searchParams }: PageProps<"/islemler">) {
const { user, household } = await requireActiveHousehold("viewer");
const params = await searchParams;
// Yalnızca tekil string değerler (dizi parametreleri yok sayılır).
const raw = Object.fromEntries(
Object.entries(params).filter(
(e): e is [string, string] => typeof e[1] === "string" && e[1] !== "",
),
);
const filters = transactionFilterSchema.parse(raw);
const [data, options, members] = await Promise.all([
listTransactions(user.id, household.id, filters),
getTransactionFormOptions(user.id, household.id),
listMemberOptions(user.id, household.id),
]);
if (!data || !options || !members) notFound();
return (
<>
<PageHeader title={tr.transactions.title} description={tr.transactions.description} />
<TransactionsView
// Filtre değişince istemci durumu (açık diyaloglar) sıfırlansın.
key={JSON.stringify(raw)}
data={data}
filters={filters}
raw={raw}
options={{ ...options, members }}
canWrite={hasHouseholdRole(household.role, "member")}
/>
</>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { CategoriesManager } from "@/features/categories/components/categories-manager";
import { listCategories } from "@/features/categories/service";
import { hasHouseholdRole } from "@/features/households/roles";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.categories.title };
export default async function CategoriesPage() {
const { user, household } = await requireActiveHousehold("viewer");
const categories = await listCategories(user.id, household.id, { includeArchived: true });
if (!categories) notFound();
return (
<>
<PageHeader title={tr.categories.title} description={tr.categories.description} />
<CategoriesManager
categories={categories}
canEdit={hasHouseholdRole(household.role, "member")}
/>
</>
);
}
+15 -6
View File
@@ -1,3 +1,4 @@
import { redirect } from "next/navigation";
import { Suspense } from "react"; import { Suspense } from "react";
import { AppHeader } from "@/components/layout/app-header"; import { AppHeader } from "@/components/layout/app-header";
import { AppShellFallback } from "@/components/layout/app-shell-fallback"; import { AppShellFallback } from "@/components/layout/app-shell-fallback";
@@ -5,8 +6,8 @@ import { AppSidebar } from "@/components/layout/app-sidebar";
import { BottomNav } from "@/components/layout/bottom-nav"; import { BottomNav } from "@/components/layout/bottom-nav";
import { QuickAddProvider } from "@/components/layout/quick-add"; import { QuickAddProvider } from "@/components/layout/quick-add";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { MOCK_HOUSEHOLDS } from "@/features/households/mock"; import { hasHouseholdRole } from "@/features/households/roles";
import { requireSession } from "@/lib/dal"; import { getHouseholdContext } from "@/lib/dal";
import { APP_VERSION } from "@/lib/version"; import { APP_VERSION } from "@/lib/version";
/** /**
@@ -23,18 +24,26 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
} }
async function AuthenticatedShell({ children }: { children: React.ReactNode }) { async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
const user = await requireSession(); const ctx = await getHouseholdContext();
if (!ctx) redirect("/login");
const { user, household, memberships } = ctx;
const canWrite = household ? hasHouseholdRole(household.role, "member") : false;
return ( return (
<SidebarProvider> <SidebarProvider>
<QuickAddProvider> <QuickAddProvider>
<AppSidebar isAdmin={user.role === "admin"} version={APP_VERSION} /> <AppSidebar isAdmin={user.role === "admin"} version={APP_VERSION} />
<SidebarInset> <SidebarInset>
{/* FAZ 5: kullanıcının üye olduğu haneler */} <AppHeader
<AppHeader user={user} households={MOCK_HOUSEHOLDS} version={APP_VERSION} /> user={user}
households={memberships}
activeHouseholdId={household?.id}
canWrite={canWrite}
version={APP_VERSION}
/>
{/* Mobilde alt menü yüksekliği kadar boşluk */} {/* Mobilde alt menü yüksekliği kadar boşluk */}
<div className="flex flex-1 flex-col gap-4 p-4 pb-24 md:p-6 md:pb-6">{children}</div> <div className="flex flex-1 flex-col gap-4 p-4 pb-24 md:p-6 md:pb-6">{children}</div>
</SidebarInset> </SidebarInset>
<BottomNav /> <BottomNav canWrite={canWrite} />
</QuickAddProvider> </QuickAddProvider>
</SidebarProvider> </SidebarProvider>
); );
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { StoresView } from "@/features/stores/components/stores-view";
import { listStores } from "@/features/stores/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.stores.title };
/** /magazalar — marketler, online platformlar ve pazarlar; uzaklık, taşıma ve zahmet skoru. */
export default async function StoresPage() {
const { user, household } = await requireActiveHousehold("viewer");
const data = await listStores(user.id, household.id);
if (!data) notFound();
return (
<>
<PageHeader title={tr.stores.title} description={tr.stores.description} />
{!data.canWrite ? (
<p className="text-sm text-muted-foreground">{tr.stores.readOnly}</p>
) : null}
<StoresView stores={data.stores} canWrite={data.canWrite} />
</>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { hasHouseholdRole } from "@/features/households/roles";
import { PlannedView } from "@/features/recurring/components/planned-view";
import { getPlannedOverview } from "@/features/recurring/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.recurring.title };
export default async function PlannedPage() {
const { user, household } = await requireActiveHousehold("viewer");
const data = await getPlannedOverview(user.id, household.id);
if (!data) notFound();
return (
<>
<PageHeader title={tr.recurring.title} description={tr.recurring.description} />
<PlannedView data={data} canWrite={hasHouseholdRole(household.role, "member")} />
</>
);
}
@@ -0,0 +1,47 @@
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
import { getShoppingReport } from "@/features/reports/extra";
import { tr } from "@/i18n/tr";
import { csvMoney, toCsv } from "@/shared/csv";
import { todayIstanbul } from "@/shared/dates";
import { parsePeriod } from "@/shared/reports";
/** GET /raporlar/alisveris/csv?donem=3|6|12&tablo=listeler|magazalar */
export async function GET(request: Request) {
const c = await csvContext(request);
if (!c) return unauthorized();
const r = await getShoppingReport(c.userId, c.householdId, parsePeriod(c.params.get("donem")));
if (!r) return forbidden();
const h = tr.reports.csvHeaders;
const today = todayIstanbul();
if (c.params.get("tablo") === "magazalar")
return csvResponse(
toCsv(
h.stores,
r.stores.map((s) => [
s.name,
csvMoney(s.amountMinor),
s.count,
csvPct(s.sharePct),
csvMoney(s.fromListsMinor),
]),
),
tr.reports.csvFiles.stores(today),
);
return csvResponse(
toCsv(
h.lists,
r.lists.map((l) => [
l.date,
l.name,
csvMoney(l.summary.plannedMinor),
csvMoney(l.summary.actualMinor),
csvMoney(l.summary.diffMinor),
l.summary.counts.same,
l.summary.counts.substitute,
l.summary.counts.skipped,
l.summary.counts.extra,
]),
),
tr.reports.csvFiles.lists(today),
);
}
+57
View File
@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import {
CsvButton,
RangeNav,
ReportFrame,
ToolsEnd,
} from "@/features/reports/components/report-frame";
import { ShoppingReportView } from "@/features/reports/components/shopping-waste-view";
import { getShoppingReport } from "@/features/reports/extra";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { parsePeriod, REPORT_PERIODS } from "@/shared/reports";
const t = tr.reports.shopping;
export const metadata: Metadata = { title: t.title };
/** /raporlar/alisveris?donem=3|6|12 — liste vs. gerçekleşen, mağaza bazlı harcama. */
export default async function ShoppingReportPage({
searchParams,
}: PageProps<"/raporlar/alisveris">) {
const { user, household } = await requireActiveHousehold("viewer");
const period = parsePeriod((await searchParams).donem);
const data = await getShoppingReport(user.id, household.id, period);
if (!data) notFound();
return (
<ReportFrame
title={t.title}
description={t.description}
tools={
<>
<RangeNav
path="/raporlar/alisveris"
label={tr.reports.periodLabel}
value={String(period)}
options={REPORT_PERIODS.map((p) => ({
value: String(p),
label: tr.reports.periods[p]!,
}))}
/>
<ToolsEnd>
<CsvButton
href={`/raporlar/alisveris/csv?donem=${period}&tablo=listeler`}
label={t.csvLists}
/>
<CsvButton
href={`/raporlar/alisveris/csv?donem=${period}&tablo=magazalar`}
label={t.csvStores}
/>
</ToolsEnd>
</>
}
>
<ShoppingReportView data={data} />
</ReportFrame>
);
}
@@ -0,0 +1,31 @@
import { REPORT_RANGES, type ReportRange } from "@/features/assets/service";
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
import { getAssetsReport } from "@/features/reports/extra";
import { tr } from "@/i18n/tr";
import { formatAssetQuantity } from "@/shared/assets";
import { csvMoney, toCsv } from "@/shared/csv";
import { todayIstanbul } from "@/shared/dates";
/** GET /raporlar/birikim/csv — varlık başına maliyet, değer ve kâr/zarar. */
export async function GET(request: Request) {
const c = await csvContext(request);
if (!c) return unauthorized();
const q = c.params.get("donem");
const range: ReportRange = REPORT_RANGES.includes(q as ReportRange) ? (q as ReportRange) : "6";
const r = await getAssetsReport(c.userId, c.householdId, range);
if (!r) return forbidden();
return csvResponse(
toCsv(
tr.reports.csvHeaders.assets,
r.items.map((a) => [
a.name || a.label,
formatAssetQuantity(a.assetKey, a.summary.quantity),
csvMoney(a.summary.costMinor),
a.summary.currentValueMinor === null ? "" : csvMoney(a.summary.currentValueMinor),
a.summary.unrealizedMinor === null ? "" : csvMoney(a.summary.unrealizedMinor),
csvMoney(a.summary.realizedMinor),
]),
),
tr.reports.csvFiles.assets(todayIstanbul()),
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { AssetsReportView } from "@/features/reports/components/contacts-assets-view";
import {
CsvButton,
RangeNav,
ReportFrame,
ToolsEnd,
} from "@/features/reports/components/report-frame";
import { getAssetsReport } from "@/features/reports/extra";
import { REPORT_RANGES, type ReportRange } from "@/features/assets/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
const t = tr.reports.assets;
export const metadata: Metadata = { title: t.title };
/** /raporlar/birikim?donem=3|6|12|tum — varlık kâr/zarar ve dönem özeti. */
export default async function AssetsReportPage({ searchParams }: PageProps<"/raporlar/birikim">) {
const { user, household } = await requireActiveHousehold("viewer");
const q = (await searchParams).donem;
const range: ReportRange = REPORT_RANGES.includes(q as ReportRange) ? (q as ReportRange) : "6";
const data = await getAssetsReport(user.id, household.id, range);
if (!data) notFound();
return (
<ReportFrame
title={t.title}
description={t.description}
tools={
<>
<RangeNav
path="/raporlar/birikim"
label={tr.reports.periodLabel}
value={range}
options={REPORT_RANGES.map((r) => ({ value: r, label: t.ranges[r]! }))}
/>
<ToolsEnd>
<CsvButton href="/raporlar/birikim/csv" />
</ToolsEnd>
</>
}
>
<AssetsReportView data={data} />
</ReportFrame>
);
}
@@ -0,0 +1,30 @@
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
import { getUnitPriceReport } from "@/features/reports/extra";
import { tr } from "@/i18n/tr";
import { csvMoney, toCsv } from "@/shared/csv";
import { todayIstanbul } from "@/shared/dates";
import { unitPriceBase } from "@/shared/prices";
/** GET /raporlar/birim-fiyat/csv?tip=<ürün tipi> — ay başına en düşük ve ortalama (gösterim biriminde). */
export async function GET(request: Request) {
const c = await csvContext(request);
if (!c) return unauthorized();
const r = await getUnitPriceReport(c.userId, c.householdId, c.params.get("tip") ?? undefined);
if (!r?.selected) return forbidden();
const sel = r.selected;
const base = unitPriceBase(sel.unit);
const money = (x: number | null) => (x === null ? "" : csvMoney(Math.round(x * base.factor)));
const header = tr.reports.csvHeaders.unitPrice.map((h, i) =>
i === 1 || i === 2 ? `${h} (TL/${base.unit})` : h,
);
return csvResponse(
toCsv(
header,
sel.points.map((p) => [p.month, money(p.minMinor), money(p.avgMinor), p.count]),
),
tr.reports.csvFiles.unitPrice(
sel.name.toLocaleLowerCase("tr-TR").replace(/\s+/g, "-"),
todayIstanbul(),
),
);
}
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { CsvButton, ReportFrame, ToolsEnd } from "@/features/reports/components/report-frame";
import { UnitPriceView } from "@/features/reports/components/unit-price-view";
import { getUnitPriceReport } from "@/features/reports/extra";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
const t = tr.reports.unitPrice;
export const metadata: Metadata = { title: t.title };
/** /raporlar/birim-fiyat?tip=<ürün tipi> — aylık en düşük ve ortalama birim fiyat. */
export default async function UnitPriceReportPage({
searchParams,
}: PageProps<"/raporlar/birim-fiyat">) {
const { user, household } = await requireActiveHousehold("viewer");
const tip = (await searchParams).tip;
const data = await getUnitPriceReport(
user.id,
household.id,
typeof tip === "string" ? tip : undefined,
);
if (!data) notFound();
return (
<ReportFrame
title={t.title}
description={t.description}
tools={
data.selected ? (
<ToolsEnd>
<CsvButton href={`/raporlar/birim-fiyat/csv?tip=${data.selected.id}`} />
</ToolsEnd>
) : undefined
}
>
<UnitPriceView data={data} />
</ReportFrame>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
import { getBudgetReport } from "@/features/reports/service";
import { tr } from "@/i18n/tr";
import { csvMoney, toCsv } from "@/shared/csv";
/** GET /raporlar/butce/csv?ay=YYYY-MM — kategori bazında limit ve gerçekleşen. */
export async function GET(request: Request) {
const c = await csvContext(request);
if (!c) return unauthorized();
const r = await getBudgetReport(c.userId, c.householdId, c.month);
if (!r) return forbidden();
const csv = toCsv(
tr.reports.csvHeaders.budget,
r.lines.map((l) => [
l.path,
l.limitMinor === null ? "" : csvMoney(l.limitMinor),
csvMoney(l.actualMinor),
l.remainingMinor === null ? "" : csvMoney(l.remainingMinor),
csvPct(l.ratioBp === null ? null : Math.round(l.ratioBp / 10) / 10),
]),
);
return csvResponse(csv, tr.reports.csvFiles.budget(r.month));
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { BudgetReportView } from "@/features/reports/components/budget-report-view";
import {
CsvButton,
ToolsEnd,
MonthNav,
ReportFrame,
monthLabel,
} from "@/features/reports/components/report-frame";
import { getBudgetReport } from "@/features/reports/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { budgetMonthSchema } from "@/shared/schemas/budget";
const t = tr.reports.budget;
export const metadata: Metadata = { title: t.title };
/** /raporlar/butce?ay=YYYY-MM — bütçe ve gerçekleşen. */
export default async function BudgetReportPage({ searchParams }: PageProps<"/raporlar/butce">) {
const { user, household } = await requireActiveHousehold("viewer");
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
const data = await getBudgetReport(user.id, household.id, ay.success ? ay.data : undefined);
if (!data) notFound();
return (
<ReportFrame
title={t.title}
description={t.description(monthLabel(data.month))}
tools={
<>
<MonthNav month={data.month} currentMonth={data.currentMonth} path="/raporlar/butce" />
<ToolsEnd>
<CsvButton href={`/raporlar/butce/csv?ay=${data.month}`} />
</ToolsEnd>
</>
}
>
<BudgetReportView data={data} />
</ReportFrame>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
import { getContactsReport } from "@/features/reports/extra";
import { tr } from "@/i18n/tr";
import { csvMoney, toCsv } from "@/shared/csv";
import { todayIstanbul } from "@/shared/dates";
/** GET /raporlar/cari/csv?tablo=cari|ayni */
export async function GET(request: Request) {
const c = await csvContext(request);
if (!c) return unauthorized();
const r = await getContactsReport(c.userId, c.householdId);
if (!r) return forbidden();
const h = tr.reports.csvHeaders;
const today = todayIstanbul();
if (c.params.get("tablo") === "ayni")
return csvResponse(
toCsv(
h.inKind,
r.inKind.map((d) => [
d.contactName,
d.direction === "receivable" ? tr.reports.contacts.receivable : tr.reports.contacts.debt,
d.remainingLabel,
d.valueMinor === null ? "" : csvMoney(d.valueMinor),
d.date,
]),
),
tr.reports.csvFiles.inKind(today),
);
return csvResponse(
toCsv(
h.contacts,
r.contacts.map((x) => [x.name, csvMoney(x.balanceMinor), x.dueDate ?? ""]),
),
tr.reports.csvFiles.contacts(today),
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ContactsReportView } from "@/features/reports/components/contacts-assets-view";
import { CsvButton, ReportFrame, ToolsEnd } from "@/features/reports/components/report-frame";
import { getContactsReport } from "@/features/reports/extra";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
const t = tr.reports.contacts;
export const metadata: Metadata = { title: t.title };
/** /raporlar/cari — cari bakiyeler ve açık aynî borç/alacaklar. */
export default async function ContactsReportPage() {
const { user, household } = await requireActiveHousehold("viewer");
const data = await getContactsReport(user.id, household.id);
if (!data) notFound();
return (
<ReportFrame
title={t.title}
description={t.description}
tools={
<ToolsEnd>
<CsvButton href="/raporlar/cari/csv?tablo=cari" label={t.csvContacts} />
<CsvButton href="/raporlar/cari/csv?tablo=ayni" label={t.csvInKind} />
</ToolsEnd>
}
>
<ContactsReportView data={data} />
</ReportFrame>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
import { getWasteReport } from "@/features/reports/extra";
import { tr } from "@/i18n/tr";
import { csvMoney, toCsv } from "@/shared/csv";
import { todayIstanbul } from "@/shared/dates";
import { quantityInput } from "@/shared/stock";
import { parsePeriod } from "@/shared/reports";
/** GET /raporlar/fire/csv?donem=3|6|12 */
export async function GET(request: Request) {
const c = await csvContext(request);
if (!c) return unauthorized();
const r = await getWasteReport(c.userId, c.householdId, parsePeriod(c.params.get("donem")));
if (!r) return forbidden();
return csvResponse(
toCsv(
tr.reports.csvHeaders.waste,
r.rows.map((x) => [
x.name,
x.unit,
quantityInput(x.wasted),
quantityInput(x.purchased),
csvPct(x.wastePct),
x.costMinor === null ? "" : csvMoney(x.costMinor),
]),
),
tr.reports.csvFiles.waste(todayIstanbul()),
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import {
CsvButton,
RangeNav,
ReportFrame,
ToolsEnd,
} from "@/features/reports/components/report-frame";
import { WasteReportView } from "@/features/reports/components/shopping-waste-view";
import { getWasteReport } from "@/features/reports/extra";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { parsePeriod, REPORT_PERIODS } from "@/shared/reports";
const t = tr.reports.waste;
export const metadata: Metadata = { title: t.title };
/** /raporlar/fire?donem=3|6|12 — fire/zarar raporu. */
export default async function WasteReportPage({ searchParams }: PageProps<"/raporlar/fire">) {
const { user, household } = await requireActiveHousehold("viewer");
const period = parsePeriod((await searchParams).donem);
const data = await getWasteReport(user.id, household.id, period);
if (!data) notFound();
return (
<ReportFrame
title={t.title}
description={t.description}
tools={
<>
<RangeNav
path="/raporlar/fire"
label={tr.reports.periodLabel}
value={String(period)}
options={REPORT_PERIODS.map((p) => ({
value: String(p),
label: tr.reports.periods[p]!,
}))}
/>
<ToolsEnd>
<CsvButton href={`/raporlar/fire/csv?donem=${period}`} />
</ToolsEnd>
</>
}
>
<WasteReportView data={data} />
</ReportFrame>
);
}
@@ -0,0 +1,29 @@
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
import { getCategoryReport } from "@/features/reports/service";
import { tr } from "@/i18n/tr";
import { csvMoney, toCsv } from "@/shared/csv";
/** GET /raporlar/kategoriler/csv?ay=YYYY-MM — üst kategori ve alt kategori satırları. */
export async function GET(request: Request) {
const c = await csvContext(request);
if (!c) return unauthorized();
const r = await getCategoryReport(c.userId, c.householdId, c.month);
if (!r) return forbidden();
const rows = r.breakdown.rows.flatMap((top) => [
top,
...top.children.map((k) => ({ ...k, parent: top.name })),
]);
const csv = toCsv(
tr.reports.csvHeaders.categories,
rows.map((x) => [
"parent" in x ? x.parent : x.name,
"parent" in x ? x.name : "",
csvMoney(x.amountMinor),
csvPct(x.sharePct),
csvMoney(x.prevMinor),
csvMoney(x.changeMinor),
csvPct(x.changePct),
]),
);
return csvResponse(csv, tr.reports.csvFiles.categories(r.month));
}
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { CategoriesView } from "@/features/reports/components/categories-view";
import {
CsvButton,
ToolsEnd,
MonthNav,
ReportFrame,
monthLabel,
} from "@/features/reports/components/report-frame";
import { getCategoryReport } from "@/features/reports/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { budgetMonthSchema } from "@/shared/schemas/budget";
const t = tr.reports.categories;
export const metadata: Metadata = { title: t.title };
/** /raporlar/kategoriler?ay=YYYY-MM — kategori dağılımı. */
export default async function CategoryReportPage({
searchParams,
}: PageProps<"/raporlar/kategoriler">) {
const { user, household } = await requireActiveHousehold("viewer");
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
const data = await getCategoryReport(user.id, household.id, ay.success ? ay.data : undefined);
if (!data) notFound();
return (
<ReportFrame
title={t.title}
description={t.description(monthLabel(data.month))}
tools={
<>
<MonthNav
month={data.month}
currentMonth={data.currentMonth}
path="/raporlar/kategoriler"
/>
<ToolsEnd>
<CsvButton href={`/raporlar/kategoriler/csv?ay=${data.month}`} />
</ToolsEnd>
</>
}
>
{data.current ? <p className="text-xs text-muted-foreground">{t.partialMonth}</p> : null}
{data.includesHidden ? (
<p className="text-xs text-muted-foreground">{tr.reports.includesHidden}</p>
) : null}
<CategoriesView data={data} />
</ReportFrame>
);
}
@@ -0,0 +1,36 @@
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
import { getPeopleAccountsReport } from "@/features/reports/service";
import { tr } from "@/i18n/tr";
import { csvMoney, toCsv } from "@/shared/csv";
/** GET /raporlar/kisi-hesap/csv?ay=YYYY-MM&tablo=kisi|hesap */
export async function GET(request: Request) {
const c = await csvContext(request);
if (!c) return unauthorized();
const r = await getPeopleAccountsReport(c.userId, c.householdId, c.month);
if (!r) return forbidden();
const h = tr.reports.csvHeaders;
if (c.params.get("tablo") === "hesap")
return csvResponse(
toCsv(
h.accounts,
r.accounts.map((a) => [
a.name,
csvMoney(a.incomeMinor),
csvMoney(a.expenseMinor),
csvMoney(a.transferInMinor),
csvMoney(a.transferOutMinor),
csvMoney(a.netMinor),
a.count,
]),
),
tr.reports.csvFiles.accounts(r.month),
);
return csvResponse(
toCsv(
h.people,
r.people.map((p) => [p.name, csvMoney(p.expenseMinor), p.count, csvPct(p.sharePct)]),
),
tr.reports.csvFiles.people(r.month),
);
}
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PeopleView } from "@/features/reports/components/people-view";
import {
CsvButton,
ToolsEnd,
MonthNav,
ReportFrame,
monthLabel,
} from "@/features/reports/components/report-frame";
import { getPeopleAccountsReport } from "@/features/reports/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { budgetMonthSchema } from "@/shared/schemas/budget";
const t = tr.reports.people;
export const metadata: Metadata = { title: t.title };
/** /raporlar/kisi-hesap?ay=YYYY-MM — kişi bazlı harcama ve hesap bazlı hareket. */
export default async function PeopleReportPage({
searchParams,
}: PageProps<"/raporlar/kisi-hesap">) {
const { user, household } = await requireActiveHousehold("viewer");
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
const data = await getPeopleAccountsReport(
user.id,
household.id,
ay.success ? ay.data : undefined,
);
if (!data) notFound();
return (
<ReportFrame
title={t.title}
description={t.description(monthLabel(data.month))}
tools={
<>
<MonthNav
month={data.month}
currentMonth={data.currentMonth}
path="/raporlar/kisi-hesap"
/>
<ToolsEnd>
<CsvButton
href={`/raporlar/kisi-hesap/csv?ay=${data.month}&tablo=kisi`}
label={t.csvPeople}
/>
<CsvButton
href={`/raporlar/kisi-hesap/csv?ay=${data.month}&tablo=hesap`}
label={t.csvAccounts}
/>
</ToolsEnd>
</>
}
>
{data.includesHidden ? (
<p className="text-xs text-muted-foreground">{tr.reports.includesHidden}</p>
) : null}
<PeopleView data={data} />
</ReportFrame>
);
}
+117
View File
@@ -0,0 +1,117 @@
import {
ChartPie,
ChevronRight,
Coins,
HandCoins,
ShoppingCart,
Tags,
Target,
Trash2,
TrendingUp,
Users,
} from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { monthLabel, formatPct } from "@/features/reports/components/report-frame";
import { getCategoryReport, getTrendReport } from "@/features/reports/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format";
const t = tr.reports;
export const metadata: Metadata = { title: t.title };
const REPORTS = [
{ href: "/raporlar/kategoriler", key: "categories", icon: ChartPie },
{ href: "/raporlar/trend", key: "trend", icon: TrendingUp },
{ href: "/raporlar/kisi-hesap", key: "people", icon: Users },
{ href: "/raporlar/butce", key: "budget", icon: Target },
{ href: "/raporlar/cari", key: "contacts", icon: HandCoins },
{ href: "/raporlar/birikim", key: "assets", icon: Coins },
{ href: "/raporlar/alisveris", key: "shopping", icon: ShoppingCart },
{ href: "/raporlar/birim-fiyat", key: "unitPrice", icon: Tags },
{ href: "/raporlar/fire", key: "waste", icon: Trash2 },
] as const;
/** /raporlar — bu bütçe ayının özeti ve raporlar. */
export default async function ReportsPage() {
const { user, household } = await requireActiveHousehold("viewer");
const [trend, cats] = await Promise.all([
getTrendReport(user.id, household.id, 1),
getCategoryReport(user.id, household.id),
]);
if (!trend || !cats) notFound();
const m = trend.months[0]!;
const top = cats.breakdown.rows.filter((r) => r.amountMinor > 0).slice(0, 3);
const stats = [
{ label: t.hub.income, value: formatMoney(m.incomeMinor), tone: "text-income" },
{ label: t.hub.expense, value: formatMoney(m.expenseMinor), tone: "text-expense" },
{
label: t.hub.net,
value: `${m.netMinor < 0 ? "−" : ""}${formatMoney(Math.abs(m.netMinor))}`,
tone: m.netMinor < 0 ? "text-expense" : undefined,
},
{ label: t.hub.savings, value: formatPct(m.savingsPct) },
];
return (
<>
<PageHeader title={t.title} description={t.description} />
<section aria-labelledby="hub-month" className="rounded-xl border bg-card p-4">
<h2 id="hub-month" className="text-sm font-medium">
{t.hub.thisMonth}: <span className="capitalize">{monthLabel(m.month)}</span>{" "}
<span className="font-normal text-muted-foreground">({t.trend.ongoing})</span>
</h2>
<dl className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
{stats.map((s) => (
<div key={s.label}>
<dt className="text-xs text-muted-foreground">{s.label}</dt>
<dd className={cn("text-lg font-semibold tabular-nums", s.tone)}>{s.value}</dd>
</div>
))}
</dl>
{top.length ? (
<div className="mt-4">
<h3 className="text-xs text-muted-foreground">{t.hub.topCategories}</h3>
<ul className="mt-1 flex flex-col gap-1 text-sm">
{top.map((r) => (
<li key={r.id} className="flex justify-between gap-3">
<span className="truncate">{r.name}</span>
<span className="tabular-nums">
{formatMoney(r.amountMinor)}{" "}
<span className="text-muted-foreground">· {formatPct(r.sharePct)}</span>
</span>
</li>
))}
</ul>
</div>
) : null}
</section>
<ul className="grid gap-3 md:grid-cols-2">
{REPORTS.map((r) => {
const c = t.hub.cards[r.key];
const Icon = r.icon;
return (
<li key={r.href}>
<Link
href={r.href}
className="flex h-full items-start gap-3 rounded-xl border bg-card p-4 transition-colors hover:bg-accent/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<Icon className="size-4.5 text-muted-foreground" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block font-medium">{c.title}</span>
<span className="block text-sm text-muted-foreground">{c.description}</span>
</span>
<ChevronRight className="mt-2 size-4 text-muted-foreground" aria-hidden />
</Link>
</li>
);
})}
</ul>
</>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
import { getTrendReport } from "@/features/reports/service";
import { tr } from "@/i18n/tr";
import { csvMoney, toCsv } from "@/shared/csv";
import { todayIstanbul } from "@/shared/dates";
/** GET /raporlar/trend/csv — son 12 bütçe ayı. */
export async function GET(request: Request) {
const c = await csvContext(request);
if (!c) return unauthorized();
const r = await getTrendReport(c.userId, c.householdId);
if (!r) return forbidden();
const csv = toCsv(
tr.reports.csvHeaders.trend,
r.months.map((m) => [
m.month,
csvMoney(m.incomeMinor),
csvMoney(m.expenseMinor),
csvMoney(m.netMinor),
csvPct(m.savingsPct),
]),
);
return csvResponse(csv, tr.reports.csvFiles.trend(todayIstanbul()));
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { CsvButton, ToolsEnd, ReportFrame } from "@/features/reports/components/report-frame";
import { TrendView } from "@/features/reports/components/trend-view";
import { getTrendReport } from "@/features/reports/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
const t = tr.reports.trend;
export const metadata: Metadata = { title: t.title };
/** /raporlar/trend — son 12 bütçe ayı gelir, gider, net ve tasarruf oranı. */
export default async function TrendReportPage() {
const { user, household } = await requireActiveHousehold("viewer");
const data = await getTrendReport(user.id, household.id);
if (!data) notFound();
return (
<ReportFrame
title={t.title}
description={t.description}
tools={
<ToolsEnd>
<CsvButton href="/raporlar/trend/csv" />
</ToolsEnd>
}
>
<TrendView data={data} />
</ReportFrame>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { TypePriceComparison } from "@/features/prices/components/type-price-comparison";
import { getTypePriceComparison } from "@/features/prices/service";
import { ProductsSection } from "@/features/products/components/products-section";
import { listProducts, listProductTypeOptions } from "@/features/products/service";
import { ProductDetailView } from "@/features/stock/components/product-detail-view";
import { getProductType, getStockOptions } from "@/features/stock/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.stock.detail.movements };
/** /stok/:id — ürün tipi: mevcut durum, partiler ve hareketler (başka hanenin kaydı → 404). */
export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) {
const { user, household } = await requireActiveHousehold("viewer");
const { id } = await params;
const [data, options, products, types, prices] = await Promise.all([
getProductType(user.id, household.id, id),
getStockOptions(user.id, household.id),
listProducts(user.id, household.id, { productTypeId: id, includeArchived: true }),
listProductTypeOptions(user.id, household.id),
getTypePriceComparison(user.id, household.id, id),
]);
if (!data || !options || !products || !types || !prices) notFound();
return (
<ProductDetailView
data={data}
categories={options.categories}
products={
<>
<TypePriceComparison data={prices} />
<ProductsSection
productTypeId={data.id}
products={products.products}
types={types}
canWrite={products.canWrite}
/>
</>
}
/>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { StockAnalysisView } from "@/features/stock/components/stock-analysis-view";
import { getStockAnalysis } from "@/features/stock/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.stock.analysis.title };
/** /stok/analiz — ürün tipi başına aylık ortalama tüketim ve fire. */
export default async function StockAnalysisPage() {
const { user, household } = await requireActiveHousehold("viewer");
const data = await getStockAnalysis(user.id, household.id);
if (!data) notFound();
return <StockAnalysisView data={data} />;
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { StockCategoriesManager } from "@/features/stock/components/stock-categories-manager";
import { getStockOptions, listStockCategories } from "@/features/stock/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.stock.categories.title };
/** /stok/kategoriler — stok kategori ağacı, gider eşlemesi ve sayım sıklığı. */
export default async function StockCategoriesPage() {
const { user, household } = await requireActiveHousehold("viewer");
const [data, options] = await Promise.all([
listStockCategories(user.id, household.id),
getStockOptions(user.id, household.id),
]);
if (!data || !options) notFound();
return (
<>
<PageHeader title={tr.stock.categories.title} description={tr.stock.categories.description} />
<StockCategoriesManager
categories={data.categories}
expenseCategories={options.expenseCategories}
canWrite={data.canWrite}
/>
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { StockView } from "@/features/stock/components/stock-view";
import { getStock, getStockOptions } from "@/features/stock/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.stock.title };
/** /stok — ev stoğu: özet, alınması önerilenler, SKT uyarıları, kategoriye göre ürünler. */
export default async function StockPage() {
const { user, household } = await requireActiveHousehold("viewer");
const [data, options] = await Promise.all([
getStock(user.id, household.id),
getStockOptions(user.id, household.id),
]);
if (!data || !options) notFound();
return (
<>
<PageHeader title={tr.stock.title} description={tr.stock.description} />
{!data.canWrite ? <p className="text-sm text-muted-foreground">{tr.stock.readOnly}</p> : null}
<StockView data={data} options={options.categories} />
</>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { CountSheet } from "@/features/stock/components/count-sheet";
import { getCountSheet } from "@/features/stock/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.stock.count.title };
/** /stok/sayim?kategori=<id> — periyodik sayım: beklenen → sayılan (kategori alt ağacıyla). */
export default async function StockCountPage({ searchParams }: PageProps<"/stok/sayim">) {
const { user, household } = await requireActiveHousehold("viewer");
const sp = await searchParams;
const kategori =
typeof sp.kategori === "string" && /^[a-f0-9]{24}$/i.test(sp.kategori) ? sp.kategori : null;
const data = await getCountSheet(user.id, household.id, kategori);
if (!data) notFound();
return <CountSheet key={`${kategori ?? "all"}-${data.today}`} data={data} />;
}
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ProductPriceView } from "@/features/prices/components/product-price-view";
import { getProductPrices } from "@/features/prices/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.products.title };
/** /stok/urunler/:id — ürün (marka/paket): mağaza fiyatları, birim fiyat, fiyat geçmişi. */
export default async function ProductPage({ params }: PageProps<"/stok/urunler/[id]">) {
const { user, household } = await requireActiveHousehold("viewer");
const { id } = await params;
const data = await getProductPrices(user.id, household.id, id);
if (!data) notFound();
return <ProductPriceView data={data} />;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ProductsCatalog } from "@/features/products/components/products-catalog";
import { listProducts, listProductTypeOptions } from "@/features/products/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.products.title };
/** /stok/urunler — ürün kataloğu (marka/paket), ürün tipine göre gruplu. */
export default async function ProductsPage() {
const { user, household } = await requireActiveHousehold("viewer");
const [data, types] = await Promise.all([
listProducts(user.id, household.id, { includeArchived: true }),
listProductTypeOptions(user.id, household.id),
]);
if (!data || !types) notFound();
return <ProductsCatalog products={data.products} types={types} canWrite={data.canWrite} />;
}
+49
View File
@@ -0,0 +1,49 @@
import type { NextRequest } from "next/server";
import { readAttachmentForUser } from "@/features/attachments/service";
import { getCurrentUser } from "@/lib/dal";
/**
* GET /api/files/:id[?v=thumb][&indir=1] — oturum ve hane yetkisi kontrol edilerek ek dosyayı
* servis eder. Erişim yoksa (başka hane, gizli hesap, silinmiş kayıt) 404: varlık sızdırılmaz.
*/
export async function GET(request: NextRequest, ctx: RouteContext<"/api/files/[id]">) {
const user = await getCurrentUser();
if (!user) return new Response(null, { status: 401 });
const { id } = await ctx.params;
const params = request.nextUrl.searchParams;
const file = await readAttachmentForUser(
user.id,
id,
params.get("v") === "thumb" ? "thumb" : "full",
);
if (!file) return new Response(null, { status: 404 });
const disposition = params.get("indir") === "1" ? "attachment" : "inline";
const fallbackName = file.mimeType === "application/pdf" ? "belge.pdf" : "gorsel.webp";
const name = toServedName(file.name || fallbackName, file.mimeType);
return new Response(new Uint8Array(file.bytes), {
headers: {
"Content-Type": file.mimeType,
"Content-Length": String(file.bytes.length),
"Content-Disposition": `${disposition}; filename="${asciiName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`,
// Yalnızca kullanıcının tarayıcısında; paylaşılan önbelleklere girmez.
"Cache-Control": "private, max-age=3600",
"X-Content-Type-Options": "nosniff",
},
});
}
/** Görseller WebP'ye çevrildiği için indirilen adın uzantısı içerikle uyumlu olsun. */
function toServedName(name: string, mimeType: string): string {
const ext = mimeType === "application/pdf" ? ".pdf" : ".webp";
return name.replace(/\.[a-z0-9]{1,5}$/i, "") + ext;
}
function asciiName(name: string): string {
return (
name
.normalize("NFKD")
.replace(/[^\x20-\x7e]/g, "")
.replace(/["\\]/g, "") || "dosya"
);
}
+53
View File
@@ -0,0 +1,53 @@
import { uploadAttachment } from "@/features/attachments/service";
import { tr } from "@/i18n/tr";
import { fail } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { env } from "@/lib/env";
/** Multipart başlıkları için tolerans. */
const MULTIPART_OVERHEAD = 64 * 1024;
/**
* POST /api/files — tek dosya yükler (`file` alanı), aktif haneye bekleyen ek olarak kaydeder.
* Kayıt (ör. işlem) kaydedilirken ek kimlikleri gönderilir ve ek kayda bağlanır.
*/
export async function POST(request: Request) {
if (!isSameOrigin(request)) return Response.json(fail(tr.common.unauthorized), { status: 403 });
const ctx = await getHouseholdActionContext("member");
if (!ctx) return Response.json(fail(tr.common.unauthorized), { status: 401 });
const maxMb = env().MAX_UPLOAD_MB;
const length = Number(request.headers.get("content-length") ?? 0);
if (length > maxMb * 1024 * 1024 + MULTIPART_OVERHEAD) {
return Response.json(fail(tr.attachments.errors.tooLarge(maxMb)), { status: 413 });
}
let file: FormDataEntryValue | null;
try {
file = (await request.formData()).get("file");
} catch {
return Response.json(fail(tr.attachments.errors.invalid), { status: 400 });
}
if (!(file instanceof File)) {
return Response.json(fail(tr.attachments.errors.empty), { status: 400 });
}
const bytes = Buffer.from(await file.arrayBuffer());
const result = await uploadAttachment(ctx.user.id, ctx.household.id, { name: file.name, bytes });
return Response.json(result, { status: result.ok ? 201 : 400 });
}
/**
* CSRF savunması: tarayıcı isteğinde Origin varsa uygulamanın kendi host'u olmalı.
* (Oturum cookie'si SameSite=Lax; bu ek bir katman.)
*/
function isSameOrigin(request: Request): boolean {
const origin = request.headers.get("origin");
if (!origin) return true;
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
try {
return new URL(origin).host === host;
} catch {
return false;
}
}
+58
View File
@@ -31,6 +31,14 @@
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-series-1: var(--series-1);
--color-series-2: var(--series-2);
--color-series-3: var(--series-3);
--color-series-4: var(--series-4);
--color-series-5: var(--series-5);
--color-series-6: var(--series-6);
--color-series-7: var(--series-7);
--color-series-8: var(--series-8);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
@@ -95,7 +103,33 @@
--chart-3: var(--expense); --chart-3: var(--expense);
--chart-4: var(--warning); --chart-4: var(--warning);
--chart-5: oklch(0.65 0.08 230); --chart-5: oklch(0.65 0.08 230);
/* Kategorik seriler (mağaza vb. kimlik; sabit sıra, döngü yok). dataviz doğrulayıcısından
geçti (açık: kart #fff; 3 yuva 3:1 altında → lejant + tablo zorunlu). */
--series-1: #2a78d6;
--series-2: #eb6834;
--series-3: #1baf7a;
--series-4: #eda100;
--series-5: #e87ba4;
--series-6: #008300;
--series-7: #6250d6;
--series-8: #e34948;
--radius: 0.625rem; --radius: 0.625rem;
/* Kullanıcı verisi renkleri (kategori/hesap): anahtar → değişken (shared/appearance.ts) */
--swatch-blue: oklch(0.6 0.15 258);
--swatch-sky: oklch(0.6 0.15 230);
--swatch-teal: oklch(0.6 0.15 185);
--swatch-green: oklch(0.6 0.15 150);
--swatch-lime: oklch(0.6 0.15 125);
--swatch-amber: oklch(0.6 0.15 80);
--swatch-orange: oklch(0.6 0.15 55);
--swatch-red: oklch(0.6 0.15 25);
--swatch-rose: oklch(0.6 0.15 10);
--swatch-pink: oklch(0.6 0.15 350);
--swatch-purple: oklch(0.6 0.15 305);
--swatch-indigo: oklch(0.6 0.15 275);
--swatch-slate: oklch(0.55 0.03 255);
--sidebar: oklch(0.975 0.008 250); --sidebar: oklch(0.975 0.008 250);
--sidebar-foreground: oklch(0.22 0.04 262); --sidebar-foreground: oklch(0.22 0.04 262);
--sidebar-primary: oklch(0.49 0.17 258); --sidebar-primary: oklch(0.49 0.17 258);
@@ -140,6 +174,30 @@
--chart-3: var(--expense); --chart-3: var(--expense);
--chart-4: var(--warning); --chart-4: var(--warning);
--chart-5: oklch(0.7 0.07 230); --chart-5: oklch(0.7 0.07 230);
/* Koyu yüzey (#101a2f) için aynı tonların koyu adımları; hepsi ≥ 3:1. */
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-5: #d55181;
--series-6: #008300;
--series-7: #9085e9;
--series-8: #e66767;
--swatch-blue: oklch(0.74 0.13 258);
--swatch-sky: oklch(0.74 0.13 230);
--swatch-teal: oklch(0.74 0.13 185);
--swatch-green: oklch(0.74 0.13 150);
--swatch-lime: oklch(0.74 0.13 125);
--swatch-amber: oklch(0.74 0.13 80);
--swatch-orange: oklch(0.74 0.13 55);
--swatch-red: oklch(0.74 0.13 25);
--swatch-rose: oklch(0.74 0.13 10);
--swatch-pink: oklch(0.74 0.13 350);
--swatch-purple: oklch(0.74 0.13 305);
--swatch-indigo: oklch(0.74 0.13 275);
--swatch-slate: oklch(0.72 0.03 255);
--sidebar: oklch(0.2 0.045 264); --sidebar: oklch(0.2 0.045 264);
--sidebar-foreground: oklch(0.96 0.01 250); --sidebar-foreground: oklch(0.96 0.01 250);
--sidebar-primary: oklch(0.76 0.12 245); --sidebar-primary: oklch(0.76 0.12 245);
+108
View File
@@ -0,0 +1,108 @@
import {
Baby,
Banknote,
Book,
Briefcase,
Building,
Bus,
Car,
CircleEllipsis,
Coffee,
Coins,
CreditCard,
Droplet,
Dumbbell,
Film,
Flame,
Fuel,
Gamepad2,
Gift,
GraduationCap,
HeartPulse,
House,
Landmark,
Music,
PawPrint,
Percent,
PiggyBank,
Pill,
Plane,
Receipt,
Repeat,
Scissors,
Shirt,
ShoppingBasket,
ShoppingCart,
Smartphone,
Sofa,
Sparkles,
SprayCan,
Tag,
Train,
TrendingUp,
Undo2,
Utensils,
Wallet,
Wifi,
Wrench,
Zap,
type LucideIcon,
type LucideProps,
} from "lucide-react";
import type { IconKey } from "@/shared/appearance";
/** Saklanan ikon anahtarı → lucide bileşeni (shared/appearance.ts ICON_KEYS ile birebir). */
export const ICONS: Record<IconKey, LucideIcon> = {
wallet: Wallet,
banknote: Banknote,
landmark: Landmark,
"piggy-bank": PiggyBank,
"credit-card": CreditCard,
utensils: Utensils,
"shopping-cart": ShoppingCart,
"shopping-basket": ShoppingBasket,
receipt: Receipt,
zap: Zap,
droplet: Droplet,
flame: Flame,
wifi: Wifi,
smartphone: Smartphone,
house: House,
building: Building,
car: Car,
fuel: Fuel,
bus: Bus,
train: Train,
"heart-pulse": HeartPulse,
pill: Pill,
"graduation-cap": GraduationCap,
book: Book,
shirt: Shirt,
gamepad: Gamepad2,
film: Film,
music: Music,
coffee: Coffee,
plane: Plane,
gift: Gift,
baby: Baby,
"paw-print": PawPrint,
wrench: Wrench,
sofa: Sofa,
repeat: Repeat,
briefcase: Briefcase,
"trending-up": TrendingUp,
coins: Coins,
percent: Percent,
undo: Undo2,
sparkles: Sparkles,
"spray-can": SprayCan,
scissors: Scissors,
dumbbell: Dumbbell,
tag: Tag,
"circle-ellipsis": CircleEllipsis,
};
export function AppIcon({ name, ...props }: { name: IconKey } & LucideProps) {
const Icon = ICONS[name] ?? Tag;
return <Icon aria-hidden {...props} />;
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { ICON_KEYS, SWATCHES, type IconKey, type Swatch } from "@/shared/appearance";
import { AppIcon } from "./app-icon";
import { swatchStyle } from "./swatch";
import { tr } from "@/i18n/tr";
/** Renk seçici (radio grubu, klavye ile gezilebilir). */
export function SwatchPicker({
value,
onChange,
labelledBy,
}: {
value: Swatch;
onChange: (v: Swatch) => void;
labelledBy: string;
}) {
return (
<div role="radiogroup" aria-labelledby={labelledBy} className="flex flex-wrap gap-2">
{SWATCHES.map((s) => (
<button
key={s}
type="button"
role="radio"
aria-checked={value === s}
aria-label={tr.swatches[s]}
title={tr.swatches[s]}
onClick={() => onChange(s)}
style={swatchStyle(s)}
className="flex size-8 items-center justify-center rounded-full bg-(--swatch) text-background ring-offset-2 ring-offset-background outline-none focus-visible:ring-3 focus-visible:ring-ring/50 aria-checked:ring-2 aria-checked:ring-(--swatch)"
>
{value === s ? <Check className="size-4" /> : null}
</button>
))}
</div>
);
}
/** İkon seçici; seçili renkle önizler. */
export function IconPicker({
value,
color,
onChange,
labelledBy,
}: {
value: IconKey;
color: Swatch;
onChange: (v: IconKey) => void;
labelledBy: string;
}) {
return (
<div
role="radiogroup"
aria-labelledby={labelledBy}
style={swatchStyle(color)}
className="grid max-h-44 grid-cols-8 gap-1 overflow-y-auto rounded-lg border p-1.5 sm:grid-cols-10"
>
{ICON_KEYS.map((key) => (
<button
key={key}
type="button"
role="radio"
aria-checked={value === key}
aria-label={key}
onClick={() => onChange(key)}
className={cn(
"flex aspect-square items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50",
"aria-checked:bg-(--swatch)/15 aria-checked:text-(--swatch) aria-checked:ring-1 aria-checked:ring-(--swatch)",
)}
>
<AppIcon name={key} className="size-4.5" />
</button>
))}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { cn } from "@/lib/utils";
import type { IconKey, Swatch } from "@/shared/appearance";
import { AppIcon } from "./app-icon";
/** Renk anahtarını CSS değişkenine bağlar: --swatch-blue → --swatch (sınıflar bunu kullanır). */
export const swatchStyle = (color: Swatch) =>
({ "--swatch": `var(--swatch-${color})` }) as React.CSSProperties;
/** Renkli yuvarlak ikon rozeti (kategori/hesap). */
export function IconBadge({
icon,
color,
className,
size = "md",
}: {
icon: IconKey;
color: Swatch;
className?: string;
size?: "sm" | "md";
}) {
return (
<span
style={swatchStyle(color)}
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full bg-(--swatch)/15 text-(--swatch)",
size === "sm" ? "size-7 [&_svg]:size-3.5" : "size-9 [&_svg]:size-4.5",
className,
)}
>
<AppIcon name={icon} />
</span>
);
}
+7 -2
View File
@@ -11,18 +11,23 @@ import { UserMenu } from "./user-menu";
export function AppHeader({ export function AppHeader({
user, user,
households, households,
activeHouseholdId,
canWrite,
version, version,
}: { }: {
user: SessionUser; user: SessionUser;
households: HouseholdSummary[]; households: HouseholdSummary[];
activeHouseholdId: string | undefined;
/** Aktif hanede en az "member" mi? (İzleyici veri giremez.) */
canWrite: boolean;
version: string; version: string;
}) { }) {
return ( return (
<header className="sticky top-0 z-30 flex h-14 items-center gap-2 border-b bg-background/95 px-3 backdrop-blur supports-backdrop-filter:bg-background/80 md:px-4"> <header className="sticky top-0 z-30 flex h-14 items-center gap-2 border-b bg-background/95 px-3 backdrop-blur supports-backdrop-filter:bg-background/80 md:px-4">
<SidebarTrigger className="hidden md:inline-flex" aria-label={tr.nav.toggleSidebar} /> <SidebarTrigger className="hidden md:inline-flex" aria-label={tr.nav.toggleSidebar} />
<HouseholdSwitcher households={households} /> <HouseholdSwitcher households={households} activeId={activeHouseholdId} />
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
<QuickAddButton className="hidden md:inline-flex" /> {canWrite ? <QuickAddButton className="hidden md:inline-flex" /> : null}
<ThemeToggle /> <ThemeToggle />
<UserMenu user={user} version={version} /> <UserMenu user={user} version={version} />
</div> </div>
+5 -3
View File
@@ -40,7 +40,7 @@ function BottomNavLink({ item, pathname }: { item: NavItem; pathname: string })
} }
/** Mobil alt gezinme çubuğu (md altı). */ /** Mobil alt gezinme çubuğu (md altı). */
export function BottomNav() { export function BottomNav({ canWrite }: { canWrite: boolean }) {
const pathname = usePathname(); const pathname = usePathname();
const { openQuickAdd } = useQuickAdd(); const { openQuickAdd } = useQuickAdd();
const { setOpenMobile } = useSidebar(); const { setOpenMobile } = useSidebar();
@@ -56,9 +56,11 @@ export function BottomNav() {
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<button <button
type="button" type="button"
onClick={openQuickAdd} onClick={() => openQuickAdd()}
disabled={!canWrite}
title={canWrite ? undefined : tr.households.viewerReadOnly}
aria-label={tr.quickAdd.button} aria-label={tr.quickAdd.button}
className="-mt-5 flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg outline-none focus-visible:ring-4 focus-visible:ring-ring/50" className="-mt-5 flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg outline-none focus-visible:ring-4 focus-visible:ring-ring/50 disabled:opacity-40"
> >
<Plus className="size-6" /> <Plus className="size-6" />
</button> </button>
+29 -7
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { House } from "lucide-react"; import { House } from "lucide-react";
import { useState } from "react"; import { useTransition } from "react";
import { toast } from "sonner";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -9,17 +10,38 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { switchHouseholdAction } from "@/features/households/actions";
import type { HouseholdSummary } from "@/features/households/types"; import type { HouseholdSummary } from "@/features/households/types";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
/** Üst bar aktif hane seçici. FAZ 5: seçim sunucuda (cookie) saklanacak. */ /** Üst bar aktif hane seçici. Seçim sunucuda doğrulanır ve cookie'ye yazılır. */
export function HouseholdSwitcher({ households }: { households: HouseholdSummary[] }) { export function HouseholdSwitcher({
const [value, setValue] = useState(households[0]?.id); households,
const current = households.find((h) => h.id === value); activeId,
}: {
households: HouseholdSummary[];
activeId: string | undefined;
}) {
const [pending, startTransition] = useTransition();
const current = households.find((h) => h.id === activeId);
if (households.length === 0) return null; if (households.length === 0) return null;
function onChange(id: string) {
if (id === activeId) return;
startTransition(async () => {
const result = await switchHouseholdAction(id);
if (result.ok) toast.success(tr.household.switched(result.data.name));
else toast.error(result.error);
});
}
return ( return (
<Select value={value} onValueChange={setValue}> <Select value={activeId} onValueChange={onChange} disabled={pending || households.length < 2}>
<SelectTrigger aria-label={tr.household.label} className="w-[min(46vw,13rem)]"> <SelectTrigger
aria-label={tr.household.label}
className="w-[min(46vw,13rem)]"
aria-busy={pending}
>
<House className="text-muted-foreground" /> <House className="text-muted-foreground" />
{/* Etiket children ile verilir: Radix aksi halde hydration'a kadar boş gösterir. */} {/* Etiket children ile verilir: Radix aksi halde hydration'a kadar boş gösterir. */}
<SelectValue placeholder={tr.household.select}>{current?.name}</SelectValue> <SelectValue placeholder={tr.household.select}>{current?.name}</SelectValue>
+19 -12
View File
@@ -8,6 +8,8 @@ import {
Package, Package,
Settings, Settings,
ShoppingCart, ShoppingCart,
Store,
Tags,
Target, Target,
UserCog, UserCog,
Users, Users,
@@ -25,21 +27,24 @@ export type NavItem = {
}; };
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
export const CURRENT_PHASE = 2; /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
export const CURRENT_PHASE = 14.2;
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
export const MAIN_NAV: NavItem[] = [ export const MAIN_NAV: NavItem[] = [
{ href: "/dashboard", label: tr.nav.dashboard, icon: LayoutDashboard, phase: 2 }, { href: "/dashboard", label: tr.nav.dashboard, icon: LayoutDashboard, phase: 2 },
{ href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6 }, { href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6.2 },
{ href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6 }, { href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 },
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7 }, { href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 },
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 }, { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 },
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 }, { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 }, { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 },
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 }, { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 },
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 }, { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 }, { href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 },
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13.1 },
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14.1 },
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 }, { href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
]; ];
@@ -49,9 +54,11 @@ export const ADMIN_NAV: NavItem[] = [
]; ];
/** Mobil alt menü: ortada Hızlı ekle, sağda "Daha fazla" (yan menüyü açar). */ /** Mobil alt menü: ortada Hızlı ekle, sağda "Daha fazla" (yan menüyü açar). */
const navByHref = (href: string) => MAIN_NAV.find((i) => i.href === href)!;
export const BOTTOM_NAV = { export const BOTTOM_NAV = {
left: [MAIN_NAV[0]!, MAIN_NAV[1]!], left: [navByHref("/dashboard"), navByHref("/islemler")],
right: [MAIN_NAV[6]!], right: [navByHref("/butce")],
}; };
export function isActivePath(pathname: string, href: string) { export function isActivePath(pathname: string, href: string) {
+21 -72
View File
@@ -1,16 +1,22 @@
"use client"; "use client";
import { ArrowDownLeft, ArrowLeftRight, ArrowUpRight, Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { createContext, use, useState } from "react"; import { createContext, use, useState } from "react";
import { toast } from "sonner";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
TransactionFormDialog,
type TransactionFormMode,
} from "@/features/transactions/components/transaction-form";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
type QuickAddKind = "expense" | "income" | "transfer"; type QuickAddApi = {
/** Hızlı ekle: yeni işlem formu (varsayılan gider; sekmelerle gelir/transfer). */
openQuickAdd: (type?: "expense" | "income" | "transfer") => void;
/** Düzenle / kopyala (işlem listesi ve ayrıntısı). */
openTransactionForm: (mode: TransactionFormMode) => void;
};
const QuickAddContext = createContext<{ openQuickAdd: () => void } | null>(null); const QuickAddContext = createContext<QuickAddApi | null>(null);
export function useQuickAdd() { export function useQuickAdd() {
const ctx = use(QuickAddContext); const ctx = use(QuickAddContext);
@@ -18,74 +24,17 @@ export function useQuickAdd() {
return ctx; return ctx;
} }
const CHOICES: { /** Uygulama genelinde tek işlem formu; her ekrandan açılabilir (Bölüm 4). */
kind: QuickAddKind;
label: string;
hint: string;
icon: typeof Plus;
tone: string;
}[] = [
{
kind: "expense",
label: tr.quickAdd.expense,
hint: tr.quickAdd.expenseHint,
icon: ArrowUpRight,
tone: "text-expense bg-expense/10",
},
{
kind: "income",
label: tr.quickAdd.income,
hint: tr.quickAdd.incomeHint,
icon: ArrowDownLeft,
tone: "text-income bg-income/10",
},
{
kind: "transfer",
label: tr.quickAdd.transfer,
hint: tr.quickAdd.transferHint,
icon: ArrowLeftRight,
tone: "text-budget bg-budget/10",
},
];
/** Hızlı ekle (gider / gelir / transfer) — her ekrandan açılabilir. */
export function QuickAddProvider({ children }: { children: React.ReactNode }) { export function QuickAddProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false); const [mode, setMode] = useState<TransactionFormMode | null>(null);
const api: QuickAddApi = {
function choose(kind: QuickAddKind) { openQuickAdd: (type = "expense") => setMode({ kind: "create", type }),
setOpen(false); openTransactionForm: setMode,
// FAZ 6: ilgili işlem formu açılacak. };
toast.info(`${CHOICES.find((c) => c.kind === kind)?.label}: ${tr.quickAdd.notYet}`);
}
return ( return (
<QuickAddContext value={{ openQuickAdd: () => setOpen(true) }}> <QuickAddContext value={api}>
{children} {children}
<ResponsiveDialog <TransactionFormDialog mode={mode} onOpenChange={(open) => !open && setMode(null)} />
open={open}
onOpenChange={setOpen}
title={tr.quickAdd.title}
description={tr.quickAdd.description}
>
<div className="grid gap-2 pb-2 sm:grid-cols-3">
{CHOICES.map(({ kind, label, hint, icon: Icon, tone }) => (
<button
key={kind}
type="button"
onClick={() => choose(kind)}
className="flex items-center gap-3 rounded-xl border p-4 text-left transition-colors outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 sm:flex-col sm:text-center"
>
<span className={cn("flex size-10 items-center justify-center rounded-full", tone)}>
<Icon className="size-5" />
</span>
<span className="flex flex-col">
<span className="font-medium">{label}</span>
<span className="text-xs text-muted-foreground">{hint}</span>
</span>
</button>
))}
</div>
</ResponsiveDialog>
</QuickAddContext> </QuickAddContext>
); );
} }
@@ -94,7 +43,7 @@ export function QuickAddProvider({ children }: { children: React.ReactNode }) {
export function QuickAddButton({ className }: { className?: string }) { export function QuickAddButton({ className }: { className?: string }) {
const { openQuickAdd } = useQuickAdd(); const { openQuickAdd } = useQuickAdd();
return ( return (
<Button onClick={openQuickAdd} className={className}> <Button onClick={() => openQuickAdd()} className={className}>
<Plus /> <Plus />
{tr.quickAdd.button} {tr.quickAdd.button}
</Button> </Button>
+49
View File
@@ -0,0 +1,49 @@
"use client";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import { formatMoneyInput, parseMoney } from "@/shared/money";
/**
* Türkçe tutar girişi ("1.250,50"). Değer string olarak tutulur (zod sunucuda/istemcide ayrıştırır);
* alandan çıkınca geçerliyse Türkçe biçime getirilir.
*/
export function MoneyInput({
value,
onChange,
onBlur,
allowNegative = false,
...props
}: Omit<React.ComponentProps<typeof InputGroupInput>, "value" | "onChange" | "type"> & {
value: string;
onChange: (value: string) => void;
allowNegative?: boolean;
}) {
return (
<InputGroup>
<InputGroupInput
{...props}
type="text"
inputMode="decimal"
autoComplete="off"
className="text-right tabular-nums"
value={value}
onChange={(e) =>
onChange(e.target.value.replace(allowNegative ? /[^\d.,\s-−]/g : /[^\d.,\s]/g, ""))
}
onBlur={(e) => {
const minor = parseMoney(value);
if (minor !== null && value.trim() !== "") onChange(formatMoneyInput(minor));
onBlur?.(e);
}}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>₺</InputGroupText>
</InputGroupAddon>
</InputGroup>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ export function PageHeader({
actions, actions,
}: { }: {
title: string; title: string;
description?: string; description?: React.ReactNode;
actions?: React.ReactNode; actions?: React.ReactNode;
}) { }) {
return ( return (

Some files were not shown because too many files have changed in this diff Show More