Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b11972fda3 | |||
| 19536f45de | |||
| c0b114efeb | |||
| afc6da1606 | |||
| 18c892bdcd | |||
| add845684c | |||
| 092db5a8c2 | |||
| 6ce8ffa4a5 | |||
| 1bdcedefdc | |||
| 7d7dd61c60 | |||
| 42561b570a | |||
| 4b5def9452 | |||
| c3c3c43bd1 | |||
| 8623c5a02e | |||
| d9a22551e1 | |||
| 0c421a2d34 | |||
| 29d7b29cfa | |||
| bc2a8a3bde | |||
| d77a2efcb8 | |||
| 47396cba52 | |||
| 6ec29aa030 | |||
| c8678da7b5 | |||
| bd5a20ce64 | |||
| 3dd9834a3d | |||
| 55f2269f82 | |||
| fa70168622 | |||
| 8ab2244755 | |||
| 0b7de17b5c | |||
| d46869c644 | |||
| 9e32f2988d | |||
| e67a1d5411 | |||
| 78503b1787 | |||
| d52f41ab0c | |||
| f0b570284e | |||
| 8e7e374778 | |||
| 6cc698f70b | |||
| 07ba531d31 | |||
| 814bdbbc2a | |||
| 5e0f1b5901 | |||
| 868bb23fd9 | |||
| d9de29c18d | |||
| abf2ea6dd7 | |||
| 0c5d959058 | |||
| d8f442c1aa | |||
| 39aa19d168 | |||
| 21b3715d35 | |||
| b5078d9251 | |||
| a274d29728 | |||
| 62023ef35d | |||
| 3fa1e949e2 | |||
| 6de6d07c66 | |||
| 660c6d5f2d | |||
| c53fe80241 | |||
| b022c1c3b0 | |||
| 9a60085d14 | |||
| 20e5a90df4 | |||
| b8306b3b5d | |||
| a818c110cf | |||
| 890ba9f521 | |||
| baec70d324 |
@@ -2,6 +2,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
out
|
out
|
||||||
|
dist
|
||||||
coverage
|
coverage
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
@@ -10,6 +11,7 @@ next-env.d.ts
|
|||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
uploads
|
uploads
|
||||||
|
import
|
||||||
|
|
||||||
# Geliştirme araçları
|
# Geliştirme araçları
|
||||||
.git
|
.git
|
||||||
|
|||||||
@@ -46,3 +46,7 @@ next-env.d.ts
|
|||||||
|
|
||||||
# local tooling
|
# local tooling
|
||||||
/scratch/
|
/scratch/
|
||||||
|
/dist
|
||||||
|
|
||||||
|
# Kişisel ekstre dosyaları (içe aktarma için) — asla repoya girmez
|
||||||
|
/import/
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ coverage
|
|||||||
uploads
|
uploads
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
docs/PROMPT.md
|
docs/PROMPT.md
|
||||||
|
dist/
|
||||||
|
|||||||
+227
-1
@@ -6,6 +6,225 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
|||||||
|
|
||||||
## [Yayınlanmamış]
|
## [Yayınlanmamış]
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-11-07
|
||||||
|
|
||||||
|
Faz 16: Kakeibo K1 — dört harcama grubu.
|
||||||
|
|
||||||
|
### Eklendi
|
||||||
|
|
||||||
|
- Kakeibo grupları: gider kategorilerine **Zorunlu / İstek / Kültür / Beklenmeyen** grubu; alt kategori grubu üst kategoriden devralır. Kategori formunda grup seçimi ve açıklaması; kategori listesinde grup rozeti (devralınan soluk) ve grupsuz kategori uyarısı.
|
||||||
|
- **Banka maliyeti:** KMH faizi, KKDF/BSMV, kart ve hesap ücretleri Kakeibo harcamasına ve dört gruba girmez, ayrı gösterilir (kategori düzeyinde işaret, alt ağaç devralır).
|
||||||
|
- İşlem formunda **Kakeibo grubu**: varsayılan kategoriden ("Kategoriden: Zorunlu"), bu kayıt için değiştirilebilir; bölünmüş işlemde kalem başına.
|
||||||
|
- Kakeibo raporu (`/raporlar/kakeibo`): Kakeibo harcaması ve grup payları (yığılmış çubuk), grup başına tutar, pay, önceki aya göre değişim ve kategori kırılımı; banka maliyeti ve grupsuz harcamalar ayrı; CSV. Bütçe sayfasında seçili ayın Kakeibo özeti.
|
||||||
|
- Migration `016-kakeibo-groups`: varsayılan kategoriler beceri eşlemesine göre doldurulur (kullanıcı kategorileri grupsuz kalır, mevcut seçimlere dokunulmaz, kategori silinmez). Migration'lar geri alınabilir: `npm run migrate -- --down <ad>`.
|
||||||
|
- Testler: grup devralma, banka maliyeti, kayıt bazında grup, grup özeti ve sıfıra bölme (birim); varsayılan gruplar, rapor toplamı = bütçenin gerçekleşeni, grup değiştirme, kategori güncelleme, migration geri alma/yeniden uygulama (entegrasyon).
|
||||||
|
|
||||||
|
### Güvenlik
|
||||||
|
|
||||||
|
- `import/` klasörü (kişisel ekstre dosyaları) git'e ve Docker imajına girmez.
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-11-07
|
||||||
|
|
||||||
|
Faz 15: canlıya alma (Dokploy + Gitea, `butcem.ta1sph.website`).
|
||||||
|
|
||||||
|
### Eklendi
|
||||||
|
|
||||||
|
- Konteyner başlatma adımı (`docker-entrypoint.sh` → `scripts/bootstrap.mjs`): MongoDB replica set ve PRIMARY kontrolü (yoksa açık hata mesajıyla durur), bekleyen migration'lar, `SEED_ADMIN_*` tanımlıysa ilk admin hesabı (varsa dokunulmaz). esbuild ile tek dosyaya derlenir; imajda `tsx` gerekmez. Acil durumda `SKIP_BOOTSTRAP=1`.
|
||||||
|
- `/api/health` yanıtında `replicaSet` alanı (transaction desteğinin canlıda doğrulanması için).
|
||||||
|
- `docs/DEPLOY.md`: Dokploy uygulama kurulumu, ortam değişkenleri, volume, domain/HTTPS, Gitea webhook ile otomatik deploy, güncelleme/geri alma, yedekleme, geri yükleme ve sorun giderme; her komut açıklamalı.
|
||||||
|
- `docs/MONGODB-KURULUM.md`: mongo:7 servisinde replica set kontrolü, keyfile ile tek düğümlü replica set'e geçiş, uygulama kullanıcısı yetkileri, bağlantı dizesi, transaction denemesi.
|
||||||
|
- `scripts/smoke-test.sh`: sağlık (veritabanı, replica set, sürüm), güvenlik başlıkları, HTTP→HTTPS, oturumsuz erişim engeli ve isteğe bağlı giriş kontrolü; veri değiştirmez.
|
||||||
|
- `scripts/backup/`: günlük yedek betiği (mongodump + uploads arşivi, SHA-256 özetleri, 7 gün saklama, parola komut satırında görünmez), onaylı geri yükleme betiği ve ayar örneği.
|
||||||
|
|
||||||
|
### Değişti
|
||||||
|
|
||||||
|
- Docker imajı: `ENTRYPOINT` başlatma adımıyla; sağlık kontrolü başlangıç payı 60 sn.
|
||||||
|
- Uygulama mongo:7.0 ile uyumlu (tüm entegrasyon testleri mongo:7 replica set'e karşı geçti).
|
||||||
|
|
||||||
|
## [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
|
## [0.8.0] - 2026-11-05
|
||||||
|
|
||||||
Faz 8: cari hesaplar ve aynî borç/alacak (8a ve 8b alt fazları).
|
Faz 8: cari hesaplar ve aynî borç/alacak (8a ve 8b alt fazları).
|
||||||
@@ -252,7 +471,14 @@ 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.5.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.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
|
||||||
|
|||||||
+8
-3
@@ -2,6 +2,7 @@
|
|||||||
# BÜTÇEM — çok aşamalı üretim imajı (Next.js standalone çıktı).
|
# BÜTÇEM — çok aşamalı üretim imajı (Next.js standalone çıktı).
|
||||||
# Build: docker build --build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
|
# Build: docker build --build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
|
||||||
# Çalıştır: docker run -p 3000:3000 --env-file .env.production -v butcem-uploads:/app/uploads butcem
|
# Çalıştır: docker run -p 3000:3000 --env-file .env.production -v butcem-uploads:/app/uploads butcem
|
||||||
|
# Başlarken: replica set kontrolü, bekleyen migration'lar, ilk admin (docker-entrypoint.sh).
|
||||||
|
|
||||||
ARG NODE_VERSION=26
|
ARG NODE_VERSION=26
|
||||||
|
|
||||||
@@ -22,7 +23,8 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
|
|||||||
APP_COMMIT=${APP_COMMIT}
|
APP_COMMIT=${APP_COMMIT}
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
# Uygulama + başlatma betiği (migration ve ilk admin; tek dosya, tsx gerekmez).
|
||||||
|
RUN npm run build && npm run build:scripts
|
||||||
|
|
||||||
# 3) Çalışma zamanı — yalnızca standalone çıktı, root olmayan kullanıcı.
|
# 3) Çalışma zamanı — yalnızca standalone çıktı, root olmayan kullanıcı.
|
||||||
FROM node:${NODE_VERSION}-alpine AS runner
|
FROM node:${NODE_VERSION}-alpine AS runner
|
||||||
@@ -43,12 +45,15 @@ RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs \
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/dist/bootstrap.mjs ./scripts/bootstrap.mjs
|
||||||
|
COPY --chown=nextjs:nodejs --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
VOLUME ["/app/uploads"]
|
VOLUME ["/app/uploads"]
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
# start-period: ilk deploy'da migration'lar için pay.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -52,19 +52,20 @@ 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 test:integration` | Entegrasyon testleri (MongoDB) |
|
||||||
| `npm run migrate` | Bekleyen migration'lar |
|
| `npm run migrate` | Bekleyen migration'lar |
|
||||||
| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil |
|
| `npm run bootstrap` | Canlı başlatma adımı (replica set kontrolü + migration; production'da ilk admin) |
|
||||||
|
| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil |
|
||||||
|
|
||||||
## Haneler ve yetki
|
## Haneler ve yetki
|
||||||
|
|
||||||
@@ -83,4 +84,13 @@ docker build --build-arg APP_VERSION=$(node -p "require('./package.json').versio
|
|||||||
--build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
|
--build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
|
||||||
```
|
```
|
||||||
|
|
||||||
Canlı ortam (Dokploy, `butcem.ta1sph.website`) kurulumu Faz 15'te `docs/DEPLOY.md` içinde anlatılacak.
|
Konteyner başlarken `docker-entrypoint.sh` önce başlatma adımını (`scripts/bootstrap.mjs`: replica
|
||||||
|
set kontrolü, bekleyen migration'lar, `SEED_ADMIN_*` tanımlıysa ilk admin) sonra uygulamayı çalıştırır.
|
||||||
|
|
||||||
|
## Canlı ortam
|
||||||
|
|
||||||
|
- Kurulum, ortam değişkenleri, domain, volume, otomatik deploy, yedekleme ve geri yükleme:
|
||||||
|
[`docs/DEPLOY.md`](docs/DEPLOY.md)
|
||||||
|
- MongoDB replica set ve uygulama kullanıcısı: [`docs/MONGODB-KURULUM.md`](docs/MONGODB-KURULUM.md)
|
||||||
|
- Duman testi: `scripts/smoke-test.sh https://butcem.ta1sph.website`
|
||||||
|
- Yedek / geri yükleme betikleri: `scripts/backup/`
|
||||||
|
|||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# BÜTÇEM konteyner girişi: önce başlatma (replica set kontrolü, migration'lar, ilk admin),
|
||||||
|
# başarılıysa uygulama. Başlatma başarısız olursa konteyner durur; Dokploy günlüğünde sebep görünür.
|
||||||
|
# Acil durumda atlamak için: SKIP_BOOTSTRAP=1
|
||||||
|
set -e
|
||||||
|
if [ "${SKIP_BOOTSTRAP:-0}" != "1" ]; then
|
||||||
|
node /app/scripts/bootstrap.mjs
|
||||||
|
fi
|
||||||
|
exec node /app/server.js
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
# Canlıya alma (Dokploy + Gitea)
|
||||||
|
|
||||||
|
BÜTÇEM'i Atlas sunucusunda Dokploy ile `https://butcem.ta1sph.website` adresinde yayınlamak,
|
||||||
|
güncellemek, yedeklemek ve geri yüklemek için adım adım kılavuz.
|
||||||
|
|
||||||
|
**Mimari:** Gitea (`main` dalı) → webhook → Dokploy, Dockerfile ile imaj build eder → konteyner
|
||||||
|
başlarken veritabanını hazırlar (replica set kontrolü, migration'lar, ilk admin) → Next.js
|
||||||
|
uygulaması 3000 portunda → Traefik HTTPS ile dışarı açar. MongoDB aynı Dokploy ağında, dışa portsuz.
|
||||||
|
Yüklenen dosyalar `/app/uploads` volume'unda.
|
||||||
|
|
||||||
|
> Sunucu komutları Atlas'ta çalıştırılır. Her komutun altında ne yaptığı yazıyor. `<…>` ile
|
||||||
|
> gösterilen yerleri kendi değerlerinizle değiştirin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ön koşullar
|
||||||
|
|
||||||
|
- [ ] MongoDB servisi **replica set** olarak çalışıyor ve `butcem_app` kullanıcısı hazır →
|
||||||
|
[`MONGODB-KURULUM.md`](MONGODB-KURULUM.md) (1–5. adımlar).
|
||||||
|
- [ ] Gitea'da BÜTÇEM reposu (`main` dalı güncel; `git push origin main`).
|
||||||
|
- [ ] DNS: `butcem.ta1sph.website` için Atlas'ın genel IP'sine **A kaydı** (ve gerekiyorsa
|
||||||
|
modemde 80/443 yönlendirmesi Traefik'e).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig +short butcem.ta1sph.website
|
||||||
|
```
|
||||||
|
|
||||||
|
Alan adının çözüldüğü IP'yi gösterir; Atlas'ın genel IP'si olmalı.
|
||||||
|
|
||||||
|
## 2. Dokploy'da uygulama
|
||||||
|
|
||||||
|
Dokploy › proje › **Create Service › Application**, ad: `butcem`.
|
||||||
|
|
||||||
|
### 2.1 Kaynak (General)
|
||||||
|
|
||||||
|
- **Provider:** Gitea (Dokploy › Settings › Git'te Gitea bağlantısı tanımlıysa) ya da **Git**
|
||||||
|
(repo URL'si + Dokploy'un SSH anahtarı Gitea'da deploy key olarak ekli).
|
||||||
|
- **Repository / Branch:** BÜTÇEM reposu, `main`.
|
||||||
|
- **Build Type:** `Dockerfile` (yol: `Dockerfile`, context: `.`).
|
||||||
|
- **Build Args** (isteğe bağlı): `APP_COMMIT=<kısa-commit>` — sağlık kontrolünde görünür; boşsa
|
||||||
|
`unknown` yazar. Sürüm `package.json`'dan otomatik gelir.
|
||||||
|
|
||||||
|
### 2.2 Ortam değişkenleri (Environment)
|
||||||
|
|
||||||
|
| Değişken | Değer | Not |
|
||||||
|
| --------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||||
|
| `MONGODB_URI` | `mongodb://butcem_app:<parola>@<iç-host>:27017/butcem?authSource=<admin\|butcem>&replicaSet=rs0&directConnection=true` | Ayrıntı: MONGODB-KURULUM.md › 5 |
|
||||||
|
| `BETTER_AUTH_SECRET` | 32+ karakter rastgele | Aşağıdaki komutla üretin; **değiştirirseniz tüm oturumlar kapanır** |
|
||||||
|
| `BETTER_AUTH_URL` | `https://butcem.ta1sph.website` | Sonunda `/` yok |
|
||||||
|
| `SEED_ADMIN_EMAIL` | yönetici e-postası | İlk açılışta admin oluşturulur |
|
||||||
|
| `SEED_ADMIN_PASSWORD` | güçlü parola (8+) | İlk girişten sonra parolayı değiştirip bu değişkeni silebilirsiniz |
|
||||||
|
| `MAX_UPLOAD_MB` | `10` | İsteğe bağlı |
|
||||||
|
|
||||||
|
`NODE_ENV=production`, `PORT=3000` ve `UPLOAD_DIR=/app/uploads` imajda tanımlıdır; eklemeyin.
|
||||||
|
`SEED_USER_PASSWORD` canlıda kullanılmaz (örnek kullanıcı oluşturulmaz).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
`BETTER_AUTH_SECRET` için 32 baytlık rastgele bir değer üretir.
|
||||||
|
|
||||||
|
### 2.3 Volume (Advanced › Volumes / Mounts)
|
||||||
|
|
||||||
|
- **Volume Mount** · ad: `butcem-uploads` · bağlama yolu: **`/app/uploads`**.
|
||||||
|
Fiş, ürün görseli ve ek dosyalar burada tutulur; imaj yeniden oluşturulduğunda kaybolmaz.
|
||||||
|
(Bind mount yerine adlandırılmış volume kullanın; imajdaki dizin izinleri — uid 1001 — volume'a
|
||||||
|
ilk oluşturulurken aktarılır.)
|
||||||
|
|
||||||
|
### 2.4 Domain (Domains)
|
||||||
|
|
||||||
|
- **Host:** `butcem.ta1sph.website` · **Path:** `/` · **Container Port:** `3000`
|
||||||
|
- **HTTPS:** açık · **Certificate:** Let's Encrypt
|
||||||
|
|
||||||
|
### 2.5 İlk deploy
|
||||||
|
|
||||||
|
**Deploy**'a basın ve **Logs**'ta şu satırları bekleyin:
|
||||||
|
|
||||||
|
```
|
||||||
|
• MongoDB rs0 PRIMARY
|
||||||
|
✓ Migration: 001-initial-indexes — … (ilk kurulumda 15 migration)
|
||||||
|
✓ Admin oluşturuldu: <e-posta> (kullanıcı adı: admin)
|
||||||
|
✓ Ready in …
|
||||||
|
```
|
||||||
|
|
||||||
|
"MongoDB replica set olarak çalışmıyor" hatası görürseniz MONGODB-KURULUM.md › 3'ü uygulayın.
|
||||||
|
Konteynerin Docker sağlık durumu birkaç saniye içinde **healthy** olur (`/api/health`).
|
||||||
|
|
||||||
|
### 2.6 İlk giriş
|
||||||
|
|
||||||
|
`https://butcem.ta1sph.website` › kullanıcı adı **`admin`** ve `SEED_ADMIN_PASSWORD`.
|
||||||
|
|
||||||
|
1. **Yönetim › Kullanıcılar**'dan aile üyelerini oluşturun.
|
||||||
|
2. **Yönetim › Haneler**'den haneyi açıp üyeleri ve rollerini ekleyin.
|
||||||
|
3. (Önerilir) Admin parolasını **Yönetim › Kullanıcılar › kendi satırınız › Şifre sıfırla** ile
|
||||||
|
değiştirip Dokploy'da `SEED_ADMIN_PASSWORD`'u silin ve yeniden deploy edin; başlatma artık
|
||||||
|
yalnızca migration'ları çalıştırır. (Admin hesabı varken bu değişken parolayı değiştirmez.)
|
||||||
|
|
||||||
|
## 3. Duman testi (smoke test)
|
||||||
|
|
||||||
|
Kendi bilgisayarınızda, repo klasöründe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Sağlık (veritabanı, replica set, sürüm), güvenlik başlıkları, HTTP→HTTPS yönlendirmesi ve
|
||||||
|
oturumsuz erişimin engellendiğini kontrol eder; **veri değiştirmez**. Tümü ✓ olmalı.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SMOKE_USER=admin SMOKE_PASSWORD='<parola>' scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Ayrıca giriş yapıp ana sayfanın açıldığını dener (parola komut geçmişine düşer; sonra
|
||||||
|
`history -d` ile silebilir ya da satırı boşlukla başlatabilirsiniz).
|
||||||
|
|
||||||
|
Elle kontrol listesi (bir kez, tarayıcıdan):
|
||||||
|
|
||||||
|
- [ ] Giriş / çıkış, telefondan PWA olarak ana ekrana ekleme.
|
||||||
|
- [ ] Bir hesap ve bir gider işlemi oluşturma (transaction'lı yazma).
|
||||||
|
- [ ] İşleme fiş fotoğrafı ekleyip görüntüleme (uploads volume).
|
||||||
|
- [ ] Bir transfer (iki hesap birden; transaction).
|
||||||
|
- [ ] Dokploy'da uygulamayı **Restart** edip fişin hâlâ açıldığını görme (volume kalıcılığı).
|
||||||
|
|
||||||
|
## 4. Otomatik deploy (Gitea webhook)
|
||||||
|
|
||||||
|
Dokploy › `butcem` › **Deployments** › **Webhook URL**'yi kopyalayın (Auto Deploy açık olmalı).
|
||||||
|
Gitea › repo › **Settings › Webhooks › Add Webhook › Gitea**:
|
||||||
|
|
||||||
|
- **Target URL:** kopyaladığınız Dokploy URL'si · **HTTP Method:** POST · **Content type:** JSON
|
||||||
|
- **Trigger On:** Push Events · **Branch filter:** `main`
|
||||||
|
- Kaydedip **Test Delivery** ile deneyin; Dokploy'da yeni bir deployment başlamalı.
|
||||||
|
|
||||||
|
Bundan sonra `git push origin main` → otomatik build ve yayın. Her deploy'da bekleyen
|
||||||
|
migration'lar uygulama açılmadan önce çalışır.
|
||||||
|
|
||||||
|
## 5. Güncelleme ve geri alma
|
||||||
|
|
||||||
|
- **Normal güncelleme:** `main`'e push (webhook) ya da Dokploy'da **Deploy**.
|
||||||
|
- **Büyük güncellemeden önce** elle yedek alın: `sudo /opt/butcem/butcem-backup.sh` (bkz. 6).
|
||||||
|
- **Geri alma:** Dokploy › Deployments › önceki başarılı deployment › **Rollback** (ya da
|
||||||
|
Gitea'da önceki commit'e dönüp push). Migration'lar yalnızca ileri yönlüdür; yeni sürümün
|
||||||
|
eklediği index/alanlar eski sürümü bozmaz. Veri bozulduysa 7'deki geri yüklemeyi kullanın.
|
||||||
|
- **Acil durum:** başlatma adımı (migration) hata verip uygulama açılmıyorsa geçici olarak
|
||||||
|
`SKIP_BOOTSTRAP=1` ortam değişkeniyle açabilirsiniz; sorunu giderince kaldırın.
|
||||||
|
|
||||||
|
## 6. Yedekleme
|
||||||
|
|
||||||
|
Günlük: `butcem` veritabanı (`mongodump`) + uploads volume arşivi + SHA-256 özetleri,
|
||||||
|
**7 gün saklama**, `/var/backups/butcem` altında. Betik: `scripts/backup/butcem-backup.sh`.
|
||||||
|
|
||||||
|
> ⚠️ Şimdilik yedekler **yalnızca sunucuda**. Disk arızasında yedekler de gider; bir sonraki adım
|
||||||
|
> olarak sunucu dışına kopya (NAS'a `rsync` ya da S3 uyumlu depolama) eklenmeli.
|
||||||
|
|
||||||
|
### 6.1 Kurulum (bir kez)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -d -m 755 /opt/butcem
|
||||||
|
```
|
||||||
|
|
||||||
|
Betikler için `/opt/butcem` klasörünü oluşturur.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 750 scripts/backup/butcem-backup.sh scripts/backup/butcem-restore.sh /opt/butcem/
|
||||||
|
```
|
||||||
|
|
||||||
|
Yedek ve geri yükleme betiklerini (repo klasöründen; sunucuya `scp` ile de kopyalayabilirsiniz)
|
||||||
|
`/opt/butcem`'e yalnızca root'un çalıştırabileceği izinle kopyalar.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 600 scripts/backup/butcem-backup.env.example /etc/butcem-backup.env
|
||||||
|
sudo nano /etc/butcem-backup.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Ayar dosyasını yalnızca root'un okuyabileceği izinle oluşturur ve düzenlemek için açar. Doldurun:
|
||||||
|
|
||||||
|
- `MONGO_CONTAINER`: MongoDB konteyner adının ayırt edici kısmı (`docker ps --format '{{.Names}}'`).
|
||||||
|
- `MONGO_ROOT_USER` / `MONGO_ROOT_PASSWORD`: `butcem_root` bilgileri.
|
||||||
|
- `UPLOADS_VOLUME`: uygulamanın volume adı.
|
||||||
|
```bash
|
||||||
|
docker volume ls | grep -i uploads
|
||||||
|
```
|
||||||
|
Volume adlarını listeler (Dokploy'da verdiğiniz `butcem-uploads` adını doğrulayın).
|
||||||
|
|
||||||
|
### 6.2 İlk çalıştırma
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /opt/butcem/butcem-backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Yedeği hemen alır; sonunda `Tamam: … veritabanı, … dosya` yazmalı.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ls -lh /var/backups/butcem && sudo sh -c 'cd /var/backups/butcem && sha256sum -c SHA256SUMS-*'
|
||||||
|
```
|
||||||
|
|
||||||
|
Yedek dosyalarını listeler ve bütünlüklerini doğrular (`OK`).
|
||||||
|
|
||||||
|
### 6.3 Günlük zamanlama
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
root'un zamanlanmış görevlerini açar. Şu satırı ekleyin (her gece 03:30):
|
||||||
|
|
||||||
|
```
|
||||||
|
30 3 * * * /opt/butcem/butcem-backup.sh >> /var/log/butcem-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo tail -n 20 /var/log/butcem-backup.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Ertesi gün son yedeğin günlüğünü gösterir.
|
||||||
|
|
||||||
|
## 7. Geri yükleme
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ls -1 /var/backups/butcem
|
||||||
|
```
|
||||||
|
|
||||||
|
Mevcut yedekleri listeler; dönülecek tarih damgasını seçin.
|
||||||
|
|
||||||
|
1. Dokploy › `butcem` › **Stop** (yazma olmasın).
|
||||||
|
2. Geri yükleyin:
|
||||||
|
```bash
|
||||||
|
sudo /opt/butcem/butcem-restore.sh /var/backups/butcem/mongo-<damga>.archive.gz /var/backups/butcem/uploads-<damga>.tar.gz
|
||||||
|
```
|
||||||
|
Bütünlüğü kontrol eder, onay ister (`evet`), `butcem` veritabanını yedektekiyle **değiştirir**
|
||||||
|
ve uploads volume'unu arşivden yeniden yazar. Yalnızca veritabanı için ikinci dosyayı vermeyin.
|
||||||
|
3. Dokploy › **Start**, ardından duman testi (3).
|
||||||
|
|
||||||
|
**Tatbikat:** Ayda bir, en son yedeği bu adımlarla deneme amaçlı bir Dokploy kopyasına ya da
|
||||||
|
lokale (`docker compose` mongo'su) yükleyip açılıp açılmadığını kontrol edin. Yedeği hiç denenmemiş
|
||||||
|
bir yedek, yedek değildir.
|
||||||
|
|
||||||
|
## 8. Sorun giderme
|
||||||
|
|
||||||
|
| Belirti | Neden / çözüm |
|
||||||
|
| --------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
|
| Logs: `MongoDB replica set olarak çalışmıyor` | MONGODB-KURULUM.md › 3 |
|
||||||
|
| Logs: `Authentication failed` | `MONGODB_URI` kullanıcı/parola/`authSource`; parola URL kodlu mu? |
|
||||||
|
| Logs: `Geçersiz ortam değişkenleri` | Listelenen değişkeni düzeltin (ör. `BETTER_AUTH_SECRET` < 32 karakter) |
|
||||||
|
| Konteyner `unhealthy` | `curl -s https://butcem.ta1sph.website/api/health` → `db` alanı; MongoDB servisi çalışıyor mu? |
|
||||||
|
| Giriş sonrası tekrar giriş sayfası | `BETTER_AUTH_URL` alan adıyla birebir aynı mı (https, sonda `/` yok)? |
|
||||||
|
| Yüklenen dosya açılmıyor / yükleme hatası | Volume `/app/uploads`'a bağlı mı? `docker exec <app> ls -ln /app/uploads` sahibi 1001 olmalı |
|
||||||
|
| Sertifika alınamadı | DNS A kaydı ve 80 portu Traefik'e ulaşıyor mu? |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs --tail 100 $(docker ps -q --filter name=<butcem-uygulama-adı> | head -n1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Uygulama konteynerinin son 100 log satırını gösterir (Dokploy › Logs ile aynı).
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# MongoDB kurulumu (canlı, Dokploy)
|
||||||
|
|
||||||
|
BÜTÇEM çok adımlı yazmaları (transfer, "Alışverişi tamamla", kart ödemesi…) **MongoDB transaction**
|
||||||
|
ile yapar. Transaction yalnızca **replica set** üzerinde çalışır; tek sunucu için **tek düğümlü
|
||||||
|
replica set** (`rs0`) yeterlidir. Uygulama başlarken bunu kontrol eder; replica set yoksa açık bir
|
||||||
|
hata mesajıyla durur.
|
||||||
|
|
||||||
|
Bu belge mevcut kurulumu esas alır: Dokploy'da **`mongo:7`** veritabanı servisi (mongo:8 sunucu
|
||||||
|
çekirdeğiyle uyumsuz), kimlik doğrulama açık, `butcem_root` (yönetici) ve `butcem_app` (uygulama)
|
||||||
|
kullanıcıları oluşturulmuş. Uygulama mongo:7.0 ile test edilmiştir (tüm entegrasyon testleri).
|
||||||
|
|
||||||
|
> Komutlar Atlas sunucusunda çalıştırılır. `<…>` ile gösterilen yerleri kendi değerlerinizle
|
||||||
|
> değiştirin. Parolaları komut geçmişine yazmamak için `mongosh` parola sorduğunda girin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. MongoDB konteynerini bul
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --format '{{.Names}}' | grep -i mongo
|
||||||
|
```
|
||||||
|
|
||||||
|
Çalışan konteynerlerin adlarından MongoDB'ninkini listeler. Dokploy ada rastgele bir son ek ekler
|
||||||
|
(ör. `butcem-mongo-a1b2c3.1.xyz`). Aşağıda bu adı `<MONGO>` olarak kullanın; adın ayırt edici
|
||||||
|
başlangıcı (ör. `butcem-mongo-a1b2c3`) da yeterlidir.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MONGO=$(docker ps -q --filter name=<mongo-servis-adı> | head -n1); echo "$MONGO"
|
||||||
|
```
|
||||||
|
|
||||||
|
Konteyner kimliğini bir değişkene alır; sonraki komutlar `$MONGO` ile kısalır.
|
||||||
|
|
||||||
|
## 2. Replica set açık mı?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||||
|
--eval 'try { const s = rs.status(); print(s.set, s.members[0].stateStr) } catch (e) { print("REPLICA SET YOK: " + e.codeName) }'
|
||||||
|
```
|
||||||
|
|
||||||
|
Yönetici olarak bağlanıp replica set durumunu yazdırır (parolayı sorar).
|
||||||
|
|
||||||
|
- Çıktı **`rs0 PRIMARY`** ise replica set hazır; **4. adıma** geçin.
|
||||||
|
- Çıktı **`REPLICA SET YOK: NoReplicationEnabled`** ise **3. adımı** uygulayın.
|
||||||
|
|
||||||
|
## 3. Tek düğümlü replica set'i aç
|
||||||
|
|
||||||
|
Dokploy'un MongoDB servisinde bir **"Replica Set"** seçeneği varsa (sürüme göre değişir) onu açıp
|
||||||
|
servisi yeniden deploy etmeniz ve 3.4'e geçmeniz yeterlidir. Yoksa aşağıdaki elle yöntemi uygulayın.
|
||||||
|
Kimlik doğrulama açıkken replica set bir **anahtar dosyası (keyfile)** ister.
|
||||||
|
|
||||||
|
### 3.1 Yedek alın
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it "$MONGO" mongodump -u butcem_root --authenticationDatabase admin --gzip --archive=/data/db/oncesi.archive.gz
|
||||||
|
docker cp "$MONGO":/data/db/oncesi.archive.gz ~/mongo-oncesi.archive.gz && docker exec "$MONGO" rm /data/db/oncesi.archive.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Değişiklikten önce tüm veritabanlarının yedeğini konteyner içinde bir dosyaya alır (parolayı sorar),
|
||||||
|
sonra ev dizinine kopyalayıp konteynerdeki kopyayı siler. Yeni kurulumda veri yoksa atlanabilir.
|
||||||
|
|
||||||
|
### 3.2 Anahtar dosyasını oluştur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec "$MONGO" sh -c 'head -c 756 /dev/urandom | base64 > /data/db/rs-keyfile && chmod 400 /data/db/rs-keyfile && chown 999:999 /data/db/rs-keyfile && ls -l /data/db/rs-keyfile'
|
||||||
|
```
|
||||||
|
|
||||||
|
Kalıcı veri volume'u (`/data/db`) içinde rastgele bir anahtar dosyası üretir; yalnızca `mongodb`
|
||||||
|
kullanıcısı (uid 999) okuyabilir. Çıktıda `-r-------- … mongodb mongodb` görmelisiniz. Dosya
|
||||||
|
volume'da olduğu için yeniden başlatmalarda korunur.
|
||||||
|
|
||||||
|
### 3.3 Başlatma komutunu değiştir ve yeniden deploy et
|
||||||
|
|
||||||
|
Dokploy › MongoDB servisi › **Advanced** sekmesi › **Command** alanına:
|
||||||
|
|
||||||
|
```
|
||||||
|
mongod --replSet rs0 --bind_ip_all --keyFile /data/db/rs-keyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
yazıp kaydedin ve servisi **Redeploy** edin. (`MONGO_INITDB_ROOT_*` ortam değişkenleri yerinde
|
||||||
|
kaldığı için kimlik doğrulama açık kalır.) Konteyner yeniden oluşacağından 1. adımdaki `MONGO`
|
||||||
|
değişkenini yeniden alın.
|
||||||
|
|
||||||
|
### 3.4 Replica set'i başlat (tek seferlik)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||||
|
--eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })'
|
||||||
|
```
|
||||||
|
|
||||||
|
`rs0` adlı tek üyeli replica set'i başlatır; çıktıda `{ ok: 1 }` görmelisiniz. Uygulama
|
||||||
|
`directConnection=true` ile bağlandığı için üye adresinin `localhost` olması sorun değildir.
|
||||||
|
|
||||||
|
Birkaç saniye sonra 2. adımdaki kontrolü tekrarlayın: **`rs0 PRIMARY`**.
|
||||||
|
|
||||||
|
## 4. Uygulama kullanıcısını doğrula
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin --eval '
|
||||||
|
for (const d of ["admin", "butcem"]) {
|
||||||
|
const u = db.getSiblingDB(d).getUser("butcem_app");
|
||||||
|
if (u) print("tanımlı veritabanı:", d, "| roller:", JSON.stringify(u.roles));
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`butcem_app` kullanıcısının hangi veritabanında tanımlı olduğunu (bağlantı dizesindeki
|
||||||
|
`authSource`) ve rollerini yazdırır. Beklenen rol: `[{"role":"readWrite","db":"butcem"}]`.
|
||||||
|
|
||||||
|
Rol farklıysa düzeltin:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||||
|
--eval 'db.getSiblingDB("<authSource>").updateUser("butcem_app", { roles: [{ role: "readWrite", db: "butcem" }] })'
|
||||||
|
```
|
||||||
|
|
||||||
|
Uygulama kullanıcısına yalnızca `butcem` veritabanında okuma-yazma yetkisi verir (migration'lar
|
||||||
|
index oluşturmak için bu yetkiyle yetinir).
|
||||||
|
|
||||||
|
## 5. Bağlantı dizesi (Dokploy uygulama ortam değişkeni)
|
||||||
|
|
||||||
|
```
|
||||||
|
MONGODB_URI=mongodb://butcem_app:<parola-url-kodlu>@<iç-host>:27017/butcem?authSource=<authSource>&replicaSet=rs0&directConnection=true
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<iç-host>`: Dokploy › MongoDB servisi › **Internal Host** (ör. `butcem-mongo-a1b2c3`). Uygulama
|
||||||
|
ve veritabanı aynı Dokploy ağında olduğundan **dışa port açmaya gerek yoktur**; açmayın.
|
||||||
|
- `<authSource>`: 4. adımın çıktısındaki veritabanı (`admin` veya `butcem`).
|
||||||
|
- Parolada `@ : / ? # %` gibi karakterler varsa URL kodlayın:
|
||||||
|
```bash
|
||||||
|
python3 -c 'import urllib.parse,getpass; print(urllib.parse.quote(getpass.getpass(), safe=""))'
|
||||||
|
```
|
||||||
|
Parolayı sorar ve bağlantı dizesine yazılacak kodlu hâlini basar (geçmişe düşmez).
|
||||||
|
|
||||||
|
## 6. Transaction denemesi (isteğe bağlı)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it "$MONGO" mongosh "mongodb://butcem_app@localhost:27017/butcem?authSource=<authSource>&replicaSet=rs0&directConnection=true" --eval '
|
||||||
|
const s = db.getMongo().startSession(); const c = s.getDatabase("butcem").getCollection("tx_probe");
|
||||||
|
s.startTransaction(); c.insertOne({ a: 1 }); s.commitTransaction();
|
||||||
|
print("transaction tamam:", db.getSiblingDB("butcem").getCollection("tx_probe").countDocuments({}));
|
||||||
|
db.getSiblingDB("butcem").getCollection("tx_probe").drop();'
|
||||||
|
```
|
||||||
|
|
||||||
|
Uygulama kullanıcısıyla bağlanıp küçük bir transaction yazar, sonucu basar ve deneme
|
||||||
|
koleksiyonunu siler. `transaction tamam: 1` görmelisiniz.
|
||||||
+16
-2
@@ -295,8 +295,13 @@ Skill fazları (0–4) uygulanır, ardından alan fazları gelir. Her faz kendi
|
|||||||
| 13 | Alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu, **alışveriş sonrası giriş ve karşılaştırma**, "alışverişi tamamla" transaction'ı | 0.13.0 |
|
| 13 | Alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu, **alışveriş sonrası giriş ve karşılaştırma**, "alışverişi tamamla" transaction'ı | 0.13.0 |
|
||||||
| 14 | Dashboard (çizgi grafik + periyotlar) ve raporlar, CSV dışa aktarma | 0.14.0 |
|
| 14 | Dashboard (çizgi grafik + periyotlar) ve raporlar, CSV dışa aktarma | 0.14.0 |
|
||||||
| 15 | Canlıya alma: `docs/DEPLOY.md`, Dokploy + Gitea, domain, volume, yedekleme, smoke test | **1.0.0** |
|
| 15 | Canlıya alma: `docs/DEPLOY.md`, Dokploy + Gitea, domain, volume, yedekleme, smoke test | **1.0.0** |
|
||||||
| 16 | `/api/v1` mobil API ve mobil kimlik doğrulama | 1.1.0 |
|
| 16 | **Kakeibo K1:** dört grup (Zorunlu/İstek/Kültür/Beklenmeyen), banka maliyeti ayrımı, kayıt bazında grup, Kakeibo raporu | 1.1.0 |
|
||||||
| 17 | Otomatik fiyat sağlayıcıları (market ve altın/döviz) araştırması ve izinliyse entegrasyonu | 1.2.0 |
|
| 17 | **Kakeibo K2:** ekstre içe aktarma (Yapı Kredi kart/hesap, Kakeibo çalışma kitabı; önizleme, tekrar engeli, ekstre toplamı kontrolü, geri alma) | 1.2.0 |
|
||||||
|
| 18 | **Kakeibo K3:** ay planı ve dört soru (ay başı sihirbazı, harcanabilir, grup bütçeleri, haftalık nakit limiti), birikim hedefleri | 1.3.0 |
|
||||||
|
| 19 | **Kakeibo K4:** haftalık kontrol, ay sonu değerlendirmesi (pişmanlık, iyileştirme hedefleri), Kakeibo panosu (KMH faizi/gün, taksit yükü, kayıtsız nakit) | 1.4.0 |
|
||||||
|
| 20 | **Kakeibo K5:** satın almadan önce kontrol ve 24 saat bekleme listesi | 1.5.0 |
|
||||||
|
| 21 | `/api/v1` mobil API ve mobil kimlik doğrulama | 1.6.0 |
|
||||||
|
| 22 | Otomatik fiyat sağlayıcıları (market ve altın/döviz) araştırması ve izinliyse entegrasyonu | 1.7.0 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -320,6 +325,15 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
|||||||
- **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır.
|
- **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 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 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).
|
||||||
|
- **Faz 15 kararları (2026-11-07):** Canlı MongoDB Dokploy servisi **mongo:7** (mongo:8 sunucu çekirdeğiyle uyumsuz); uygulama mongo:7.0'a karşı tüm entegrasyon testleriyle doğrulandı. Replica set kimlik doğrulamalı tek düğüm (`rs0`, keyfile), uygulama `directConnection=true` ile bağlanır (`docs/MONGODB-KURULUM.md`). Migration'lar ve ilk admin konteyner başlarken otomatik (`scripts/bootstrap.mjs`; replica set yoksa uygulama açılmaz). Gitea `main` push → Dokploy webhook ile otomatik deploy. Yedek: günlük `mongodump` + uploads arşivi, 7 gün, şimdilik yalnızca sunucuda (sunucu dışı kopya sonraki adım).
|
||||||
|
- **Kakeibo kararları (2026-11-07):** Uygulama `.claude/skills/kakeibo` becerisine göre Kakeibo yöntemine uyarlanır (Faz 16–20); mobil API ve fiyat sağlayıcıları öncelik değil, 21–22'ye kaydı. KMH faizi, KKDF/BSMV ve banka ücretleri "banka maliyeti"dir: dört gruba ve Kakeibo harcama toplamına girmez, ayrı gösterilir. Günlük plan korunur, Kakeibo haftalık kontrolü yanına eklenir. Kişilere giden havale/EFT gider sayılır; kampanya iadeleri "İade" geliri. Ekstre dosyaları `import/` (git ve Docker dışı); gerçek veri önce dev veritabanında denenir, canlıya aktarmadan önce sorulur.
|
||||||
|
- **Faz 16 kararları (2026-11-07):** Kakeibo grubu gider kategorisinde tutulur ve alt kategoriye devredilir; işlem ya da bölünmüş kalem bazında değiştirilebilir ("kategoriyi ürün değil amaç belirler"). Banka maliyeti işareti kategori düzeyinde (varsayılan "Banka ve kart giderleri" ağacı); grup değiştirilse bile banka maliyeti kalır. Varsayılan kategoriler beceri eşlemesine göre doldurulur, kullanıcı kategorileri grupsuz kalır ve uyarı gösterilir. Migration'lar artık geri alınabilir (`npm run migrate -- --down <ad>`).
|
||||||
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const eslintConfig = defineConfig([
|
|||||||
".next/**",
|
".next/**",
|
||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
|
// Derlenmiş başlatma betiği (esbuild paketi).
|
||||||
|
"dist/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
Generated
+372
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.8.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.8.0",
|
"version": "1.1.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",
|
||||||
@@ -24,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",
|
||||||
@@ -4051,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",
|
||||||
@@ -4964,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",
|
||||||
@@ -5022,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",
|
||||||
@@ -6573,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",
|
||||||
@@ -6666,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",
|
||||||
@@ -7140,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",
|
||||||
@@ -7658,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",
|
||||||
@@ -8282,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",
|
||||||
@@ -8324,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",
|
||||||
@@ -10715,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",
|
||||||
@@ -10803,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",
|
||||||
@@ -10856,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",
|
||||||
@@ -12405,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",
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.8.0",
|
"version": "1.1.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": {
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
"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",
|
"migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts",
|
||||||
|
"bootstrap": "tsx --conditions=react-server --env-file=.env.local scripts/bootstrap.ts",
|
||||||
|
"build:scripts": "esbuild scripts/bootstrap.ts --bundle --platform=node --format=esm --target=node22 --conditions=react-server --outfile=dist/bootstrap.mjs --external:sharp --external:kerberos --external:snappy --external:socks --external:aws4 --external:@mongodb-js/zstd --external:mongodb-client-encryption --external:gcp-metadata --external:@aws-sdk/credential-providers --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --log-level=warning",
|
||||||
"cleanup": "tsx --conditions=react-server --env-file=.env.local scripts/cleanup.ts"
|
"cleanup": "tsx --conditions=react-server --env-file=.env.local scripts/cleanup.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -42,6 +44,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",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# /etc/butcem-backup.env — butcem-backup.sh ayarları. İzin: sudo chmod 600 /etc/butcem-backup.env
|
||||||
|
# Değerler için docs/DEPLOY.md › Yedekleme.
|
||||||
|
|
||||||
|
# MongoDB konteyner adı veya adının ayırt edici bir parçası (docker ps --format '{{.Names}}')
|
||||||
|
MONGO_CONTAINER="butcem-mongo"
|
||||||
|
MONGO_ROOT_USER="butcem_root"
|
||||||
|
MONGO_ROOT_PASSWORD=""
|
||||||
|
|
||||||
|
# Uygulamanın /app/uploads volume adı (docker volume ls)
|
||||||
|
UPLOADS_VOLUME="butcem-uploads"
|
||||||
|
|
||||||
|
BACKUP_DIR="/var/backups/butcem"
|
||||||
|
RETENTION_DAYS="7"
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# BÜTÇEM günlük yedek — sunucuda (Atlas) root olarak, cron ile çalışır. Ayrıntı: docs/DEPLOY.md › Yedekleme
|
||||||
|
#
|
||||||
|
# Üretir (BACKUP_DIR altında, tarih damgalı):
|
||||||
|
# mongo-YYYYmmdd-HHMMSS.archive.gz → butcem veritabanı (mongodump --archive --gzip)
|
||||||
|
# uploads-YYYYmmdd-HHMMSS.tar.gz → yüklenen dosyalar (uploads volume)
|
||||||
|
# SHA256SUMS-YYYYmmdd-HHMMSS → bütünlük özetleri
|
||||||
|
# RETENTION_DAYS günden eski yedekleri siler.
|
||||||
|
#
|
||||||
|
# Ayarlar /etc/butcem-backup.env dosyasından okunur (yalnızca root okuyabilmeli: chmod 600).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONFIG="${BUTCEM_BACKUP_CONFIG:-/etc/butcem-backup.env}"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||||
|
|
||||||
|
: "${MONGO_CONTAINER:?MONGO_CONTAINER tanımlı değil (MongoDB konteyner adı veya adının bir parçası)}"
|
||||||
|
: "${MONGO_ROOT_USER:?MONGO_ROOT_USER tanımlı değil}"
|
||||||
|
: "${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD tanımlı değil}"
|
||||||
|
: "${UPLOADS_VOLUME:?UPLOADS_VOLUME tanımlı değil (uygulamanın /app/uploads volume adı)}"
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/var/backups/butcem}"
|
||||||
|
RETENTION_DAYS="${RETENTION_DAYS:-7}"
|
||||||
|
DB_NAME="${DB_NAME:-butcem}"
|
||||||
|
HELPER_IMAGE="${HELPER_IMAGE:-alpine:3.20}"
|
||||||
|
|
||||||
|
TS="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
log() { echo "[$(date '+%F %T')] $*"; }
|
||||||
|
|
||||||
|
# Dokploy konteyner adlarına rastgele son ek ekler; adın bir parçasıyla eşleşen çalışan konteyner.
|
||||||
|
CID="$(docker ps -q --filter "name=${MONGO_CONTAINER}" | head -n1)"
|
||||||
|
[ -n "$CID" ] || { log "HATA: '${MONGO_CONTAINER}' adlı çalışan MongoDB konteyneri yok."; exit 1; }
|
||||||
|
docker volume inspect "$UPLOADS_VOLUME" >/dev/null 2>&1 || { log "HATA: '${UPLOADS_VOLUME}' volume'u yok."; exit 1; }
|
||||||
|
|
||||||
|
install -d -m 700 "$BACKUP_DIR"
|
||||||
|
MONGO_FILE="$BACKUP_DIR/mongo-$TS.archive.gz"
|
||||||
|
UPLOADS_FILE="$BACKUP_DIR/uploads-$TS.tar.gz"
|
||||||
|
|
||||||
|
# 1) MongoDB: parola komut satırında görünmesin diye geçici yapılandırma dosyasıyla.
|
||||||
|
log "MongoDB yedeği alınıyor ($DB_NAME)…"
|
||||||
|
CONF="/tmp/butcem-dump-$TS.yaml"
|
||||||
|
printf 'password: "%s"\n' "$MONGO_ROOT_PASSWORD" | docker exec -i "$CID" sh -c "umask 077 && cat > $CONF"
|
||||||
|
if ! docker exec "$CID" mongodump --quiet --config "$CONF" --username "$MONGO_ROOT_USER" \
|
||||||
|
--authenticationDatabase admin --db "$DB_NAME" --archive --gzip >"$MONGO_FILE"; then
|
||||||
|
docker exec "$CID" rm -f "$CONF"
|
||||||
|
rm -f "$MONGO_FILE"
|
||||||
|
log "HATA: mongodump başarısız."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker exec "$CID" rm -f "$CONF"
|
||||||
|
[ -s "$MONGO_FILE" ] || { log "HATA: MongoDB yedeği boş."; exit 1; }
|
||||||
|
|
||||||
|
# 2) Yüklenen dosyalar: volume salt okunur bağlanır.
|
||||||
|
log "Uploads yedeği alınıyor ($UPLOADS_VOLUME)…"
|
||||||
|
docker run --rm -v "$UPLOADS_VOLUME":/data:ro -v "$BACKUP_DIR":/backup "$HELPER_IMAGE" \
|
||||||
|
tar czf "/backup/$(basename "$UPLOADS_FILE")" -C /data .
|
||||||
|
[ -s "$UPLOADS_FILE" ] || { log "HATA: uploads yedeği boş."; exit 1; }
|
||||||
|
|
||||||
|
# 3) Bütünlük özeti ve izinler
|
||||||
|
( cd "$BACKUP_DIR" && sha256sum "$(basename "$MONGO_FILE")" "$(basename "$UPLOADS_FILE")" >"SHA256SUMS-$TS" )
|
||||||
|
chmod 600 "$MONGO_FILE" "$UPLOADS_FILE" "$BACKUP_DIR/SHA256SUMS-$TS"
|
||||||
|
|
||||||
|
# 4) Saklama süresi
|
||||||
|
find "$BACKUP_DIR" -maxdepth 1 -type f \( -name 'mongo-*.archive.gz' -o -name 'uploads-*.tar.gz' -o -name 'SHA256SUMS-*' \) \
|
||||||
|
-mtime +"$RETENTION_DAYS" -print -delete | sed 's/^/ silindi: /'
|
||||||
|
|
||||||
|
log "Tamam: $(du -h "$MONGO_FILE" | cut -f1) veritabanı, $(du -h "$UPLOADS_FILE" | cut -f1) dosya → $BACKUP_DIR"
|
||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# BÜTÇEM geri yükleme — sunucuda root olarak, ELLE çalıştırılır. Ayrıntı: docs/DEPLOY.md › Geri yükleme
|
||||||
|
#
|
||||||
|
# sudo scripts/backup/butcem-restore.sh /var/backups/butcem/mongo-….archive.gz [/var/backups/butcem/uploads-….tar.gz]
|
||||||
|
#
|
||||||
|
# DİKKAT: butcem veritabanındaki koleksiyonlar yedektekilerle DEĞİŞTİRİLİR (--drop); uploads arşivi
|
||||||
|
# verilirse volume içeriği silinip arşivden yazılır. Önce uygulamayı durdurun (Dokploy › Stop).
|
||||||
|
# Ayarlar butcem-backup.sh ile aynı dosyadan: /etc/butcem-backup.env
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONFIG="${BUTCEM_BACKUP_CONFIG:-/etc/butcem-backup.env}"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||||
|
: "${MONGO_CONTAINER:?}" "${MONGO_ROOT_USER:?}" "${MONGO_ROOT_PASSWORD:?}" "${UPLOADS_VOLUME:?}"
|
||||||
|
DB_NAME="${DB_NAME:-butcem}"
|
||||||
|
HELPER_IMAGE="${HELPER_IMAGE:-alpine:3.20}"
|
||||||
|
|
||||||
|
MONGO_FILE="${1:?Kullanım: butcem-restore.sh <mongo-arşivi> [uploads-arşivi]}"
|
||||||
|
UPLOADS_FILE="${2:-}"
|
||||||
|
[ -s "$MONGO_FILE" ] || { echo "Arşiv yok veya boş: $MONGO_FILE"; exit 1; }
|
||||||
|
[ -z "$UPLOADS_FILE" ] || [ -s "$UPLOADS_FILE" ] || { echo "Arşiv yok veya boş: $UPLOADS_FILE"; exit 1; }
|
||||||
|
|
||||||
|
# Bütünlük kontrolü (aynı klasörde SHA256SUMS varsa)
|
||||||
|
DIR="$(cd "$(dirname "$MONGO_FILE")" && pwd)"
|
||||||
|
STAMP="$(basename "$MONGO_FILE" | sed -E 's/^mongo-(.*)\.archive\.gz$/\1/')"
|
||||||
|
if [ -f "$DIR/SHA256SUMS-$STAMP" ]; then
|
||||||
|
(cd "$DIR" && sha256sum -c "SHA256SUMS-$STAMP") || { echo "Bütünlük kontrolü başarısız."; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
CID="$(docker ps -q --filter "name=${MONGO_CONTAINER}" | head -n1)"
|
||||||
|
[ -n "$CID" ] || { echo "Çalışan MongoDB konteyneri yok: $MONGO_CONTAINER"; exit 1; }
|
||||||
|
|
||||||
|
echo "Geri yüklenecek: $MONGO_FILE${UPLOADS_FILE:+ ve $UPLOADS_FILE}"
|
||||||
|
echo "'$DB_NAME' veritabanındaki mevcut veri yedektekiyle DEĞİŞTİRİLECEK."
|
||||||
|
if [ "${ASSUME_YES:-0}" != "1" ]; then
|
||||||
|
read -r -p "Devam etmek için 'evet' yazın: " ANSWER
|
||||||
|
[ "$ANSWER" = "evet" ] || { echo "Vazgeçildi."; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONF="/tmp/butcem-restore-$$.yaml"
|
||||||
|
printf 'password: "%s"\n' "$MONGO_ROOT_PASSWORD" | docker exec -i "$CID" sh -c "umask 077 && cat > $CONF"
|
||||||
|
trap 'docker exec "$CID" rm -f "$CONF" >/dev/null 2>&1 || true' EXIT
|
||||||
|
docker exec -i "$CID" mongorestore --quiet --config "$CONF" --username "$MONGO_ROOT_USER" \
|
||||||
|
--authenticationDatabase admin --archive --gzip --drop --nsInclude "${DB_NAME}.*" <"$MONGO_FILE"
|
||||||
|
echo "✓ Veritabanı geri yüklendi."
|
||||||
|
|
||||||
|
if [ -n "$UPLOADS_FILE" ]; then
|
||||||
|
docker run --rm -v "$UPLOADS_VOLUME":/data -v "$(cd "$(dirname "$UPLOADS_FILE")" && pwd)":/backup:ro "$HELPER_IMAGE" \
|
||||||
|
sh -c "find /data -mindepth 1 -delete && tar xzf /backup/$(basename "$UPLOADS_FILE") -C /data && chown -R 1001:1001 /data"
|
||||||
|
echo "✓ Yüklenen dosyalar geri yüklendi."
|
||||||
|
fi
|
||||||
|
echo "Uygulamayı yeniden başlatın (Dokploy › Start) ve duman testini çalıştırın."
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Canlı başlatma (Docker entrypoint, uygulamadan önce): replica set kontrolü, bekleyen migration'lar
|
||||||
|
* ve SEED_ADMIN_* tanımlıysa ilk admin hesabı (varsa dokunulmaz). İdempotent; her deploy'da çalışır.
|
||||||
|
* İmajda esbuild ile tek dosyaya derlenir (scripts/bootstrap.mjs). Lokal: npm run bootstrap
|
||||||
|
*/
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { getDb, getMongoClient } from "@/lib/db/mongo";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { runMigrations } from "./migrations";
|
||||||
|
import { closeSeedConnections, runSeed } from "./seed/run";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const e = env();
|
||||||
|
const hello = await getDb().admin().command({ hello: 1 });
|
||||||
|
if (!hello.setName) {
|
||||||
|
throw new Error(
|
||||||
|
"MongoDB replica set olarak çalışmıyor; transaction'lı yazmalar başarısız olur. " +
|
||||||
|
"docs/DEPLOY.md › 'MongoDB replica set' adımlarını uygulayın.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hello.isWritablePrimary) throw new Error("MongoDB düğümü PRIMARY değil (yazılamaz).");
|
||||||
|
console.log(`• MongoDB ${hello.setName} PRIMARY`);
|
||||||
|
|
||||||
|
if (e.NODE_ENV === "production" && e.SEED_ADMIN_EMAIL && e.SEED_ADMIN_PASSWORD) {
|
||||||
|
// Production'da seed yalnızca migration + admin yapar (örnek veri oluşturulmaz).
|
||||||
|
await runSeed();
|
||||||
|
} else {
|
||||||
|
await runMigrations();
|
||||||
|
if (e.NODE_ENV === "production")
|
||||||
|
console.log("• SEED_ADMIN_* tanımlı değil; admin kontrolü atlandı.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error(`✗ Başlatma hatası: ${err instanceof Error ? err.message : err}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await closeSeedConnections();
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await getMongoClient().close();
|
||||||
|
});
|
||||||
+10
-2
@@ -4,9 +4,17 @@
|
|||||||
*/
|
*/
|
||||||
import mongoose from "mongoose";
|
import mongoose from "mongoose";
|
||||||
import { getMongoClient } from "@/lib/db/mongo";
|
import { getMongoClient } from "@/lib/db/mongo";
|
||||||
import { runMigrations } from "./migrations";
|
import { revertMigration, runMigrations } from "./migrations";
|
||||||
|
|
||||||
runMigrations()
|
// npm run migrate → bekleyen migration'lar
|
||||||
|
// npm run migrate -- --down <ad> → bir migration'ı geri al
|
||||||
|
const downIndex = process.argv.indexOf("--down");
|
||||||
|
const task =
|
||||||
|
downIndex >= 0
|
||||||
|
? revertMigration(process.argv[downIndex + 1] ?? "")
|
||||||
|
: runMigrations().then(() => {});
|
||||||
|
|
||||||
|
task
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
console.error(err instanceof Error ? err.message : err);
|
console.error(err instanceof Error ? err.message : err);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { defaultKakeibo } from "@/features/categories/service";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kakeibo dört grup (Faz 16): gider kategorilerine `kakeiboGroup` ve `bankCost`. Varsayılan
|
||||||
|
* kategoriler beceri eşlemesine göre doldurulur; kullanıcının kategorileri grupsuz kalır (arayüz
|
||||||
|
* "grup seç" uyarısı gösterir). Alanı zaten olan kategoriye dokunulmaz. Hiçbir kategori silinmez.
|
||||||
|
* Geri alma: alanlar kaldırılır (npm run migrate -- --down 016-kakeibo-groups).
|
||||||
|
*/
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "016-kakeibo-groups",
|
||||||
|
description: "gider kategorilerine Kakeibo grubu ve banka maliyeti; varsayılanlar dolduruldu",
|
||||||
|
async up() {
|
||||||
|
const defaults = await CategoryModel.find({
|
||||||
|
kind: "expense",
|
||||||
|
defaultKey: { $type: "string" },
|
||||||
|
kakeiboGroup: { $exists: false },
|
||||||
|
})
|
||||||
|
.select({ defaultKey: 1 })
|
||||||
|
.lean<{ _id: unknown; defaultKey: string }[]>();
|
||||||
|
const ops = defaults.map((c) => ({
|
||||||
|
updateOne: { filter: { _id: c._id }, update: { $set: defaultKakeibo(c.defaultKey) } },
|
||||||
|
}));
|
||||||
|
if (ops.length) await CategoryModel.bulkWrite(ops);
|
||||||
|
await CategoryModel.updateMany(
|
||||||
|
{ kakeiboGroup: { $exists: false } },
|
||||||
|
{ $set: { kakeiboGroup: null, bankCost: false } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async down() {
|
||||||
|
await CategoryModel.updateMany({}, { $unset: { kakeiboGroup: "", bankCost: "" } });
|
||||||
|
await TransactionModel.updateMany(
|
||||||
|
{ kakeiboGroup: { $exists: true } },
|
||||||
|
{ $unset: { kakeiboGroup: "" } },
|
||||||
|
);
|
||||||
|
await TransactionModel.updateMany(
|
||||||
|
{ "splits.kakeiboGroup": { $exists: true } },
|
||||||
|
{ $unset: { "splits.$[].kakeiboGroup": "" } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -8,10 +8,35 @@ import { migration as m005 } from "./005-recurring";
|
|||||||
import { migration as m006 } from "./006-card-statements";
|
import { migration as m006 } from "./006-card-statements";
|
||||||
import { migration as m007 } from "./007-contacts";
|
import { migration as m007 } from "./007-contacts";
|
||||||
import { migration as m008 } from "./008-in-kind";
|
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 { migration as m016 } from "./016-kakeibo-groups";
|
||||||
import type { Migration } from "./types";
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
/** 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];
|
export const MIGRATIONS: Migration[] = [
|
||||||
|
m001,
|
||||||
|
m002,
|
||||||
|
m003,
|
||||||
|
m004,
|
||||||
|
m005,
|
||||||
|
m006,
|
||||||
|
m007,
|
||||||
|
m008,
|
||||||
|
m009,
|
||||||
|
m010,
|
||||||
|
m011,
|
||||||
|
m012,
|
||||||
|
m013,
|
||||||
|
m014,
|
||||||
|
m015,
|
||||||
|
m016,
|
||||||
|
];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
|
|
||||||
@@ -40,3 +65,23 @@ export async function runMigrations(log: (line: string) => void = console.log):
|
|||||||
if (!ran.length) log("• Bekleyen migration yok.");
|
if (!ran.length) log("• Bekleyen migration yok.");
|
||||||
return ran;
|
return ran;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bir migration'ı geri alır (yalnızca `down` tanımlıysa) ve kaydını siler; bir sonraki
|
||||||
|
* `runMigrations` onu yeniden uygular. Kullanım: npm run migrate -- --down 016-kakeibo-groups
|
||||||
|
*/
|
||||||
|
export async function revertMigration(
|
||||||
|
name: string,
|
||||||
|
log: (line: string) => void = console.log,
|
||||||
|
): Promise<void> {
|
||||||
|
const m = MIGRATIONS.find((x) => x.name === name);
|
||||||
|
if (!m) throw new Error(`Migration bulunamadı: ${name}`);
|
||||||
|
if (!m.down) throw new Error(`${name} geri alınamaz (down tanımlı değil).`);
|
||||||
|
const db = getDb();
|
||||||
|
await connectMongoose();
|
||||||
|
const col = db.collection<{ name: string }>(COLLECTION);
|
||||||
|
if (!(await col.findOne({ name }))) throw new Error(`${name} uygulanmamış.`);
|
||||||
|
await m.down(db);
|
||||||
|
await col.deleteOne({ name });
|
||||||
|
log(`↩ Geri alındı: ${name}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { Db } from "mongodb";
|
import type { Db } from "mongodb";
|
||||||
|
|
||||||
/** Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). Geri alma yok; ileri yönlü. */
|
/**
|
||||||
|
* Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). İleri yönlü; Faz 16'dan
|
||||||
|
* itibaren veri değiştirenler `down` ile geri alınabilir (npm run migrate -- --down <ad>).
|
||||||
|
*/
|
||||||
export type Migration = {
|
export type Migration = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
up: (db: Db) => Promise<void>;
|
up: (db: Db) => Promise<void>;
|
||||||
|
down?: (db: Db) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -32,6 +32,12 @@ describe("seed", () => {
|
|||||||
expect(summary.created.categories).toBeGreaterThan(40);
|
expect(summary.created.categories).toBeGreaterThan(40);
|
||||||
expect(summary.created.accounts).toBe(7);
|
expect(summary.created.accounts).toBe(7);
|
||||||
expect(summary.created.transactions).toBeGreaterThan(150);
|
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);
|
||||||
});
|
});
|
||||||
@@ -49,6 +55,13 @@ describe("seed", () => {
|
|||||||
installmentPlans: 0,
|
installmentPlans: 0,
|
||||||
contacts: 0,
|
contacts: 0,
|
||||||
inKind: 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);
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { HouseholdMemberModel } from "@/models/household-member";
|
|||||||
import { storedEmail } from "@/shared/account-email";
|
import { storedEmail } from "@/shared/account-email";
|
||||||
import { runMigrations } from "../migrations";
|
import { runMigrations } from "../migrations";
|
||||||
import { seedContacts } from "./contacts";
|
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 { seedInKind } from "./in-kind";
|
||||||
import { seedInstallments } from "./installments";
|
import { seedInstallments } from "./installments";
|
||||||
import { seedRecurring } from "./recurring";
|
import { seedRecurring } from "./recurring";
|
||||||
@@ -51,6 +55,13 @@ export type SeedSummary = {
|
|||||||
installmentPlans: number;
|
installmentPlans: number;
|
||||||
contacts: number;
|
contacts: number;
|
||||||
inKind: number;
|
inKind: number;
|
||||||
|
assets: number;
|
||||||
|
budgets: number;
|
||||||
|
stock: number;
|
||||||
|
stores: number;
|
||||||
|
products: number;
|
||||||
|
prices: number;
|
||||||
|
shoppingLists: number;
|
||||||
};
|
};
|
||||||
accounts: SeedAccountRow[];
|
accounts: SeedAccountRow[];
|
||||||
};
|
};
|
||||||
@@ -71,6 +82,17 @@ const DOMAIN_COLLECTIONS = [
|
|||||||
"contacts",
|
"contacts",
|
||||||
"assetPrices",
|
"assetPrices",
|
||||||
"inKindDebts",
|
"inKindDebts",
|
||||||
|
"assets",
|
||||||
|
"assetMovements",
|
||||||
|
"budgets",
|
||||||
|
"stockCategories",
|
||||||
|
"productTypes",
|
||||||
|
"stockMovements",
|
||||||
|
"stockCounts",
|
||||||
|
"stores",
|
||||||
|
"products",
|
||||||
|
"productPrices",
|
||||||
|
"shoppingLists",
|
||||||
];
|
];
|
||||||
|
|
||||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||||
@@ -124,6 +146,13 @@ export async function runSeed({
|
|||||||
installmentPlans: 0,
|
installmentPlans: 0,
|
||||||
contacts: 0,
|
contacts: 0,
|
||||||
inKind: 0,
|
inKind: 0,
|
||||||
|
assets: 0,
|
||||||
|
budgets: 0,
|
||||||
|
stock: 0,
|
||||||
|
stores: 0,
|
||||||
|
products: 0,
|
||||||
|
prices: 0,
|
||||||
|
shoppingLists: 0,
|
||||||
},
|
},
|
||||||
accounts: [],
|
accounts: [],
|
||||||
};
|
};
|
||||||
@@ -302,6 +331,22 @@ export async function runSeed({
|
|||||||
if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`);
|
if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`);
|
||||||
summary.created.inKind = await seedInKind(householdIds, userIdMap);
|
summary.created.inKind = await seedInKind(householdIds, userIdMap);
|
||||||
if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`);
|
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
|
// 7) Geliştirme hesapları tablosu
|
||||||
const all = [
|
const all = [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# BÜTÇEM duman testi (smoke test) — canlıya alma sonrası, yalnızca OKUMA yapar; veri değiştirmez.
|
||||||
|
#
|
||||||
|
# Kullanım:
|
||||||
|
# scripts/smoke-test.sh https://butcem.ta1sph.website [beklenen-sürüm]
|
||||||
|
# SMOKE_USER=admin SMOKE_PASSWORD='…' scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||||
|
#
|
||||||
|
# Gerekenler: bash, curl. Çıkış kodu: 0 = hepsi geçti, 1 = en az bir kontrol başarısız.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
BASE="${1:?Kullanım: scripts/smoke-test.sh <adres> [beklenen-sürüm]}"
|
||||||
|
BASE="${BASE%/}"
|
||||||
|
EXPECT_VERSION="${2:-}"
|
||||||
|
FAIL=0
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
|
||||||
|
bad() { printf ' \033[31m✗\033[0m %s\n' "$1"; FAIL=1; }
|
||||||
|
check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; }
|
||||||
|
|
||||||
|
echo "BÜTÇEM duman testi: $BASE"
|
||||||
|
|
||||||
|
echo "1) Sağlık kontrolü"
|
||||||
|
HEALTH_CODE=$(curl -s -o "$TMP/health" -w '%{http_code}' "$BASE/api/health")
|
||||||
|
HEALTH=$(cat "$TMP/health")
|
||||||
|
echo " $HEALTH"
|
||||||
|
check "GET /api/health 200" '[ "$HEALTH_CODE" = 200 ]'
|
||||||
|
check "veritabanı erişilebilir" 'grep -q "\"db\":\"ok\"" <<<"$HEALTH"'
|
||||||
|
check "MongoDB replica set (transaction için)" '! grep -q "\"replicaSet\":null" <<<"$HEALTH" && grep -q "\"replicaSet\":\"" <<<"$HEALTH"'
|
||||||
|
if [ -n "$EXPECT_VERSION" ]; then
|
||||||
|
check "sürüm $EXPECT_VERSION" 'grep -q "\"version\":\"$EXPECT_VERSION\"" <<<"$HEALTH"'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "2) Sayfalar ve güvenlik başlıkları"
|
||||||
|
LOGIN_CODE=$(curl -s -D "$TMP/login-headers" -o /dev/null -w '%{http_code}' "$BASE/login")
|
||||||
|
H=$(tr -d '\r' <"$TMP/login-headers" | tr 'A-Z' 'a-z')
|
||||||
|
check "GET /login 200" '[ "$LOGIN_CODE" = 200 ]'
|
||||||
|
check "Content-Security-Policy var" 'grep -q "^content-security-policy:" <<<"$H"'
|
||||||
|
check "X-Frame-Options: DENY" 'grep -q "^x-frame-options: deny" <<<"$H"'
|
||||||
|
check "X-Content-Type-Options: nosniff" 'grep -q "^x-content-type-options: nosniff" <<<"$H"'
|
||||||
|
if [[ "$BASE" == https://* ]]; then
|
||||||
|
check "Strict-Transport-Security var" 'grep -q "^strict-transport-security:" <<<"$H"'
|
||||||
|
HTTP_URL="http://${BASE#https://}/login"
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$HTTP_URL")
|
||||||
|
check "HTTP → HTTPS yönlendirmesi ($HTTP_CODE)" '[[ "$HTTP_CODE" =~ ^30[1278]$ ]]'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "3) Oturumsuz erişim engelleniyor"
|
||||||
|
DASH=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$BASE/dashboard")
|
||||||
|
check "/dashboard → giriş sayfasına yönlendirme ($DASH)" '[[ "$DASH" =~ ^30[27]\ .*/login ]]'
|
||||||
|
FILE_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/files/000000000000000000000000")
|
||||||
|
check "/api/files oturumsuz 401 ($FILE_CODE)" '[ "$FILE_CODE" = 401 ]'
|
||||||
|
CSV=$(curl -s -o "$TMP/csv" -w '%{http_code}' "$BASE/raporlar/kategoriler/csv")
|
||||||
|
check "rapor CSV'si oturumsuz veri vermiyor ($CSV)" '! grep -q "Toplam gidere oran" "$TMP/csv"'
|
||||||
|
|
||||||
|
if [ -n "${SMOKE_USER:-}" ] && [ -n "${SMOKE_PASSWORD:-}" ]; then
|
||||||
|
echo "4) Giriş ($SMOKE_USER)"
|
||||||
|
SIGN=$(curl -s -c "$TMP/jar" -o "$TMP/signin" -w '%{http_code}' -X POST "$BASE/api/auth/sign-in/username" \
|
||||||
|
-H 'Content-Type: application/json' -H "Origin: $BASE" \
|
||||||
|
--data "$(printf '{"username":"%s","password":"%s"}' "$SMOKE_USER" "$SMOKE_PASSWORD")")
|
||||||
|
check "giriş 200 ($SIGN)" '[ "$SIGN" = 200 ]'
|
||||||
|
PAGE=$(curl -s -b "$TMP/jar" -L -o /dev/null -w '%{http_code} %{url_effective}' "$BASE/dashboard")
|
||||||
|
check "oturumla ana sayfa açılıyor ($PAGE)" '[[ "$PAGE" =~ ^200\ ]] && [[ ! "$PAGE" =~ /login ]]'
|
||||||
|
curl -s -b "$TMP/jar" -X POST "$BASE/api/auth/sign-out" -H "Origin: $BASE" -o /dev/null
|
||||||
|
else
|
||||||
|
echo "4) Giriş testi atlandı (SMOKE_USER ve SMOKE_PASSWORD tanımlı değil)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FAIL" = 0 ]; then echo "Sonuç: tüm kontroller geçti."; else echo "Sonuç: başarısız kontrol var."; fi
|
||||||
|
exit "$FAIL"
|
||||||
@@ -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"} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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 { KakeiboBudgetCard } from "@/features/reports/components/kakeibo-card";
|
||||||
|
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}
|
||||||
|
summary={
|
||||||
|
<KakeiboBudgetCard userId={user.id} householdId={household.id} month={data.month} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
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 { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
||||||
|
import { getNetWorth } from "@/features/net-worth/service";
|
||||||
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
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 { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -16,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) => (
|
||||||
@@ -39,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 ? (
|
||||||
@@ -53,7 +86,7 @@ async function SummaryCards() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage({ searchParams }: PageProps<"/dashboard">) {
|
||||||
const { user, household } = await requireActiveHousehold("viewer");
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -64,12 +97,29 @@ export default async function DashboardPage() {
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<OverdueBanner userId={user.id} householdId={household.id} />
|
<OverdueBanner userId={user.id} householdId={household.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<BudgetWarningBanner userId={user.id} householdId={household.id} />
|
||||||
|
</Suspense>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ContactsDueBanner userId={user.id} householdId={household.id} />
|
<ContactsDueBanner userId={user.id} householdId={household.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||||
<SummaryCards />
|
<SummaryCards userId={user.id} householdId={household.id} />
|
||||||
</Suspense>
|
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,24 @@
|
|||||||
|
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||||
|
import { getKakeiboReport } from "@/features/reports/kakeibo";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { csvMoney, toCsv } from "@/shared/csv";
|
||||||
|
import { KAKEIBO_GROUPS, type SpendingClass } from "@/shared/kakeibo";
|
||||||
|
|
||||||
|
/** GET /raporlar/kakeibo/csv?ay=YYYY-MM — sınıf × kategori tutarları. */
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const c = await csvContext(request);
|
||||||
|
if (!c) return unauthorized();
|
||||||
|
const r = await getKakeiboReport(c.userId, c.householdId, c.month);
|
||||||
|
if (!r) return forbidden();
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
const label = (cls: SpendingClass) =>
|
||||||
|
cls === "BANK_COST" ? k.bankCost : cls === "UNGROUPED" ? k.ungrouped : k.groups[cls];
|
||||||
|
const order: SpendingClass[] = [...KAKEIBO_GROUPS, "UNGROUPED", "BANK_COST"];
|
||||||
|
const rows = order.flatMap((cls) =>
|
||||||
|
r.now.byClass[cls].map((x) => [label(cls), x.path, csvMoney(x.amountMinor)]),
|
||||||
|
);
|
||||||
|
return csvResponse(
|
||||||
|
toCsv(tr.reports.kakeiboReport.csvHeader, rows),
|
||||||
|
tr.reports.kakeiboReport.csvFile(r.month),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { KakeiboReportView } from "@/features/reports/components/kakeibo-view";
|
||||||
|
import {
|
||||||
|
CsvButton,
|
||||||
|
MonthNav,
|
||||||
|
ReportFrame,
|
||||||
|
ToolsEnd,
|
||||||
|
monthLabel,
|
||||||
|
} from "@/features/reports/components/report-frame";
|
||||||
|
import { getKakeiboReport } from "@/features/reports/kakeibo";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||||
|
|
||||||
|
const t = tr.reports.kakeiboReport;
|
||||||
|
export const metadata: Metadata = { title: t.title };
|
||||||
|
|
||||||
|
/** /raporlar/kakeibo?ay=YYYY-MM — Kakeibo dört grup, banka maliyeti, grupsuzlar. */
|
||||||
|
export default async function KakeiboReportPage({ searchParams }: PageProps<"/raporlar/kakeibo">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||||
|
const data = await getKakeiboReport(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/kakeibo" />
|
||||||
|
<ToolsEnd>
|
||||||
|
<CsvButton href={`/raporlar/kakeibo/csv?ay=${data.month}`} />
|
||||||
|
</ToolsEnd>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{data.includesHidden ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{tr.reports.includesHidden}</p>
|
||||||
|
) : null}
|
||||||
|
<KakeiboReportView 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
BookOpenCheck,
|
||||||
|
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/kakeibo", key: "kakeibo", icon: BookOpenCheck },
|
||||||
|
{ 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { connection } from "next/server";
|
import { connection } from "next/server";
|
||||||
import { pingDb } from "@/lib/db/mongo";
|
import { getDb, pingDb } from "@/lib/db/mongo";
|
||||||
import { APP_COMMIT, APP_VERSION } from "@/lib/version";
|
import { APP_COMMIT, APP_VERSION } from "@/lib/version";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,10 +10,21 @@ export async function GET() {
|
|||||||
// Cache Components açıkken GET handler'ı build'de prerender edilmesin.
|
// Cache Components açıkken GET handler'ı build'de prerender edilmesin.
|
||||||
await connection();
|
await connection();
|
||||||
const dbOk = await pingDb();
|
const dbOk = await pingDb();
|
||||||
|
// Transaction'lar için replica set şart; adı yoksa (standalone) null.
|
||||||
|
let replicaSet: string | null = null;
|
||||||
|
if (dbOk) {
|
||||||
|
try {
|
||||||
|
const hello = await getDb().admin().command({ hello: 1 }, { timeoutMS: 2_000 });
|
||||||
|
replicaSet = typeof hello.setName === "string" ? hello.setName : null;
|
||||||
|
} catch {
|
||||||
|
replicaSet = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
status: dbOk ? "ok" : "degraded",
|
status: dbOk ? "ok" : "degraded",
|
||||||
db: dbOk ? "ok" : "unreachable",
|
db: dbOk ? "ok" : "unreachable",
|
||||||
|
replicaSet,
|
||||||
version: APP_VERSION,
|
version: APP_VERSION,
|
||||||
commit: APP_COMMIT,
|
commit: APP_COMMIT,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
|
|||||||
@@ -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,6 +103,17 @@
|
|||||||
--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) */
|
/* Kullanıcı verisi renkleri (kategori/hesap): anahtar → değişken (shared/appearance.ts) */
|
||||||
@@ -156,6 +175,16 @@
|
|||||||
--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-blue: oklch(0.74 0.13 258);
|
||||||
--swatch-sky: oklch(0.74 0.13 230);
|
--swatch-sky: oklch(0.74 0.13 230);
|
||||||
--swatch-teal: oklch(0.74 0.13 185);
|
--swatch-teal: oklch(0.74 0.13 185);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Settings,
|
Settings,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
|
Store,
|
||||||
Tags,
|
Tags,
|
||||||
Target,
|
Target,
|
||||||
UserCog,
|
UserCog,
|
||||||
@@ -27,7 +28,7 @@ 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. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||||
export const CURRENT_PHASE = 8.2;
|
export const CURRENT_PHASE = 16;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
@@ -38,11 +39,12 @@ export const MAIN_NAV: NavItem[] = [
|
|||||||
{ href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 },
|
{ href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 },
|
||||||
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 },
|
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 },
|
||||||
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
|
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
|
||||||
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 },
|
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 },
|
||||||
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
|
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 },
|
||||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
|
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
|
||||||
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
|
{ href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 },
|
||||||
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
|
{ 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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as RechartsPrimitive from "recharts";
|
||||||
|
import type { TooltipValueType } from "recharts";
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
|
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
|
||||||
|
type TooltipNameType = number | string;
|
||||||
|
|
||||||
|
export type ChartConfig = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
initialDimension = INITIAL_DIMENSION,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||||
|
initialDimension?: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: "line" | "dot" | "dashed";
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
} & Omit<
|
||||||
|
RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>,
|
||||||
|
"accessibilityLayer"
|
||||||
|
>) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string" ? (config[label]?.label ?? label) : itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||||
|
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor = color ?? item.payload?.fill ?? item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
|
indicator === "dot" && "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label ?? item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value != null && (
|
||||||
|
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||||
|
{typeof item.value === "number"
|
||||||
|
? item.value.toLocaleString()
|
||||||
|
: String(item.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.dataKey ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
|
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||||
|
import {
|
||||||
|
addMovement,
|
||||||
|
createAsset,
|
||||||
|
deleteAsset,
|
||||||
|
deleteMovement,
|
||||||
|
listAssets,
|
||||||
|
updateAsset,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||||
|
|
||||||
|
function fieldErrors(error: z.ZodError) {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const issue of error.issues) {
|
||||||
|
const key = issue.path.join(".");
|
||||||
|
if (key && !out[key]) out[key] = issue.message;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run<T>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
input: unknown,
|
||||||
|
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<unknown>>,
|
||||||
|
): Promise<ActionResult<unknown>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const parsed = schema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||||
|
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||||
|
if (result.ok) revalidatePath("/", "layout");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yeni varlık (assetId boş) veya mevcut varlığı düzenle. */
|
||||||
|
export async function saveAssetAction(assetId: unknown, input: unknown) {
|
||||||
|
if (assetId) {
|
||||||
|
const id = idSchema.safeParse(assetId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(assetFormSchema, input, (u, h, d) => updateAsset(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
return run(assetFormSchema, input, createAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAssetAction(assetId: unknown) {
|
||||||
|
const id = idSchema.safeParse(assetId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => deleteAsset(u, h, id.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMovementAction(assetId: unknown, input: unknown) {
|
||||||
|
const id = idSchema.safeParse(assetId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(movementFormSchema, input, (u, h, d) => addMovement(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMovementAction(movementId: unknown) {
|
||||||
|
const id = idSchema.safeParse(movementId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => deleteMovement(u, h, id.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssetOption = { id: string; name: string; assetKey: string; quantity: number };
|
||||||
|
|
||||||
|
/** Aynî ödeme diyaloğu için görünür varlıklar (ad, tür, eldeki miktar). */
|
||||||
|
export async function getAssetOptionsAction(): Promise<ActionResult<AssetOption[]>> {
|
||||||
|
const ctx = await getHouseholdActionContext("viewer");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const data = await listAssets(ctx.user.id, ctx.household.id);
|
||||||
|
if (!data) return fail(tr.common.unauthorized);
|
||||||
|
return ok(
|
||||||
|
data.items.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
assetKey: a.assetKey,
|
||||||
|
quantity: a.summary.quantity,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
EllipsisVertical,
|
||||||
|
Gift,
|
||||||
|
HandCoins,
|
||||||
|
Lock,
|
||||||
|
Pencil,
|
||||||
|
ShoppingBag,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { ASSETS, formatAssetQuantity } from "@/shared/assets";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { isInflow, type MovementKind } from "@/shared/portfolio";
|
||||||
|
import { deleteAssetAction, deleteMovementAction } from "../actions";
|
||||||
|
import type { AssetDetail, MovementRow } from "../service";
|
||||||
|
import { AssetFormDialog, MovementDialog } from "./asset-dialogs";
|
||||||
|
import { ProfitLoss, Stat } from "./assets-view";
|
||||||
|
|
||||||
|
const t = tr.assets;
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
|
||||||
|
type Pending =
|
||||||
|
| { kind: "edit" }
|
||||||
|
| { kind: "movement"; movement: MovementKind }
|
||||||
|
| { kind: "deleteMovement"; row: MovementRow }
|
||||||
|
| { kind: "deleteAsset" }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
/** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */
|
||||||
|
export function AssetDetailView({
|
||||||
|
data,
|
||||||
|
report,
|
||||||
|
}: {
|
||||||
|
data: AssetDetail;
|
||||||
|
/** Dönemsel rapor (sunucuda hazırlanır). */
|
||||||
|
report?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState<Pending>(null);
|
||||||
|
const close = () => setPending(null);
|
||||||
|
const s = data.summary;
|
||||||
|
const unit = ASSETS[data.assetKey].unit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href="/birikim">
|
||||||
|
<ArrowLeft />
|
||||||
|
{t.back}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<PageHeader
|
||||||
|
title={data.name}
|
||||||
|
description={ASSETS[data.assetKey].label}
|
||||||
|
actions={
|
||||||
|
data.canWrite ? (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="icon" variant="outline" aria-label={tr.recurring.actions.more}>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={() => setPending({ kind: "edit" })}>
|
||||||
|
<Pencil />
|
||||||
|
{tr.common.edit}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
disabled={data.movementCount > 0}
|
||||||
|
onSelect={() => setPending({ kind: "deleteAsset" })}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
{t.delete}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{data.isPrivate ? (
|
||||||
|
<Badge variant="outline" className="-mt-2 w-fit gap-1">
|
||||||
|
<Lock className="size-3" />
|
||||||
|
{t.privateBadge}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card size="sm">
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label={t.stats.quantity}>{formatAssetQuantity(data.assetKey, s.quantity)}</Stat>
|
||||||
|
<Stat label={t.stats.avgCost}>
|
||||||
|
{s.avgCostMinor !== null
|
||||||
|
? `${formatMoney(s.avgCostMinor)} ${t.stats.perUnit(unit)}`
|
||||||
|
: "—"}
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.stats.cost}>{formatMoney(s.costMinor)}</Stat>
|
||||||
|
<Stat label={t.stats.currentValue}>
|
||||||
|
{s.currentValueMinor !== null ? formatMoney(s.currentValueMinor) : t.stats.noPrice}
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.stats.unrealized}>
|
||||||
|
<ProfitLoss minor={s.unrealizedMinor} bp={s.unrealizedBp} />
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.stats.realized}>
|
||||||
|
<ProfitLoss minor={s.realizedMinor} />
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.stats.invested}>{formatMoney(s.investedMinor)}</Stat>
|
||||||
|
<Stat label={t.stats.proceeds}>{formatMoney(s.proceedsMinor)}</Stat>
|
||||||
|
</div>
|
||||||
|
{data.price ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.stats.price(formatMoney(data.price.buyMinor), d(data.price.date))} ·{" "}
|
||||||
|
<Link href="/fiyatlar" className="underline-offset-2 hover:underline">
|
||||||
|
{t.prices}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{data.storage ? (
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{t.movement.storage}:</span> {data.storage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{data.note ? <p className="text-sm text-muted-foreground">{data.note}</p> : null}
|
||||||
|
{data.canWrite ? (
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPending({ kind: "movement", movement: "gift_in" })}
|
||||||
|
>
|
||||||
|
<Gift />
|
||||||
|
{t.kinds.gift_in}
|
||||||
|
</Button>
|
||||||
|
{s.quantity > 0 ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPending({ kind: "movement", movement: "sell" })}
|
||||||
|
>
|
||||||
|
<HandCoins />
|
||||||
|
{t.kinds.sell}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button size="sm" onClick={() => setPending({ kind: "movement", movement: "buy" })}>
|
||||||
|
<ShoppingBag />
|
||||||
|
{t.kinds.buy}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{report}
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-2" aria-labelledby="movements-title">
|
||||||
|
<h2 id="movements-title" className="text-base font-medium">
|
||||||
|
{t.history}
|
||||||
|
</h2>
|
||||||
|
{data.movements.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.noMovements}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{data.movements.map((row) => (
|
||||||
|
<MovementItem
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
data={data}
|
||||||
|
canWrite={data.canWrite}
|
||||||
|
onDelete={() => setPending({ kind: "deleteMovement", row })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{pending?.kind === "edit" ? <AssetFormDialog item={data} onClose={close} /> : null}
|
||||||
|
{pending?.kind === "movement" ? (
|
||||||
|
<MovementDialog item={data} initialKind={pending.movement} onClose={close} />
|
||||||
|
) : null}
|
||||||
|
<AlertDialog
|
||||||
|
open={pending?.kind === "deleteMovement" || pending?.kind === "deleteAsset"}
|
||||||
|
onOpenChange={(o) => !o && close()}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{pending?.kind === "deleteAsset" ? t.deleteTitle : t.deleteMovementTitle}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{pending?.kind === "deleteAsset" ? t.deleteDescription : t.deleteMovementDescription}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
if (pending?.kind === "deleteAsset") {
|
||||||
|
const r = await deleteAssetAction(data.id);
|
||||||
|
close();
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.deleted);
|
||||||
|
router.push("/birikim");
|
||||||
|
} else toast.error(r.error);
|
||||||
|
} else if (pending?.kind === "deleteMovement") {
|
||||||
|
const r = await deleteMovementAction(pending.row.id);
|
||||||
|
close();
|
||||||
|
if (r.ok) toast.success(t.toast.movementDeleted);
|
||||||
|
else toast.error(r.error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pending?.kind === "deleteAsset" ? t.delete : t.deleteMovement}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MovementItem({
|
||||||
|
row,
|
||||||
|
data,
|
||||||
|
canWrite,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
row: MovementRow;
|
||||||
|
data: AssetDetail;
|
||||||
|
canWrite: boolean;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const inflow = isInflow(row.kind);
|
||||||
|
const qty = formatAssetQuantity(data.assetKey, row.quantity);
|
||||||
|
const unit = ASSETS[data.assetKey].unit;
|
||||||
|
return (
|
||||||
|
<li className="flex items-start gap-3 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="flex flex-wrap items-center gap-x-2 text-sm font-medium">
|
||||||
|
<span>{t.kinds[row.kind]}</span>
|
||||||
|
<span className={inflow ? "text-income" : "text-expense"}>
|
||||||
|
{inflow ? "+" : "−"}
|
||||||
|
{qty}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{d(row.date)}
|
||||||
|
{row.unitPriceMinor !== null
|
||||||
|
? ` · ${formatMoney(row.unitPriceMinor)} ${t.stats.perUnit(unit)}`
|
||||||
|
: ""}
|
||||||
|
{row.account ? ` · ${row.account}` : ""}
|
||||||
|
{row.storage ? ` · ${row.storage}` : ""}
|
||||||
|
</p>
|
||||||
|
{row.note ? <p className="text-xs text-muted-foreground">{row.note}</p> : null}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.table.balance}: {formatAssetQuantity(data.assetKey, row.quantityAfter)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end text-sm">
|
||||||
|
{row.kind !== "gift_out" ? (
|
||||||
|
<span className="tabular-nums">{formatMoney(row.amountMinor)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{t.stats.cost} {formatMoney(row.costOutMinor)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.kind === "sell" ? (
|
||||||
|
<span className="text-xs">
|
||||||
|
{t.table.result}: <ProfitLoss minor={row.realizedMinor} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{canWrite && !row.fromInKind ? (
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t.deleteMovement}
|
||||||
|
onClick={onDelete}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MoneyInput } from "@/components/money-input";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useAccounts } from "@/features/in-kind/components/in-kind-section";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { ASSET_KEYS, ASSETS, formatAssetQuantity, type AssetKey } from "@/shared/assets";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { MOVEMENT_KINDS, type MovementKind } from "@/shared/portfolio";
|
||||||
|
import { addMovementAction, saveAssetAction } from "../actions";
|
||||||
|
import type { AssetView } from "../service";
|
||||||
|
|
||||||
|
const t = tr.assets;
|
||||||
|
type Errors = Record<string, string | undefined>;
|
||||||
|
const NONE = "__none__";
|
||||||
|
|
||||||
|
function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form={formId} disabled={busy}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yeni varlık / varlığı düzenle. */
|
||||||
|
export function AssetFormDialog({
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
item: AssetView | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated?: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [v, setV] = useState({
|
||||||
|
assetKey: (item?.assetKey ?? "gram_gold") as AssetKey,
|
||||||
|
name: item?.name ?? "",
|
||||||
|
note: item?.note ?? "",
|
||||||
|
isPrivate: item?.isPrivate ?? false,
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Errors>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||||
|
const locked = Boolean(item?.movementCount);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await saveAssetAction(item?.id ?? null, {
|
||||||
|
...v,
|
||||||
|
name: v.name.trim() || ASSETS[v.assetKey].label,
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.saved);
|
||||||
|
onClose();
|
||||||
|
if (!item) onCreated?.((r.data as { id: string }).id);
|
||||||
|
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const f = t.fields;
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={item ? t.editTitle : t.createTitle}
|
||||||
|
footer={<Footer busy={busy} formId="asset-form" onClose={onClose} />}
|
||||||
|
>
|
||||||
|
<form id="asset-form" onSubmit={submit} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={Boolean(errors.assetKey)}>
|
||||||
|
<FieldLabel htmlFor="as-key">{f.asset}</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={v.assetKey}
|
||||||
|
onValueChange={(x) => set("assetKey", x as AssetKey)}
|
||||||
|
disabled={locked}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="as-key" className="w-full">
|
||||||
|
<SelectValue>{ASSETS[v.assetKey].label}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ASSET_KEYS.map((k) => (
|
||||||
|
<SelectItem key={k} value={k}>
|
||||||
|
{ASSETS[k].label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.assetKey ? <FieldError>{errors.assetKey}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.name)}>
|
||||||
|
<FieldLabel htmlFor="as-name">{f.name}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="as-name"
|
||||||
|
value={v.name}
|
||||||
|
placeholder={ASSETS[v.assetKey].label}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.name)}
|
||||||
|
/>
|
||||||
|
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="as-note">{f.note}</FieldLabel>
|
||||||
|
<Input id="as-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={v.isPrivate}
|
||||||
|
onCheckedChange={(x) => set("isPrivate", x === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{f.isPrivate}
|
||||||
|
<span className="block text-xs text-muted-foreground">{f.isPrivateHint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alım, gelen hediye, satış veya çıkış hareketi. */
|
||||||
|
export function MovementDialog({
|
||||||
|
item,
|
||||||
|
initialKind,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
item: AssetView;
|
||||||
|
initialKind: MovementKind;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const accounts = useAccounts();
|
||||||
|
const [kind, setKind] = useState<MovementKind>(initialKind);
|
||||||
|
const [v, setV] = useState({
|
||||||
|
date: todayIstanbul(),
|
||||||
|
quantity: "",
|
||||||
|
amount: "",
|
||||||
|
unitPrice: "",
|
||||||
|
accountId: "",
|
||||||
|
storage: item.storage,
|
||||||
|
note: "",
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Errors>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||||
|
const withAccount = kind === "buy" || kind === "sell";
|
||||||
|
const priced = kind !== "gift_out";
|
||||||
|
const inflow = kind === "buy" || kind === "gift_in";
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await addMovementAction(item.id, {
|
||||||
|
kind,
|
||||||
|
date: v.date,
|
||||||
|
quantity: v.quantity,
|
||||||
|
amount: priced ? v.amount : "",
|
||||||
|
unitPrice: priced ? v.unitPrice : "",
|
||||||
|
accountId: withAccount ? v.accountId : "",
|
||||||
|
storage: inflow ? v.storage : "",
|
||||||
|
note: v.note,
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.movement);
|
||||||
|
onClose();
|
||||||
|
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = t.movement;
|
||||||
|
const unit = ASSETS[item.assetKey].unit;
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={m.title(t.kinds[kind], item.name)}
|
||||||
|
description={m.held(formatAssetQuantity(item.assetKey, item.summary.quantity))}
|
||||||
|
footer={<Footer busy={busy} formId="movement-form" onClose={onClose} />}
|
||||||
|
>
|
||||||
|
<form id="movement-form" onSubmit={submit} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
<Tabs
|
||||||
|
value={kind}
|
||||||
|
onValueChange={(x) => {
|
||||||
|
setKind(x as MovementKind);
|
||||||
|
setErrors({});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabsList className="grid h-auto w-full grid-cols-2 sm:grid-cols-4">
|
||||||
|
{MOVEMENT_KINDS.map((k) => (
|
||||||
|
<TabsTrigger key={k} value={k}>
|
||||||
|
{t.kinds[k]}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<p className="-mt-2 text-xs text-muted-foreground">{t.kindHints[kind]}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.quantity)}>
|
||||||
|
<FieldLabel htmlFor="mv-qty">
|
||||||
|
{m.quantity} ({unit})
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="mv-qty"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={v.quantity}
|
||||||
|
onChange={(e) => set("quantity", e.target.value)}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.quantity)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.date)}>
|
||||||
|
<FieldLabel htmlFor="mv-date">{m.date}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="mv-date"
|
||||||
|
type="date"
|
||||||
|
value={v.date}
|
||||||
|
max={todayIstanbul()}
|
||||||
|
onChange={(e) => set("date", e.target.value)}
|
||||||
|
/>
|
||||||
|
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{priced ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.amount)}>
|
||||||
|
<FieldLabel htmlFor="mv-amount">{m.amount}</FieldLabel>
|
||||||
|
<MoneyInput id="mv-amount" value={v.amount} onChange={(x) => set("amount", x)} />
|
||||||
|
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.unitPrice)}>
|
||||||
|
<FieldLabel htmlFor="mv-unit">{m.unitPrice}</FieldLabel>
|
||||||
|
<MoneyInput
|
||||||
|
id="mv-unit"
|
||||||
|
value={v.unitPrice}
|
||||||
|
onChange={(x) => set("unitPrice", x)}
|
||||||
|
/>
|
||||||
|
{errors.unitPrice ? <FieldError>{errors.unitPrice}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<FieldDescription className="-mt-2">
|
||||||
|
{kind === "gift_in" ? m.giftValueHint : m.priceHint}
|
||||||
|
</FieldDescription>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{withAccount ? (
|
||||||
|
<Field data-invalid={Boolean(errors.accountId)}>
|
||||||
|
<FieldLabel htmlFor="mv-account">{m.account}</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={v.accountId || NONE}
|
||||||
|
onValueChange={(x) => set("accountId", x === NONE ? "" : x)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="mv-account" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{accounts?.find((a) => a.id === v.accountId)?.name ?? m.accountNone}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
<SelectItem value={NONE}>{m.accountNone}</SelectItem>
|
||||||
|
{(accounts ?? []).map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldDescription>{m.accountHint}</FieldDescription>
|
||||||
|
{errors.accountId ? <FieldError>{errors.accountId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
{inflow ? (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="mv-storage">{m.storage}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="mv-storage"
|
||||||
|
value={v.storage}
|
||||||
|
placeholder={m.storagePlaceholder}
|
||||||
|
onChange={(e) => set("storage", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="mv-note">{m.note}</FieldLabel>
|
||||||
|
<Input id="mv-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronRight, Coins, Lock, Plus } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { formatBp } from "@/shared/portfolio";
|
||||||
|
import type { AssetTotals, AssetView } from "../service";
|
||||||
|
import { AssetFormDialog } from "./asset-dialogs";
|
||||||
|
|
||||||
|
const t = tr.assets;
|
||||||
|
|
||||||
|
/** Kâr/zarar tutarı: + yeşil, − kırmızı. */
|
||||||
|
export function ProfitLoss({
|
||||||
|
minor,
|
||||||
|
bp,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
minor: number | null;
|
||||||
|
bp?: number | null;
|
||||||
|
/** Mobilde oranı gizle (dar satırlar). */
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
if (minor === null) return <span className="text-muted-foreground">—</span>;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-semibold whitespace-nowrap tabular-nums",
|
||||||
|
minor > 0 ? "text-income" : minor < 0 ? "text-expense" : undefined,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{minor > 0 ? "+" : minor < 0 ? "−" : ""}
|
||||||
|
{formatMoney(Math.abs(minor))}
|
||||||
|
{bp !== undefined && bp !== null ? (
|
||||||
|
<span className={cn("ml-1 text-xs font-normal", compact && "hidden sm:inline")}>
|
||||||
|
({formatBp(bp)})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Stat({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<div className="font-semibold tabular-nums">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /birikim: toplamlar ve varlık kartları. */
|
||||||
|
export function AssetsView({
|
||||||
|
items,
|
||||||
|
totals,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
items: AssetView[];
|
||||||
|
totals: AssetTotals;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const held = items.filter((i) => i.summary.quantity > 0 || i.movementCount === 0);
|
||||||
|
const closed = items.filter((i) => i.summary.quantity === 0 && i.movementCount > 0);
|
||||||
|
const unrealizedBp =
|
||||||
|
totals.costMinor > 0 ? Math.round((totals.unrealizedMinor * 10_000) / totals.costMinor) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/fiyatlar">
|
||||||
|
<Coins />
|
||||||
|
{t.prices}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{canWrite ? (
|
||||||
|
<Button size="sm" onClick={() => setCreating(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.new}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card size="sm">
|
||||||
|
<CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label={t.totals.value}>{formatMoney(totals.valueMinor)}</Stat>
|
||||||
|
<Stat label={t.totals.cost}>{formatMoney(totals.costMinor)}</Stat>
|
||||||
|
<Stat label={t.totals.unrealized}>
|
||||||
|
<ProfitLoss minor={totals.unrealizedMinor} bp={unrealizedBp} />
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.totals.realized}>
|
||||||
|
<ProfitLoss minor={totals.realizedMinor} />
|
||||||
|
</Stat>
|
||||||
|
{totals.missingPrice ? (
|
||||||
|
<p className="col-span-full text-xs text-muted-foreground">
|
||||||
|
{t.totals.missingPrice(totals.missingPrice)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.empty}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{held.map((item) => (
|
||||||
|
<AssetRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{closed.length ? (
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground">{t.closedGroup}</h2>
|
||||||
|
<ul className="flex flex-col gap-2 opacity-70">
|
||||||
|
{closed.map((item) => (
|
||||||
|
<AssetRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{creating ? (
|
||||||
|
<AssetFormDialog
|
||||||
|
item={null}
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
onCreated={(id) => router.push(`/birikim/${id}`)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssetRow({ item }: { item: AssetView }) {
|
||||||
|
const s = item.summary;
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`/birikim/${item.id}`}
|
||||||
|
className="flex items-center gap-3 rounded-xl border bg-card p-3 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
|
||||||
|
<Coins className="size-4.5" />
|
||||||
|
</span>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="flex min-w-0 items-center gap-1.5 font-medium">
|
||||||
|
<span className="truncate">{item.name}</span>
|
||||||
|
{item.isPrivate ? (
|
||||||
|
<Badge variant="outline" className="shrink-0 gap-1" title={t.privateBadge}>
|
||||||
|
<Lock className="size-3" />
|
||||||
|
<span className="hidden sm:inline">{t.privateBadge}</span>
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<span className="shrink-0 text-sm font-semibold tabular-nums">
|
||||||
|
{s.currentValueMinor !== null ? formatMoney(s.currentValueMinor) : t.stats.noPrice}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<p className="min-w-0 truncate text-muted-foreground">
|
||||||
|
{item.label}
|
||||||
|
{s.avgCostMinor !== null
|
||||||
|
? ` · ${t.stats.avgCost} ${formatMoney(s.avgCostMinor)}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
<span className="shrink-0">
|
||||||
|
{s.quantity > 0 ? (
|
||||||
|
<ProfitLoss minor={s.unrealizedMinor} bp={s.unrealizedBp} compact />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t.stats.realized}: <ProfitLoss minor={s.realizedMinor} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import type { PeriodReport } from "@/shared/portfolio";
|
||||||
|
import type { ReportRange } from "../service";
|
||||||
|
import { ProfitLoss, Stat } from "./assets-view";
|
||||||
|
|
||||||
|
const t = tr.assets.report;
|
||||||
|
const RANGES: ReportRange[] = ["3", "6", "12", "tum"];
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
const tickFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
});
|
||||||
|
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
value: { label: t.value, color: "var(--budget)" },
|
||||||
|
cost: { label: t.cost, color: "var(--muted-foreground)" },
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
/** Dönem seçici + özet + aylık değer/maliyet grafiği + tablo görünümü. */
|
||||||
|
export function PeriodReportView({
|
||||||
|
report,
|
||||||
|
range,
|
||||||
|
basePath,
|
||||||
|
}: {
|
||||||
|
report: PeriodReport & { excluded: number };
|
||||||
|
range: ReportRange;
|
||||||
|
basePath: string;
|
||||||
|
}) {
|
||||||
|
const data = report.points.map((p) => ({
|
||||||
|
date: p.date,
|
||||||
|
label: tickFmt.format(new Date(`${p.date}T12:00:00+03:00`)),
|
||||||
|
// Kuruş → TL (eksen ve ipucu TL gösterir).
|
||||||
|
value: p.valueMinor === null ? null : p.valueMinor / 100,
|
||||||
|
cost: p.costMinor / 100,
|
||||||
|
}));
|
||||||
|
const hasData = report.points.some((p) => p.costMinor > 0 || (p.valueMinor ?? 0) > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||||
|
<CardDescription>{t.period(d(report.from), d(report.to))}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<nav aria-label={t.rangeLabel} className="flex rounded-lg bg-muted p-0.5 text-sm">
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<Link
|
||||||
|
key={r}
|
||||||
|
href={`${basePath}?donem=${r}`}
|
||||||
|
scroll={false}
|
||||||
|
aria-current={r === range ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-2.5 py-1 text-muted-foreground transition-colors",
|
||||||
|
r === range && "bg-background font-medium text-foreground shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.ranges[r]}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label={t.startValue}>
|
||||||
|
{report.startValueMinor !== null ? formatMoney(report.startValueMinor) : t.noPrice}
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.invested}>{formatMoney(report.investedMinor)}</Stat>
|
||||||
|
<Stat label={t.endValue}>
|
||||||
|
{report.endValueMinor !== null ? formatMoney(report.endValueMinor) : t.noPrice}
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.profit}>
|
||||||
|
<ProfitLoss minor={report.profitMinor} bp={report.profitBp} />
|
||||||
|
</Stat>
|
||||||
|
{report.proceedsMinor ? (
|
||||||
|
<Stat label={t.proceeds}>{formatMoney(report.proceedsMinor)}</Stat>
|
||||||
|
) : null}
|
||||||
|
{report.giftInMinor ? (
|
||||||
|
<Stat label={t.giftIn}>{formatMoney(report.giftInMinor)}</Stat>
|
||||||
|
) : null}
|
||||||
|
{report.giftOutMinor ? (
|
||||||
|
<Stat label={t.giftOut}>{formatMoney(report.giftOutMinor)}</Stat>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t.formula}</p>
|
||||||
|
{report.excluded ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.excluded(report.excluded)}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasData ? (
|
||||||
|
<>
|
||||||
|
<ChartContainer
|
||||||
|
config={config}
|
||||||
|
className="aspect-auto h-56 w-full sm:h-64"
|
||||||
|
aria-label={t.chartLabel}
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<LineChart data={data} margin={{ left: 4, right: 12, top: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={16}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={48}
|
||||||
|
tickFormatter={(v: number) => compact.format(v)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const date = payload?.[0]?.payload?.date as string | undefined;
|
||||||
|
return date ? d(date) : "";
|
||||||
|
}}
|
||||||
|
formatter={(value, name) => (
|
||||||
|
<div className="flex w-full items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{config[name as keyof typeof config]?.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
|
{formatMoney(Math.round(Number(value) * 100))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
<Line
|
||||||
|
dataKey="cost"
|
||||||
|
type="stepAfter"
|
||||||
|
stroke="var(--color-cost)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="value"
|
||||||
|
type="linear"
|
||||||
|
stroke="var(--color-value)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="cursor-pointer text-xs text-muted-foreground">{t.table}</summary>
|
||||||
|
<table className="mt-2 w-full text-right tabular-nums">
|
||||||
|
<thead className="text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 text-left font-normal">{t.date}</th>
|
||||||
|
<th className="py-1 font-normal">{t.value}</th>
|
||||||
|
<th className="py-1 font-normal">{t.cost}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{report.points.map((p) => (
|
||||||
|
<tr key={p.date}>
|
||||||
|
<td className="py-1 text-left">{d(p.date)}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{p.valueMinor !== null ? formatMoney(p.valueMinor) : t.noPrice}
|
||||||
|
</td>
|
||||||
|
<td className="py-1">{formatMoney(p.costMinor)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.empty}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { listAccounts } from "@/features/accounts/service";
|
||||||
|
import { savePrice } from "@/features/in-kind/service";
|
||||||
|
import {
|
||||||
|
listTransactions,
|
||||||
|
setTransactionDeleted,
|
||||||
|
updateTransaction,
|
||||||
|
} from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { AssetModel } from "@/models/asset";
|
||||||
|
import { AssetMovementModel } from "@/models/asset-movement";
|
||||||
|
import { AssetPriceModel } from "@/models/asset-price";
|
||||||
|
import { AuditLogModel } from "@/models/audit-log";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||||
|
import { assetPriceFormSchema } from "@/shared/schemas/in-kind";
|
||||||
|
import { transactionFilterSchema, transactionFormSchema } from "@/shared/schemas/transaction";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
addMovement,
|
||||||
|
createAsset,
|
||||||
|
deleteAsset,
|
||||||
|
deleteMovement,
|
||||||
|
getAsset,
|
||||||
|
listAssets,
|
||||||
|
updateAsset,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const daysAgo = (n: number) => addDays(today, -n);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
emre = ids.user("emre");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
// Seed örnekleri bu testin senaryolarını etkilemesin.
|
||||||
|
await Promise.all([
|
||||||
|
AssetPriceModel.deleteMany({}),
|
||||||
|
AssetModel.deleteMany({}),
|
||||||
|
AssetMovementModel.deleteMany({}),
|
||||||
|
TransactionModel.deleteMany({ assetId: { $ne: null } }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
const acc = (name: string) =>
|
||||||
|
AccountModel.findOne({ householdId: cakir, name })
|
||||||
|
.lean()
|
||||||
|
.then((a) => String(a!._id));
|
||||||
|
async function balance(accountId: string) {
|
||||||
|
return (await listAccounts(ayse, cakir))!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
|
||||||
|
}
|
||||||
|
const mv = (input: Record<string, unknown>) => movementFormSchema.parse({ date: today, ...input });
|
||||||
|
async function asset(name: string, assetKey = "gram_gold", isPrivate = false, by = ayse) {
|
||||||
|
const r = await createAsset(by, cakir, assetFormSchema.parse({ assetKey, name, isPrivate }));
|
||||||
|
return r.ok ? r.data.id : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("varlık lotları ve ortalama maliyet", () => {
|
||||||
|
let gold = "";
|
||||||
|
|
||||||
|
it("alım hesaptan transfer olarak çıkar; gelir/gider toplamları değişmez", async () => {
|
||||||
|
gold = await asset("Altın birikimi");
|
||||||
|
const ziraat = await acc("Ziraat Maaş");
|
||||||
|
const z0 = await balance(ziraat);
|
||||||
|
const sums0 = (await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums;
|
||||||
|
const r = await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({
|
||||||
|
kind: "buy",
|
||||||
|
date: daysAgo(60),
|
||||||
|
quantity: "10",
|
||||||
|
amount: "25.000",
|
||||||
|
accountId: ziraat,
|
||||||
|
storage: "Ev kasası",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(await balance(ziraat)).toBe(z0 - 25_000_00);
|
||||||
|
expect((await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums).toEqual(
|
||||||
|
sums0,
|
||||||
|
);
|
||||||
|
// İşlem listesinde varlık adıyla görünür.
|
||||||
|
const page = (await listTransactions(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
transactionFilterSchema.parse({ q: "Varlık alımı" }),
|
||||||
|
))!;
|
||||||
|
expect(page.items[0]).toMatchObject({ asset: { id: gold, name: "Altın birikimi" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("birim fiyatla alım ve fiyat tablosundan (satış fiyatı) alım; özet", async () => {
|
||||||
|
await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "buy", date: daysAgo(30), quantity: "2", unitPrice: "2.600" }),
|
||||||
|
);
|
||||||
|
// Fiyat yok → reddedilir.
|
||||||
|
expect(
|
||||||
|
(await addMovement(ayse, cakir, gold, mv({ kind: "buy", date: daysAgo(10), quantity: "2" })))
|
||||||
|
.ok,
|
||||||
|
).toBe(false);
|
||||||
|
await savePrice(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
assetPriceFormSchema.parse({
|
||||||
|
assetKey: "gram_gold",
|
||||||
|
date: daysAgo(10),
|
||||||
|
buy: "2.650",
|
||||||
|
sell: "2.700",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await addMovement(ayse, cakir, gold, mv({ kind: "buy", date: daysAgo(10), quantity: "2" })))
|
||||||
|
.ok,
|
||||||
|
).toBe(true);
|
||||||
|
await savePrice(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
assetPriceFormSchema.parse({
|
||||||
|
assetKey: "gram_gold",
|
||||||
|
date: today,
|
||||||
|
buy: "3.000",
|
||||||
|
sell: "3.100",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const v = (await getAsset(ayse, cakir, gold))!;
|
||||||
|
// 14 gr, 25.000 + 5.200 + 5.400 = 35.600; değer alış 3.000 ile 42.000.
|
||||||
|
expect(v.summary).toMatchObject({
|
||||||
|
quantity: 14_000,
|
||||||
|
costMinor: 35_600_00,
|
||||||
|
investedMinor: 35_600_00,
|
||||||
|
avgCostMinor: 2_542_86,
|
||||||
|
currentValueMinor: 42_000_00,
|
||||||
|
unrealizedMinor: 6_400_00,
|
||||||
|
unrealizedBp: 1798,
|
||||||
|
});
|
||||||
|
expect(v.storage).toBe("Ev kasası");
|
||||||
|
expect(v.movements[0]!.kind).toBe("buy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("satış: hesaba girer, ortalama maliyetle gerçekleşmiş K/Z; eldekinden fazlası reddedilir", async () => {
|
||||||
|
const cuzdan = await acc("Cüzdan");
|
||||||
|
const c0 = await balance(cuzdan);
|
||||||
|
expect(
|
||||||
|
(await addMovement(ayse, cakir, gold, mv({ kind: "sell", quantity: "15", amount: "1" }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
// Geçmiş tarihli satış o tarihteki miktarla sınırlı (60 gün önce yalnızca 10 gr vardı).
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "sell", date: daysAgo(45), quantity: "11", amount: "1" }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
const r = await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "sell", quantity: "4", amount: "12.000", accountId: cuzdan }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(await balance(cuzdan)).toBe(c0 + 12_000_00);
|
||||||
|
const v = (await getAsset(ayse, cakir, gold))!;
|
||||||
|
// 4 gr maliyeti: 35.600 × 4/14 = 10.171,43 → K/Z 1.828,57
|
||||||
|
expect(v.movements[0]).toMatchObject({
|
||||||
|
kind: "sell",
|
||||||
|
costOutMinor: 10_171_43,
|
||||||
|
realizedMinor: 1_828_57,
|
||||||
|
quantityAfter: 10_000,
|
||||||
|
});
|
||||||
|
expect(v.summary.realizedMinor).toBe(1_828_57);
|
||||||
|
expect(v.summary.proceedsMinor).toBe(12_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hediye çıkışı K/Z yazmaz, hesap hareketi oluşturmaz", async () => {
|
||||||
|
const before = (await getAsset(ayse, cakir, gold))!.summary;
|
||||||
|
const r = await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "gift_out", quantity: "1", note: "Yeğene" }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const m = await AssetMovementModel.findById(r.ok ? r.data.id : "").lean();
|
||||||
|
expect(m).toMatchObject({ amountMinor: 0, transactionId: null });
|
||||||
|
const after = (await getAsset(ayse, cakir, gold))!.summary;
|
||||||
|
expect(after.quantity).toBe(9_000);
|
||||||
|
expect(after.realizedMinor).toBe(before.realizedMinor);
|
||||||
|
expect(after.avgCostMinor).toBe(before.avgCostMinor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bağlı işlem tek başına düzenlenemez/silinemez; hareket silinince işlem de silinir", async () => {
|
||||||
|
const tx = (await AssetMovementModel.findOne({ assetId: gold, kind: "sell" }).lean())!;
|
||||||
|
const txId = String(tx.transactionId);
|
||||||
|
const locked = { ok: false, error: tr.transactions.assetLocked };
|
||||||
|
expect(await setTransactionDeleted(ayse, cakir, txId, true)).toMatchObject(locked);
|
||||||
|
const edit = transactionFormSchema.parse({
|
||||||
|
type: "transfer",
|
||||||
|
date: today,
|
||||||
|
amount: "1",
|
||||||
|
accountId: await acc("Cüzdan"),
|
||||||
|
toAccountId: await acc("Ziraat Maaş"),
|
||||||
|
});
|
||||||
|
expect(await updateTransaction(ayse, cakir, txId, edit)).toMatchObject(locked);
|
||||||
|
|
||||||
|
const cuzdan = await acc("Cüzdan");
|
||||||
|
const c0 = await balance(cuzdan);
|
||||||
|
expect((await deleteMovement(ayse, cakir, String(tx._id))).ok).toBe(true);
|
||||||
|
expect(await balance(cuzdan)).toBe(c0 - 12_000_00);
|
||||||
|
expect(
|
||||||
|
await AuditLogModel.exists({ action: "asset.movement.delete", targetId: String(tx._id) }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect((await getAsset(ayse, cakir, gold))!.summary.realizedMinor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("silinince sonraki çıkış eldekini aşacaksa reddedilir; hareketli varlık silinemez", async () => {
|
||||||
|
const first = (await AssetMovementModel.findOne({ assetId: gold, kind: "buy" })
|
||||||
|
.sort({ date: 1 })
|
||||||
|
.lean())!;
|
||||||
|
// Kalan: 10 + 2 + 2 alım, bugün 1 gr çıkış. Sonraki alımlar silinebilir; ilk alım silinirse
|
||||||
|
// çıkış eldekini aşar → reddedilir.
|
||||||
|
const buys = await AssetMovementModel.find({ assetId: gold, kind: "buy", deletedAt: null })
|
||||||
|
.sort({ date: -1 })
|
||||||
|
.lean();
|
||||||
|
expect(buys.at(-1)!._id.equals(first._id)).toBe(true);
|
||||||
|
for (const b of buys.slice(0, -1))
|
||||||
|
expect((await deleteMovement(ayse, cakir, String(b._id))).ok).toBe(true);
|
||||||
|
expect((await deleteMovement(ayse, cakir, String(first._id))).ok).toBe(false);
|
||||||
|
expect((await deleteAsset(ayse, cakir, gold)).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await updateAsset(ayse, cakir, gold, assetFormSchema.parse({ assetKey: "usd", name: "X" })))
|
||||||
|
.ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gelen hediye: maliyet o günkü alış fiyatı, yatırım sayılmaz", async () => {
|
||||||
|
const z = await asset("Düğün ziynetleri", "quarter_gold");
|
||||||
|
await savePrice(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
assetPriceFormSchema.parse({ assetKey: "quarter_gold", date: daysAgo(100), buy: "5.000" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
movementFormSchema.safeParse({
|
||||||
|
kind: "gift_in",
|
||||||
|
date: today,
|
||||||
|
quantity: "1",
|
||||||
|
accountId: await acc("Cüzdan"),
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await addMovement(ayse, cakir, z, mv({ kind: "gift_in", date: daysAgo(90), quantity: "4" })))
|
||||||
|
.ok,
|
||||||
|
).toBe(true);
|
||||||
|
const v = (await getAsset(ayse, cakir, z))!;
|
||||||
|
expect(v.summary).toMatchObject({ quantity: 4_000, costMinor: 20_000_00, investedMinor: 0 });
|
||||||
|
// Güncel değer: bugünden önceki en yakın fiyat.
|
||||||
|
expect(v.summary.currentValueMinor).toBe(20_000_00);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("görünürlük ve yetki", () => {
|
||||||
|
it("gizli varlık ve bağlı işlemi başkasına görünmez; izleyici yazamaz; başka hane göremez", async () => {
|
||||||
|
const secret = await asset("Emre dolar", "usd", true, emre);
|
||||||
|
await savePrice(
|
||||||
|
emre,
|
||||||
|
cakir,
|
||||||
|
assetPriceFormSchema.parse({ assetKey: "usd", date: today, buy: "40" }),
|
||||||
|
);
|
||||||
|
const r = await addMovement(
|
||||||
|
emre,
|
||||||
|
cakir,
|
||||||
|
secret,
|
||||||
|
mv({ kind: "buy", quantity: "100", amount: "4.000", accountId: await acc("Cüzdan") }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect((await listAssets(emre, cakir))!.items.some((a) => a.id === secret)).toBe(true);
|
||||||
|
expect((await listAssets(ayse, cakir))!.items.some((a) => a.id === secret)).toBe(false);
|
||||||
|
expect(await getAsset(ayse, cakir, secret)).toBeNull();
|
||||||
|
const ayseTx = (await listTransactions(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
transactionFilterSchema.parse({ q: "Emre dolar" }),
|
||||||
|
))!;
|
||||||
|
expect(ayseTx.total).toBe(0);
|
||||||
|
expect(
|
||||||
|
(await addMovement(ayse, cakir, secret, mv({ kind: "gift_out", quantity: "1" }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
const shared = (await listAssets(ayse, cakir))!.items[0]!.id;
|
||||||
|
expect(
|
||||||
|
(await addMovement(zeynep, cakir, shared, mv({ kind: "gift_out", quantity: "1" }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect((await listAssets(zeynep, cakir))!.canWrite).toBe(false);
|
||||||
|
expect(await listAssets(fatma, cakir)).toBeNull();
|
||||||
|
expect(await getAsset(fatma, cakir, shared)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toplamlar: fiyatı olmayan varlık toplam değere katılmaz", async () => {
|
||||||
|
const eur = await asset("Euro kumbara", "eur");
|
||||||
|
await addMovement(ayse, cakir, eur, mv({ kind: "gift_in", quantity: "50", amount: "2.400" }));
|
||||||
|
const data = (await listAssets(ayse, cakir))!;
|
||||||
|
expect(data.totals.missingPrice).toBe(1);
|
||||||
|
const withPrice = data.items.filter((i) => i.summary.currentValueMinor !== null);
|
||||||
|
expect(data.totals.valueMinor).toBe(
|
||||||
|
withPrice.reduce((a, i) => a + (i.summary.currentValueMinor ?? 0), 0),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
import "server-only";
|
||||||
|
import mongoose, { Types } from "mongoose";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { buyPriceLookup, priceOn, type PriceQuote } from "@/features/in-kind/prices";
|
||||||
|
import { loadContext, type Ctx } from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { writeAudit } from "@/lib/audit";
|
||||||
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
import { AssetModel } from "@/models/asset";
|
||||||
|
import { AssetMovementModel } from "@/models/asset-movement";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { formatAssetQuantity, valueOf, type AssetKey } from "@/shared/assets";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import {
|
||||||
|
holdingSummary,
|
||||||
|
periodReport,
|
||||||
|
runMovements,
|
||||||
|
sumReports,
|
||||||
|
type HoldingSummary,
|
||||||
|
type MovementKind,
|
||||||
|
type PeriodReport,
|
||||||
|
} from "@/shared/portfolio";
|
||||||
|
import { addDays, addMonths } from "@/shared/recurrence";
|
||||||
|
import { QUANTITY_SCALE } from "@/shared/quantity";
|
||||||
|
import type { AssetForm, MovementForm } from "@/shared/schemas/asset";
|
||||||
|
import { compareTr, foldTr } from "@/shared/search";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Birikim ve varlık takibi (Bölüm 6.6). Okuma: viewer; yazma: member. Varlıklar hane ortaktır;
|
||||||
|
* "yalnızca ben" işaretli varlık ve hareketleri (bağlı hesap işlemleri dahil) yalnızca sahibine
|
||||||
|
* görünür. Değerleme alış fiyatıyla (Faz 0). Alım/satış hesaba bağlıysa transfer işlemi oluşur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.assets;
|
||||||
|
|
||||||
|
type AssetDoc = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
assetKey: AssetKey;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
ownerUserId?: Types.ObjectId | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MovementDoc = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
assetId: Types.ObjectId;
|
||||||
|
kind: MovementKind;
|
||||||
|
date: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPriceMinor?: number | null;
|
||||||
|
amountMinor: number;
|
||||||
|
accountId?: Types.ObjectId | null;
|
||||||
|
transactionId?: Types.ObjectId | null;
|
||||||
|
storage: string;
|
||||||
|
note: string;
|
||||||
|
inKindDebtId?: Types.ObjectId | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetView = {
|
||||||
|
id: string;
|
||||||
|
assetKey: AssetKey;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
label: string;
|
||||||
|
price: PriceQuote | null;
|
||||||
|
summary: HoldingSummary;
|
||||||
|
/** Son alımın saklama yeri. */
|
||||||
|
storage: string;
|
||||||
|
movementCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetTotals = {
|
||||||
|
valueMinor: number;
|
||||||
|
costMinor: number;
|
||||||
|
unrealizedMinor: number;
|
||||||
|
realizedMinor: number;
|
||||||
|
/** Fiyatı olmadığı için toplam değere katılmayan (elde miktarı olan) varlık sayısı. */
|
||||||
|
missingPrice: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MovementRow = {
|
||||||
|
id: string;
|
||||||
|
kind: MovementKind;
|
||||||
|
date: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPriceMinor: number | null;
|
||||||
|
amountMinor: number;
|
||||||
|
account: string | null;
|
||||||
|
storage: string;
|
||||||
|
note: string;
|
||||||
|
costOutMinor: number;
|
||||||
|
realizedMinor: number;
|
||||||
|
quantityAfter: number;
|
||||||
|
fromInKind: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetDetail = AssetView & { movements: MovementRow[]; canWrite: boolean };
|
||||||
|
|
||||||
|
const sortMovements = (a: MovementDoc, b: MovementDoc) =>
|
||||||
|
a.date === b.date ? a.createdAt.getTime() - b.createdAt.getTime() : a.date < b.date ? -1 : 1;
|
||||||
|
|
||||||
|
function visibleAsset(ctx: Ctx, id: Types.ObjectId | string) {
|
||||||
|
const a = ctx.assetMap.get(String(id));
|
||||||
|
return a && !a.hidden && !a.deleted ? a : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMovements(householdId: Types.ObjectId, assetIds: Types.ObjectId[]) {
|
||||||
|
const docs = await AssetMovementModel.find({
|
||||||
|
householdId,
|
||||||
|
assetId: { $in: assetIds },
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<MovementDoc[]>();
|
||||||
|
const by = new Map<string, MovementDoc[]>();
|
||||||
|
for (const m of docs) {
|
||||||
|
const list = by.get(String(m.assetId)) ?? [];
|
||||||
|
list.push(m);
|
||||||
|
by.set(String(m.assetId), list);
|
||||||
|
}
|
||||||
|
for (const list of by.values()) list.sort(sortMovements);
|
||||||
|
return by;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toView(a: AssetDoc, movements: MovementDoc[], price: PriceQuote | null): AssetView {
|
||||||
|
const run = runMovements(movements);
|
||||||
|
const summary = holdingSummary(run, price?.buyMinor ?? null);
|
||||||
|
const storage = [...movements].reverse().find((m) => m.storage)?.storage ?? "";
|
||||||
|
return {
|
||||||
|
id: String(a._id),
|
||||||
|
assetKey: a.assetKey,
|
||||||
|
name: a.name,
|
||||||
|
note: a.note,
|
||||||
|
isPrivate: a.isPrivate,
|
||||||
|
label: formatAssetQuantity(a.assetKey, summary.quantity),
|
||||||
|
price,
|
||||||
|
summary,
|
||||||
|
storage,
|
||||||
|
movementCount: movements.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Görünür varlıklar ve toplamlar (net varlık ve dashboard da kullanır). */
|
||||||
|
export async function listAssets(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<{ items: AssetView[]; totals: AssetTotals; canWrite: boolean } | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
return { ...(await assetViews(ctx)), canWrite: hasHouseholdRole(ctx.m.role, "member") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assetViews(ctx: Ctx, date = todayIstanbul()) {
|
||||||
|
const docs = (
|
||||||
|
await AssetModel.find({ householdId: ctx.m.householdId, deletedAt: null }).lean<AssetDoc[]>()
|
||||||
|
).filter((a) => visibleAsset(ctx, a._id));
|
||||||
|
const movements = await loadMovements(
|
||||||
|
ctx.m.householdId,
|
||||||
|
docs.map((a) => a._id),
|
||||||
|
);
|
||||||
|
const prices = new Map<string, PriceQuote | null>();
|
||||||
|
for (const key of new Set(docs.map((a) => a.assetKey)))
|
||||||
|
prices.set(key, await priceOn(ctx.m.householdId, key, date));
|
||||||
|
const items = docs
|
||||||
|
.map((a) => toView(a, movements.get(String(a._id)) ?? [], prices.get(a.assetKey) ?? null))
|
||||||
|
.sort(
|
||||||
|
(x, y) =>
|
||||||
|
Number(y.summary.quantity > 0) - Number(x.summary.quantity > 0) ||
|
||||||
|
(y.summary.currentValueMinor ?? 0) - (x.summary.currentValueMinor ?? 0) ||
|
||||||
|
compareTr(x.name, y.name),
|
||||||
|
);
|
||||||
|
const totals: AssetTotals = {
|
||||||
|
valueMinor: 0,
|
||||||
|
costMinor: 0,
|
||||||
|
unrealizedMinor: 0,
|
||||||
|
realizedMinor: 0,
|
||||||
|
missingPrice: 0,
|
||||||
|
};
|
||||||
|
for (const v of items) {
|
||||||
|
totals.realizedMinor += v.summary.realizedMinor;
|
||||||
|
if (v.summary.quantity === 0) continue;
|
||||||
|
if (v.summary.currentValueMinor === null) {
|
||||||
|
totals.missingPrice++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
totals.valueMinor += v.summary.currentValueMinor;
|
||||||
|
totals.costMinor += v.summary.costMinor;
|
||||||
|
totals.unrealizedMinor += v.summary.unrealizedMinor ?? 0;
|
||||||
|
}
|
||||||
|
return { items, totals };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAsset(ctx: Ctx, assetId: string) {
|
||||||
|
const id = toObjectId(assetId);
|
||||||
|
if (!id || !visibleAsset(ctx, id)) return null;
|
||||||
|
return AssetModel.findOne({
|
||||||
|
_id: id,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<AssetDoc>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAsset(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
assetId: string,
|
||||||
|
): Promise<AssetDetail | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return null;
|
||||||
|
const movements = (await loadMovements(ctx.m.householdId, [a._id])).get(String(a._id)) ?? [];
|
||||||
|
const price = await priceOn(ctx.m.householdId, a.assetKey, todayIstanbul());
|
||||||
|
const run = runMovements(movements);
|
||||||
|
const rows = movements.map((m, i): MovementRow => {
|
||||||
|
const acc = m.accountId ? ctx.accountMap.get(String(m.accountId)) : undefined;
|
||||||
|
const step = run.steps[i]!;
|
||||||
|
return {
|
||||||
|
id: String(m._id),
|
||||||
|
kind: m.kind,
|
||||||
|
date: m.date,
|
||||||
|
quantity: m.quantity,
|
||||||
|
unitPriceMinor: m.unitPriceMinor ?? null,
|
||||||
|
amountMinor: m.amountMinor,
|
||||||
|
account: acc ? (acc.hidden ? tr.transactions.list.hiddenAccount : acc.name) : null,
|
||||||
|
storage: m.storage,
|
||||||
|
note: m.note,
|
||||||
|
costOutMinor: step.costOutMinor,
|
||||||
|
realizedMinor: step.realizedMinor,
|
||||||
|
quantityAfter: step.quantityAfter,
|
||||||
|
fromInKind: Boolean(m.inKindDebtId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...toView(a, movements, price),
|
||||||
|
movements: rows.reverse(),
|
||||||
|
canWrite: hasHouseholdRole(ctx.m.role, "member"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Varlık kaydı ---
|
||||||
|
|
||||||
|
export async function createAsset(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: AssetForm,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const doc = await AssetModel.create({
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
assetKey: input.assetKey,
|
||||||
|
name: input.name,
|
||||||
|
note: input.note,
|
||||||
|
isPrivate: input.isPrivate,
|
||||||
|
ownerUserId: input.isPrivate ? actor : null,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
return ok({ id: String(doc._id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAsset(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
assetId: string,
|
||||||
|
input: AssetForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return fail(tr.common.notFound);
|
||||||
|
if (
|
||||||
|
input.assetKey !== a.assetKey &&
|
||||||
|
(await AssetMovementModel.exists({ assetId: a._id, deletedAt: null }))
|
||||||
|
)
|
||||||
|
return fail(t.errors.assetKeyLocked, { assetKey: t.errors.assetKeyLocked });
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const owner = a.ownerUserId ?? (input.isPrivate ? actor : null);
|
||||||
|
await AssetModel.updateOne(
|
||||||
|
{ _id: a._id, householdId: ctx.m.householdId },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
assetKey: input.assetKey,
|
||||||
|
name: input.name,
|
||||||
|
note: input.note,
|
||||||
|
isPrivate: input.isPrivate,
|
||||||
|
ownerUserId: input.isPrivate ? owner : null,
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAsset(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
assetId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return fail(tr.common.notFound);
|
||||||
|
if (await AssetMovementModel.exists({ assetId: a._id, deletedAt: null }))
|
||||||
|
return fail(t.errors.hasMovements);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await AssetModel.updateOne({ _id: a._id }, { $set: { deletedAt: new Date(), updatedBy: actor } });
|
||||||
|
await writeAudit({
|
||||||
|
action: "asset.delete",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
targetType: "asset",
|
||||||
|
targetId: String(a._id),
|
||||||
|
diff: { name: a.name, assetKey: a.assetKey },
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hareketler ---
|
||||||
|
|
||||||
|
function checkAccount(ctx: Ctx, id: string | null) {
|
||||||
|
if (!id) return null;
|
||||||
|
const a = ctx.accountMap.get(id);
|
||||||
|
return a && !a.hidden && !a.archived ? a : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tutar ve birim fiyatı tamamlar (bkz. movementFormSchema). */
|
||||||
|
async function resolveAmount(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
assetKey: AssetKey,
|
||||||
|
input: MovementForm,
|
||||||
|
): Promise<{ unitPriceMinor: number | null; amountMinor: number } | null> {
|
||||||
|
if (input.kind === "gift_out") return { unitPriceMinor: null, amountMinor: 0 };
|
||||||
|
if (input.amount !== null)
|
||||||
|
return {
|
||||||
|
amountMinor: input.amount,
|
||||||
|
unitPriceMinor:
|
||||||
|
input.unitPrice ?? Math.round((input.amount * QUANTITY_SCALE) / input.quantity),
|
||||||
|
};
|
||||||
|
let unit = input.unitPrice;
|
||||||
|
if (unit === null) {
|
||||||
|
const p = await priceOn(householdId, assetKey, input.date);
|
||||||
|
// Alımda kurumun satış fiyatı ödenir; satış ve hediye değeri alış fiyatıyla.
|
||||||
|
unit = p ? (input.kind === "buy" ? p.sellMinor : p.buyMinor) : null;
|
||||||
|
}
|
||||||
|
if (unit === null) return null;
|
||||||
|
return { unitPriceMinor: unit, amountMinor: valueOf(input.quantity, unit) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMovement(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
assetId: string,
|
||||||
|
input: MovementForm,
|
||||||
|
opts: { inKindDebtId?: Types.ObjectId; session?: mongoose.ClientSession } = {},
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return fail(tr.common.notFound);
|
||||||
|
if (input.date > todayIstanbul()) return fail(t.errors.futureDate, { date: t.errors.futureDate });
|
||||||
|
const date = istanbulDayStart(input.date);
|
||||||
|
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
|
||||||
|
const account = checkAccount(ctx, input.accountId);
|
||||||
|
if (input.accountId && !account)
|
||||||
|
return fail(tr.transactions.validation.accountInvalid, {
|
||||||
|
accountId: tr.transactions.validation.accountInvalid,
|
||||||
|
});
|
||||||
|
const amount = await resolveAmount(ctx.m.householdId, a.assetKey, input);
|
||||||
|
if (!amount) return fail(t.errors.noPrice, { amount: t.errors.noPrice });
|
||||||
|
|
||||||
|
// Eldeki miktar: yeni hareket aynı günün sonuna eklenmiş gibi doğrulanır.
|
||||||
|
const existing = (await loadMovements(ctx.m.householdId, [a._id])).get(String(a._id)) ?? [];
|
||||||
|
const now = new Date();
|
||||||
|
const candidate = [
|
||||||
|
...existing,
|
||||||
|
{ kind: input.kind, date: input.date, quantity: input.quantity, createdAt: now } as MovementDoc,
|
||||||
|
]
|
||||||
|
.map((m) => ({ ...m, amountMinor: m.amountMinor ?? 0 }))
|
||||||
|
.sort(sortMovements);
|
||||||
|
if (runMovements(candidate).negativeAt !== null)
|
||||||
|
return fail(t.errors.insufficient, { quantity: t.errors.insufficient });
|
||||||
|
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const movementId = new Types.ObjectId();
|
||||||
|
const label = formatAssetQuantity(a.assetKey, input.quantity);
|
||||||
|
const write = async (session: mongoose.ClientSession) => {
|
||||||
|
let transactionId: Types.ObjectId | null = null;
|
||||||
|
if (account && amount.amountMinor > 0 && (input.kind === "buy" || input.kind === "sell")) {
|
||||||
|
const out = input.kind === "buy";
|
||||||
|
const description = t.txDescription[input.kind](label, a.name);
|
||||||
|
const [tx] = await TransactionModel.create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
type: "transfer",
|
||||||
|
status: "posted",
|
||||||
|
date,
|
||||||
|
// Alım hesaptan çıkar (varlığa); satış hesaba girer (varlıktan).
|
||||||
|
accountId: out ? account.id : null,
|
||||||
|
toAccountId: out ? null : account.id,
|
||||||
|
amountMinor: amount.amountMinor,
|
||||||
|
description,
|
||||||
|
searchText: foldTr(description),
|
||||||
|
assetId: a._id,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
transactionId = tx!._id;
|
||||||
|
}
|
||||||
|
await AssetMovementModel.create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
_id: movementId,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
assetId: a._id,
|
||||||
|
kind: input.kind,
|
||||||
|
date: input.date,
|
||||||
|
quantity: input.quantity,
|
||||||
|
unitPriceMinor: amount.unitPriceMinor,
|
||||||
|
amountMinor: amount.amountMinor,
|
||||||
|
accountId: account && transactionId ? account.id : null,
|
||||||
|
transactionId,
|
||||||
|
storage: input.storage,
|
||||||
|
note: input.note,
|
||||||
|
inKindDebtId: opts.inKindDebtId ?? null,
|
||||||
|
createdBy: actor,
|
||||||
|
createdAt: now,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (opts.session) await write(opts.session);
|
||||||
|
else await mongoose.connection.transaction(write);
|
||||||
|
return ok({ id: String(movementId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMovement(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
movementId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const id = toObjectId(movementId);
|
||||||
|
if (!id) return fail(tr.common.notFound);
|
||||||
|
const m = await AssetMovementModel.findOne({
|
||||||
|
_id: id,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<MovementDoc>();
|
||||||
|
if (!m || !visibleAsset(ctx, m.assetId)) return fail(tr.common.notFound);
|
||||||
|
const rest = (
|
||||||
|
(await loadMovements(ctx.m.householdId, [m.assetId])).get(String(m.assetId)) ?? []
|
||||||
|
).filter((x) => !x._id.equals(m._id));
|
||||||
|
if (runMovements(rest).negativeAt !== null) return fail(t.errors.deleteBreaks);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
const now = new Date();
|
||||||
|
await AssetMovementModel.updateOne({ _id: m._id }, { $set: { deletedAt: now } }, { session });
|
||||||
|
if (m.transactionId)
|
||||||
|
await TransactionModel.updateOne(
|
||||||
|
{ _id: m.transactionId, householdId: ctx.m.householdId },
|
||||||
|
{ $set: { deletedAt: now, updatedBy: actor } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
await writeAudit(
|
||||||
|
{
|
||||||
|
action: "asset.movement.delete",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
targetType: "assetMovement",
|
||||||
|
targetId: String(m._id),
|
||||||
|
diff: { kind: m.kind, quantity: m.quantity, amountMinor: m.amountMinor, date: m.date },
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dönemsel rapor ---
|
||||||
|
|
||||||
|
export const REPORT_RANGES = ["3", "6", "12", "tum"] as const;
|
||||||
|
export type ReportRange = (typeof REPORT_RANGES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seçilen aralıkta (son N ay veya ilk hareketten bugüne) yatırılan TL, dönem sonu değer,
|
||||||
|
* kâr/zarar ve aylık değer noktaları. `assetId` verilirse yalnızca o varlık, yoksa tüm görünür
|
||||||
|
* varlıkların toplamı.
|
||||||
|
*/
|
||||||
|
export async function getPeriodReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
range: ReportRange,
|
||||||
|
assetId?: string,
|
||||||
|
): Promise<(PeriodReport & { excluded: number }) | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
let docs: AssetDoc[];
|
||||||
|
if (assetId) {
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return null;
|
||||||
|
docs = [a];
|
||||||
|
} else {
|
||||||
|
docs = (
|
||||||
|
await AssetModel.find({ householdId: ctx.m.householdId, deletedAt: null }).lean<AssetDoc[]>()
|
||||||
|
).filter((a) => visibleAsset(ctx, a._id));
|
||||||
|
}
|
||||||
|
const movements = await loadMovements(
|
||||||
|
ctx.m.householdId,
|
||||||
|
docs.map((a) => a._id),
|
||||||
|
);
|
||||||
|
const to = todayIstanbul();
|
||||||
|
let from: string;
|
||||||
|
if (range === "tum") {
|
||||||
|
const first = [...movements.values()]
|
||||||
|
.flat()
|
||||||
|
.map((m) => m.date)
|
||||||
|
.sort()[0];
|
||||||
|
from = first && first < to ? first : addDays(to, -1);
|
||||||
|
} else from = addMonths(to, -Number(range));
|
||||||
|
const price = await buyPriceLookup(
|
||||||
|
ctx.m.householdId,
|
||||||
|
[...new Set(docs.map((a) => a.assetKey))],
|
||||||
|
to,
|
||||||
|
);
|
||||||
|
const reports = docs.map((a) =>
|
||||||
|
periodReport(movements.get(String(a._id)) ?? [], (date) => price(a.assetKey, date), from, to),
|
||||||
|
);
|
||||||
|
if (assetId) return { ...reports[0]!, excluded: 0 };
|
||||||
|
// Toplamda fiyatı eksik varlıklar (kâr hesaplanamayan) dışarıda bırakılır ve sayılır.
|
||||||
|
const priced = reports.filter((r) => r.profitMinor !== null);
|
||||||
|
return { ...sumReports(priced, from, to), excluded: reports.length - priced.length };
|
||||||
|
}
|
||||||
@@ -38,11 +38,17 @@ export function AttachmentPicker({
|
|||||||
onChange,
|
onChange,
|
||||||
allowedKinds,
|
allowedKinds,
|
||||||
onBusyChange,
|
onBusyChange,
|
||||||
|
hint,
|
||||||
|
addLabel,
|
||||||
}: {
|
}: {
|
||||||
value: AttachmentInfo[];
|
value: AttachmentInfo[];
|
||||||
onChange: (items: AttachmentInfo[]) => void;
|
onChange: (items: AttachmentInfo[]) => void;
|
||||||
allowedKinds: readonly AttachmentKind[];
|
allowedKinds: readonly AttachmentKind[];
|
||||||
onBusyChange?: (busy: boolean) => void;
|
onBusyChange?: (busy: boolean) => void;
|
||||||
|
/** Boşken gösterilen ipucu (varsayılan: fiş/fatura). */
|
||||||
|
hint?: string;
|
||||||
|
/** Düğme metni (varsayılan: "Ek ekle"). */
|
||||||
|
addLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = useState(0);
|
const [uploading, setUploading] = useState(0);
|
||||||
@@ -91,14 +97,16 @@ export function AttachmentPicker({
|
|||||||
disabled={room <= 0 || uploading > 0}
|
disabled={room <= 0 || uploading > 0}
|
||||||
>
|
>
|
||||||
{uploading > 0 ? <Loader2 className="animate-spin" /> : <Paperclip />}
|
{uploading > 0 ? <Loader2 className="animate-spin" /> : <Paperclip />}
|
||||||
{uploading > 0 ? t.uploading : t.add}
|
{uploading > 0 ? t.uploading : (addLabel ?? t.add)}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{value.length
|
{value.length
|
||||||
? t.count(value.length)
|
? t.count(value.length)
|
||||||
: allowedKinds.includes("pdf")
|
: hint
|
||||||
? t.addHint.imagePdf
|
? hint
|
||||||
: t.addHint.image}
|
: allowedKinds.includes("pdf")
|
||||||
|
? t.addHint.imagePdf
|
||||||
|
: t.addHint.image}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getStorage } from "@/lib/storage";
|
|||||||
import { isPlausiblePdf, processImage, UnreadableImageError } from "@/lib/uploads/process";
|
import { isPlausiblePdf, processImage, UnreadableImageError } from "@/lib/uploads/process";
|
||||||
import { AccountModel } from "@/models/account";
|
import { AccountModel } from "@/models/account";
|
||||||
import { AttachmentModel, type AttachmentOwnerType } from "@/models/attachment";
|
import { AttachmentModel, type AttachmentOwnerType } from "@/models/attachment";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
import { TransactionModel } from "@/models/transaction";
|
import { TransactionModel } from "@/models/transaction";
|
||||||
import {
|
import {
|
||||||
detectFileType,
|
detectFileType,
|
||||||
@@ -267,6 +268,9 @@ export async function readAttachmentForUser(
|
|||||||
ownerUserId: { $ne: new Types.ObjectId(userId) },
|
ownerUserId: { $ne: new Types.ObjectId(userId) },
|
||||||
});
|
});
|
||||||
if (hidden) return null;
|
if (hidden) return null;
|
||||||
|
} else if (a.ownerType === "product") {
|
||||||
|
// Ürün görselleri hane ortaktır (arşivli ürün dahil).
|
||||||
|
if (!(await ProductModel.exists({ _id: a.ownerId, householdId: a.householdId }))) return null;
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
|
import { budgetLimitsSchema, budgetMonthSchema, incomeItemsSchema } from "@/shared/schemas/budget";
|
||||||
|
import { saveIncomeItems } from "./plan";
|
||||||
|
import { budgetFromActuals, copyPreviousBudget, saveBudget } from "./service";
|
||||||
|
|
||||||
|
function fieldErrors(error: z.ZodError) {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const issue of error.issues) {
|
||||||
|
const key = issue.path.join(".");
|
||||||
|
if (key && !out[key]) out[key] = issue.message;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run<T>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
input: unknown,
|
||||||
|
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<unknown>>,
|
||||||
|
): Promise<ActionResult<unknown>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const parsed = schema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||||
|
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||||
|
if (result.ok) revalidatePath("/", "layout");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBudgetAction(input: unknown) {
|
||||||
|
return run(budgetLimitsSchema, input, saveBudget);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyPreviousBudgetAction(month: unknown) {
|
||||||
|
return run(budgetMonthSchema, month, copyPreviousBudget);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function budgetFromActualsAction(month: unknown) {
|
||||||
|
return run(budgetMonthSchema, month, budgetFromActuals);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveIncomeItemsAction(input: unknown) {
|
||||||
|
return run(incomeItemsSchema, input, saveIncomeItems);
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Copy,
|
||||||
|
EllipsisVertical,
|
||||||
|
History,
|
||||||
|
Pencil,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { IconBadge } from "@/components/appearance/swatch";
|
||||||
|
import { MoneyInput } from "@/components/money-input";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { shiftMonth, type BudgetStatus } from "@/shared/budget";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { formatMoneyInput, parseMoney } from "@/shared/money";
|
||||||
|
import { budgetFromActualsAction, copyPreviousBudgetAction, saveBudgetAction } from "../actions";
|
||||||
|
import type { BudgetLineView, BudgetView as Data } from "../service";
|
||||||
|
|
||||||
|
const t = tr.budget;
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
});
|
||||||
|
const monthLabel = (m: string) => monthFmt.format(new Date(`${m}-15T12:00:00+03:00`));
|
||||||
|
|
||||||
|
const BAR: Record<BudgetStatus, string> = {
|
||||||
|
ok: "bg-budget",
|
||||||
|
warning: "bg-amber-500",
|
||||||
|
over: "bg-expense",
|
||||||
|
none: "bg-muted-foreground/40",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Yüzde çubuğu; `marker` ayın geçen kısmını (yüzde) gösterir. */
|
||||||
|
function Bar({
|
||||||
|
ratioBp,
|
||||||
|
status,
|
||||||
|
marker,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
ratioBp: number | null;
|
||||||
|
status: BudgetStatus;
|
||||||
|
marker?: number;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const pct = ratioBp === null ? 0 : Math.min(100, ratioBp / 100);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={label}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={Math.round(ratioBp === null ? 0 : ratioBp / 100)}
|
||||||
|
>
|
||||||
|
<div className={cn("h-full rounded-full", BAR[status])} style={{ width: `${pct}%` }} />
|
||||||
|
{marker !== undefined && marker > 0 && marker < 100 ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute top-0 h-full w-0.5 bg-foreground/60"
|
||||||
|
style={{ left: `${marker}%` }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({
|
||||||
|
status,
|
||||||
|
forecastOver,
|
||||||
|
exceeded,
|
||||||
|
}: {
|
||||||
|
status: BudgetStatus;
|
||||||
|
forecastOver: boolean;
|
||||||
|
/** Limit aşıldı mı (tam %100'de "doldu"). */
|
||||||
|
exceeded: boolean;
|
||||||
|
}) {
|
||||||
|
if (status === "over")
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<AlertTriangle className="size-3" />
|
||||||
|
{exceeded ? t.status.over : t.status.full}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
if (status === "warning")
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-3" />
|
||||||
|
{t.status.warning}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
if (forecastOver)
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-muted-foreground">
|
||||||
|
{t.status.forecastOver}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className={cn("text-lg font-semibold tabular-nums", tone)}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pending = { kind: "edit" } | { kind: "replace"; source: "copy" | "actuals" } | null;
|
||||||
|
|
||||||
|
export function BudgetView({ data, summary }: { data: Data; summary?: React.ReactNode }) {
|
||||||
|
const [pending, setPending] = useState<Pending>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const close = () => setPending(null);
|
||||||
|
const tt = data.totals;
|
||||||
|
const monthPct = Math.round((data.elapsedDays * 100) / data.totalDays);
|
||||||
|
const warnings = data.lines.filter((l) => l.status === "warning" || l.status === "over");
|
||||||
|
|
||||||
|
async function generate(source: "copy" | "actuals") {
|
||||||
|
setBusy(true);
|
||||||
|
const r =
|
||||||
|
source === "copy"
|
||||||
|
? await copyPreviousBudgetAction(data.month)
|
||||||
|
: await budgetFromActualsAction(data.month);
|
||||||
|
setBusy(false);
|
||||||
|
close();
|
||||||
|
if (r.ok) toast.success(source === "copy" ? t.toast.copied : t.toast.created);
|
||||||
|
else toast.error(r.error);
|
||||||
|
}
|
||||||
|
const askOrRun = (source: "copy" | "actuals") =>
|
||||||
|
data.hasBudget ? setPending({ kind: "replace", source }) : generate(source);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<Link href={`/butce?ay=${shiftMonth(data.month, -1)}`} aria-label={t.prevMonth}>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-36 text-center">
|
||||||
|
<p className="font-medium capitalize">{monthLabel(data.month)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.period(d(data.period.from), d(data.period.to))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<Link href={`/butce?ay=${shiftMonth(data.month, 1)}`} aria-label={t.nextMonth}>
|
||||||
|
<ChevronRight />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{data.month !== data.currentMonth ? (
|
||||||
|
<Button variant="link" size="sm" asChild>
|
||||||
|
<Link href="/butce">{t.thisMonth}</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{data.canWrite ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={() => setPending({ kind: "edit" })}>
|
||||||
|
<Pencil />
|
||||||
|
{t.edit}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="outline" aria-label={tr.recurring.actions.more}>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={!data.hasPrevious || busy}
|
||||||
|
onSelect={() => askOrRun("copy")}
|
||||||
|
>
|
||||||
|
<Copy />
|
||||||
|
{t.copyPrevious}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem disabled={busy} onSelect={() => askOrRun("actuals")}>
|
||||||
|
<History />
|
||||||
|
{t.fromActuals}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card size="sm">
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat
|
||||||
|
label={t.totals.limit}
|
||||||
|
value={data.hasBudget ? formatMoney(tt.limitMinor) : "—"}
|
||||||
|
/>
|
||||||
|
<Stat label={t.totals.actual} value={formatMoney(tt.actualMinor)} tone="text-expense" />
|
||||||
|
<Stat
|
||||||
|
label={tt.remainingMinor < 0 ? t.totals.over : t.totals.remaining}
|
||||||
|
value={data.hasBudget ? formatMoney(Math.abs(tt.remainingMinor)) : "—"}
|
||||||
|
tone={tt.remainingMinor < 0 ? "text-expense" : "text-budget"}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={t.totals.forecast}
|
||||||
|
value={formatMoney(tt.forecastMinor)}
|
||||||
|
tone={tt.forecastOver ? "text-expense" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{data.hasBudget ? (
|
||||||
|
<Bar
|
||||||
|
ratioBp={tt.ratioBp}
|
||||||
|
status={tt.status}
|
||||||
|
marker={monthPct}
|
||||||
|
label={`${t.totals.actual} / ${t.totals.limit}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{t.totals.monthProgress(data.elapsedDays, data.totalDays, String(monthPct))}
|
||||||
|
{tt.ratioBp !== null ? ` · %${Math.round(tt.ratioBp / 100)}` : ""}
|
||||||
|
</span>
|
||||||
|
{tt.plannedMinor ? <span>{t.totals.planned(formatMoney(tt.plannedMinor))}</span> : null}
|
||||||
|
{data.hasBudget && tt.unbudgetedMinor ? (
|
||||||
|
<span>
|
||||||
|
{t.totals.unbudgeted}: {formatMoney(tt.unbudgetedMinor)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{data.includesHidden ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.includesHidden}</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{warnings.length ? (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||||
|
{t.warnings(warnings.length)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!data.hasBudget ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed p-6 text-center">
|
||||||
|
<p className="font-medium">{t.empty}</p>
|
||||||
|
<p className="max-w-md text-sm text-muted-foreground">{t.emptyHint}</p>
|
||||||
|
{data.canWrite ? (
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
<Button size="sm" onClick={() => setPending({ kind: "edit" })}>
|
||||||
|
<Pencil />
|
||||||
|
{t.edit}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!data.hasPrevious || busy}
|
||||||
|
onClick={() => generate("copy")}
|
||||||
|
>
|
||||||
|
<Copy />
|
||||||
|
{t.copyPrevious}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => generate("actuals")}
|
||||||
|
>
|
||||||
|
<History />
|
||||||
|
{t.fromActuals}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{summary}
|
||||||
|
|
||||||
|
{data.lines.length ? (
|
||||||
|
<ul className="flex flex-col divide-y rounded-xl border bg-card">
|
||||||
|
{data.lines.map((l) => (
|
||||||
|
<Line key={l.categoryId} line={l} marker={monthPct} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : data.hasBudget ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.noSpending}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{pending?.kind === "edit" ? <EditDialog data={data} onClose={close} /> : null}
|
||||||
|
<AlertDialog open={pending?.kind === "replace"} onOpenChange={(o) => !o && close()}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t.replaceTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t.replaceDescription}
|
||||||
|
{pending?.kind === "replace" && pending.source === "actuals"
|
||||||
|
? ` ${t.fromActualsHint}`
|
||||||
|
: ""}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => pending?.kind === "replace" && generate(pending.source)}
|
||||||
|
>
|
||||||
|
{t.replace}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Line({ line: l, marker }: { line: BudgetLineView; marker: number }) {
|
||||||
|
const hasLimit = l.limitMinor !== null;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className="flex items-start gap-3 p-3"
|
||||||
|
style={{ paddingLeft: `${0.75 + l.depth * 1.25}rem` }}
|
||||||
|
>
|
||||||
|
<IconBadge icon={l.icon} color={l.color} />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
|
||||||
|
<p className="flex min-w-0 items-center gap-2 font-medium">
|
||||||
|
<span className="truncate">{l.name}</span>
|
||||||
|
<StatusBadge
|
||||||
|
status={l.status}
|
||||||
|
forecastOver={l.forecastOver}
|
||||||
|
exceeded={(l.remainingMinor ?? 0) < 0}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm tabular-nums">
|
||||||
|
{hasLimit
|
||||||
|
? t.line.ofLimit(formatMoney(l.actualMinor), formatMoney(l.limitMinor!))
|
||||||
|
: formatMoney(l.actualMinor)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{hasLimit ? (
|
||||||
|
<Bar
|
||||||
|
ratioBp={l.ratioBp}
|
||||||
|
status={l.status}
|
||||||
|
marker={marker}
|
||||||
|
label={`${l.name}: ${t.line.ofLimit(formatMoney(l.actualMinor), formatMoney(l.limitMinor!))}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<p className="flex flex-wrap gap-x-3 text-xs text-muted-foreground">
|
||||||
|
{hasLimit ? (
|
||||||
|
<span className={cn(l.remainingMinor! < 0 && "text-expense")}>
|
||||||
|
{l.remainingMinor! < 0
|
||||||
|
? t.line.over(formatMoney(-l.remainingMinor!))
|
||||||
|
: t.line.remaining(formatMoney(l.remainingMinor!))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>{l.covered ? t.status.covered : t.status.none}</span>
|
||||||
|
)}
|
||||||
|
{l.plannedMinor ? <span>{t.line.planned(formatMoney(l.plannedMinor))}</span> : null}
|
||||||
|
{l.forecastMinor !== l.actualMinor ? (
|
||||||
|
<span className={cn(l.forecastOver && "text-expense")}>
|
||||||
|
{t.line.forecast(formatMoney(l.forecastMinor))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditDialog({ data, onClose }: { data: Data; onClose: () => void }) {
|
||||||
|
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
data.editor.categories.map((c) => {
|
||||||
|
const v = data.editor.limits[c.id];
|
||||||
|
return [c.id, v ? formatMoneyInput(v) : ""];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Toplam: limiti olan ve üstünde limit olmayan kategoriler (alt limitler iki kez sayılmaz).
|
||||||
|
let total = 0;
|
||||||
|
const stack: { depth: number; limited: boolean }[] = [];
|
||||||
|
for (const c of data.editor.categories) {
|
||||||
|
while (stack.length && stack.at(-1)!.depth >= c.depth) stack.pop();
|
||||||
|
const covered = stack.some((s) => s.limited);
|
||||||
|
const v = parseMoney(values[c.id] ?? "") ?? 0;
|
||||||
|
if (v > 0 && !covered) total += v;
|
||||||
|
stack.push({ depth: c.depth, limited: v > 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await saveBudgetAction({
|
||||||
|
month: data.month,
|
||||||
|
limits: Object.entries(values).map(([categoryId, amount]) => ({ categoryId, amount })),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.saved);
|
||||||
|
onClose();
|
||||||
|
} else setError(r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={t.editTitle(monthLabel(data.month))}
|
||||||
|
description={t.editHint}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<span className="mr-auto self-center text-sm font-medium tabular-nums">
|
||||||
|
{t.editTotal(formatMoney(total))}
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="budget-form" disabled={busy}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="budget-form" onSubmit={submit} noValidate className="flex flex-col">
|
||||||
|
{data.editor.categories.map((c) => (
|
||||||
|
<label
|
||||||
|
key={c.id}
|
||||||
|
className="flex items-center justify-between gap-3 border-b py-1.5 last:border-b-0"
|
||||||
|
style={{ paddingLeft: `${c.depth * 1.25}rem` }}
|
||||||
|
>
|
||||||
|
<span className={cn("min-w-0 truncate text-sm", c.depth === 0 && "font-medium")}>
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
<div className="w-36 shrink-0">
|
||||||
|
<MoneyInput
|
||||||
|
id={`limit-${c.id}`}
|
||||||
|
value={values[c.id] ?? ""}
|
||||||
|
onChange={(x) => setValues((s) => ({ ...s, [c.id]: x }))}
|
||||||
|
aria-label={c.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{error ? <p className="pt-2 text-sm text-destructive">{error}</p> : null}
|
||||||
|
</form>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { getDailyPlan } from "../plan";
|
||||||
|
import { DailyPlanView } from "./daily-plan-view";
|
||||||
|
|
||||||
|
/** Dashboard günlük plan kartı (sunucu): veriyi yükler, görünümü istemciye verir. */
|
||||||
|
export async function DailyPlanCard({
|
||||||
|
userId,
|
||||||
|
householdId,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
householdId: string;
|
||||||
|
}) {
|
||||||
|
const data = await getDailyPlan(userId, householdId);
|
||||||
|
if (!data) return null;
|
||||||
|
return <DailyPlanView data={data} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import type { DailyPlanView as Data } from "../plan";
|
||||||
|
|
||||||
|
const t = tr.budget.daily;
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
});
|
||||||
|
const signed = (minor: number) => `${minor < 0 ? "−" : ""}${formatMoney(Math.abs(minor))}`;
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: string;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className={cn("text-lg font-semibold tabular-nums", tone)}>{value}</p>
|
||||||
|
{hint ? <p className="text-[11px] leading-tight text-muted-foreground">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard: ayın günlük planı ve gelirle karşılaştırma (grafik ana grafikte: Aylık). */
|
||||||
|
export function DailyPlanView({ data }: { data: Data }) {
|
||||||
|
const p = data.plan;
|
||||||
|
if (!data.hasBudget) {
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||||
|
<CardDescription>{t.noBudget}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
|
<Link href="/butce">{tr.budget.goToBudget}</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const nextDiff =
|
||||||
|
data.next.budgetMinor === null ? null : data.next.expectedIncomeMinor - data.next.budgetMinor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{p.spentPct !== null ? t.headline(p.monthPct, p.spentPct) : null}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<Stat
|
||||||
|
label={t.today}
|
||||||
|
value={signed(p.todayAllowanceMinor)}
|
||||||
|
tone={p.todayAllowanceMinor < 0 ? "text-expense" : "text-budget"}
|
||||||
|
hint={t.todayHint}
|
||||||
|
/>
|
||||||
|
<Stat label={t.plannedToDate} value={formatMoney(p.plannedToDateMinor)} />
|
||||||
|
<Stat
|
||||||
|
label={t.actualToDate}
|
||||||
|
value={formatMoney(p.actualToDateMinor)}
|
||||||
|
tone={p.actualToDateMinor > p.plannedToDateMinor ? "text-expense" : undefined}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={t.projectedLeft}
|
||||||
|
value={signed(data.projectedLeftMinor)}
|
||||||
|
tone={data.projectedLeftMinor < 0 ? "text-expense" : "text-income"}
|
||||||
|
hint={tr.budget.income.projectedLeftHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.modes[data.mode]} {p.fixedMinor ? t.fixedNote(formatMoney(p.fixedMinor)) : ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-3 text-sm">
|
||||||
|
<p className="mb-2 font-medium capitalize">
|
||||||
|
{t.next(monthFmt.format(new Date(`${data.next.month}-15T12:00:00+03:00`)))}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Stat label={t.nextIncome} value={formatMoney(data.next.expectedIncomeMinor)} />
|
||||||
|
<Stat
|
||||||
|
label={t.nextBudget}
|
||||||
|
value={
|
||||||
|
data.next.budgetMinor === null ? t.nextNoBudget : formatMoney(data.next.budgetMinor)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={t.nextDiff}
|
||||||
|
value={nextDiff === null ? "—" : signed(nextDiff)}
|
||||||
|
tone={nextDiff !== null && nextDiff < 0 ? "text-expense" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="cursor-pointer text-xs text-muted-foreground">{t.table}</summary>
|
||||||
|
<div className="mt-2 max-h-80 overflow-y-auto">
|
||||||
|
<table className="w-full text-right tabular-nums">
|
||||||
|
<thead className="sticky top-0 bg-card text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 text-left font-normal">{t.day}</th>
|
||||||
|
<th className="py-1 font-normal">{t.planned}</th>
|
||||||
|
<th className="py-1 font-normal">{t.actual}</th>
|
||||||
|
<th className="py-1 font-normal">{t.cumPlanned}</th>
|
||||||
|
<th className="py-1 font-normal">{t.cumActual}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{p.days.map((day, i) => (
|
||||||
|
<tr key={day.date} className={cn(i === p.todayIndex && "bg-muted font-medium")}>
|
||||||
|
<td className="py-1 text-left">{d(day.date)}</td>
|
||||||
|
<td className="py-1">{formatMoney(day.plannedMinor)}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{p.todayIndex === -1 || i <= p.todayIndex
|
||||||
|
? formatMoney(day.actualMinor)
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-1">{formatMoney(day.cumPlannedMinor)}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{p.todayIndex === -1 || i <= p.todayIndex
|
||||||
|
? formatMoney(day.cumActualMinor)
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MoneyInput } from "@/components/money-input";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import { saveIncomeItemsAction } from "../actions";
|
||||||
|
import type { IncomePlan } from "../plan";
|
||||||
|
|
||||||
|
const t = tr.budget.income;
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: string;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className={cn("font-semibold tabular-nums", tone)}>{value}</p>
|
||||||
|
{hint ? <p className="text-[11px] text-muted-foreground">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signed = (minor: number) => `${minor < 0 ? "−" : ""}${formatMoney(Math.abs(minor))}`;
|
||||||
|
|
||||||
|
/** /butce: ayın gelir planı ve gelir − gider karşılaştırması. */
|
||||||
|
export function IncomePlanSection({
|
||||||
|
month,
|
||||||
|
monthLabel,
|
||||||
|
income,
|
||||||
|
limitMinor,
|
||||||
|
forecastMinor,
|
||||||
|
hasBudget,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
month: string;
|
||||||
|
monthLabel: string;
|
||||||
|
income: IncomePlan;
|
||||||
|
limitMinor: number;
|
||||||
|
forecastMinor: number;
|
||||||
|
hasBudget: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const projected = income.expectedMinor - forecastMinor;
|
||||||
|
const planned = income.expectedMinor - limitMinor;
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||||
|
<CardDescription>{t.description}</CardDescription>
|
||||||
|
</div>
|
||||||
|
{canWrite ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.edit}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label={t.actual} value={formatMoney(income.actualMinor)} tone="text-income" />
|
||||||
|
<Stat label={t.pending} value={formatMoney(income.pendingMinor)} />
|
||||||
|
<Stat label={t.manual} value={formatMoney(income.manualMinor)} />
|
||||||
|
<Stat label={t.expected} value={formatMoney(income.expectedMinor)} tone="text-income" />
|
||||||
|
{hasBudget ? (
|
||||||
|
<Stat
|
||||||
|
label={t.plannedLeft}
|
||||||
|
value={signed(planned)}
|
||||||
|
tone={planned < 0 ? "text-expense" : undefined}
|
||||||
|
hint={t.plannedLeftHint}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Stat
|
||||||
|
label={t.projectedLeft}
|
||||||
|
value={signed(projected)}
|
||||||
|
tone={projected < 0 ? "text-expense" : undefined}
|
||||||
|
hint={t.projectedLeftHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{income.entries.length || income.manual.length ? (
|
||||||
|
<ul className="divide-y rounded-lg border text-sm">
|
||||||
|
{income.entries.map((e) => (
|
||||||
|
<li key={e.id} className="flex items-center justify-between gap-2 p-2">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{d(e.date)} · {e.label}
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
<Badge variant={e.status === "posted" ? "secondary" : "outline"}>
|
||||||
|
{e.status === "posted" ? t.received : t.planned}
|
||||||
|
</Badge>
|
||||||
|
<span className="tabular-nums">{formatMoney(e.amountMinor)}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{income.manual.map((m) => (
|
||||||
|
<li key={m.id} className="flex items-center justify-between gap-2 p-2">
|
||||||
|
<span className="min-w-0 truncate">{m.name}</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
<Badge variant="outline">{t.manual}</Badge>
|
||||||
|
<span className="tabular-nums">{formatMoney(m.amountMinor)}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.empty}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
{editing ? (
|
||||||
|
<ManualDialog
|
||||||
|
month={month}
|
||||||
|
monthLabel={monthLabel}
|
||||||
|
items={income.manual}
|
||||||
|
onClose={() => setEditing(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManualDialog({
|
||||||
|
month,
|
||||||
|
monthLabel,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
month: string;
|
||||||
|
monthLabel: string;
|
||||||
|
items: IncomePlan["manual"];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState(() =>
|
||||||
|
items.length
|
||||||
|
? items.map((i) => ({ key: i.id, name: i.name, amount: formatMoneyInput(i.amountMinor) }))
|
||||||
|
: [{ key: "new-0", name: "", amount: "" }],
|
||||||
|
);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await saveIncomeItemsAction({
|
||||||
|
month,
|
||||||
|
items: rows
|
||||||
|
.filter((x) => x.name.trim() || x.amount.trim())
|
||||||
|
.map((x) => ({ name: x.name, amount: x.amount })),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.saved);
|
||||||
|
onClose();
|
||||||
|
} else setErrors({ ...(r.fieldErrors ?? {}), root: r.fieldErrors ? "" : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={t.editTitle(monthLabel)}
|
||||||
|
description={t.editHint}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="income-form" disabled={busy}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="income-form" onSubmit={submit} noValidate className="flex flex-col gap-2">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div key={row.key} className="flex items-start gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Input
|
||||||
|
value={row.name}
|
||||||
|
placeholder={t.itemName}
|
||||||
|
aria-label={t.itemName}
|
||||||
|
aria-invalid={Boolean(errors[`items.${i}.name`])}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRows((s) => s.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{errors[`items.${i}.name`] ? (
|
||||||
|
<p className="text-xs text-destructive">{errors[`items.${i}.name`]}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="w-36 shrink-0">
|
||||||
|
<MoneyInput
|
||||||
|
value={row.amount}
|
||||||
|
aria-label={t.itemAmount}
|
||||||
|
onChange={(v) =>
|
||||||
|
setRows((s) => s.map((x, j) => (j === i ? { ...x, amount: v } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{errors[`items.${i}.amount`] ? (
|
||||||
|
<p className="text-xs text-destructive">{errors[`items.${i}.amount`]}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t.removeItem}
|
||||||
|
onClick={() => setRows((s) => s.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
disabled={rows.length >= 30}
|
||||||
|
onClick={() => setRows((s) => [...s, { key: `new-${Date.now()}`, name: "", amount: "" }])}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
{t.addItem}
|
||||||
|
</Button>
|
||||||
|
{errors.root ? <p className="text-sm text-destructive">{errors.root}</p> : null}
|
||||||
|
</form>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { countBudgetWarnings } from "../service";
|
||||||
|
|
||||||
|
/** Dashboard: bu ay %80'i geçilen veya aşılan bütçe kategorileri (uygulama içi uyarı). */
|
||||||
|
export async function BudgetWarningBanner({
|
||||||
|
userId,
|
||||||
|
householdId,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
householdId: string;
|
||||||
|
}) {
|
||||||
|
const n = await countBudgetWarnings(userId, householdId);
|
||||||
|
if (!n) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="flex flex-wrap items-center gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
|
||||||
|
<span className="flex-1">{tr.budget.warnings(n)}</span>
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link href="/butce">{tr.budget.goToBudget}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { HouseholdModel } from "@/models/household";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import { incomeItemsSchema } from "@/shared/schemas/budget";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { getDailyPlan, getIncomePlan, saveIncomeItems } from "./plan";
|
||||||
|
import { getBudget } from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const month = periodOfDate(today);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
emre = ids.user("emre");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
async function income(
|
||||||
|
date: string,
|
||||||
|
amountMinor: number,
|
||||||
|
status: "posted" | "planned",
|
||||||
|
accountId?: Types.ObjectId,
|
||||||
|
) {
|
||||||
|
const actor = new Types.ObjectId(ayse);
|
||||||
|
const acc =
|
||||||
|
accountId ??
|
||||||
|
(await AccountModel.findOne({ householdId: cakir, name: "Ziraat Maaş" }).lean())!._id;
|
||||||
|
await TransactionModel.create({
|
||||||
|
householdId: new Types.ObjectId(cakir),
|
||||||
|
type: "income",
|
||||||
|
status,
|
||||||
|
date: istanbulDayStart(date)!,
|
||||||
|
accountId: acc,
|
||||||
|
amountMinor,
|
||||||
|
description: "Test gelir",
|
||||||
|
recurringRuleId: status === "planned" ? new Types.ObjectId() : null,
|
||||||
|
occurrenceIndex: status === "planned" ? 0 : null,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gelir planı", () => {
|
||||||
|
it("gerçekleşen + bekleyen planlı + elle eklenen = beklenen", async () => {
|
||||||
|
const p0 = (await getIncomePlan(ayse, cakir, month))!;
|
||||||
|
const { to } = budgetPeriod(month);
|
||||||
|
await income(to, 3_000_00, "planned");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await saveIncomeItems(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
incomeItemsSchema.parse({ month, items: [{ name: "Prim", amount: "5.000" }] }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(true);
|
||||||
|
const p = (await getIncomePlan(ayse, cakir, month))!;
|
||||||
|
expect(p.pendingMinor).toBe(p0.pendingMinor + 3_000_00);
|
||||||
|
expect(p.manual).toEqual([expect.objectContaining({ name: "Prim", amountMinor: 5_000_00 })]);
|
||||||
|
expect(p.expectedMinor).toBe(p.actualMinor + p.pendingMinor + 5_000_00);
|
||||||
|
expect(p.entries.some((e) => e.status === "planned" && e.amountMinor === 3_000_00)).toBe(true);
|
||||||
|
// Elle eklenenler bütçe limitlerini bozmaz.
|
||||||
|
expect((await getBudget(ayse, cakir, month))!.editor.limits).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gizli hesaba gelen gelir hane ayarına göre; izleyici yazamaz; başka hane göremez", async () => {
|
||||||
|
const actor = new Types.ObjectId(emre);
|
||||||
|
const secret = await AccountModel.create({
|
||||||
|
householdId: new Types.ObjectId(cakir),
|
||||||
|
name: "Emre Gizli Gelir",
|
||||||
|
type: "bank",
|
||||||
|
openingBalanceMinor: 0,
|
||||||
|
openingDate: istanbulDayStart(shiftMonth(month, -2) + "-01")!,
|
||||||
|
isPrivate: true,
|
||||||
|
ownerUserId: actor,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
const a0 = (await getIncomePlan(ayse, cakir, month))!.actualMinor;
|
||||||
|
await income(today, 1_000_00, "posted", secret._id);
|
||||||
|
expect((await getIncomePlan(ayse, cakir, month))!.actualMinor).toBe(a0 + 1_000_00);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.includePrivateAccountsInTotals": false } },
|
||||||
|
);
|
||||||
|
expect((await getIncomePlan(ayse, cakir, month))!.actualMinor).toBe(a0);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.includePrivateAccountsInTotals": true } },
|
||||||
|
);
|
||||||
|
// Sahibi kendi gizli hesabını her zaman görür.
|
||||||
|
expect((await getIncomePlan(emre, cakir, month))!.actualMinor).toBe(a0 + 1_000_00);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await saveIncomeItems(zeynep, cakir, incomeItemsSchema.parse({ month, items: [] }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(await getIncomePlan(fatma, cakir, month)).toBeNull();
|
||||||
|
expect(incomeItemsSchema.safeParse({ month, items: [{ name: "", amount: "1" }] }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
incomeItemsSchema.safeParse({ month, items: [{ name: "X", amount: "0" }] }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("günlük plan", () => {
|
||||||
|
it("plan toplamı bütçeye eşit; bugüne kadarki gerçekleşen bütçe motoruyla tutarlı; elde kalacak", async () => {
|
||||||
|
const v = (await getDailyPlan(ayse, cakir))!;
|
||||||
|
const b = (await getBudget(ayse, cakir))!;
|
||||||
|
expect(v.month).toBe(month);
|
||||||
|
expect(v.hasBudget).toBe(true);
|
||||||
|
const last = v.plan.days.at(-1)!;
|
||||||
|
expect(last.cumPlannedMinor).toBe(Math.max(b.totals.limitMinor, v.plan.fixedMinor));
|
||||||
|
expect(v.plan.days).toHaveLength(b.totalDays);
|
||||||
|
expect(v.plan.todayIndex).toBe(b.elapsedDays - 1);
|
||||||
|
// Bugünden sonraki gerçekleşmiş işlem yok: bugüne kadar harcanan = ayın gerçekleşeni.
|
||||||
|
expect(v.plan.actualToDateMinor).toBe(b.totals.actualMinor);
|
||||||
|
expect(v.projectedLeftMinor).toBe(v.income.expectedMinor - b.totals.forecastMinor);
|
||||||
|
expect(v.plannedLeftMinor).toBe(v.income.expectedMinor - b.totals.limitMinor);
|
||||||
|
expect(v.next.month).toBe(shiftMonth(month, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("önceki ay örüntüsü modu: dağılım değişir, toplam korunur; bütçesiz hane", async () => {
|
||||||
|
const even = (await getDailyPlan(ayse, cakir))!;
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.dailyPlanMode": "previous_month_pattern" } },
|
||||||
|
);
|
||||||
|
const pattern = (await getDailyPlan(ayse, cakir))!;
|
||||||
|
await HouseholdModel.updateOne({ _id: cakir }, { $set: { "settings.dailyPlanMode": "even" } });
|
||||||
|
expect(pattern.mode).toBe("previous_month_pattern");
|
||||||
|
expect(pattern.plan.days.at(-1)!.cumPlannedMinor).toBe(even.plan.days.at(-1)!.cumPlannedMinor);
|
||||||
|
expect(pattern.plan.days.map((d) => d.plannedMinor)).not.toEqual(
|
||||||
|
even.plan.days.map((d) => d.plannedMinor),
|
||||||
|
);
|
||||||
|
|
||||||
|
const annemler = ids.household("Annemler");
|
||||||
|
const a = (await getDailyPlan(fatma, annemler))!;
|
||||||
|
expect(a.hasBudget).toBe(false);
|
||||||
|
expect(await getDailyPlan(fatma, cakir)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types, type PipelineStage } from "mongoose";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { loadContext, type Ctx } from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { BudgetModel } from "@/models/budget";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { budgetPeriod, daysBetween, periodOfDate, shiftMonth } from "@/shared/budget";
|
||||||
|
import { dailyPlan, patternWeights, type DailyPlan } from "@/shared/daily-plan";
|
||||||
|
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import type { IncomeItemsForm } from "@/shared/schemas/budget";
|
||||||
|
import { getBudget, settingsOf, type Settings } from "./service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gelir planı ve günlük plan (Bölüm 6.9).
|
||||||
|
*
|
||||||
|
* Gelir planı: dönemdeki gerçekleşmiş gelirler + henüz gerçekleşmemiş planlı gelirler (düzenli
|
||||||
|
* işlem tekrarları) + elle eklenen beklenen gelirler. Elle eklenen kalem gelir işlemi olarak
|
||||||
|
* girildiğinde kullanıcı kalemi kaldırır (aksi halde iki kez sayılır).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
function baseMatch(ctx: Ctx, s: Settings, type: "income" | "expense", from: string, to: string) {
|
||||||
|
const m: Record<string, unknown> = {
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
type,
|
||||||
|
deletedAt: null,
|
||||||
|
status: { $in: ["posted", "planned"] },
|
||||||
|
date: {
|
||||||
|
$gte: istanbulDayStart(from)!,
|
||||||
|
$lt: new Date(istanbulDayStart(to)!.getTime() + DAY_MS),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!s.includePrivate && ctx.hiddenAccountIds.length)
|
||||||
|
m.accountId = { $nin: ctx.hiddenAccountIds };
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IncomeEntry = {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
label: string;
|
||||||
|
amountMinor: number;
|
||||||
|
status: "posted" | "planned";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IncomePlan = {
|
||||||
|
/** Gerçekleşen gelir. */
|
||||||
|
actualMinor: number;
|
||||||
|
/** Henüz gerçekleşmemiş planlı gelir. */
|
||||||
|
pendingMinor: number;
|
||||||
|
manualMinor: number;
|
||||||
|
/** Beklenen toplam = gerçekleşen + planlı + elle. */
|
||||||
|
expectedMinor: number;
|
||||||
|
/** Planlı/düzenli gelir kalemleri (gerçekleşmiş düzenli gelirler dahil). */
|
||||||
|
entries: IncomeEntry[];
|
||||||
|
manual: { id: string; name: string; amountMinor: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function incomePlan(ctx: Ctx, s: Settings, month: string): Promise<IncomePlan> {
|
||||||
|
const { from, to } = budgetPeriod(month, s.startDay);
|
||||||
|
const [docs, budget] = await Promise.all([
|
||||||
|
TransactionModel.find(baseMatch(ctx, s, "income", from, to))
|
||||||
|
.select({
|
||||||
|
date: 1,
|
||||||
|
status: 1,
|
||||||
|
amountMinor: 1,
|
||||||
|
description: 1,
|
||||||
|
payeeName: 1,
|
||||||
|
categoryId: 1,
|
||||||
|
recurringRuleId: 1,
|
||||||
|
})
|
||||||
|
.sort({ date: 1 })
|
||||||
|
.lean(),
|
||||||
|
BudgetModel.findOne({ householdId: ctx.m.householdId, month }).lean(),
|
||||||
|
]);
|
||||||
|
let actual = 0;
|
||||||
|
let pending = 0;
|
||||||
|
const entries: IncomeEntry[] = [];
|
||||||
|
for (const d of docs) {
|
||||||
|
if (d.status === "posted") actual += d.amountMinor;
|
||||||
|
else pending += d.amountMinor;
|
||||||
|
if (d.status === "planned" || d.recurringRuleId) {
|
||||||
|
const cat = d.categoryId ? ctx.categoryMap.get(String(d.categoryId)) : undefined;
|
||||||
|
entries.push({
|
||||||
|
id: String(d._id),
|
||||||
|
date: toIstanbulYmd(d.date),
|
||||||
|
label: d.payeeName || d.description || cat?.name || tr.transactions.types.income,
|
||||||
|
amountMinor: d.amountMinor,
|
||||||
|
status: d.status === "posted" ? "posted" : "planned",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const manual = (budget?.incomeItems ?? []).map((i) => ({
|
||||||
|
id: String(i._id),
|
||||||
|
name: i.name,
|
||||||
|
amountMinor: i.amountMinor,
|
||||||
|
}));
|
||||||
|
const manualMinor = manual.reduce((a, i) => a + i.amountMinor, 0);
|
||||||
|
return {
|
||||||
|
actualMinor: actual,
|
||||||
|
pendingMinor: pending,
|
||||||
|
manualMinor,
|
||||||
|
expectedMinor: actual + pending + manualMinor,
|
||||||
|
entries,
|
||||||
|
manual,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIncomePlan(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
month: string,
|
||||||
|
): Promise<IncomePlan | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
return incomePlan(ctx, await settingsOf(ctx.m.householdId), month);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveIncomeItems(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: IncomeItemsForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await BudgetModel.updateOne(
|
||||||
|
{ householdId: ctx.m.householdId, month: input.month },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
incomeItems: input.items.map((i) => ({ name: i.name, amountMinor: i.amount })),
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
$setOnInsert: { createdBy: actor, limits: [] },
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Günlük plan ---
|
||||||
|
|
||||||
|
/** Güne göre gider: sabit (düzenli/taksit; gerçekleşmiş + planlı) ve gerçekleşen (tümü). */
|
||||||
|
export async function expensesByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||||||
|
const pipeline: PipelineStage[] = [
|
||||||
|
{ $match: baseMatch(ctx, s, "expense", from, to) },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: {
|
||||||
|
d: { $dateToString: { date: "$date", format: "%Y-%m-%d", timezone: "Europe/Istanbul" } },
|
||||||
|
s: "$status",
|
||||||
|
f: {
|
||||||
|
$or: [
|
||||||
|
{ $ne: [{ $ifNull: ["$recurringRuleId", null] }, null] },
|
||||||
|
{ $ne: [{ $ifNull: ["$installment.planId", null] }, null] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sum: { $sum: "$amountMinor" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const rows = await TransactionModel.aggregate<{
|
||||||
|
_id: { d: string; s: string; f: boolean };
|
||||||
|
sum: number;
|
||||||
|
}>(pipeline);
|
||||||
|
const fixed = new Map<string, number>();
|
||||||
|
const actual = new Map<string, number>();
|
||||||
|
const variable = new Map<string, number>();
|
||||||
|
const add = (m: Map<string, number>, k: string, v: number) => m.set(k, (m.get(k) ?? 0) + v);
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r._id.f) add(fixed, r._id.d, r.sum);
|
||||||
|
if (r._id.s === "posted") {
|
||||||
|
add(actual, r._id.d, r.sum);
|
||||||
|
if (!r._id.f) add(variable, r._id.d, r.sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { fixed, actual, variable };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Güne göre beklenen gelir (gerçekleşmiş + planlı gelir işlemleri). */
|
||||||
|
export async function incomeByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||||||
|
const rows = await TransactionModel.aggregate<{ _id: string; sum: number }>([
|
||||||
|
{ $match: baseMatch(ctx, s, "income", from, to) },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: { $dateToString: { date: "$date", format: "%Y-%m-%d", timezone: "Europe/Istanbul" } },
|
||||||
|
sum: { $sum: "$amountMinor" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return new Map(rows.map((r) => [r._id, r.sum]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DailyPlanView = {
|
||||||
|
month: string;
|
||||||
|
period: { from: string; to: string };
|
||||||
|
hasBudget: boolean;
|
||||||
|
plan: DailyPlan;
|
||||||
|
mode: "even" | "previous_month_pattern";
|
||||||
|
income: IncomePlan;
|
||||||
|
/** Bütçe ayı sonu tahmini gider (bütçe motorundan). */
|
||||||
|
forecastExpenseMinor: number;
|
||||||
|
/** Bu tempoda ay sonunda elde kalacak: beklenen gelir − tahmini gider. */
|
||||||
|
projectedLeftMinor: number;
|
||||||
|
/** Plana göre elde kalacak: beklenen gelir − bütçe. */
|
||||||
|
plannedLeftMinor: number;
|
||||||
|
next: { month: string; expectedIncomeMinor: number; budgetMinor: number | null };
|
||||||
|
canWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bir bütçe ayının günlük planı: sabit giderler kendi günlerine, değişken bütçe hane ayarına göre
|
||||||
|
* (eşit ya da önceki ayın örüntüsü) dağıtılır.
|
||||||
|
*/
|
||||||
|
export async function monthDailyPlan(
|
||||||
|
ctx: Ctx,
|
||||||
|
s: Settings,
|
||||||
|
month: string,
|
||||||
|
budgetMinor: number,
|
||||||
|
today = todayIstanbul(),
|
||||||
|
): Promise<DailyPlan> {
|
||||||
|
const period = budgetPeriod(month, s.startDay);
|
||||||
|
const byDay = await expensesByDay(ctx, s, period.from, period.to);
|
||||||
|
let weights: number[] | undefined;
|
||||||
|
if (s.dailyMode === "previous_month_pattern") {
|
||||||
|
const prev = budgetPeriod(shiftMonth(month, -1), s.startDay);
|
||||||
|
const prevDays = await expensesByDay(ctx, s, prev.from, prev.to);
|
||||||
|
const len = daysBetween(prev.from, prev.to);
|
||||||
|
weights = patternWeights(
|
||||||
|
Array.from({ length: len }, (_, i) => prevDays.variable.get(addDays(prev.from, i)) ?? 0),
|
||||||
|
daysBetween(period.from, period.to),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return dailyPlan({
|
||||||
|
from: period.from,
|
||||||
|
to: period.to,
|
||||||
|
today,
|
||||||
|
budgetMinor,
|
||||||
|
fixedByDay: byDay.fixed,
|
||||||
|
actualByDay: byDay.actual,
|
||||||
|
weights,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard günlük planı: içinde bulunulan bütçe ayı. */
|
||||||
|
export async function getDailyPlan(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<DailyPlanView | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const s = await settingsOf(ctx.m.householdId);
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const month = periodOfDate(today, s.startDay);
|
||||||
|
const period = budgetPeriod(month, s.startDay);
|
||||||
|
const nextMonth = shiftMonth(month, 1);
|
||||||
|
const mode = s.dailyMode;
|
||||||
|
const [budget, income, nextIncome, nextBudget] = await Promise.all([
|
||||||
|
getBudget(userId, householdId, month),
|
||||||
|
incomePlan(ctx, s, month),
|
||||||
|
incomePlan(ctx, s, nextMonth),
|
||||||
|
getBudget(userId, householdId, nextMonth),
|
||||||
|
]);
|
||||||
|
if (!budget) return null;
|
||||||
|
const plan = await monthDailyPlan(ctx, s, month, budget.totals.limitMinor, today);
|
||||||
|
const forecastExpense = budget.totals.forecastMinor;
|
||||||
|
return {
|
||||||
|
month,
|
||||||
|
period,
|
||||||
|
hasBudget: budget.hasBudget,
|
||||||
|
plan,
|
||||||
|
mode,
|
||||||
|
income,
|
||||||
|
forecastExpenseMinor: forecastExpense,
|
||||||
|
projectedLeftMinor: income.expectedMinor - forecastExpense,
|
||||||
|
plannedLeftMinor: income.expectedMinor - budget.totals.limitMinor,
|
||||||
|
next: {
|
||||||
|
month: nextMonth,
|
||||||
|
expectedIncomeMinor: nextIncome.expectedMinor,
|
||||||
|
budgetMinor: nextBudget?.hasBudget ? nextBudget.totals.limitMinor : null,
|
||||||
|
},
|
||||||
|
canWrite: hasHouseholdRole(ctx.m.role, "member"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { createCategory } from "@/features/categories/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { BudgetModel } from "@/models/budget";
|
||||||
|
import { HouseholdModel } from "@/models/household";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { budgetPeriod, shiftMonth } from "@/shared/budget";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { budgetLimitsSchema } from "@/shared/schemas/budget";
|
||||||
|
import { categoryFormSchema } from "@/shared/schemas/category";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { budgetFromActuals, copyPreviousBudget, getBudget, saveBudget } from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
// Seed verisinin olmadığı geçmiş bir ay: toplamlar yalnızca bu testin işlemlerinden oluşur.
|
||||||
|
const M = shiftMonth(todayIstanbul().slice(0, 7), -24);
|
||||||
|
const P = shiftMonth(M, -1);
|
||||||
|
const cat = { ev: "", kira: "", aidat: "", keyif: "" };
|
||||||
|
type CatKey = keyof typeof cat;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
emre = ids.user("emre");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
const mk = async (key: CatKey, name: string, parent = "") => {
|
||||||
|
const r = await createCategory(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
categoryFormSchema.parse({
|
||||||
|
kind: "expense",
|
||||||
|
name,
|
||||||
|
parentId: parent,
|
||||||
|
icon: "tag",
|
||||||
|
color: "blue",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
cat[key] = r.ok ? r.data.id : "";
|
||||||
|
};
|
||||||
|
await mk("ev", "Test Ev");
|
||||||
|
await mk("kira", "Test Kira", cat.ev);
|
||||||
|
await mk("aidat", "Test Aidat", cat.ev);
|
||||||
|
await mk("keyif", "Test Keyif");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
const acc = (name: string) =>
|
||||||
|
AccountModel.findOne({ householdId: cakir, name })
|
||||||
|
.lean()
|
||||||
|
.then((a) => a!._id);
|
||||||
|
|
||||||
|
async function expense(input: {
|
||||||
|
date: string;
|
||||||
|
amountMinor: number;
|
||||||
|
category?: string;
|
||||||
|
splits?: { category: string; amountMinor: number }[];
|
||||||
|
account?: string;
|
||||||
|
status?: "posted" | "planned";
|
||||||
|
recurring?: boolean;
|
||||||
|
installment?: { planId: Types.ObjectId; index: number; count: number };
|
||||||
|
}) {
|
||||||
|
const actor = new Types.ObjectId(ayse);
|
||||||
|
const splits = (input.splits ?? []).map((s) => ({
|
||||||
|
categoryId: new Types.ObjectId(s.category),
|
||||||
|
amountMinor: s.amountMinor,
|
||||||
|
}));
|
||||||
|
await TransactionModel.create({
|
||||||
|
householdId: new Types.ObjectId(cakir),
|
||||||
|
type: "expense",
|
||||||
|
status: input.status ?? "posted",
|
||||||
|
date: istanbulDayStart(input.date)!,
|
||||||
|
accountId: await acc(input.account ?? "Cüzdan"),
|
||||||
|
amountMinor: input.amountMinor,
|
||||||
|
categoryId: input.category ? new Types.ObjectId(input.category) : null,
|
||||||
|
splits,
|
||||||
|
effectiveCategoryIds: input.category
|
||||||
|
? [new Types.ObjectId(input.category)]
|
||||||
|
: splits.map((s) => s.categoryId),
|
||||||
|
recurringRuleId: input.recurring ? new Types.ObjectId() : null,
|
||||||
|
occurrenceIndex: input.recurring ? 0 : null,
|
||||||
|
installment: input.installment ?? null,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const limits = (month: string, list: Partial<Record<CatKey, string>>) =>
|
||||||
|
budgetLimitsSchema.parse({
|
||||||
|
month,
|
||||||
|
limits: Object.entries(list).map(([k, amount]) => ({ categoryId: cat[k as CatKey], amount })),
|
||||||
|
});
|
||||||
|
const line = async (key: CatKey, month = M, user = ayse) =>
|
||||||
|
(await getBudget(user, cakir, month))!.lines.find((l) => l.categoryId === cat[key]);
|
||||||
|
|
||||||
|
describe("aylık bütçe", () => {
|
||||||
|
const { from, to } = budgetPeriod(M);
|
||||||
|
|
||||||
|
it("limitler; üst limit alt kategorileri kapsar; bölünmüş işlem kalem bazında; planlı ayrı", async () => {
|
||||||
|
await expense({ date: from, amountMinor: 15_000_00, category: cat.kira, recurring: true });
|
||||||
|
await expense({
|
||||||
|
date: addDays(from, 5),
|
||||||
|
amountMinor: 3_000_00,
|
||||||
|
splits: [
|
||||||
|
{ category: cat.aidat, amountMinor: 1_200_00 },
|
||||||
|
{ category: cat.keyif, amountMinor: 1_800_00 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expense({ date: to, amountMinor: 500_00, category: cat.aidat, status: "planned" });
|
||||||
|
expect((await saveBudget(ayse, cakir, limits(M, { ev: "17.000", kira: "15.000" }))).ok).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const b = (await getBudget(ayse, cakir, M))!;
|
||||||
|
expect(b.hasBudget).toBe(true);
|
||||||
|
// Geçmiş ay: tüm günler geçti.
|
||||||
|
expect(b.elapsedDays).toBe(b.totalDays);
|
||||||
|
expect(b.totals.limitMinor).toBe(17_000_00);
|
||||||
|
expect(b.totals.actualMinor).toBe(18_000_00);
|
||||||
|
expect(b.totals.unbudgetedMinor).toBe(1_800_00);
|
||||||
|
expect(await line("ev")).toMatchObject({
|
||||||
|
actualMinor: 16_200_00,
|
||||||
|
plannedMinor: 500_00,
|
||||||
|
forecastMinor: 16_700_00,
|
||||||
|
status: "warning",
|
||||||
|
forecastOver: false,
|
||||||
|
});
|
||||||
|
expect(await line("kira")).toMatchObject({ status: "over", remainingMinor: 0 });
|
||||||
|
expect(await line("keyif")).toMatchObject({ limitMinor: null, actualMinor: 1_800_00 });
|
||||||
|
// Boş limit kaldırılır.
|
||||||
|
await saveBudget(ayse, cakir, limits(M, { ev: "17.000", kira: "" }));
|
||||||
|
expect((await line("kira"))!.limitMinor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gizli hesap harcaması hane ayarına göre", async () => {
|
||||||
|
const actor = new Types.ObjectId(emre);
|
||||||
|
const secret = await AccountModel.create({
|
||||||
|
householdId: new Types.ObjectId(cakir),
|
||||||
|
name: "Emre Gizli Test",
|
||||||
|
type: "cash",
|
||||||
|
openingBalanceMinor: 0,
|
||||||
|
openingDate: istanbulDayStart(addDays(from, -10))!,
|
||||||
|
isPrivate: true,
|
||||||
|
ownerUserId: actor,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
await expense({
|
||||||
|
date: addDays(from, 2),
|
||||||
|
amountMinor: 700_00,
|
||||||
|
category: cat.keyif,
|
||||||
|
account: "Emre Gizli Test",
|
||||||
|
});
|
||||||
|
expect((await line("keyif"))!.actualMinor).toBe(2_500_00);
|
||||||
|
const b = (await getBudget(ayse, cakir, M))!;
|
||||||
|
expect(b.includesHidden).toBe(true);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.includePrivateAccountsInTotals": false } },
|
||||||
|
);
|
||||||
|
expect((await line("keyif"))!.actualMinor).toBe(1_800_00);
|
||||||
|
// Sahibi her zaman görür.
|
||||||
|
expect((await line("keyif", M, emre))!.actualMinor).toBe(2_500_00);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.includePrivateAccountsInTotals": true } },
|
||||||
|
);
|
||||||
|
expect(secret).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("taksit modu: her taksit kendi ayına veya tamamı alış ayına", async () => {
|
||||||
|
const planId = new Types.ObjectId();
|
||||||
|
const next = budgetPeriod(shiftMonth(M, 1));
|
||||||
|
await expense({
|
||||||
|
date: addDays(from, 10),
|
||||||
|
amountMinor: 1_000_00,
|
||||||
|
category: cat.aidat,
|
||||||
|
installment: { planId, index: 1, count: 2 },
|
||||||
|
});
|
||||||
|
await expense({
|
||||||
|
date: addDays(next.from, 10),
|
||||||
|
amountMinor: 1_000_00,
|
||||||
|
category: cat.aidat,
|
||||||
|
installment: { planId, index: 2, count: 2 },
|
||||||
|
status: "planned",
|
||||||
|
});
|
||||||
|
const before = (await line("aidat"))!.actualMinor;
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.installmentBudgetMode": "purchase_month" } },
|
||||||
|
);
|
||||||
|
expect((await line("aidat"))!.actualMinor).toBe(before + 1_000_00);
|
||||||
|
// Tamamı alış ayında sayıldığı için sonraki ayda görünmez.
|
||||||
|
expect((await line("aidat", shiftMonth(M, 1)))?.actualMinor ?? 0).toBe(0);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.installmentBudgetMode": "per_installment" } },
|
||||||
|
);
|
||||||
|
// Her taksit kendi ayında (geçmiş ay olduğu için vadesi gelen taksit gerçekleşmiştir).
|
||||||
|
expect((await line("aidat", shiftMonth(M, 1)))!.actualMinor).toBe(1_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tahmin: değişken gider tempoyla, sabit gider olduğu gibi (gelecek ayda değişken sayılmaz)", async () => {
|
||||||
|
const F = shiftMonth(todayIstanbul().slice(0, 7), 24);
|
||||||
|
const f = budgetPeriod(F);
|
||||||
|
await expense({ date: f.from, amountMinor: 900_00, category: cat.keyif });
|
||||||
|
await expense({ date: f.from, amountMinor: 4_000_00, category: cat.kira, recurring: true });
|
||||||
|
const keyif = (await line("keyif", F))!;
|
||||||
|
expect(keyif.actualMinor).toBe(900_00);
|
||||||
|
// Henüz başlamamış dönem, geçmiş yok: gerçekleşen değişken olduğu gibi, tempo eklenmez.
|
||||||
|
expect(keyif.forecastMinor).toBe(900_00);
|
||||||
|
expect((await line("kira", F))!.forecastMinor).toBe(4_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("içinde bulunulan ayda değişken gider tempoyla büyür, sabit gider büyümez", async () => {
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const cur = today.slice(0, 7);
|
||||||
|
if (budgetPeriod(cur).to === today) return; // ayın son günü: kalan gün yok
|
||||||
|
await expense({ date: today, amountMinor: 1_000_00, category: cat.keyif });
|
||||||
|
await expense({ date: today, amountMinor: 2_000_00, category: cat.kira, recurring: true });
|
||||||
|
const keyif = (await line("keyif", cur))!;
|
||||||
|
expect(keyif.actualMinor).toBe(1_000_00);
|
||||||
|
expect(keyif.forecastMinor).toBeGreaterThan(1_000_00);
|
||||||
|
expect((await line("kira", cur))!.forecastMinor).toBe(2_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bütçe ayı başlangıç günü dönemi kaydırır", async () => {
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.budgetMonthStartDay": 15 } },
|
||||||
|
);
|
||||||
|
const b = (await getBudget(ayse, cakir, M))!;
|
||||||
|
expect(b.period).toEqual(budgetPeriod(M, 15));
|
||||||
|
// Ayın 1'indeki kira artık önceki bütçe ayında.
|
||||||
|
expect((await line("kira"))?.actualMinor ?? 0).toBe(0);
|
||||||
|
await HouseholdModel.updateOne({ _id: cakir }, { $set: { "settings.budgetMonthStartDay": 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("önceki aydan kopyala ve önceki ayın harcamalarından oluştur", async () => {
|
||||||
|
const N = shiftMonth(M, 1);
|
||||||
|
expect((await copyPreviousBudget(ayse, cakir, P)).ok).toBe(false);
|
||||||
|
expect((await copyPreviousBudget(ayse, cakir, N)).ok).toBe(true);
|
||||||
|
expect((await getBudget(ayse, cakir, N))!.editor.limits[cat.ev]).toBe(17_000_00);
|
||||||
|
// M'nin harcamaları: Ev kökü 16.200 + taksit 1.000 → 17.200; Keyif 2.500 (gizli dahil).
|
||||||
|
expect((await budgetFromActuals(ayse, cakir, N)).ok).toBe(true);
|
||||||
|
const l = (await BudgetModel.findOne({ householdId: cakir, month: N }).lean())!.limits;
|
||||||
|
const get = (k: CatKey) => l.find((x) => String(x.categoryId) === cat[k])?.amountMinor;
|
||||||
|
expect(get("ev")).toBe(17_200_00);
|
||||||
|
expect(get("keyif")).toBe(2_500_00);
|
||||||
|
expect(get("kira")).toBeUndefined();
|
||||||
|
expect((await budgetFromActuals(ayse, cakir, shiftMonth(M, -5))).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yetki: izleyici yazamaz; başka hane göremez; geçersiz kategori reddedilir", async () => {
|
||||||
|
expect((await getBudget(zeynep, cakir, M))!.canWrite).toBe(false);
|
||||||
|
expect((await saveBudget(zeynep, cakir, limits(M, { ev: "1" }))).ok).toBe(false);
|
||||||
|
expect(await getBudget(fatma, cakir, M)).toBeNull();
|
||||||
|
const annemler = ids.household("Annemler");
|
||||||
|
expect((await saveBudget(fatma, annemler, limits(M, { ev: "1" }))).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types, type PipelineStage } from "mongoose";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { loadContext, type Ctx } from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { BudgetModel } from "@/models/budget";
|
||||||
|
import { HouseholdModel } from "@/models/household";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
|
import {
|
||||||
|
budgetPeriod,
|
||||||
|
computeBudget,
|
||||||
|
daysBetween,
|
||||||
|
periodOfDate,
|
||||||
|
roundUpLimit,
|
||||||
|
shiftMonth,
|
||||||
|
type BudgetLine,
|
||||||
|
type BudgetTotals,
|
||||||
|
type CategoryAmounts,
|
||||||
|
} from "@/shared/budget";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import type { BudgetLimitsForm } from "@/shared/schemas/budget";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aylık bütçe (Bölüm 6.9). Okuma: viewer; yazma: member.
|
||||||
|
*
|
||||||
|
* Gerçekleşen: dönemdeki gerçekleşmiş (posted) giderler; bölünmüş işlemler kalem bazında.
|
||||||
|
* Planlı: dönemdeki planlı giderler (düzenli işlem tekrarları, gelecek taksitler).
|
||||||
|
* Taksit modu (hane ayarı): her taksit 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) sayılır.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.budget;
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
export type Settings = {
|
||||||
|
startDay: number;
|
||||||
|
installmentMode: "per_installment" | "purchase_month";
|
||||||
|
includePrivate: boolean;
|
||||||
|
dailyMode: "even" | "previous_month_pattern";
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function settingsOf(householdId: Types.ObjectId): Promise<Settings> {
|
||||||
|
const h = await HouseholdModel.findById(householdId).select({ settings: 1 }).lean();
|
||||||
|
return {
|
||||||
|
startDay: h?.settings?.budgetMonthStartDay ?? 1,
|
||||||
|
installmentMode: h?.settings?.installmentBudgetMode ?? "per_installment",
|
||||||
|
includePrivate: h?.settings?.includePrivateAccountsInTotals ?? true,
|
||||||
|
dailyMode: h?.settings?.dailyPlanMode ?? "even",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kategori bazında (kendi) sabit/değişken gerçekleşen ve planlı gider. */
|
||||||
|
export async function expenseAmounts(
|
||||||
|
ctx: Ctx,
|
||||||
|
s: Settings,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): Promise<Map<string, CategoryAmounts>> {
|
||||||
|
const start = istanbulDayStart(from)!;
|
||||||
|
const end = new Date(istanbulDayStart(to)!.getTime() + DAY_MS);
|
||||||
|
const base: Record<string, unknown> = {
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
type: "expense",
|
||||||
|
deletedAt: null,
|
||||||
|
};
|
||||||
|
if (!s.includePrivate && ctx.hiddenAccountIds.length)
|
||||||
|
base.accountId = { $nin: ctx.hiddenAccountIds };
|
||||||
|
|
||||||
|
const itemsStage: PipelineStage = {
|
||||||
|
$project: {
|
||||||
|
status: 1,
|
||||||
|
fixed: {
|
||||||
|
$or: [
|
||||||
|
{ $ne: [{ $ifNull: ["$recurringRuleId", null] }, null] },
|
||||||
|
{ $ne: [{ $ifNull: ["$installment.planId", null] }, null] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
$cond: [
|
||||||
|
{ $gt: [{ $size: { $ifNull: ["$splits", []] } }, 0] },
|
||||||
|
"$splits",
|
||||||
|
[{ categoryId: "$categoryId", amountMinor: "$amountMinor" }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const group: PipelineStage[] = [
|
||||||
|
itemsStage,
|
||||||
|
{ $unwind: "$items" },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: { c: "$items.categoryId", s: "$status", f: "$fixed" },
|
||||||
|
sum: { $sum: "$items.amountMinor" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const match: Record<string, unknown> = {
|
||||||
|
...base,
|
||||||
|
status: { $in: ["posted", "planned"] },
|
||||||
|
date: { $gte: start, $lt: end },
|
||||||
|
};
|
||||||
|
if (s.installmentMode === "purchase_month") match["installment.planId"] = { $in: [null] };
|
||||||
|
const rows = await TransactionModel.aggregate<{
|
||||||
|
_id: { c: Types.ObjectId | null; s: string; f: boolean };
|
||||||
|
sum: number;
|
||||||
|
}>([{ $match: match }, ...group]);
|
||||||
|
|
||||||
|
const out = new Map<string, CategoryAmounts>();
|
||||||
|
const add = (cat: string, k: keyof CategoryAmounts, v: number) => {
|
||||||
|
const cur = out.get(cat) ?? { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 };
|
||||||
|
cur[k] += v;
|
||||||
|
out.set(cat, cur);
|
||||||
|
};
|
||||||
|
for (const r of rows) {
|
||||||
|
const cat = r._id.c ? String(r._id.c) : "";
|
||||||
|
if (r._id.s === "planned") add(cat, "plannedMinor", r.sum);
|
||||||
|
else add(cat, r._id.f ? "fixedMinor" : "variableMinor", r.sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tamamı alış ayına: ilk taksiti bu dönemde olan planların tüm taksitleri gerçekleşmiş sayılır.
|
||||||
|
if (s.installmentMode === "purchase_month") {
|
||||||
|
const plans = await TransactionModel.aggregate<{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
first: Date;
|
||||||
|
items: { categoryId: Types.ObjectId | null; amountMinor: number }[][];
|
||||||
|
}>([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
...base,
|
||||||
|
status: { $in: ["posted", "planned"] },
|
||||||
|
"installment.planId": { $ne: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: "$installment.planId",
|
||||||
|
first: { $min: "$date" },
|
||||||
|
items: {
|
||||||
|
$push: {
|
||||||
|
$cond: [
|
||||||
|
{ $gt: [{ $size: { $ifNull: ["$splits", []] } }, 0] },
|
||||||
|
"$splits",
|
||||||
|
[{ categoryId: "$categoryId", amountMinor: "$amountMinor" }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $match: { first: { $gte: start, $lt: end } } },
|
||||||
|
]);
|
||||||
|
for (const p of plans)
|
||||||
|
for (const list of p.items)
|
||||||
|
for (const it of list)
|
||||||
|
add(it.categoryId ? String(it.categoryId) : "", "fixedMinor", it.amountMinor);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BudgetLineView = BudgetLine & { icon: IconKey; color: Swatch; path: string };
|
||||||
|
|
||||||
|
export type BudgetView = {
|
||||||
|
month: string;
|
||||||
|
currentMonth: string;
|
||||||
|
period: { from: string; to: string };
|
||||||
|
elapsedDays: number;
|
||||||
|
totalDays: number;
|
||||||
|
lines: BudgetLineView[];
|
||||||
|
totals: BudgetTotals;
|
||||||
|
hasBudget: boolean;
|
||||||
|
/** Limit düzenleyici: etkin gider kategorileri (ağaç sırası) ve mevcut limitler. */
|
||||||
|
editor: {
|
||||||
|
categories: { id: string; name: string; depth: number }[];
|
||||||
|
limits: Record<string, number>;
|
||||||
|
};
|
||||||
|
hasPrevious: boolean;
|
||||||
|
includesHidden: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function expenseCategories(ctx: Ctx) {
|
||||||
|
return ctx.flatCategories.filter((c) => c.kind === "expense");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBudget(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
month?: string,
|
||||||
|
): Promise<BudgetView | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
return budgetFor(ctx, await settingsOf(ctx.m.householdId), month);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bağlam ve ayarlar hazırken bütçe (raporlarda birden çok ay için yeniden yüklemeden). */
|
||||||
|
export async function budgetFor(ctx: Ctx, s: Settings, month?: string): Promise<BudgetView> {
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const currentMonth = periodOfDate(today, s.startDay);
|
||||||
|
const m = month ?? currentMonth;
|
||||||
|
const period = budgetPeriod(m, s.startDay);
|
||||||
|
const totalDays = daysBetween(period.from, period.to);
|
||||||
|
const elapsedDays =
|
||||||
|
today < period.from ? 0 : today > period.to ? totalDays : daysBetween(period.from, today);
|
||||||
|
|
||||||
|
const [doc, prev, amounts] = await Promise.all([
|
||||||
|
BudgetModel.findOne({ householdId: ctx.m.householdId, month: m }).lean(),
|
||||||
|
BudgetModel.exists({
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
month: shiftMonth(m, -1),
|
||||||
|
"limits.0": { $exists: true },
|
||||||
|
}),
|
||||||
|
expenseAmounts(ctx, s, period.from, period.to),
|
||||||
|
]);
|
||||||
|
const limits = new Map((doc?.limits ?? []).map((l) => [String(l.categoryId), l.amountMinor]));
|
||||||
|
// Taban hız (bitmemiş aylar için): önceki dönemin değişken gideri, kategori bazında.
|
||||||
|
let baselineDays: number | undefined;
|
||||||
|
if (elapsedDays < totalDays) {
|
||||||
|
const prev = budgetPeriod(shiftMonth(m, -1), s.startDay);
|
||||||
|
const prevAmounts = await expenseAmounts(ctx, s, prev.from, prev.to);
|
||||||
|
let any = 0;
|
||||||
|
for (const [id, a] of prevAmounts) {
|
||||||
|
if (a.variableMinor <= 0) continue;
|
||||||
|
any += a.variableMinor;
|
||||||
|
const cur = amounts.get(id) ?? { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 };
|
||||||
|
amounts.set(id, { ...cur, baselineMinor: a.variableMinor });
|
||||||
|
}
|
||||||
|
if (any > 0) baselineDays = daysBetween(prev.from, prev.to);
|
||||||
|
}
|
||||||
|
const cats = expenseCategories(ctx);
|
||||||
|
// Arşivli kategori yalnızca limiti veya harcaması varsa görünür.
|
||||||
|
const visibleCats = cats.filter((c) => !c.archived || limits.has(c.id) || amounts.has(c.id));
|
||||||
|
const known = new Set(visibleCats.map((c) => c.id));
|
||||||
|
// Kategorisiz / bilinmeyen kategorideki gider "Kategorisiz" satırında toplanır.
|
||||||
|
const orphan = { fixedMinor: 0, variableMinor: 0, plannedMinor: 0, baselineMinor: 0 };
|
||||||
|
for (const [id, a] of amounts)
|
||||||
|
if (!known.has(id)) {
|
||||||
|
orphan.fixedMinor += a.fixedMinor;
|
||||||
|
orphan.variableMinor += a.variableMinor;
|
||||||
|
orphan.plannedMinor += a.plannedMinor;
|
||||||
|
orphan.baselineMinor += a.baselineMinor ?? 0;
|
||||||
|
}
|
||||||
|
const ORPHAN = "__none__";
|
||||||
|
const inputCats = visibleCats.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
parentId: c.parentId,
|
||||||
|
name: c.name,
|
||||||
|
order: c.order,
|
||||||
|
}));
|
||||||
|
if (orphan.fixedMinor + orphan.variableMinor + orphan.plannedMinor > 0) {
|
||||||
|
inputCats.push({ id: ORPHAN, parentId: null, name: t.uncategorized, order: 1e9 });
|
||||||
|
amounts.set(ORPHAN, orphan);
|
||||||
|
}
|
||||||
|
const { lines, totals } = computeBudget({
|
||||||
|
categories: inputCats,
|
||||||
|
limits,
|
||||||
|
amounts,
|
||||||
|
elapsedDays,
|
||||||
|
totalDays,
|
||||||
|
baselineDays,
|
||||||
|
});
|
||||||
|
const editorCats = cats.filter((c) => !c.archived);
|
||||||
|
// Düzenleyici için ağaç sırası ve derinlik (tüm etkin kategoriler, limit 0 ile).
|
||||||
|
const ordered = computeBudget({
|
||||||
|
categories: editorCats.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
parentId: c.parentId,
|
||||||
|
name: c.name,
|
||||||
|
order: c.order,
|
||||||
|
})),
|
||||||
|
limits: new Map(editorCats.map((c) => [c.id, 0])),
|
||||||
|
amounts: new Map(),
|
||||||
|
elapsedDays: 0,
|
||||||
|
totalDays: 1,
|
||||||
|
}).lines;
|
||||||
|
|
||||||
|
return {
|
||||||
|
month: m,
|
||||||
|
currentMonth,
|
||||||
|
period,
|
||||||
|
elapsedDays,
|
||||||
|
totalDays,
|
||||||
|
lines: lines.map((l) => {
|
||||||
|
const c = ctx.categoryMap.get(l.categoryId);
|
||||||
|
return {
|
||||||
|
...l,
|
||||||
|
icon: (c?.icon ?? "tag") as IconKey,
|
||||||
|
color: (c?.color ?? "slate") as Swatch,
|
||||||
|
path: c?.path ?? l.name,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
totals,
|
||||||
|
hasBudget: limits.size > 0,
|
||||||
|
editor: {
|
||||||
|
categories: ordered.map((l) => ({ id: l.categoryId, name: l.name, depth: l.depth })),
|
||||||
|
limits: Object.fromEntries(limits),
|
||||||
|
},
|
||||||
|
hasPrevious: Boolean(prev),
|
||||||
|
includesHidden: s.includePrivate && ctx.hiddenAccountIds.length > 0,
|
||||||
|
canWrite: hasHouseholdRole(ctx.m.role, "member"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeLimits(
|
||||||
|
ctx: Ctx,
|
||||||
|
actorId: string,
|
||||||
|
month: string,
|
||||||
|
limits: { categoryId: string; amountMinor: number }[],
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const valid = new Set(
|
||||||
|
expenseCategories(ctx)
|
||||||
|
.filter((c) => !c.archived)
|
||||||
|
.map((c) => c.id),
|
||||||
|
);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const l of limits) {
|
||||||
|
if (!valid.has(l.categoryId) || seen.has(l.categoryId)) return fail(t.errors.category);
|
||||||
|
seen.add(l.categoryId);
|
||||||
|
}
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await BudgetModel.updateOne(
|
||||||
|
{ householdId: ctx.m.householdId, month },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
limits: limits.map((l) => ({
|
||||||
|
categoryId: new Types.ObjectId(l.categoryId),
|
||||||
|
amountMinor: l.amountMinor,
|
||||||
|
})),
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
$setOnInsert: { createdBy: actor },
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBudget(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: BudgetLimitsForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
return writeLimits(ctx, actorId, input.month, input.limits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Önceki ayın limitlerini kopyalar (arşivlenmiş kategoriler atlanır). */
|
||||||
|
export async function copyPreviousBudget(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
month: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const prev = await BudgetModel.findOne({
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
month: shiftMonth(month, -1),
|
||||||
|
}).lean();
|
||||||
|
if (!prev?.limits.length) return fail(t.errors.noPrevious);
|
||||||
|
const active = new Set(
|
||||||
|
expenseCategories(ctx)
|
||||||
|
.filter((c) => !c.archived)
|
||||||
|
.map((c) => c.id),
|
||||||
|
);
|
||||||
|
return writeLimits(
|
||||||
|
ctx,
|
||||||
|
actorId,
|
||||||
|
month,
|
||||||
|
prev.limits
|
||||||
|
.map((l) => ({ categoryId: String(l.categoryId), amountMinor: l.amountMinor }))
|
||||||
|
.filter((l) => active.has(l.categoryId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Önceki ayın gerçekleşen giderlerinden limit: ana kategori (kök) bazında toplam, 100 TL'ye
|
||||||
|
* yukarı yuvarlanır. Kategorisiz gider atlanır.
|
||||||
|
*/
|
||||||
|
export async function budgetFromActuals(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
month: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const s = await settingsOf(ctx.m.householdId);
|
||||||
|
const period = budgetPeriod(shiftMonth(month, -1), s.startDay);
|
||||||
|
const amounts = await expenseAmounts(ctx, s, period.from, period.to);
|
||||||
|
const cats = expenseCategories(ctx);
|
||||||
|
const byId = new Map(cats.map((c) => [c.id, c]));
|
||||||
|
const rootOf = (id: string) => {
|
||||||
|
let cur = byId.get(id);
|
||||||
|
let guard = 0;
|
||||||
|
while (cur?.parentId && byId.has(cur.parentId) && guard++ < 6) cur = byId.get(cur.parentId);
|
||||||
|
return cur;
|
||||||
|
};
|
||||||
|
const totals = new Map<string, number>();
|
||||||
|
for (const [id, a] of amounts) {
|
||||||
|
const root = rootOf(id);
|
||||||
|
if (!root || root.archived) continue;
|
||||||
|
totals.set(root.id, (totals.get(root.id) ?? 0) + a.fixedMinor + a.variableMinor);
|
||||||
|
}
|
||||||
|
const limits = [...totals]
|
||||||
|
.filter(([, v]) => v > 0)
|
||||||
|
.map(([categoryId, v]) => ({ categoryId, amountMinor: roundUpLimit(v) }));
|
||||||
|
if (!limits.length) return fail(t.errors.noPreviousSpending);
|
||||||
|
return writeLimits(ctx, actorId, month, limits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard uyarısı: bu ayın %80'i geçilmiş veya aşılmış limitli kategori sayısı. */
|
||||||
|
export async function countBudgetWarnings(userId: string, householdId: string): Promise<number> {
|
||||||
|
const b = await getBudget(userId, householdId);
|
||||||
|
if (!b?.hasBudget) return 0;
|
||||||
|
return b.lines.filter((l) => l.status === "warning" || l.status === "over").length;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { IconBadge } from "@/components/appearance/swatch";
|
import { IconBadge } from "@/components/appearance/swatch";
|
||||||
|
import { KakeiboBadge } from "./kakeibo-badge";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
@@ -61,6 +62,9 @@ export function CategoriesManager({
|
|||||||
[categories, kind, showArchived],
|
[categories, kind, showArchived],
|
||||||
);
|
);
|
||||||
const archivedCount = categories.filter((c) => c.kind === kind && c.archived).length;
|
const archivedCount = categories.filter((c) => c.kind === kind && c.archived).length;
|
||||||
|
const ungrouped = categories.filter(
|
||||||
|
(c) => c.kind === "expense" && !c.archived && !c.kakeibo.group && !c.kakeibo.bankCost,
|
||||||
|
).length;
|
||||||
|
|
||||||
function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) {
|
function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -90,6 +94,9 @@ export function CategoriesManager({
|
|||||||
<span className={cn("min-w-0 flex-1 truncate", node.depth === 0 && "font-medium")}>
|
<span className={cn("min-w-0 flex-1 truncate", node.depth === 0 && "font-medium")}>
|
||||||
{node.name}
|
{node.name}
|
||||||
</span>
|
</span>
|
||||||
|
{node.kind === "expense" && !node.archived ? (
|
||||||
|
<KakeiboBadge kakeibo={node.kakeibo} />
|
||||||
|
) : null}
|
||||||
{node.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
{node.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
@@ -191,6 +198,11 @@ export function CategoriesManager({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!canEdit ? <p className="text-sm text-muted-foreground">{t.readOnly}</p> : null}
|
{!canEdit ? <p className="text-sm text-muted-foreground">{t.readOnly}</p> : null}
|
||||||
|
{kind === "expense" && ungrouped ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||||
|
{tr.kakeibo.ungroupedCount(ungrouped)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{tree.length ? (
|
{tree.length ? (
|
||||||
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
|
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
|||||||
import { IconPicker, SwatchPicker } from "@/components/appearance/pickers";
|
import { IconPicker, SwatchPicker } from "@/components/appearance/pickers";
|
||||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||||
import {
|
import {
|
||||||
categoryFormSchema,
|
categoryFormSchema,
|
||||||
type CategoryForm,
|
type CategoryForm,
|
||||||
@@ -60,6 +62,8 @@ export function CategoryFormDialog({
|
|||||||
parentId: c.parentId ?? "",
|
parentId: c.parentId ?? "",
|
||||||
icon: c.icon,
|
icon: c.icon,
|
||||||
color: c.color,
|
color: c.color,
|
||||||
|
kakeiboGroup: c.kakeiboGroup ?? "",
|
||||||
|
bankCost: c.bankCost,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const parent = categories.find((c) => c.id === state.parentId);
|
const parent = categories.find((c) => c.id === state.parentId);
|
||||||
@@ -69,6 +73,9 @@ export function CategoryFormDialog({
|
|||||||
parentId: state.parentId ?? "",
|
parentId: state.parentId ?? "",
|
||||||
icon: parent?.icon ?? "tag",
|
icon: parent?.icon ?? "tag",
|
||||||
color: parent?.color ?? "blue",
|
color: parent?.color ?? "blue",
|
||||||
|
// Yeni alt kategori grubu üstten devralır.
|
||||||
|
kakeiboGroup: "",
|
||||||
|
bankCost: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [state, categories, form]);
|
}, [state, categories, form]);
|
||||||
@@ -105,6 +112,10 @@ export function CategoryFormDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const color = useWatch({ control: form.control, name: "color" }) ?? "blue";
|
const color = useWatch({ control: form.control, name: "color" }) ?? "blue";
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
const parentId = useWatch({ control: form.control, name: "parentId" });
|
||||||
|
const parentGroup = categories.find((c) => c.id === parentId)?.kakeibo.group ?? null;
|
||||||
|
const parentBank = categories.find((c) => c.id === parentId)?.kakeibo.bankCost ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveDialog
|
<ResponsiveDialog
|
||||||
@@ -177,6 +188,60 @@ export function CategoryFormDialog({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{kind === "expense" ? (
|
||||||
|
<>
|
||||||
|
<Controller
|
||||||
|
name="kakeiboGroup"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="cat-kakeibo">{k.field}</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value || ROOT}
|
||||||
|
onValueChange={(v) => field.onChange(v === ROOT ? "" : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="cat-kakeibo" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ROOT}>
|
||||||
|
{parentGroup ? k.inheritWith(k.groups[parentGroup]) : k.inherit}
|
||||||
|
</SelectItem>
|
||||||
|
{KAKEIBO_GROUPS.map((g) => (
|
||||||
|
<SelectItem key={g} value={g}>
|
||||||
|
{k.groups[g]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldDescription>
|
||||||
|
{field.value
|
||||||
|
? k.groupHints[field.value as keyof typeof k.groupHints]
|
||||||
|
: k.ungroupedHint}
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="bankCost"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
id="cat-bank"
|
||||||
|
checked={Boolean(field.value) || parentBank}
|
||||||
|
disabled={parentBank}
|
||||||
|
onCheckedChange={(x) => field.onChange(x === true)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<FieldLabel htmlFor="cat-bank">{k.bankCost}</FieldLabel>
|
||||||
|
<FieldDescription>{k.bankCostHint}</FieldDescription>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<Controller
|
<Controller
|
||||||
name="color"
|
name="color"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { KakeiboGroup, ResolvedKakeibo } from "@/shared/kakeibo";
|
||||||
|
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
|
||||||
|
/** Kakeibo grup renkleri: doğrulanmış kategorik palet (series-1…4); etiket her zaman yanında. */
|
||||||
|
export const KAKEIBO_COLOR: Record<KakeiboGroup, string> = {
|
||||||
|
ZORUNLU: "var(--series-1)",
|
||||||
|
ISTEK: "var(--series-2)",
|
||||||
|
KULTUR: "var(--series-3)",
|
||||||
|
BEKLENMEYEN: "var(--series-4)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KakeiboDot({
|
||||||
|
group,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
group: KakeiboGroup | null;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"inline-block size-2.5 shrink-0 rounded-full",
|
||||||
|
!group && "border border-dashed border-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={group ? { backgroundColor: KAKEIBO_COLOR[group] } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kategorinin etkin Kakeibo grubu; devralınan grup soluk, banka maliyeti ve grupsuz ayrı. */
|
||||||
|
export function KakeiboBadge({ kakeibo }: { kakeibo: ResolvedKakeibo }) {
|
||||||
|
if (kakeibo.bankCost)
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" title={k.bankCostHint}>
|
||||||
|
{k.bankCost}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
if (!kakeibo.group)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5 border-dashed text-muted-foreground"
|
||||||
|
title={k.ungroupedHint}
|
||||||
|
>
|
||||||
|
<KakeiboDot group={null} />
|
||||||
|
{k.ungrouped}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn("gap-1.5", kakeibo.inherited && "text-muted-foreground")}
|
||||||
|
title={
|
||||||
|
kakeibo.inherited
|
||||||
|
? `${k.groups[kakeibo.group]} (${k.inherited})`
|
||||||
|
: k.groupHints[kakeibo.group]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<KakeiboDot group={kakeibo.group} />
|
||||||
|
{k.groups[kakeibo.group]}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ const form = (over: Partial<Parameters<typeof createCategory>[2]> = {}) => ({
|
|||||||
parentId: null,
|
parentId: null,
|
||||||
icon: "tag" as const,
|
icon: "tag" as const,
|
||||||
color: "blue" as const,
|
color: "blue" as const,
|
||||||
|
kakeiboGroup: null,
|
||||||
|
bankCost: false,
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user