Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+156
-1
@@ -6,6 +6,158 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
|||||||
|
|
||||||
## [Yayınlanmamış]
|
## [Yayınlanmamış]
|
||||||
|
|
||||||
|
## [0.14.0] - 2026-11-07
|
||||||
|
|
||||||
|
Faz 14: dashboard ana grafiği ve raporlar (14a ve 14b alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 14b: cari, birikim, alışveriş, birim fiyat ve fire raporları
|
||||||
|
|
||||||
|
- Cari ve aynî borçlar (`/raporlar/cari`): bakiyesi olan cariler (bana borçlu / ben borçluyum, vade durumu), açık aynî borç ve alacaklar kalan miktar ve güncel TL değeriyle; toplamlar.
|
||||||
|
- Birikim kâr/zarar (`/raporlar/birikim`): varlık başına miktar, maliyet, güncel değer, gerçekleşmemiş ve gerçekleşen K/Z; son 3/6/12 ay veya tümü için dönem özeti (başlangıç değeri, yatırılan, satıştan alınan, bitiş değeri, dönem kârı).
|
||||||
|
- Alışveriş (`/raporlar/alisveris`): tamamlanan listelerde planlanan / gerçekleşen / fark, eşleşme sayıları ve farkın nedenleri; mağaza bazlı harcama (alıcısı mağaza adıyla başlayan giderler, şubeler birlikte, "Alışverişi tamamla"dan gelen kısım ayrıca).
|
||||||
|
- Birim fiyat geçmişi (`/raporlar/birim-fiyat`): ürün tipi seçimi, son 12 bütçe ayında aylık en düşük ve ortalama birim fiyat (grafik ve tablo), mağazalarda dönemin en düşük birim fiyatı.
|
||||||
|
- Fire / zarar (`/raporlar/fire`): ürün tipi başına fire, alım, fire/alım oranı, nedenlere göre dağılım ve tahmini maliyet (fire × son bilinen birim fiyat).
|
||||||
|
- Hepsinde CSV dışa aktarma; rapor ana sayfasında yeni rapor kartları.
|
||||||
|
- Testler: mağaza adı eşleşmesi ve aylık birim fiyat özeti (birim); cari/aynî, birikim, alışveriş (tamamlanan liste dahil), birim fiyat ve fire raporları (entegrasyon).
|
||||||
|
|
||||||
|
### Eklendi — Faz 14a: dashboard ana grafiği ve finans raporları
|
||||||
|
|
||||||
|
- Dashboard ana çizgi grafiği: Planlanan gelir (yeşil), Planlanan gider / bütçe (mavi, kesikli), Gerçekleşen gider (kırmızı). Periyotlar: Haftalık (bu hafta, günlük), Aylık (bütçe ayı, günlük), Altı aylık ve Yıllık (bütçe ayları). Kümülatif / dönemsel görünüm; seçim URL'de (`?periyot=…&gorunum=…`). Bütçesi olmayan dönemde bütçe çizgisi boş, gelecek günlerde gerçekleşen çizilmez; grafiğin altında tablo görünümü.
|
||||||
|
- Raporlar (`/raporlar`): bu bütçe ayının özeti (gelir, gider, net, tasarruf oranı, en çok harcanan kategoriler) ve rapor sayfaları; her raporda ay seçici ve CSV dışa aktarma (Türkçe Excel uyumlu).
|
||||||
|
- Kategori dağılımı: halka grafik ve tablo; üst kategori toplamları ve alt kategori kırılımı, toplam gidere oran (%14,3 gibi), önceki aya göre tutar ve yüzde değişim. Dilim renkleri kategorinin kendi rengi; 7'den fazla kategori "Diğer"de toplanır.
|
||||||
|
- Aylık trend: son 12 bütçe ayı gelir ve gider sütunları, ayrı grafikte tasarruf oranı (devam eden ay grafikte gösterilmez), net ve 12 ay toplamı.
|
||||||
|
- Kişi ve hesap bazlı: gideri kaydeden üyeye göre harcama ve pay; hesap başına gelir, gider, gelen/giden transfer ve net değişim. Başka üyelerin "yalnızca ben" hesapları hesap tablosunda hiç görünmez.
|
||||||
|
- Bütçe ve gerçekleşen: kategori bazında limit, gerçekleşen, kalan ve kullanım; son 6 ayın limit/gerçekleşen karşılaştırması.
|
||||||
|
- Kurallar bütçeyle aynı: bütçe ayı, gerçekleşmiş işlemler, bölünmüş işlemler kalem bazında, taksit modu ve gizli hesap giderleri hane ayarına göre.
|
||||||
|
- Testler: periyot aralıkları ve kümülatif dönüşüm, kategori payı/değişim/tasarruf oranı ve alt kategori kırılımı (birim); rapor toplamlarının bütçeyle tutarlılığı, gizli hesap görünürlüğü, dashboard periyotları (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 14a
|
||||||
|
|
||||||
|
- Dashboard'daki günlük plan kartı istatistik ve tabloya indi; kümülatif ay grafiği ana grafiğin "Aylık" görünümüne taşındı.
|
||||||
|
- Bütçe, gelir planı ve günlük plan hesapları bağlam alan yardımcılara ayrıldı (raporlarda birden çok ay için yeniden kullanılır).
|
||||||
|
|
||||||
|
## [0.13.0] - 2026-11-06
|
||||||
|
|
||||||
|
Faz 13: alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu ve alışveriş sonrası giriş (13a ve 13b alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 13b: alışveriş sonrası giriş, karşılaştırma ve "Alışverişi tamamla"
|
||||||
|
|
||||||
|
- Alışveriş sonrası giriş (`/alisveris/:id/tamamla`): liste kalemleri tek tek işaretlenir; her biri için alınan ürün (aynı ya da aynı ürün tipinden muadil; ürün tipi kaleminde marka belirtmeden de), mağaza, paket adedi ve ödenen tutar. Tutar, bilinen paket fiyatıyla ön doldurulur. Listede olmayan ekstra ürünler eklenebilir. Mağaza başına ödeme hesabı (yemek kartı geçen mağazada uygun yemek kartı önseçili) ve fiş görselleri.
|
||||||
|
- "Alışverişi tamamla" tek MongoDB transaction'ı: stok alım hareketleri, fiyat kayıtları (kaynak `receipt`; ürün × mağaza başına, paket fiyatı = tutar / paket), mağaza başına bölünmüş gider işlemi (kalemler stok kategorisinin eşlendiği gider kategorisine; eşlenmemiş ve serbest metin kalemleri seçilen yedek kategoriye), fiş görselleri işlemin eki, alınmayanların taşınması ve listenin kapanması. Herhangi bir adım başarısızsa hiçbir şey yazılmaz.
|
||||||
|
- Alınmayanlar: başka aktif listeye taşı, yeni liste aç ya da bırak.
|
||||||
|
- Karşılaştırma raporu (tamamlanan listede): planlanan (tamamlama anındaki akıllı öneri) vs. gerçekleşen tutar ve fark; farkın nedenleri (aynı üründe fiyat farkı, miktar farkı, muadil, alınmayanlar, plansız ekstralar, planda fiyatı olmayanlar; toplamları farka eşit); kalem bazında Aynı / Muadil / Alınmadı / Plansız ekstra; mağaza giderleri ve kullanılan hesaplar. Etiketler tamamlama anındaki hâliyle saklanır.
|
||||||
|
- Liste sayfasında ve alışveriş modunda "Alışverişi tamamla" düğmesi.
|
||||||
|
- Stok hareketleri ve işlemlerde `shoppingListId` (kaynağı izlemek için).
|
||||||
|
- Testler: eşleşme ve fark ayrıştırması (birim); tamamlama ekranı verisi, doğrulamalar (muadil tipi, gelecek tarih, izleyici), ek dosya hatasında geri alma, stok/fiyat/mağaza başına bölünmüş gider, markasız kalem, kalanların taşınması ve rapor (entegrasyon).
|
||||||
|
|
||||||
|
### Eklendi — Faz 13a: alışveriş listeleri, stok önerileri, akıllı mağaza önerisi, alışveriş modu
|
||||||
|
|
||||||
|
- Alışveriş listeleri (`shoppingLists`, `/alisveris`): birden çok aktif liste; kalem olarak ürün tipi (genel), belirli ürün (marka/paket) ya da serbest metin. Miktar ürün tipinde tipin biriminde, üründe paket adedi; boşsa en az bir paket. Aynı ürün listede iki kez olamaz; kalem düzenleme, çıkarma ve başka aktif listeye taşıma. Liste silme soft delete ve denetim kaydı. Aile üyeleri aynı listeyi kullanır: sayfa odaklanınca ve 30 saniyede bir yenilenir.
|
||||||
|
- Stok eşiği önerileri: eşiğin altındaki ürün tipleri hiçbir aktif listede yoksa liste sayfasında onayla/reddet önerisi olarak gelir (fire ayarlı önerilen miktar ve nedeni). Onaylanan önerilen miktarla eklenir ("Stok önerisi" rozeti), "Tümünü ekle" ile toplu. Reddedilen öneri, ürün için yeni alım girilene (stok yenilenene) kadar gösterilmez.
|
||||||
|
- Akıllı mağaza önerisi (saf fonksiyon, birim testli): son bilinen fiyatlarla her kalem için mağaza ve marka; ihtiyacı karşılayan paket sayısı × paket fiyatı. Fiziksel mağaza ziyareti ve hacimli kalemler için zahmet skoruna bağlı maliyet cezası, online teslimat ücreti ve ücretsiz teslimat alt limiti, yemek kartı önceliği (kartın geçtiği mağazada kartla ödenebilecek tutar avantajlı sayılır; bakiye mağazalar arasında bir kez harcanır). En az mağazalı eksiksiz plandan başlanır; ek mağaza ancak tasarruf hane ayarındaki eşiği aşarsa eklenir. Çıktı: "Tek mağaza en iyi seçenek: X" ya da "Önerilen dağılım: A (7 ürün), B (3 ürün): Y (Z tasarruf)"; önerilen ve tek mağaza dağılımları, fiyatı bilinmeyen kalemler.
|
||||||
|
- Alışveriş modu (`/alisveris/:id/mod`): plana göre mağazaya gruplanmış, büyük dokunma alanlı kontrol listesi; "sepete alındı" işareti anında görünür ve kaydedilir, ilerleme çubuğu; önerilen / tek mağaza planı arasında geçiş.
|
||||||
|
- Ürün tipine "Ağır / hacimli" işareti (akıllı öneride taşıma cezası).
|
||||||
|
- Dashboard'da "Alışveriş" kartı (aktif listeler ve bekleyen öneri sayısı); stok sayfasındaki "Alınması önerilenler" alışveriş listelerine bağlanır. Kenar menüde "Alışveriş".
|
||||||
|
- Migration `015-shopping-lists`. Seed: "Haftalık market" (9 kalem: tip, marka, serbest metin, fiyatı olmayan domates) ve "Ay sonu toptan" listeleri; deterjan, yumuşatıcı, çamaşır suyu ve tuvalet kağıdı hacimli.
|
||||||
|
- Testler: planlayıcı (paket yuvarlama, marka kısıtı, eşik, eksik kapsama, zahmet ve hacimli cezası, teslimat, yemek kartı), miktar ve öneri reddi kuralları (birim); liste/kalem işlemleri, yetki, öneri onay/red, plan çıktısı, silme ve denetim (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 13a
|
||||||
|
|
||||||
|
- Fiyat servisinde ürün tipi teklifleri ortak yükleyicide (`loadTypeOffers`); tüketim analizindeki son birim fiyat da bunu kullanır.
|
||||||
|
|
||||||
|
## [0.12.0] - 2026-11-06
|
||||||
|
|
||||||
|
Faz 12: mağazalar, ürünler, fiyatlar ve birim fiyat (12a ve 12b alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 12b: fiyat kayıtları ve birim fiyat
|
||||||
|
|
||||||
|
- Fiyat kayıtları (`productPrices`): ürün × mağaza × tarih × paket fiyatı; kampanya/indirim işareti ve geçerlilik sonu; kaynak `manual` | `receipt` | `provider:<ad>` (şimdilik elle giriş). Gelecek tarihli fiyat ve arşivli ürün/mağazaya fiyat girilemez; kayıt silme denetim kaydına yazılır.
|
||||||
|
- Birim fiyat: her fiyat ürün tipinin birimine çevrilir (TL/kg, TL/lt, TL/adet, TL/yıkama; g ve ml gösterimde kg ve lt). Yuvarlama yalnızca gösterimde. Geçerli fiyat mağaza başına son kayıt; süresi biten kampanyada önceki normal fiyata dönülür ("Kampanya bitti, normal fiyat"), 90 günden eski fiyat işaretlenir.
|
||||||
|
- Ürün tipi detayında "Birim fiyat karşılaştırması": tüm marka/paket ve mağazalar birim fiyata göre sıralı, en uygun vurgulu, diğerleri "%X pahalı"; buradan fiyat girişi (birim fiyat anlık önizlemesiyle).
|
||||||
|
- Ürün sayfası (`/stok/urunler/:id`): görseller, mağazalardaki geçerli fiyatlar, mağaza başına fiyat geçmişi grafiği (basamaklı çizgi, bugüne uzanır) ve fiyat kayıtları listesi. Mağaza renkleri sabit kategorik paletten (mağazanın ekleniş sırası; açık/koyu tema için doğrulanmış `--series-1…8`).
|
||||||
|
- Tüketim analizinde aylık ortalama maliyet: aylık ortalama tüketim × ürün tipinin son bilinen birim fiyatı; fiyatı bilinen ürünlerin toplamı.
|
||||||
|
- Migration `014-product-prices`. Seed: 22 ürün için son 6 ayın 265 fiyat kaydı (mağaza farkları, aylık artış, süren ve biten kampanya, eski fiyat örneği).
|
||||||
|
- Testler: birim fiyat ve gösterim birimi, geçerli fiyat (biten kampanya, eski fiyat), sıralama ve eşitlik, renk yuvası (birim); karşılaştırma sırası, kampanya dönüşü, fiyat girişi kuralları, silme ve denetim, analiz maliyeti (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 12b
|
||||||
|
|
||||||
|
- Sayfa başlığı açıklaması bağlantı içerebilir; ek dosya seçicisine özel düğme metni ve ipucu.
|
||||||
|
|
||||||
|
### Eklendi — Faz 12a: mağazalar ve ürünler (marka/paket)
|
||||||
|
|
||||||
|
- Mağazalar (`stores`, `/magazalar`): ad, tür (fiziksel market / online platform / pazar), şube, eve uzaklık (km), taşıma güçlüğü (1–5), online ise teslimat ücreti ve ücretsiz teslimat alt limiti, yemek kartı geçerliliği ve geçerli sağlayıcılar, not. Zahmet skoru (0–100; uzaklık 20 km'ye kadar ve taşıma güçlüğü eşit ağırlıkla, online mağazada uzaklık sayılmaz) ve düzeyi; "ada göre / en yakın / en uzak / en zahmetli" sıralama. Aynı ad + şube tekil; arşivleme denetim kaydına yazılır. Kenar menüde "Mağazalar".
|
||||||
|
- Ürünler (`products`): ürün tipine bağlı marka, tanım, isteğe bağlı barkod (8–14 rakam, hanede tekil), paket miktarı ve birimi. Paket birimi ürün tipinin birimine kendiliğinden çevrilebiliyorsa (aynı birim, kg↔g, lt↔ml) dönüşüm otomatik; çevrilemiyorsa dönüşüm zorunlu ("5 kg = 33 yıkama", "1 paket = 50 tablet", "75 ml = 1 adet"). Ürün tipinin birimi sonradan değişirse "Dönüşüm gerekli" uyarısı.
|
||||||
|
- Ürün görselleri: birden çok görsel, mevcut ek dosya altyapısıyla (WebP'ye dönüştürme, küçük resim, yetkili okuma; hane üyeleri görür). Liste küçük resmi ve görüntüleyici.
|
||||||
|
- Ürün tipi detayında "Ürünler (marka / paket)" bölümü; `/stok/urunler` ürün kataloğu (ürün tipine göre gruplu, marka/tanım/barkodla arama, arşiv).
|
||||||
|
- Migration `013-stores-products` (index'ler). Seed: Çakır Ailesi için 8 mağaza ve 22 ürün.
|
||||||
|
- Testler: zahmet skoru, sıralama, uzaklık gösterimi, paket dönüşümü ve açıklaması (birim); mağaza şeması ve kuralları, ürün dönüşüm zorunluluğu, barkod tekilliği, görsel bağlama/sıralama/çıkarma ve okuma yetkisi, arşiv (entegrasyon).
|
||||||
|
|
||||||
|
## [0.11.0] - 2026-11-06
|
||||||
|
|
||||||
|
Faz 11: ev stoğu (11a ve 11b alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 11b: periyodik sayım, fire ayarlı öneri, tüketim analizi
|
||||||
|
|
||||||
|
- Sayım oturumları (`stockCounts`): her sayımda ürün başına beklenen, sayılan ve fireye ayrılan miktar; fark sıfır olsa da kayıt tutulur ve oluşan hareketler oturuma bağlanır (`countId`). Ürün detayındaki tek ürün sayımı da oturum oluşturur.
|
||||||
|
- Periyodik sayım ekranı (`/stok/sayim?kategori=…`): kategori (alt ağacıyla) veya tüm stok; liste hâlinde beklenen → sayılan, anlık fark; eksiğin bir kısmı nedenle fire olarak işaretlenebilir (fire, eksikten fazla olamaz), kalan fark sayım düzeltmesi olur. "Yalnızca sayımı gelenler", "Beklenenle doldur", tek transaction'da kayıt.
|
||||||
|
- Sayım takvimi: sıklık ürün tipinden, yoksa stok kategorisinden (üstten devralınır); son sayımdan (hiç sayılmadıysa ilk hareketten) n gün sonra sayım gelir. `/stok`'ta "Sayımı gelenler" kartı (kategori, ürün sayısı, gecikme) ve dashboard hatırlatması.
|
||||||
|
- Önerilen alım miktarı motoru: hedef − mevcut; son 3 alımda fire varsa (fireler FIFO ile hangi alımdan düştüyse ona yazılır, ≥ %5) fire oranı kadar düşürülür; haftalık tüketim adımdan büyükse haftalık tüketimin katına, değilse adıma yuvarlanır; hedefi aşmaz. Gerekçe gösterilir ("Son 2 alımda %30 fire, miktar azaltıldı.", "Haftalık tüketime (0,3 kg) göre yuvarlandı.", "Mevcutla birlikte yaklaşık 46 gün yeter.").
|
||||||
|
- Tüketim hızı (son 90 gün; tüketim + sayımda çıkan eksik, fire hariç) ve periyodik sayım durumu ürün detayında.
|
||||||
|
- Tüketim analizi (`/stok/analiz`): ürün tipi başına son 6 ayın tüketim/fire çubukları, aylık ortalama tüketim ve fire, fire oranı (≥ %15 vurgulu), bu ayın tüketimi. Maliyet ürün fiyatlarıyla (Faz 12) eklenecek.
|
||||||
|
- Migration `012-stock-counts`: index'ler; Faz 11a'da oturumsuz kaydedilen sayım düzeltmeleri, beklenen miktar yeniden hesaplanarak sayım oturumlarına bağlanır.
|
||||||
|
- Seed: Çakır Ailesi'nde Mutfak 15, Genel Temizlik 30 günde bir sayılır; sayım oturumları ve ara sıra sayım farkı; Mutfak'ın sayımı gecikmiş.
|
||||||
|
- Testler: FIFO fire dağılımı, son alımların fire oranı, öneri (fire indirimi, tüketim hızına yuvarlama, hedef sınırı), sayım takvimi, aylık kullanım (birim); seed sayım takvimi, toplu sayım (fire + düzeltme, oturum, sıfır fark, yetki), tüketim analizi (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 11b
|
||||||
|
|
||||||
|
- Dashboard stok kartı "Stok uyarıları" oldu: azalan ürünler, SKT yaklaşan partiler ve sayımı gelen kategoriler.
|
||||||
|
- Seed'deki sayım farkları artık periyodik sayım oturumlarında oluşur.
|
||||||
|
|
||||||
|
### Eklendi — Faz 11a: ev stoğu
|
||||||
|
|
||||||
|
- Stok kategorileri (`stockCategories`): gider kategorilerinden ayrı, sınırsız derinlikte ağaç; varsayılan set (Mutfak › Manav, Şarküteri, Bakliyat; Genel Temizlik › Bulaşık, Çamaşır, Genel; Kişisel Temizlik › Şampuan, Tıraş, Kişisel Bakım) yeni hanede ve migration ile mevcut hanelere yüklenir. Her kategori bir gider kategorisine eşlenebilir (alt kategoriler üstten devralır; Faz 13'te alışveriş gideri için) ve sayım sıklığı tutar. Taşıma, arşiv (içinde aktif ürün yoksa) ve arşivden çıkarma; `/stok/kategoriler`.
|
||||||
|
- Ürün tipleri (`productTypes`): kategori, temel ölçü birimi (kg, lt, adet … veya özel birim: "yıkama", "tablet"), hedef stok, kritik eşik yüzdesi (varsayılan %20), saklama yeri, "1 azalt" miktarı, not; ilk kurulumda evdeki başlangıç miktarı.
|
||||||
|
- Stok hareketleri (`stockMovements`): alım (isteğe bağlı SKT), tüketim, fire/zarar (nedenli) ve sayım düzeltmesi (gerçek miktar girilir, fark işlenir). Mevcut miktar hareketlerden türetilir; stokta olandan fazla tüketim/fire reddedilir. Hareket silme denetim kaydına yazılır.
|
||||||
|
- SKT takibi FIFO partileriyle: çıkışlar en eski girişten düşer; evde kalan miktarın SKT'si 7 gün içinde dolan veya geçmiş partiler uyarılır.
|
||||||
|
- `/stok`: özet, "Alınması önerilenler" (eşiğin altındakiler ve hedefe tamamlayan miktar), SKT uyarıları, kategoriye göre ürün listesi (mevcut/hedef çubuğu ve eşik çizgisi, arama, "yalnızca azalanlar"), tek dokunuşla "1 azalt". `/stok/:id`: partiler ve hareket geçmişi.
|
||||||
|
- Dashboard "Azalan stoklar" kartı.
|
||||||
|
- Seed: Çakır Ailesi için 19 ürün tipi ve son 6 ayın haftalık alım/tüketim/fire/sayım simülasyonu.
|
||||||
|
- Testler: mevcut miktar, FIFO partileri, SKT durumu, eşik ve öneri (birim); görünüm, izolasyon, hareket kuralları, sayım, kategori kuralları (entegrasyon).
|
||||||
|
|
||||||
|
## [0.10.0] - 2026-11-06
|
||||||
|
|
||||||
|
Faz 10: aylık bütçe, gelir planı ve günlük plan (10a ve 10b alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 10b: gelir planı ve günlük plan
|
||||||
|
|
||||||
|
- Gelir planı (`/butce` altında): gerçekleşen gelirler, henüz gerçekleşmemiş planlı (düzenli) gelirler ve elle eklenen beklenen gelirler (`budgets.incomeItems`, ör. prim); beklenen toplam. "Plana göre elde kalacak" (beklenen gelir − bütçe) ve "Bu tempoda elde kalacak" (beklenen gelir − ay sonu tahmini gider).
|
||||||
|
- Günlük plan (dashboard): ayın bütçesi günlere yayılır; tarihe bağlı giderler (düzenli işlemler, taksitler) kendi günlerine, kalan değişken bütçe günlere eşit veya hane ayarına göre önceki ayın günlük örüntüsüyle (kuruşu kuruşuna toplam korunur). "Bugün ayın %X'i geçti, bütçenin %Y'si harcandı", bugün için harcanabilir tutar (önceki günlerden kalan/aşan dahil), bugüne kadar planlanan ve harcanan, ay sonunda elde kalacak.
|
||||||
|
- Kümülatif planlanan / gerçekleşen gider grafiği, beklenen gelir referans çizgisi ve gün tablosu; sonraki ayın beklenen geliri ile bütçesi karşılaştırması.
|
||||||
|
- Dashboard özet kartları: "Bu ay gelir", "Bu ay gider" ve "Kalan" gerçek veriyle (bütçe ayına göre).
|
||||||
|
- Testler: dağıtım ve kuruş yuvarlama, örüntü uyarlama, günlük plan ve bugünkü pay, taban hızlı tahmin (birim); gelir planı, gizli hesap ayarı, elle kalemler, yetki, günlük plan tutarlılığı ve örüntü modu (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 10b
|
||||||
|
|
||||||
|
- Ay sonu tahmini: değişken giderin günlük hızı, bu ayın hızı ile önceki dönemin değişken harcama hızının karışımı (ay ilerledikçe bu ayın ağırlığı artar); ay başındaki tek bir büyük harcama artık tüm aya yayılmıyor. Geçmişi olmayan hanede yalnızca bu ayın hızı.
|
||||||
|
|
||||||
|
### Düzeltildi — Faz 10b
|
||||||
|
|
||||||
|
- Bütçede "sabit gider" (düzenli işlem / taksit) ayrımı her işlemi sabit sayıyordu (`installment` boşken alan yolu eksik değer verir ve `null`'a eşit sayılmaz); ay sonu tahmini değişken gideri hiç yansıtmıyordu. `$ifNull` ile düzeltildi, regresyon testi eklendi.
|
||||||
|
|
||||||
|
### Eklendi — Faz 10a: aylık bütçe
|
||||||
|
|
||||||
|
- Bütçe sayfası (`/butce?ay=YYYY-MM`): ay gezintisi, dönem sınırları hane ayarı `budgetMonthStartDay`'e göre (ör. 15'i → 15 Kasım – 14 Aralık).
|
||||||
|
- Kategori limitleri (`budgets`): her gider kategorisine limit; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır ve toplam bütçede iki kez sayılmaz. Limitsiz alt kategoriler "Üst kategori limitinde" olarak gösterilir; hiçbir limitin kapsamadığı harcama "Bütçesiz harcama" toplamında.
|
||||||
|
- Gerçekleşen / limit / kalan, yüzde çubuğu (ayın geçen kısmı işaretli), %80'de uyarı, %100'de "Limit doldu", aşımda "Limit aşıldı".
|
||||||
|
- Ay sonu tahmini: sabit giderler (düzenli işlem, taksit) olduğu gibi + kalan planlı giderler + değişken giderin günlük hızıyla ay sonuna yansıtılması; tahmin limiti aşıyorsa işaret.
|
||||||
|
- Bölünmüş işlemler kalem bazında; taksitler hane ayarına göre her biri kendi ayına (varsayılan) veya tamamı alış ayına. Başkasının "yalnızca ben" hesabından giderler hane ayarına göre yalnızca tutar olarak sayılır.
|
||||||
|
- "Önceki aydan kopyala" ve "Önceki ayın harcamalarından oluştur" (ana kategori bazında, 100 TL'ye yukarı yuvarlanır); mevcut limitler varsa onay istenir.
|
||||||
|
- Limit düzenleyici: tüm etkin gider kategorileri ağaç sırasıyla, anlık toplam bütçe.
|
||||||
|
- Dashboard: %80'i geçilen veya aşılan kategori sayısıyla uygulama içi bütçe uyarısı.
|
||||||
|
- Seed: Çakır Ailesi'nde önceki ve bu ay için harcamalardan oluşturulmuş bütçeler (bu ay Market limiti düşük, Elektrik alt limiti).
|
||||||
|
- `010` migration'ı: `budgets` index'i (hane × ay benzersiz).
|
||||||
|
- Testler: dönem sınırları, eşikler, tahmin, üst/alt limit toplama, bütçesiz harcama, sıra (birim); limitler, bölünmüş işlem, planlı, gizli hesap ayarı, taksit modları, başlangıç günü, kopyala/oluştur, yetki (entegrasyon).
|
||||||
|
|
||||||
## [0.9.0] - 2026-11-06
|
## [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ı).
|
Faz 9: birikim ve varlık takibi, fiyat tablosu (elle), kâr/zarar, net varlık (9a ve 9b alt fazları).
|
||||||
@@ -284,7 +436,10 @@ 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.9.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.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.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.7.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.6.0...v0.7.0
|
||||||
|
|||||||
@@ -320,6 +320,12 @@ 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).
|
||||||
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.9.0",
|
"version": "0.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.9.0",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^7.0.2",
|
"@hookform/resolvers": "^7.0.2",
|
||||||
"@tanstack/react-table": "^9.3.1",
|
"@tanstack/react-table": "^9.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.9.0",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -9,10 +9,32 @@ 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 m009 } from "./009-assets";
|
||||||
|
import { migration as m010 } from "./010-budgets";
|
||||||
|
import { migration as m011 } from "./011-stock";
|
||||||
|
import { migration as m012 } from "./012-stock-counts";
|
||||||
|
import { migration as m013 } from "./013-stores-products";
|
||||||
|
import { migration as m014 } from "./014-product-prices";
|
||||||
|
import { migration as m015 } from "./015-shopping-lists";
|
||||||
import type { Migration } from "./types";
|
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, m009];
|
export const MIGRATIONS: Migration[] = [
|
||||||
|
m001,
|
||||||
|
m002,
|
||||||
|
m003,
|
||||||
|
m004,
|
||||||
|
m005,
|
||||||
|
m006,
|
||||||
|
m007,
|
||||||
|
m008,
|
||||||
|
m009,
|
||||||
|
m010,
|
||||||
|
m011,
|
||||||
|
m012,
|
||||||
|
m013,
|
||||||
|
m014,
|
||||||
|
m015,
|
||||||
|
];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -33,6 +33,11 @@ describe("seed", () => {
|
|||||||
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.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);
|
||||||
});
|
});
|
||||||
@@ -51,6 +56,12 @@ describe("seed", () => {
|
|||||||
contacts: 0,
|
contacts: 0,
|
||||||
inKind: 0,
|
inKind: 0,
|
||||||
assets: 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);
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ 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 { 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";
|
||||||
@@ -53,6 +56,12 @@ export type SeedSummary = {
|
|||||||
contacts: number;
|
contacts: number;
|
||||||
inKind: number;
|
inKind: number;
|
||||||
assets: number;
|
assets: number;
|
||||||
|
budgets: number;
|
||||||
|
stock: number;
|
||||||
|
stores: number;
|
||||||
|
products: number;
|
||||||
|
prices: number;
|
||||||
|
shoppingLists: number;
|
||||||
};
|
};
|
||||||
accounts: SeedAccountRow[];
|
accounts: SeedAccountRow[];
|
||||||
};
|
};
|
||||||
@@ -75,6 +84,15 @@ const DOMAIN_COLLECTIONS = [
|
|||||||
"inKindDebts",
|
"inKindDebts",
|
||||||
"assets",
|
"assets",
|
||||||
"assetMovements",
|
"assetMovements",
|
||||||
|
"budgets",
|
||||||
|
"stockCategories",
|
||||||
|
"productTypes",
|
||||||
|
"stockMovements",
|
||||||
|
"stockCounts",
|
||||||
|
"stores",
|
||||||
|
"products",
|
||||||
|
"productPrices",
|
||||||
|
"shoppingLists",
|
||||||
];
|
];
|
||||||
|
|
||||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||||
@@ -129,6 +147,12 @@ export async function runSeed({
|
|||||||
contacts: 0,
|
contacts: 0,
|
||||||
inKind: 0,
|
inKind: 0,
|
||||||
assets: 0,
|
assets: 0,
|
||||||
|
budgets: 0,
|
||||||
|
stock: 0,
|
||||||
|
stores: 0,
|
||||||
|
products: 0,
|
||||||
|
prices: 0,
|
||||||
|
shoppingLists: 0,
|
||||||
},
|
},
|
||||||
accounts: [],
|
accounts: [],
|
||||||
};
|
};
|
||||||
@@ -309,6 +333,20 @@ export async function runSeed({
|
|||||||
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);
|
summary.created.assets = await seedAssets(householdIds, userIdMap);
|
||||||
if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`);
|
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;
|
||||||
|
}
|
||||||
@@ -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,43 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { BudgetView } from "@/features/budgets/components/budget-view";
|
||||||
|
import { IncomePlanSection } from "@/features/budgets/components/income-plan";
|
||||||
|
import { getIncomePlan } from "@/features/budgets/plan";
|
||||||
|
import { getBudget } from "@/features/budgets/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.budget.title };
|
||||||
|
|
||||||
|
/** /butce?ay=YYYY-MM — aylık bütçe (varsayılan: içinde bulunulan bütçe ayı). */
|
||||||
|
export default async function BudgetPage({ searchParams }: PageProps<"/butce">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||||
|
const data = await getBudget(user.id, household.id, ay.success ? ay.data : undefined);
|
||||||
|
if (!data) notFound();
|
||||||
|
const income = await getIncomePlan(user.id, household.id, data.month);
|
||||||
|
if (!income) notFound();
|
||||||
|
const monthLabel = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
}).format(new Date(`${data.month}-15T12:00:00+03:00`));
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title={tr.budget.title} description={tr.budget.description} />
|
||||||
|
<BudgetView key={data.month} data={data} />
|
||||||
|
<IncomePlanSection
|
||||||
|
key={`income-${data.month}`}
|
||||||
|
month={data.month}
|
||||||
|
monthLabel={monthLabel}
|
||||||
|
income={income}
|
||||||
|
limitMinor={data.totals.limitMinor}
|
||||||
|
forecastMinor={data.totals.forecastMinor}
|
||||||
|
hasBudget={data.hasBudget}
|
||||||
|
canWrite={data.canWrite}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,10 +3,18 @@ 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 { 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";
|
||||||
@@ -22,15 +30,25 @@ type SummaryCard = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Özet kartları. Bakiye, kart borcu ve net varlık gerçek (Faz 9); bu ayın gelir/gider/kalan
|
* Özet kartları (içinde bulunulan bütçe ayı): gerçekleşen gelir, gider ve farkı; hesap bakiyesi,
|
||||||
* kartları Faz 14'te dolar.
|
* kart borcu ve net varlık.
|
||||||
*/
|
*/
|
||||||
async function getSummary(userId: string, householdId: string): Promise<SummaryCard[]> {
|
async function getSummary(userId: string, householdId: string): Promise<SummaryCard[]> {
|
||||||
const nw = await getNetWorth(userId, householdId);
|
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: "monthRemaining",
|
||||||
|
tone: "text-budget",
|
||||||
|
amountMinor: inc !== null && exp !== null ? inc - exp : null,
|
||||||
|
},
|
||||||
{ key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" },
|
{ key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" },
|
||||||
{ key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null },
|
{ key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null },
|
||||||
{ key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" },
|
{ key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" },
|
||||||
@@ -68,7 +86,7 @@ async function SummaryCards({ userId, householdId }: { userId: string; household
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
||||||
<>
|
<>
|
||||||
@@ -79,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 userId={user.id} householdId={household.id} />
|
<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,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,117 @@
|
|||||||
|
import {
|
||||||
|
ChartPie,
|
||||||
|
ChevronRight,
|
||||||
|
Coins,
|
||||||
|
HandCoins,
|
||||||
|
ShoppingCart,
|
||||||
|
Tags,
|
||||||
|
Target,
|
||||||
|
Trash2,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { monthLabel, formatPct } from "@/features/reports/components/report-frame";
|
||||||
|
import { getCategoryReport, getTrendReport } from "@/features/reports/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
|
||||||
|
const t = tr.reports;
|
||||||
|
export const metadata: Metadata = { title: t.title };
|
||||||
|
|
||||||
|
const REPORTS = [
|
||||||
|
{ href: "/raporlar/kategoriler", key: "categories", icon: ChartPie },
|
||||||
|
{ href: "/raporlar/trend", key: "trend", icon: TrendingUp },
|
||||||
|
{ href: "/raporlar/kisi-hesap", key: "people", icon: Users },
|
||||||
|
{ href: "/raporlar/butce", key: "budget", icon: Target },
|
||||||
|
{ href: "/raporlar/cari", key: "contacts", icon: HandCoins },
|
||||||
|
{ href: "/raporlar/birikim", key: "assets", icon: Coins },
|
||||||
|
{ href: "/raporlar/alisveris", key: "shopping", icon: ShoppingCart },
|
||||||
|
{ href: "/raporlar/birim-fiyat", key: "unitPrice", icon: Tags },
|
||||||
|
{ href: "/raporlar/fire", key: "waste", icon: Trash2 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** /raporlar — bu bütçe ayının özeti ve raporlar. */
|
||||||
|
export default async function ReportsPage() {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const [trend, cats] = await Promise.all([
|
||||||
|
getTrendReport(user.id, household.id, 1),
|
||||||
|
getCategoryReport(user.id, household.id),
|
||||||
|
]);
|
||||||
|
if (!trend || !cats) notFound();
|
||||||
|
const m = trend.months[0]!;
|
||||||
|
const top = cats.breakdown.rows.filter((r) => r.amountMinor > 0).slice(0, 3);
|
||||||
|
const stats = [
|
||||||
|
{ label: t.hub.income, value: formatMoney(m.incomeMinor), tone: "text-income" },
|
||||||
|
{ label: t.hub.expense, value: formatMoney(m.expenseMinor), tone: "text-expense" },
|
||||||
|
{
|
||||||
|
label: t.hub.net,
|
||||||
|
value: `${m.netMinor < 0 ? "−" : ""}${formatMoney(Math.abs(m.netMinor))}`,
|
||||||
|
tone: m.netMinor < 0 ? "text-expense" : undefined,
|
||||||
|
},
|
||||||
|
{ label: t.hub.savings, value: formatPct(m.savingsPct) },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title={t.title} description={t.description} />
|
||||||
|
<section aria-labelledby="hub-month" className="rounded-xl border bg-card p-4">
|
||||||
|
<h2 id="hub-month" className="text-sm font-medium">
|
||||||
|
{t.hub.thisMonth}: <span className="capitalize">{monthLabel(m.month)}</span>{" "}
|
||||||
|
<span className="font-normal text-muted-foreground">({t.trend.ongoing})</span>
|
||||||
|
</h2>
|
||||||
|
<dl className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
{stats.map((s) => (
|
||||||
|
<div key={s.label}>
|
||||||
|
<dt className="text-xs text-muted-foreground">{s.label}</dt>
|
||||||
|
<dd className={cn("text-lg font-semibold tabular-nums", s.tone)}>{s.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
{top.length ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-xs text-muted-foreground">{t.hub.topCategories}</h3>
|
||||||
|
<ul className="mt-1 flex flex-col gap-1 text-sm">
|
||||||
|
{top.map((r) => (
|
||||||
|
<li key={r.id} className="flex justify-between gap-3">
|
||||||
|
<span className="truncate">{r.name}</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatMoney(r.amountMinor)}{" "}
|
||||||
|
<span className="text-muted-foreground">· {formatPct(r.sharePct)}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
|
{REPORTS.map((r) => {
|
||||||
|
const c = t.hub.cards[r.key];
|
||||||
|
const Icon = r.icon;
|
||||||
|
return (
|
||||||
|
<li key={r.href}>
|
||||||
|
<Link
|
||||||
|
href={r.href}
|
||||||
|
className="flex h-full items-start gap-3 rounded-xl border bg-card p-4 transition-colors hover:bg-accent/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<Icon className="size-4.5 text-muted-foreground" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block font-medium">{c.title}</span>
|
||||||
|
<span className="block text-sm text-muted-foreground">{c.description}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="mt-2 size-4 text-muted-foreground" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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 = 9.2;
|
export const CURRENT_PHASE = 14.2;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
@@ -39,10 +40,11 @@ export const MAIN_NAV: NavItem[] = [
|
|||||||
{ 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.1 },
|
{ 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 (
|
||||||
|
|||||||
@@ -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,480 @@
|
|||||||
|
"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 }: { data: Data }) {
|
||||||
|
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}
|
||||||
|
|
||||||
|
{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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { OVERVIEW_PERIODS, type OverviewPeriod } from "@/shared/overview";
|
||||||
|
import { getOverview } from "../service";
|
||||||
|
import { OverviewChart } from "./overview-chart";
|
||||||
|
|
||||||
|
type Search = Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
|
const periodOf = (x: unknown): OverviewPeriod =>
|
||||||
|
OVERVIEW_PERIODS.includes(x as OverviewPeriod) ? (x as OverviewPeriod) : "month";
|
||||||
|
|
||||||
|
/** Dashboard ana grafiği (sunucu): ?periyot=week|month|half|year&gorunum=kumulatif|donemsel. */
|
||||||
|
export async function OverviewCard({
|
||||||
|
userId,
|
||||||
|
householdId,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
householdId: string;
|
||||||
|
searchParams: Promise<Search>;
|
||||||
|
}) {
|
||||||
|
const q = await searchParams;
|
||||||
|
const data = await getOverview(userId, householdId, periodOf(q.periyot));
|
||||||
|
if (!data) return null;
|
||||||
|
return <OverviewChart data={data} view={q.gorunum === "donemsel" ? "periodic" : "cumulative"} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"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 { cumulative, OVERVIEW_PERIODS, type OverviewPeriod } from "@/shared/overview";
|
||||||
|
import type { OverviewData } from "../service";
|
||||||
|
|
||||||
|
const t = tr.dashboard.overview;
|
||||||
|
export type OverviewView = "cumulative" | "periodic";
|
||||||
|
|
||||||
|
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||||
|
const dayShort = new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" });
|
||||||
|
const weekdayShort = new Intl.DateTimeFormat("tr-TR", { weekday: "short", day: "numeric" });
|
||||||
|
const monthShort = new Intl.DateTimeFormat("tr-TR", { month: "short", year: "2-digit" });
|
||||||
|
const monthLong = new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" });
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
plannedIncome: { label: t.series.plannedIncome, color: "var(--income)" },
|
||||||
|
budget: { label: t.series.budget, color: "var(--budget)" },
|
||||||
|
actual: { label: t.series.actual, color: "var(--expense)" },
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
const SERIES = ["plannedIncome", "budget", "actual"] as const;
|
||||||
|
|
||||||
|
const asDate = (key: string) =>
|
||||||
|
new Date(key.length === 7 ? `${key}-15T12:00:00+03:00` : `${key}T12:00:00+03:00`);
|
||||||
|
|
||||||
|
function bucketLabel(key: string, period: OverviewPeriod) {
|
||||||
|
const d = asDate(key);
|
||||||
|
if (key.length === 7) return monthShort.format(d);
|
||||||
|
return period === "week" ? weekdayShort.format(d) : dayShort.format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bölüm 6.13 ana çizgi grafik: periyot seçici ve kümülatif / dönemsel görünüm (URL'de). */
|
||||||
|
export function OverviewChart({ data, view }: { data: OverviewData; view: OverviewView }) {
|
||||||
|
const points = view === "cumulative" ? cumulative(data.points) : data.points;
|
||||||
|
const chart = points.map((p) => ({
|
||||||
|
key: p.key,
|
||||||
|
label: bucketLabel(p.key, data.period),
|
||||||
|
plannedIncome: p.plannedIncome === null ? null : p.plannedIncome / 100,
|
||||||
|
budget: p.budget === null ? null : p.budget / 100,
|
||||||
|
actual: p.actual === null ? null : p.actual / 100,
|
||||||
|
}));
|
||||||
|
const hasGap = data.points.some((p) => p.budget === null);
|
||||||
|
const href = (period: OverviewPeriod, v: OverviewView) =>
|
||||||
|
`?periyot=${period}&gorunum=${v === "cumulative" ? "kumulatif" : "donemsel"}`;
|
||||||
|
const rangeText =
|
||||||
|
data.range.granularity === "month"
|
||||||
|
? `${monthLong.format(asDate(data.range.buckets[0]!))} – ${monthLong.format(asDate(data.range.buckets.at(-1)!))}`
|
||||||
|
: `${formatDate(asDate(data.range.from))} – ${formatDate(asDate(data.range.to))}`;
|
||||||
|
const pill = (active: boolean) =>
|
||||||
|
cn(
|
||||||
|
"rounded-md px-2.5 py-1 text-sm transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||||
|
active
|
||||||
|
? "bg-background font-medium shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader className="flex flex-wrap items-start gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||||
|
<CardDescription>{t.description(rangeText)}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<nav aria-label={t.periodLabel} className="flex rounded-lg bg-muted p-0.5">
|
||||||
|
{OVERVIEW_PERIODS.map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p}
|
||||||
|
href={href(p, view)}
|
||||||
|
scroll={false}
|
||||||
|
replace
|
||||||
|
aria-current={p === data.period ? "page" : undefined}
|
||||||
|
className={pill(p === data.period)}
|
||||||
|
>
|
||||||
|
{t.periods[p]}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<nav aria-label={t.viewLabel} className="flex rounded-lg bg-muted p-0.5">
|
||||||
|
{(["cumulative", "periodic"] as const).map((v) => (
|
||||||
|
<Link
|
||||||
|
key={v}
|
||||||
|
href={href(data.period, v)}
|
||||||
|
scroll={false}
|
||||||
|
replace
|
||||||
|
aria-current={v === view ? "page" : undefined}
|
||||||
|
className={pill(v === view)}
|
||||||
|
>
|
||||||
|
{t.views[v]}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<ChartContainer
|
||||||
|
config={config}
|
||||||
|
className="aspect-auto h-64 w-full"
|
||||||
|
aria-label={t.chartLabel}
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<LineChart data={chart} 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
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const key = payload?.[0]?.payload?.key as string | undefined;
|
||||||
|
if (!key) return "";
|
||||||
|
return key.length === 7
|
||||||
|
? monthLong.format(asDate(key))
|
||||||
|
: formatDate(asDate(key));
|
||||||
|
}}
|
||||||
|
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 />} />
|
||||||
|
{SERIES.map((k) => (
|
||||||
|
<Line
|
||||||
|
key={k}
|
||||||
|
dataKey={k}
|
||||||
|
type="linear"
|
||||||
|
stroke={`var(--color-${k})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray={k === "budget" ? "6 3" : undefined}
|
||||||
|
dot={
|
||||||
|
data.range.granularity === "month" || data.period === "week" ? { r: 3 } : false
|
||||||
|
}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
{hasGap ? <p className="text-xs text-muted-foreground">{t.noBudget}</p> : null}
|
||||||
|
<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.bucket[data.range.granularity]}</th>
|
||||||
|
{SERIES.map((k) => (
|
||||||
|
<th key={k} className="py-1 font-normal">
|
||||||
|
{t.series[k]}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{points.map((p) => (
|
||||||
|
<tr key={p.key}>
|
||||||
|
<td className="py-1 text-left">
|
||||||
|
{p.key.length === 7
|
||||||
|
? monthLong.format(asDate(p.key))
|
||||||
|
: formatDate(asDate(p.key))}
|
||||||
|
</td>
|
||||||
|
{SERIES.map((k) => (
|
||||||
|
<td key={k} className="py-1">
|
||||||
|
{p[k] === null ? "—" : formatMoney(p[k])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { budgetFor, settingsOf } from "@/features/budgets/service";
|
||||||
|
import { incomeByDay, incomePlan, monthDailyPlan } from "@/features/budgets/plan";
|
||||||
|
import { loadContext } from "@/features/transactions/service";
|
||||||
|
import { budgetPeriod, periodOfDate } from "@/shared/budget";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import {
|
||||||
|
overviewRange,
|
||||||
|
type OverviewPeriod,
|
||||||
|
type OverviewPoint,
|
||||||
|
type OverviewRange,
|
||||||
|
} from "@/shared/overview";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard ana grafiği (Bölüm 6.13). Bütçe ve gelir planı motorlarıyla aynı kurallar: bütçe ayı,
|
||||||
|
* taksit modu ve gizli hesap ayarı.
|
||||||
|
*
|
||||||
|
* Aylık noktalar: beklenen gelir (gerçekleşen + planlı + elle), bütçe toplamı (bütçe yoksa boş),
|
||||||
|
* gerçekleşen gider. Günlük noktalar: o günün gelirleri (elle eklenen beklenen gelir dönemin ilk
|
||||||
|
* gününe), günlük plan payı, o günün gerçekleşen gideri; gelecek günlerde gerçekleşen boş.
|
||||||
|
*/
|
||||||
|
export type OverviewData = {
|
||||||
|
period: OverviewPeriod;
|
||||||
|
range: OverviewRange;
|
||||||
|
points: OverviewPoint[];
|
||||||
|
today: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getOverview(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
period: OverviewPeriod,
|
||||||
|
): Promise<OverviewData | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const s = await settingsOf(ctx.m.householdId);
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const range = overviewRange(period, today, s.startDay);
|
||||||
|
|
||||||
|
if (range.granularity === "month") {
|
||||||
|
const points = await Promise.all(
|
||||||
|
range.buckets.map(async (month) => {
|
||||||
|
const [budget, income] = await Promise.all([
|
||||||
|
budgetFor(ctx, s, month),
|
||||||
|
incomePlan(ctx, s, month),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
key: month,
|
||||||
|
plannedIncome: income.expectedMinor,
|
||||||
|
budget: budget.hasBudget ? budget.totals.limitMinor : null,
|
||||||
|
actual: budget.totals.actualMinor,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { period, range, points, today };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Günlük: aralığın dokunduğu bütçe ayları (hafta iki aya yayılabilir).
|
||||||
|
const months = [...new Set([range.from, range.to].map((d) => periodOfDate(d, s.startDay)))];
|
||||||
|
const planned = new Map<string, number | null>();
|
||||||
|
const actual = new Map<string, number>();
|
||||||
|
const income = new Map<string, number>();
|
||||||
|
for (const month of months) {
|
||||||
|
const [budget, plan] = await Promise.all([budgetFor(ctx, s, month), incomePlan(ctx, s, month)]);
|
||||||
|
const dp = await monthDailyPlan(ctx, s, month, budget.totals.limitMinor, today);
|
||||||
|
for (const d of dp.days) {
|
||||||
|
planned.set(d.date, budget.hasBudget ? d.plannedMinor : null);
|
||||||
|
actual.set(d.date, d.actualMinor);
|
||||||
|
}
|
||||||
|
const first = budgetPeriod(month, s.startDay).from;
|
||||||
|
if (plan.manualMinor) income.set(first, (income.get(first) ?? 0) + plan.manualMinor);
|
||||||
|
}
|
||||||
|
for (const [d, v] of await incomeByDay(ctx, s, range.from, range.to))
|
||||||
|
income.set(d, (income.get(d) ?? 0) + v);
|
||||||
|
|
||||||
|
const points = range.buckets.map((d) => ({
|
||||||
|
key: d,
|
||||||
|
plannedIncome: income.get(d) ?? 0,
|
||||||
|
budget: planned.get(d) ?? null,
|
||||||
|
actual: d <= today ? (actual.get(d) ?? 0) : null,
|
||||||
|
}));
|
||||||
|
return { period, range, points, today };
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { getDb } from "@/lib/db/mongo";
|
|||||||
import { connectMongoose } from "@/lib/db/mongoose";
|
import { connectMongoose } from "@/lib/db/mongoose";
|
||||||
import { toObjectId } from "@/lib/object-id";
|
import { toObjectId } from "@/lib/object-id";
|
||||||
import { installDefaultCategories } from "@/features/categories/service";
|
import { installDefaultCategories } from "@/features/categories/service";
|
||||||
|
import { installDefaultStockCategories } from "@/features/stock/service";
|
||||||
import { HouseholdModel } from "@/models/household";
|
import { HouseholdModel } from "@/models/household";
|
||||||
import { HouseholdMemberModel } from "@/models/household-member";
|
import { HouseholdMemberModel } from "@/models/household-member";
|
||||||
import type { HouseholdSettings } from "@/shared/schemas/household";
|
import type { HouseholdSettings } from "@/shared/schemas/household";
|
||||||
@@ -324,6 +325,8 @@ export async function adminCreateHousehold(
|
|||||||
);
|
);
|
||||||
// Varsayılan Türkçe kategori seti aynı transaction'da (Bölüm 6.2).
|
// Varsayılan Türkçe kategori seti aynı transaction'da (Bölüm 6.2).
|
||||||
await installDefaultCategories(household!._id, actor, session);
|
await installDefaultCategories(household!._id, actor, session);
|
||||||
|
// Varsayılan stok kategorileri (gider eşlemesi yukarıdaki kategorilerden).
|
||||||
|
await installDefaultStockCategories(household!._id, actor, session);
|
||||||
});
|
});
|
||||||
return ok({ id: createdId });
|
return ok({ id: createdId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"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 { productPriceFormSchema } from "@/shared/schemas/product-price";
|
||||||
|
import { addProductPrice, deleteProductPrice } 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, R>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
input: unknown,
|
||||||
|
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
|
||||||
|
): Promise<ActionResult<R>> {
|
||||||
|
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 addProductPriceAction(input: unknown) {
|
||||||
|
return run(productPriceFormSchema, input, (u, h, d) => addProductPrice(u, h, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProductPriceAction(priceId: unknown) {
|
||||||
|
const id = idSchema.safeParse(priceId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => deleteProductPrice(u, h, id.data));
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Clock, Tag, Trophy } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { formatUnitPrice, type RankedOffer } from "@/shared/prices";
|
||||||
|
import type { StoreOffer } from "../service";
|
||||||
|
|
||||||
|
const t = tr.productPrices;
|
||||||
|
export const ymdLabel = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
|
||||||
|
/** Mağaza renk işareti (kimlik; metin her zaman yanında). */
|
||||||
|
export function StoreDot({ slot, className }: { slot: number; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn("mr-1.5 inline-block size-2.5 shrink-0 rounded-full", className)}
|
||||||
|
style={{ backgroundColor: `var(--series-${slot})` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Teklif rozetleri: en uygun, kampanya, kampanya bitti, eski fiyat. */
|
||||||
|
function OfferBadges({ offer }: { offer: RankedOffer<StoreOffer> }) {
|
||||||
|
const p = offer.price;
|
||||||
|
return (
|
||||||
|
<span className="flex flex-wrap items-center gap-1">
|
||||||
|
{offer.cheapest ? (
|
||||||
|
<Badge className="gap-1 bg-income text-income-foreground">
|
||||||
|
<Trophy className="size-3" aria-hidden />
|
||||||
|
{t.cheapest}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{p.isPromo && !offer.promoEnded ? (
|
||||||
|
<Badge variant="outline" className="gap-1 border-budget/50 text-budget">
|
||||||
|
<Tag className="size-3" aria-hidden />
|
||||||
|
{t.promo}
|
||||||
|
{p.promoUntil ? ` · ${t.promoUntil(ymdLabel(p.promoUntil))}` : ""}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{offer.fellBack ? <Badge variant="secondary">{t.fellBack}</Badge> : null}
|
||||||
|
{offer.promoEnded ? <Badge variant="secondary">{t.promoEnded}</Badge> : null}
|
||||||
|
{offer.stale ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||||
|
title={t.staleHint}
|
||||||
|
>
|
||||||
|
<Clock className="size-3" aria-hidden />
|
||||||
|
{t.stale(offer.ageDays)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Birim fiyata göre sıralı teklifler. `showProduct`: ürün tipi karşılaştırmasında marka/paket
|
||||||
|
* ve ürün sayfası bağlantısı.
|
||||||
|
*/
|
||||||
|
export function OffersList({
|
||||||
|
offers,
|
||||||
|
typeUnit,
|
||||||
|
showProduct = false,
|
||||||
|
}: {
|
||||||
|
offers: RankedOffer<StoreOffer>[];
|
||||||
|
typeUnit: string;
|
||||||
|
showProduct?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ol className="divide-y rounded-xl border bg-card">
|
||||||
|
{offers.map((o) => (
|
||||||
|
<li
|
||||||
|
key={o.key}
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1 p-3",
|
||||||
|
o.cheapest && "bg-income/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="pt-0.5 text-sm text-muted-foreground tabular-nums">{o.rank ?? "–"}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{showProduct ? (
|
||||||
|
<p className="truncate font-medium">
|
||||||
|
<Link href={`/stok/urunler/${o.productId}`} className="hover:underline">
|
||||||
|
{o.productLabel}
|
||||||
|
</Link>{" "}
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">{o.packageLabel}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center",
|
||||||
|
showProduct ? "text-sm text-muted-foreground" : "font-medium",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StoreDot slot={o.store.slot} />
|
||||||
|
{o.store.label}
|
||||||
|
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
||||||
|
· {ymdLabel(o.price.date)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div className="mt-1">
|
||||||
|
<OfferBadges offer={o} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right tabular-nums">
|
||||||
|
<p className="font-semibold">
|
||||||
|
{o.unitPriceMinor === null ? (
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">{t.noConversion}</span>
|
||||||
|
) : (
|
||||||
|
formatUnitPrice(o.unitPriceMinor, typeUnit)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatMoney(o.price.priceMinor)}
|
||||||
|
{o.diffPct ? ` · ${t.diff(o.diffPct)}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
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 { tr } from "@/i18n/tr";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { parseMoney } from "@/shared/money";
|
||||||
|
import { formatUnitPrice, unitPrice } from "@/shared/prices";
|
||||||
|
import { addProductPriceAction } from "../actions";
|
||||||
|
import type { PriceProductOption } from "../options";
|
||||||
|
import type { StoreRef } from "../service";
|
||||||
|
import { StoreDot } from "./offers-list";
|
||||||
|
|
||||||
|
const t = tr.productPrices;
|
||||||
|
type Errors = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
/** Elle fiyat girişi: ürün (sabit olabilir), mağaza, tarih, paket fiyatı, kampanya. */
|
||||||
|
export function PriceFormDialog({
|
||||||
|
products,
|
||||||
|
stores,
|
||||||
|
productId,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
products: PriceProductOption[];
|
||||||
|
stores: StoreRef[];
|
||||||
|
/** Ürün sayfasından açılınca sabit. */
|
||||||
|
productId?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [v, setV] = useState({
|
||||||
|
productId: productId ?? (products.length === 1 ? products[0]!.id : ""),
|
||||||
|
storeId: "",
|
||||||
|
date: todayIstanbul(),
|
||||||
|
price: "",
|
||||||
|
isPromo: false,
|
||||||
|
promoUntil: "",
|
||||||
|
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 product = products.find((p) => p.id === v.productId);
|
||||||
|
const minor = parseMoney(v.price);
|
||||||
|
const preview =
|
||||||
|
product?.inTypeUnit && minor && minor > 0
|
||||||
|
? formatUnitPrice(unitPrice(minor, product.inTypeUnit), product.typeUnit)
|
||||||
|
: null;
|
||||||
|
const f = t.fields;
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await addProductPriceAction(v);
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.saved);
|
||||||
|
onClose();
|
||||||
|
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={t.addTitle}
|
||||||
|
description={product ? `${product.label} · ${product.packageLabel}` : undefined}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="price-form" disabled={busy}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="price-form" onSubmit={submit} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
{productId ? null : (
|
||||||
|
<Field data-invalid={Boolean(errors.productId)}>
|
||||||
|
<FieldLabel htmlFor="pp-product">{f.product}</FieldLabel>
|
||||||
|
<Select value={v.productId} onValueChange={(x) => set("productId", x)}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="pp-product"
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={Boolean(errors.productId)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={t.validation.product} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{products.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.label}{" "}
|
||||||
|
<span className="text-xs text-muted-foreground">({p.packageLabel})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.productId ? <FieldError>{errors.productId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field data-invalid={Boolean(errors.storeId)}>
|
||||||
|
<FieldLabel htmlFor="pp-store">{f.store}</FieldLabel>
|
||||||
|
<Select value={v.storeId} onValueChange={(x) => set("storeId", x)}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="pp-store"
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={Boolean(errors.storeId)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={t.validation.store} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{stores.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
<StoreDot slot={s.slot} />
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.storeId ? <FieldError>{errors.storeId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.date)}>
|
||||||
|
<FieldLabel htmlFor="pp-date">{f.date}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pp-date"
|
||||||
|
type="date"
|
||||||
|
value={v.date}
|
||||||
|
max={todayIstanbul()}
|
||||||
|
onChange={(e) => set("date", e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.date)}
|
||||||
|
/>
|
||||||
|
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.price)}>
|
||||||
|
<FieldLabel htmlFor="pp-price">{f.price}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pp-price"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={v.price}
|
||||||
|
onChange={(e) => set("price", e.target.value)}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.price)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{errors.price ? <FieldError>{errors.price}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{preview ? (
|
||||||
|
<FieldDescription className="-mt-2 text-right tabular-nums">
|
||||||
|
{f.unitPreview(preview)}
|
||||||
|
</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
id="pp-promo"
|
||||||
|
checked={v.isPromo}
|
||||||
|
onCheckedChange={(x) => set("isPromo", x === true)}
|
||||||
|
/>
|
||||||
|
<FieldLabel htmlFor="pp-promo">{f.isPromo}</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
{v.isPromo ? (
|
||||||
|
<Field data-invalid={Boolean(errors.promoUntil)}>
|
||||||
|
<FieldLabel htmlFor="pp-until">{f.promoUntil}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pp-until"
|
||||||
|
type="date"
|
||||||
|
value={v.promoUntil}
|
||||||
|
min={v.date}
|
||||||
|
onChange={(e) => set("promoUntil", e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.promoUntil)}
|
||||||
|
/>
|
||||||
|
<FieldDescription>{f.promoUntilHint}</FieldDescription>
|
||||||
|
{errors.promoUntil ? <FieldError>{errors.promoUntil}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="pp-note">{f.note}</FieldLabel>
|
||||||
|
<Input id="pp-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,271 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||||
|
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, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import { AttachmentStrip } from "@/features/attachments/components/attachment-strip";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { formatUnitPrice } from "@/shared/prices";
|
||||||
|
import { deleteProductPriceAction } from "../actions";
|
||||||
|
import { priceProductOption } from "../options";
|
||||||
|
import type { PriceRow, ProductPriceDetail } from "../service";
|
||||||
|
import { OffersList, StoreDot, ymdLabel } from "./offers-list";
|
||||||
|
import { PriceFormDialog } from "./price-dialog";
|
||||||
|
|
||||||
|
const t = tr.productPrices;
|
||||||
|
|
||||||
|
const shortDate = (ymd: string) =>
|
||||||
|
new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" }).format(
|
||||||
|
new Date(`${ymd}T12:00:00+03:00`),
|
||||||
|
);
|
||||||
|
const compactTl = (minor: number) =>
|
||||||
|
new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 0 }).format(minor / 100) + " ₺";
|
||||||
|
|
||||||
|
/** Mağaza başına paket fiyatı; fiyat yeni kayda kadar geçerli (basamaklı çizgi). */
|
||||||
|
function PriceChart({ data }: { data: ProductPriceDetail }) {
|
||||||
|
const config = Object.fromEntries(
|
||||||
|
data.series.map((s) => [`s${s.id}`, { label: s.label, color: `var(--series-${s.slot})` }]),
|
||||||
|
) satisfies ChartConfig;
|
||||||
|
return (
|
||||||
|
<ChartContainer
|
||||||
|
config={config}
|
||||||
|
className="aspect-auto h-64 w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label={t.detail.chartLabel(data.product.label)}
|
||||||
|
>
|
||||||
|
<LineChart data={data.points} margin={{ left: 4, right: 12, top: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={24}
|
||||||
|
tickFormatter={(v: string) => shortDate(v)}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={64}
|
||||||
|
domain={["auto", "auto"]}
|
||||||
|
tickFormatter={(v: number) => compactTl(v)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) =>
|
||||||
|
payload?.[0] ? ymdLabel(String(payload[0].payload.date)) : ""
|
||||||
|
}
|
||||||
|
formatter={(value, name) => (
|
||||||
|
<div className="flex w-full items-center justify-between gap-3">
|
||||||
|
<span className="flex items-center">
|
||||||
|
<StoreDot slot={data.series.find((s) => `s${s.id}` === name)?.slot ?? 1} />
|
||||||
|
{config[String(name)]?.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tabular-nums">{formatMoney(Number(value))}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
{data.series.map((s) => (
|
||||||
|
<Line
|
||||||
|
key={s.id}
|
||||||
|
dataKey={`s${s.id}`}
|
||||||
|
type="stepAfter"
|
||||||
|
stroke={`var(--color-s${s.id})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
connectNulls
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /stok/urunler/:id — ürün bilgisi, mağazalardaki fiyatlar, fiyat geçmişi ve kayıtlar. */
|
||||||
|
export function ProductPriceView({ data }: { data: ProductPriceDetail }) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<PriceRow | null>(null);
|
||||||
|
const p = data.product;
|
||||||
|
const canAdd = data.canWrite && !p.archived && data.stores.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href="/stok/urunler">
|
||||||
|
<ArrowLeft />
|
||||||
|
{t.detail.back}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<PageHeader
|
||||||
|
title={p.label}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<Link href={`/stok/${p.productTypeId}`} className="hover:underline">
|
||||||
|
{t.detail.typeLink(p.productTypeName)}
|
||||||
|
</Link>
|
||||||
|
{" · "}
|
||||||
|
{p.packageLabel}
|
||||||
|
{p.barcode ? ` · ${tr.products.barcode(p.barcode)}` : ""}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
canAdd ? (
|
||||||
|
<Button size="sm" onClick={() => setAdding(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.add}
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="-mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
{p.archived ? <Badge variant="secondary">{tr.products.archived}</Badge> : null}
|
||||||
|
{p.conversionMissing ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
{tr.products.conversionMissing}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<AttachmentStrip items={p.images} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t.detail.currentTitle}</CardTitle>
|
||||||
|
<CardDescription>{t.detail.currentDescription}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{data.offers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.noPrices}</p>
|
||||||
|
) : (
|
||||||
|
<OffersList offers={data.offers} typeUnit={p.typeUnit} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{data.history.length > 1 ? (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t.detail.chartTitle}</CardTitle>
|
||||||
|
<CardDescription>{t.detail.chartDescription}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PriceChart data={data} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data.history.length ? (
|
||||||
|
<section className="flex flex-col gap-2" aria-labelledby="price-history-title">
|
||||||
|
<h2 id="price-history-title" className="text-base font-medium">
|
||||||
|
{t.detail.historyTitle}
|
||||||
|
</h2>
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{data.history.map((r) => (
|
||||||
|
<li key={r.id} className="flex items-start gap-3 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="flex items-center text-sm font-medium">
|
||||||
|
<StoreDot slot={r.store.slot} />
|
||||||
|
{r.store.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{ymdLabel(r.date)}
|
||||||
|
{r.isPromo
|
||||||
|
? ` · ${r.promoEnded ? t.promoEnded : t.promo}${
|
||||||
|
r.promoUntil ? ` (${t.promoUntil(ymdLabel(r.promoUntil))})` : ""
|
||||||
|
}`
|
||||||
|
: ""}
|
||||||
|
{` · ${t.sources[r.source.split(":")[0]!] ?? r.source}`}
|
||||||
|
{r.note ? ` · ${r.note}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm tabular-nums">
|
||||||
|
<p className="font-medium">{formatMoney(r.priceMinor)}</p>
|
||||||
|
{r.unitPriceMinor !== null ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatUnitPrice(r.unitPriceMinor, p.typeUnit)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{data.canWrite ? (
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t.detail.deletePrice}
|
||||||
|
onClick={() => setDeleting(r)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{adding ? (
|
||||||
|
<PriceFormDialog
|
||||||
|
products={[priceProductOption(p)]}
|
||||||
|
productId={p.id}
|
||||||
|
stores={data.stores}
|
||||||
|
onClose={() => setAdding(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AlertDialog open={deleting !== null} onOpenChange={(o) => !o && setDeleting(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t.detail.deleteTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t.detail.deleteDescription}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
const r = deleting;
|
||||||
|
setDeleting(null);
|
||||||
|
if (!r) return;
|
||||||
|
const res = await deleteProductPriceAction(r.id);
|
||||||
|
if (res.ok) toast.success(t.toast.deleted);
|
||||||
|
else toast.error(res.error);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.detail.deletePrice}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { unitPriceBase } from "@/shared/prices";
|
||||||
|
import type { TypePriceComparison as Data } from "../service";
|
||||||
|
import { OffersList } from "./offers-list";
|
||||||
|
import { PriceFormDialog } from "./price-dialog";
|
||||||
|
|
||||||
|
const t = tr.productPrices;
|
||||||
|
|
||||||
|
/** /stok/:id: markalar ve mağazalar birim fiyata göre; en uygun vurgulu. */
|
||||||
|
export function TypePriceComparison({ data }: { data: Data }) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{t.comparisonTitle}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t.comparisonDescription(unitPriceBase(data.typeUnit).unit)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{data.canWrite && data.products.length && data.stores.length ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.add}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{data.products.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.noProducts}</p>
|
||||||
|
) : data.offers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.noPrices}</p>
|
||||||
|
) : (
|
||||||
|
<OffersList offers={data.offers} typeUnit={data.typeUnit} showProduct />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
{adding ? (
|
||||||
|
<PriceFormDialog
|
||||||
|
products={data.products}
|
||||||
|
stores={data.stores}
|
||||||
|
onClose={() => setAdding(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ProductView } from "@/features/products/service";
|
||||||
|
|
||||||
|
/** Fiyat formu ürün seçeneği (birim fiyat önizlemesi için dönüşümle). İstemcide de kullanılır. */
|
||||||
|
export type PriceProductOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
packageLabel: string;
|
||||||
|
inTypeUnit: number | null;
|
||||||
|
typeUnit: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const priceProductOption = (p: ProductView): PriceProductOption => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
packageLabel: p.packageLabel,
|
||||||
|
inTypeUnit: p.inTypeUnit,
|
||||||
|
typeUnit: p.typeUnit,
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { getStockAnalysis } from "@/features/stock/service";
|
||||||
|
import { AuditLogModel } from "@/models/audit-log";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { productPriceFormSchema } from "@/shared/schemas/product-price";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
addProductPrice,
|
||||||
|
deleteProductPrice,
|
||||||
|
getProductPrices,
|
||||||
|
getTypePriceComparison,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
const today = todayIstanbul();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
const ref = (
|
||||||
|
model: typeof ProductModel | typeof StoreModel | typeof ProductTypeModel,
|
||||||
|
seedRef: string,
|
||||||
|
) =>
|
||||||
|
(model as typeof ProductModel)
|
||||||
|
.findOne({ seedRef })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
|
||||||
|
describe("birim fiyat karşılaştırması", () => {
|
||||||
|
it("toz deterjan: birim fiyata göre sıralı, en uygun işaretli, süren kampanya", async () => {
|
||||||
|
const c = (await getTypePriceComparison(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await ref(ProductTypeModel, "toz-deterjan"),
|
||||||
|
))!;
|
||||||
|
expect(c.typeUnit).toBe("yıkama");
|
||||||
|
const units = c.offers.map((o) => o.unitPriceMinor!);
|
||||||
|
expect(units).toEqual([...units].sort((a, b) => a - b));
|
||||||
|
expect(c.offers[0]!.cheapest).toBe(true);
|
||||||
|
expect(c.offers.filter((o) => o.cheapest)).toHaveLength(1);
|
||||||
|
const ariel = c.offers.find(
|
||||||
|
(o) => o.productLabel.startsWith("Ariel") && o.store.label.startsWith("Migros"),
|
||||||
|
)!;
|
||||||
|
expect(ariel.price).toMatchObject({ isPromo: true, promoUntil: addDays(today, 6) });
|
||||||
|
expect(ariel.promoEnded).toBe(false);
|
||||||
|
// Birim fiyat = paket / 33 yıkama.
|
||||||
|
expect(ariel.unitPriceMinor!).toBeCloseTo((ariel.price.priceMinor * 1000) / 33_000, 6);
|
||||||
|
expect(c.products).toHaveLength(3);
|
||||||
|
expect(
|
||||||
|
await getTypePriceComparison(fatma, cakir, await ref(ProductTypeModel, "toz-deterjan")),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("süresi biten kampanyada normal fiyata dönülür; eski fiyat işaretlenir", async () => {
|
||||||
|
const sut = (await getProductPrices(ayse, cakir, await ref(ProductModel, "pinar-sut")))!;
|
||||||
|
const a101 = sut.offers.find((o) => o.store.label.startsWith("A101"))!;
|
||||||
|
expect(a101).toMatchObject({ fellBack: true, promoEnded: false });
|
||||||
|
expect(a101.price.isPromo).toBe(false);
|
||||||
|
expect(a101.price.date).toBe(addDays(today, -30));
|
||||||
|
expect(sut.history.some((h) => h.isPromo && h.promoEnded)).toBe(true);
|
||||||
|
expect(sut.series.length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(sut.points.length).toBeGreaterThan(5);
|
||||||
|
|
||||||
|
const reis = (await getProductPrices(ayse, cakir, await ref(ProductModel, "reis-mercimek")))!;
|
||||||
|
expect(reis.offers.find((o) => o.store.label.startsWith("Metro"))!.stale).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fiyat girişi", () => {
|
||||||
|
it("ekle, gelecek tarih ve arşivli mağaza reddi, izleyici yazamaz, sil + denetim", async () => {
|
||||||
|
const productId = await ref(ProductModel, "omo-6kg");
|
||||||
|
const storeId = await ref(StoreModel, "sok");
|
||||||
|
const form = (over: Record<string, unknown> = {}) =>
|
||||||
|
productPriceFormSchema.parse({
|
||||||
|
productId,
|
||||||
|
storeId,
|
||||||
|
date: today,
|
||||||
|
price: "449,90",
|
||||||
|
isPromo: false,
|
||||||
|
promoUntil: "",
|
||||||
|
note: "",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
productPriceFormSchema.safeParse({ ...form(), price: "0", isPromo: false }).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
productPriceFormSchema.safeParse({
|
||||||
|
productId,
|
||||||
|
storeId,
|
||||||
|
date: today,
|
||||||
|
price: "10",
|
||||||
|
isPromo: true,
|
||||||
|
promoUntil: addDays(today, -1),
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect((await addProductPrice(ayse, cakir, form({ date: addDays(today, 1) }))).ok).toBe(false);
|
||||||
|
expect((await addProductPrice(zeynep, cakir, form())).ok).toBe(false);
|
||||||
|
|
||||||
|
const r = await addProductPrice(ayse, cakir, form());
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
let d = (await getProductPrices(ayse, cakir, productId))!;
|
||||||
|
const sok = d.offers.find((o) => o.store.id === storeId)!;
|
||||||
|
expect(sok.price).toMatchObject({ priceMinor: 449_90, date: today, source: "manual" });
|
||||||
|
// 6 kg = 40 yıkama → 11,2475 TL/yıkama.
|
||||||
|
expect(sok.unitPriceMinor).toBeCloseTo(1124.75, 6);
|
||||||
|
|
||||||
|
const priceId = r.ok ? r.data.id : "";
|
||||||
|
expect((await deleteProductPrice(fatma, cakir, priceId)).ok).toBe(false);
|
||||||
|
expect((await deleteProductPrice(ayse, cakir, priceId)).ok).toBe(true);
|
||||||
|
expect(await AuditLogModel.exists({ action: "price.delete", targetId: priceId })).toBeTruthy();
|
||||||
|
d = (await getProductPrices(ayse, cakir, productId))!;
|
||||||
|
expect(d.history.some((h) => h.id === priceId)).toBe(false);
|
||||||
|
|
||||||
|
await StoreModel.updateOne({ _id: storeId }, { $set: { archivedAt: new Date() } });
|
||||||
|
expect(await addProductPrice(ayse, cakir, form())).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
fieldErrors: { storeId: expect.any(String) },
|
||||||
|
});
|
||||||
|
await StoreModel.updateOne({ _id: storeId }, { $set: { archivedAt: null } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tüketim analizinde maliyet", () => {
|
||||||
|
it("aylık ortalama tüketim × son birim fiyat", async () => {
|
||||||
|
const a = (await getStockAnalysis(ayse, cakir))!;
|
||||||
|
const deterjan = a.rows.find((r) => r.name === "Toz Deterjan")!;
|
||||||
|
expect(deterjan.unitPrice).not.toBeNull();
|
||||||
|
// Maliyet yuvarlanmamış ortalamadan; gösterilen ortalama yuvarlıdır (≤ %1 fark).
|
||||||
|
const expected = (deterjan.avgMonthlyUsed! * deterjan.unitPrice!.minor) / 1000;
|
||||||
|
expect(Math.abs(deterjan.avgMonthlyCostMinor! - expected) / expected).toBeLessThan(0.01);
|
||||||
|
// Ürünü (marka/paket) olmayan tipte maliyet yok.
|
||||||
|
expect(a.rows.find((r) => r.name === "Domates")!.avgMonthlyCostMinor).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||||
|
import { listProducts, type ProductView } from "@/features/products/service";
|
||||||
|
import { storeLabel } from "@/features/stores/service";
|
||||||
|
import { priceProductOption, type PriceProductOption } from "./options";
|
||||||
|
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 { ProductModel } from "@/models/product";
|
||||||
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import {
|
||||||
|
effectivePrice,
|
||||||
|
promoEnded,
|
||||||
|
rankOffers,
|
||||||
|
seriesSlot,
|
||||||
|
unitPrice,
|
||||||
|
type PriceSource,
|
||||||
|
type RankedOffer,
|
||||||
|
} from "@/shared/prices";
|
||||||
|
import { packageInTypeUnit } from "@/shared/products";
|
||||||
|
import type { ProductPriceForm } from "@/shared/schemas/product-price";
|
||||||
|
import { compareTr } from "@/shared/search";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün fiyatları (Bölüm 6.11). Hane bazlı; okuma viewer, yazma member. Birim fiyat ürünün
|
||||||
|
* dönüşümüyle okuma sırasında hesaplanır (saf motor: shared/prices).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.productPrices;
|
||||||
|
|
||||||
|
type PriceLean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
productId: Types.ObjectId;
|
||||||
|
storeId: Types.ObjectId;
|
||||||
|
date: string;
|
||||||
|
priceMinor: number;
|
||||||
|
isPromo: boolean;
|
||||||
|
promoUntil: string | null;
|
||||||
|
source: PriceSource;
|
||||||
|
note: string;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoreLean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
branch: string;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreRef = { id: string; label: string; slot: number; archived: boolean };
|
||||||
|
|
||||||
|
export type PriceRow = {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
store: StoreRef;
|
||||||
|
date: string;
|
||||||
|
priceMinor: number;
|
||||||
|
isPromo: boolean;
|
||||||
|
promoUntil: string | null;
|
||||||
|
promoEnded: boolean;
|
||||||
|
source: PriceSource;
|
||||||
|
note: string;
|
||||||
|
/** Ürün tipi biriminde 1 birimin fiyatı (kesirli kuruş; dönüşüm yoksa null). */
|
||||||
|
unitPriceMinor: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreOffer = {
|
||||||
|
/** Teklif anahtarı: ürün + mağaza. */
|
||||||
|
key: string;
|
||||||
|
productId: string;
|
||||||
|
productLabel: string;
|
||||||
|
packageLabel: string;
|
||||||
|
store: StoreRef;
|
||||||
|
price: PriceRow;
|
||||||
|
/** Son kayıt süresi biten kampanyaydı; önceki normal fiyat gösteriliyor. */
|
||||||
|
fellBack: boolean;
|
||||||
|
promoEnded: boolean;
|
||||||
|
ageDays: number;
|
||||||
|
stale: boolean;
|
||||||
|
unitPriceMinor: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductPriceDetail = {
|
||||||
|
product: ProductView;
|
||||||
|
offers: RankedOffer<StoreOffer>[];
|
||||||
|
/** Tüm kayıtlar, yeniden eskiye. */
|
||||||
|
history: PriceRow[];
|
||||||
|
/** Grafik: mağaza serileri ve tarih noktaları (paket fiyatı, kuruş). */
|
||||||
|
series: StoreRef[];
|
||||||
|
points: Record<string, number | string>[];
|
||||||
|
stores: StoreRef[];
|
||||||
|
canWrite: boolean;
|
||||||
|
today: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TypePriceComparison = {
|
||||||
|
typeUnit: string;
|
||||||
|
offers: RankedOffer<StoreOffer>[];
|
||||||
|
/** Fiyat girişi seçenekleri. */
|
||||||
|
products: PriceProductOption[];
|
||||||
|
stores: StoreRef[];
|
||||||
|
canWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hanenin tüm mağazaları; renk yuvası ekleniş sırasına göre sabit. */
|
||||||
|
async function loadStores(householdId: Types.ObjectId) {
|
||||||
|
const rows = await StoreModel.find({ householdId })
|
||||||
|
.select({ name: 1, branch: 1, archivedAt: 1, createdAt: 1 })
|
||||||
|
.sort({ createdAt: 1, _id: 1 })
|
||||||
|
.lean<StoreLean[]>();
|
||||||
|
return new Map(
|
||||||
|
rows.map((s, i) => [
|
||||||
|
String(s._id),
|
||||||
|
{
|
||||||
|
id: String(s._id),
|
||||||
|
label: storeLabel(s),
|
||||||
|
slot: seriesSlot(i),
|
||||||
|
archived: Boolean(s.archivedAt),
|
||||||
|
} satisfies StoreRef,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortRecords = (a: PriceLean, b: PriceLean) =>
|
||||||
|
a.date === b.date ? a.createdAt.getTime() - b.createdAt.getTime() : a.date < b.date ? -1 : 1;
|
||||||
|
|
||||||
|
async function loadPrices(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
|
||||||
|
const rows = await ProductPriceModel.find({
|
||||||
|
householdId,
|
||||||
|
productId: { $in: productIds },
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<PriceLean[]>();
|
||||||
|
return rows.sort(sortRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRow(r: PriceLean, store: StoreRef, inTypeUnit: number | null, today: string): PriceRow {
|
||||||
|
return {
|
||||||
|
id: String(r._id),
|
||||||
|
productId: String(r.productId),
|
||||||
|
store,
|
||||||
|
date: r.date,
|
||||||
|
priceMinor: r.priceMinor,
|
||||||
|
isPromo: r.isPromo,
|
||||||
|
promoUntil: r.promoUntil,
|
||||||
|
promoEnded: promoEnded(r, today),
|
||||||
|
source: r.source,
|
||||||
|
note: r.note,
|
||||||
|
unitPriceMinor: inTypeUnit ? unitPrice(r.priceMinor, inTypeUnit) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün × mağaza başına geçerli fiyat teklifleri (sıralanmamış). */
|
||||||
|
function offersFor(
|
||||||
|
products: ProductView[],
|
||||||
|
prices: PriceLean[],
|
||||||
|
stores: Map<string, StoreRef>,
|
||||||
|
today: string,
|
||||||
|
): StoreOffer[] {
|
||||||
|
const groups = new Map<string, PriceLean[]>();
|
||||||
|
for (const r of prices) {
|
||||||
|
const key = `${r.productId}:${r.storeId}`;
|
||||||
|
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||||
|
}
|
||||||
|
const byId = new Map(products.map((p) => [p.id, p]));
|
||||||
|
const out: StoreOffer[] = [];
|
||||||
|
for (const [key, records] of groups) {
|
||||||
|
const p = byId.get(String(records[0]!.productId));
|
||||||
|
const store = stores.get(String(records[0]!.storeId));
|
||||||
|
if (!p || !store) continue;
|
||||||
|
const eff = effectivePrice(records, today)!;
|
||||||
|
const row = toRow(eff.record, store, p.inTypeUnit, today);
|
||||||
|
out.push({
|
||||||
|
key,
|
||||||
|
productId: p.id,
|
||||||
|
productLabel: p.label,
|
||||||
|
packageLabel: p.packageLabel,
|
||||||
|
store,
|
||||||
|
price: row,
|
||||||
|
fellBack: eff.fellBack,
|
||||||
|
promoEnded: eff.promoEnded,
|
||||||
|
ageDays: eff.ageDays,
|
||||||
|
stale: eff.stale,
|
||||||
|
unitPriceMinor: row.unitPriceMinor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeStores = (stores: Map<string, StoreRef>) =>
|
||||||
|
[...stores.values()].filter((s) => !s.archived).sort((a, b) => compareTr(a.label, b.label));
|
||||||
|
|
||||||
|
/** /stok/urunler/:id — ürünün mağaza bazında fiyatları, geçmişi ve grafiği. */
|
||||||
|
export async function getProductPrices(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
productId: string,
|
||||||
|
): Promise<ProductPriceDetail | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const id = toObjectId(productId);
|
||||||
|
if (!id) return null;
|
||||||
|
const doc = await ProductModel.findOne({ _id: id, householdId: m.householdId })
|
||||||
|
.select({ productTypeId: 1 })
|
||||||
|
.lean<{ productTypeId: Types.ObjectId }>();
|
||||||
|
if (!doc) return null;
|
||||||
|
const list = await listProducts(userId, householdId, {
|
||||||
|
productTypeId: String(doc.productTypeId),
|
||||||
|
includeArchived: true,
|
||||||
|
});
|
||||||
|
const product = list?.products.find((p) => p.id === productId);
|
||||||
|
if (!product) return null;
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const [stores, prices] = await Promise.all([
|
||||||
|
loadStores(m.householdId),
|
||||||
|
loadPrices(m.householdId, [id]),
|
||||||
|
]);
|
||||||
|
const history = prices
|
||||||
|
.map((r) => toRow(r, stores.get(String(r.storeId))!, product.inTypeUnit, today))
|
||||||
|
.filter((r) => r.store)
|
||||||
|
.reverse();
|
||||||
|
const seriesIds = [...new Set(prices.map((r) => String(r.storeId)))];
|
||||||
|
const series = seriesIds
|
||||||
|
.map((sid) => stores.get(sid)!)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.slot - b.slot);
|
||||||
|
// Aynı gün aynı mağazada birden çok kayıt: son giriş geçerli.
|
||||||
|
const byDate = new Map<string, Record<string, number | string>>();
|
||||||
|
for (const r of prices) {
|
||||||
|
const point = byDate.get(r.date) ?? { date: r.date };
|
||||||
|
point[`s${String(r.storeId)}`] = r.priceMinor;
|
||||||
|
byDate.set(r.date, point);
|
||||||
|
}
|
||||||
|
// Son nokta bugün: her mağazanın geçerli fiyatı (çizgi bugüne uzar; biten kampanyada normal fiyat).
|
||||||
|
const offers = offersFor([product], prices, stores, today);
|
||||||
|
if (offers.length) {
|
||||||
|
const point = byDate.get(today) ?? { date: today };
|
||||||
|
for (const o of offers) point[`s${o.store.id}`] ??= o.price.priceMinor;
|
||||||
|
byDate.set(today, point);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
product,
|
||||||
|
offers: rankOffers(offers),
|
||||||
|
history,
|
||||||
|
series,
|
||||||
|
points: [...byDate.values()].sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1)),
|
||||||
|
stores: activeStores(stores),
|
||||||
|
canWrite: hasHouseholdRole(m.role, "member"),
|
||||||
|
today,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün tipi detayı: tüm marka/paket ve mağazalar birim fiyata göre. */
|
||||||
|
export async function getTypePriceComparison(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
productTypeId: string,
|
||||||
|
): Promise<TypePriceComparison | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const list = await listProducts(userId, householdId, { productTypeId });
|
||||||
|
if (!list) return null;
|
||||||
|
const type = await ProductTypeModel.findOne({
|
||||||
|
_id: new Types.ObjectId(productTypeId),
|
||||||
|
householdId: m.householdId,
|
||||||
|
})
|
||||||
|
.select({ unit: 1 })
|
||||||
|
.lean<{ unit: string }>();
|
||||||
|
if (!type) return null;
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const [stores, prices] = await Promise.all([
|
||||||
|
loadStores(m.householdId),
|
||||||
|
loadPrices(
|
||||||
|
m.householdId,
|
||||||
|
list.products.map((p) => new Types.ObjectId(p.id)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
typeUnit: type.unit,
|
||||||
|
offers: rankOffers(
|
||||||
|
offersFor(list.products, prices, stores, today).filter((o) => !o.store.archived),
|
||||||
|
),
|
||||||
|
products: list.products.map(priceProductOption),
|
||||||
|
stores: activeStores(stores),
|
||||||
|
canWrite: list.canWrite,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün tipi birimine çevrilebilen ürünlerin mağaza başına geçerli fiyatı (planlayıcı girdisi). */
|
||||||
|
export type TypeOffer = {
|
||||||
|
productId: string;
|
||||||
|
productTypeId: string;
|
||||||
|
storeId: string;
|
||||||
|
priceMinor: number;
|
||||||
|
inTypeUnit: number;
|
||||||
|
unitPriceMinor: number;
|
||||||
|
date: string;
|
||||||
|
stale: boolean;
|
||||||
|
isPromo: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün tiplerinin tüm (arşivsiz) ürünlerinin mağaza başına geçerli fiyatları. Dönüşümü eksik
|
||||||
|
* ürünler atlanır. `activeOnly`: arşivli ürün ve mağazaları dışarıda bırak.
|
||||||
|
*/
|
||||||
|
export async function loadTypeOffers(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
productTypeIds: Types.ObjectId[],
|
||||||
|
{ today = todayIstanbul(), activeOnly = false } = {},
|
||||||
|
): Promise<TypeOffer[]> {
|
||||||
|
if (!productTypeIds.length) return [];
|
||||||
|
const [products, types, archivedStores] = await Promise.all([
|
||||||
|
ProductModel.find({
|
||||||
|
householdId,
|
||||||
|
productTypeId: { $in: productTypeIds },
|
||||||
|
...(activeOnly ? { archivedAt: null } : {}),
|
||||||
|
})
|
||||||
|
.select({ productTypeId: 1, packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
|
||||||
|
.lean<
|
||||||
|
{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
productTypeId: Types.ObjectId;
|
||||||
|
packageQuantity: number;
|
||||||
|
packageUnit: string;
|
||||||
|
typeQuantity: number | null;
|
||||||
|
}[]
|
||||||
|
>(),
|
||||||
|
ProductTypeModel.find({ householdId, _id: { $in: productTypeIds } })
|
||||||
|
.select({ unit: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; unit: string }[]>(),
|
||||||
|
activeOnly
|
||||||
|
? StoreModel.find({ householdId, archivedAt: { $ne: null } })
|
||||||
|
.select({ _id: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId }[]>()
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
const skipStore = new Set(archivedStores.map((x) => String(x._id)));
|
||||||
|
const unitOf = new Map(types.map((x) => [String(x._id), x.unit]));
|
||||||
|
const prices = await loadPrices(
|
||||||
|
householdId,
|
||||||
|
products.map((p) => p._id),
|
||||||
|
);
|
||||||
|
const groups = new Map<string, PriceLean[]>();
|
||||||
|
for (const r of prices) {
|
||||||
|
if (skipStore.has(String(r.storeId))) continue;
|
||||||
|
const key = `${r.productId}:${r.storeId}`;
|
||||||
|
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||||
|
}
|
||||||
|
const productById = new Map(products.map((p) => [String(p._id), p]));
|
||||||
|
const out: TypeOffer[] = [];
|
||||||
|
for (const records of groups.values()) {
|
||||||
|
const p = productById.get(String(records[0]!.productId))!;
|
||||||
|
const typeId = String(p.productTypeId);
|
||||||
|
const inType = packageInTypeUnit({
|
||||||
|
packageQuantity: p.packageQuantity,
|
||||||
|
packageUnit: p.packageUnit,
|
||||||
|
typeUnit: unitOf.get(typeId) ?? "",
|
||||||
|
typeQuantity: p.typeQuantity,
|
||||||
|
});
|
||||||
|
if (!inType) continue;
|
||||||
|
const eff = effectivePrice(records, today)!;
|
||||||
|
out.push({
|
||||||
|
productId: String(p._id),
|
||||||
|
productTypeId: typeId,
|
||||||
|
storeId: String(records[0]!.storeId),
|
||||||
|
priceMinor: eff.record.priceMinor,
|
||||||
|
inTypeUnit: inType,
|
||||||
|
unitPriceMinor: unitPrice(eff.record.priceMinor, inType),
|
||||||
|
date: eff.record.date,
|
||||||
|
stale: eff.stale,
|
||||||
|
isPromo: eff.record.isPromo && !eff.promoEnded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bir ürün tipinin tüm fiyat kayıtları, ürün tipi biriminde birim fiyatla (dönüşümsüzler hariç). */
|
||||||
|
export async function loadTypePriceHistory(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
productTypeId: Types.ObjectId,
|
||||||
|
): Promise<{ date: string; productId: string; storeId: string; unitPriceMinor: number }[]> {
|
||||||
|
const [products, type] = await Promise.all([
|
||||||
|
ProductModel.find({ householdId, productTypeId })
|
||||||
|
.select({ packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
|
||||||
|
.lean<
|
||||||
|
{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
packageQuantity: number;
|
||||||
|
packageUnit: string;
|
||||||
|
typeQuantity: number | null;
|
||||||
|
}[]
|
||||||
|
>(),
|
||||||
|
ProductTypeModel.findOne({ householdId, _id: productTypeId })
|
||||||
|
.select({ unit: 1 })
|
||||||
|
.lean<{ unit: string }>(),
|
||||||
|
]);
|
||||||
|
if (!type) return [];
|
||||||
|
const inType = new Map(
|
||||||
|
products.map((p) => [
|
||||||
|
String(p._id),
|
||||||
|
packageInTypeUnit({
|
||||||
|
packageQuantity: p.packageQuantity,
|
||||||
|
packageUnit: p.packageUnit,
|
||||||
|
typeUnit: type.unit,
|
||||||
|
typeQuantity: p.typeQuantity,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const prices = await loadPrices(
|
||||||
|
householdId,
|
||||||
|
products.map((p) => p._id),
|
||||||
|
);
|
||||||
|
return prices.flatMap((r) => {
|
||||||
|
const q = inType.get(String(r.productId));
|
||||||
|
return q
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
date: r.date,
|
||||||
|
productId: String(r.productId),
|
||||||
|
storeId: String(r.storeId),
|
||||||
|
unitPriceMinor: unitPrice(r.priceMinor, q),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar).
|
||||||
|
* Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır.
|
||||||
|
*/
|
||||||
|
export async function latestTypeUnitPrices(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
productTypeIds: Types.ObjectId[],
|
||||||
|
today = todayIstanbul(),
|
||||||
|
): Promise<Map<string, { unitPriceMinor: number; date: string }>> {
|
||||||
|
const out = new Map<string, { unitPriceMinor: number; date: string }>();
|
||||||
|
for (const o of await loadTypeOffers(householdId, productTypeIds, { today })) {
|
||||||
|
const prev = out.get(o.productTypeId);
|
||||||
|
if (!prev || o.date > prev.date)
|
||||||
|
out.set(o.productTypeId, { unitPriceMinor: o.unitPriceMinor, date: o.date });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Yazma ---
|
||||||
|
|
||||||
|
async function activeRefs(m: Membership, input: Pick<ProductPriceForm, "productId" | "storeId">) {
|
||||||
|
const [product, store] = await Promise.all([
|
||||||
|
ProductModel.exists({
|
||||||
|
_id: new Types.ObjectId(input.productId),
|
||||||
|
householdId: m.householdId,
|
||||||
|
archivedAt: null,
|
||||||
|
}),
|
||||||
|
StoreModel.exists({
|
||||||
|
_id: new Types.ObjectId(input.storeId),
|
||||||
|
householdId: m.householdId,
|
||||||
|
archivedAt: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (!product) return fail(t.validation.product, { productId: t.validation.product });
|
||||||
|
if (!store) return fail(t.validation.store, { storeId: t.validation.store });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fiyat kaydı ekler. Gelecek tarihli fiyat girilemez. */
|
||||||
|
export async function addProductPrice(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: ProductPriceForm,
|
||||||
|
source: PriceSource = "manual",
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
if (input.date > todayIstanbul())
|
||||||
|
return fail(t.validation.futureDate, { date: t.validation.futureDate });
|
||||||
|
const bad = await activeRefs(m, input);
|
||||||
|
if (bad) return bad;
|
||||||
|
const doc = await ProductPriceModel.create({
|
||||||
|
householdId: m.householdId,
|
||||||
|
productId: new Types.ObjectId(input.productId),
|
||||||
|
storeId: new Types.ObjectId(input.storeId),
|
||||||
|
date: input.date,
|
||||||
|
priceMinor: input.priceMinor,
|
||||||
|
isPromo: input.isPromo,
|
||||||
|
promoUntil: input.promoUntil,
|
||||||
|
source,
|
||||||
|
note: input.note,
|
||||||
|
createdBy: new Types.ObjectId(actorId),
|
||||||
|
});
|
||||||
|
return ok({ id: String(doc._id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProductPrice(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
priceId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const id = toObjectId(priceId);
|
||||||
|
const row = id
|
||||||
|
? await ProductPriceModel.findOne({
|
||||||
|
_id: id,
|
||||||
|
householdId: m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<PriceLean>()
|
||||||
|
: null;
|
||||||
|
if (!row) return fail(tr.common.notFound);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await ProductPriceModel.updateOne({ _id: row._id }, { $set: { deletedAt: new Date() } });
|
||||||
|
await writeAudit({
|
||||||
|
action: "price.delete",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: m.householdId,
|
||||||
|
targetType: "productPrice",
|
||||||
|
targetId: priceId,
|
||||||
|
diff: {
|
||||||
|
productId: String(row.productId),
|
||||||
|
storeId: String(row.storeId),
|
||||||
|
date: row.date,
|
||||||
|
priceMinor: row.priceMinor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PriceProductOption };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"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 { productFormSchema } from "@/shared/schemas/product";
|
||||||
|
import { createProduct, setProductArchived, updateProduct } 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, R>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
input: unknown,
|
||||||
|
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
|
||||||
|
): Promise<ActionResult<R>> {
|
||||||
|
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 (id boş) veya mevcut ürün. */
|
||||||
|
export async function saveProductAction(productId: unknown, input: unknown) {
|
||||||
|
if (productId) {
|
||||||
|
const id = idSchema.safeParse(productId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(productFormSchema, input, (u, h, d) => updateProduct(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
return run(productFormSchema, input, createProduct);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setProductArchivedAction(productId: unknown, archived: unknown) {
|
||||||
|
const id = idSchema.safeParse(productId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.boolean(), archived, (u, h, a) => setProductArchived(u, h, id.data, a));
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
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 { AttachmentPicker } from "@/features/attachments/components/attachment-picker";
|
||||||
|
import type { AttachmentInfo } from "@/features/attachments/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { autoConvertible, packageInTypeUnit } from "@/shared/products";
|
||||||
|
import { formatQuantity, parseQuantity } from "@/shared/quantity";
|
||||||
|
import { quantityInput, STOCK_UNITS } from "@/shared/stock";
|
||||||
|
import { saveProductAction } from "../actions";
|
||||||
|
import type { ProductTypeOption, ProductView } from "../service";
|
||||||
|
|
||||||
|
const t = tr.products;
|
||||||
|
const CUSTOM = "__custom__";
|
||||||
|
type Errors = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
/** Yeni ürün / düzenle: marka, tanım, barkod, paket, gerekirse dönüşüm, görseller. */
|
||||||
|
export function ProductFormDialog({
|
||||||
|
product,
|
||||||
|
types,
|
||||||
|
defaultTypeId,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
product: ProductView | null;
|
||||||
|
types: ProductTypeOption[];
|
||||||
|
defaultTypeId?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const initialType = product?.productTypeId ?? defaultTypeId ?? "";
|
||||||
|
const typeUnitOf = (id: string) => types.find((x) => x.id === id)?.unit ?? "";
|
||||||
|
const unitChoices = (typeUnit: string) => [
|
||||||
|
...new Set([typeUnit, ...STOCK_UNITS].filter(Boolean)),
|
||||||
|
];
|
||||||
|
const initialUnit = product?.packageUnit ?? typeUnitOf(initialType) ?? "adet";
|
||||||
|
const known = unitChoices(typeUnitOf(initialType)).includes(initialUnit);
|
||||||
|
|
||||||
|
const [v, setV] = useState({
|
||||||
|
productTypeId: initialType,
|
||||||
|
brand: product?.brand ?? "",
|
||||||
|
name: product?.name ?? "",
|
||||||
|
barcode: product?.barcode ?? "",
|
||||||
|
packageQuantity: product ? quantityInput(product.packageQuantity) : "1",
|
||||||
|
unitChoice: known ? initialUnit || "adet" : CUSTOM,
|
||||||
|
customUnit: known ? "" : initialUnit,
|
||||||
|
typeQuantity: product?.typeQuantity ? quantityInput(product.typeQuantity) : "",
|
||||||
|
note: product?.note ?? "",
|
||||||
|
});
|
||||||
|
const [images, setImages] = useState<AttachmentInfo[]>(product?.images ?? []);
|
||||||
|
const [uploading, setUploading] = useState(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 typeUnit = typeUnitOf(v.productTypeId);
|
||||||
|
const packageUnit = v.unitChoice === CUSTOM ? v.customUnit.trim() : v.unitChoice;
|
||||||
|
const needsConversion =
|
||||||
|
Boolean(typeUnit && packageUnit) && !autoConvertible(packageUnit, typeUnit);
|
||||||
|
const pkgQty = parseQuantity(v.packageQuantity);
|
||||||
|
const pkgText = pkgQty ? formatQuantity(pkgQty, packageUnit) : `1 ${packageUnit}`;
|
||||||
|
const autoValue =
|
||||||
|
pkgQty && typeUnit && !needsConversion
|
||||||
|
? packageInTypeUnit({ packageQuantity: pkgQty, packageUnit, typeUnit, typeQuantity: null })
|
||||||
|
: null;
|
||||||
|
const f = t.fields;
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await saveProductAction(product?.id ?? null, {
|
||||||
|
productTypeId: v.productTypeId,
|
||||||
|
brand: v.brand,
|
||||||
|
name: v.name,
|
||||||
|
barcode: v.barcode,
|
||||||
|
packageQuantity: v.packageQuantity,
|
||||||
|
packageUnit,
|
||||||
|
typeQuantity: needsConversion ? v.typeQuantity : "",
|
||||||
|
note: v.note,
|
||||||
|
imageIds: images.map((i) => i.id),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.saved);
|
||||||
|
onClose();
|
||||||
|
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={product ? t.editTitle : t.createTitle}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="product-variant-form" disabled={busy || uploading}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="product-variant-form" onSubmit={submit} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={Boolean(errors.productTypeId)}>
|
||||||
|
<FieldLabel htmlFor="pr-type">{f.productType}</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={v.productTypeId}
|
||||||
|
onValueChange={(x) => {
|
||||||
|
set("productTypeId", x);
|
||||||
|
if (!product && v.unitChoice !== CUSTOM) set("unitChoice", typeUnitOf(x) || "adet");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="pr-type"
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={Boolean(errors.productTypeId)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={t.validation.productType} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{types.map((x) => (
|
||||||
|
<SelectItem key={x.id} value={x.id}>
|
||||||
|
{x.name} <span className="text-xs text-muted-foreground">({x.unit})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.productTypeId ? <FieldError>{errors.productTypeId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.brand)}>
|
||||||
|
<FieldLabel htmlFor="pr-brand">{f.brand}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pr-brand"
|
||||||
|
value={v.brand}
|
||||||
|
placeholder={f.brandPlaceholder}
|
||||||
|
onChange={(e) => set("brand", e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.brand)}
|
||||||
|
autoFocus={!product}
|
||||||
|
/>
|
||||||
|
{errors.brand ? <FieldError>{errors.brand}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="pr-name">{f.name}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pr-name"
|
||||||
|
value={v.name}
|
||||||
|
placeholder={f.namePlaceholder}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.packageQuantity)}>
|
||||||
|
<FieldLabel htmlFor="pr-qty">{f.packageQuantity}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pr-qty"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={v.packageQuantity}
|
||||||
|
onChange={(e) => set("packageQuantity", e.target.value)}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.packageQuantity)}
|
||||||
|
/>
|
||||||
|
{errors.packageQuantity ? <FieldError>{errors.packageQuantity}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.packageUnit)}>
|
||||||
|
<FieldLabel htmlFor="pr-unit">{f.packageUnit}</FieldLabel>
|
||||||
|
<Select value={v.unitChoice} onValueChange={(x) => set("unitChoice", x)}>
|
||||||
|
<SelectTrigger id="pr-unit" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{v.unitChoice === CUSTOM ? tr.stock.fields.customUnit : v.unitChoice}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{unitChoices(typeUnit).map((u) => (
|
||||||
|
<SelectItem key={u} value={u}>
|
||||||
|
{u}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value={CUSTOM}>{tr.stock.fields.customUnitOption}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{v.unitChoice === CUSTOM ? (
|
||||||
|
<Input
|
||||||
|
value={v.customUnit}
|
||||||
|
placeholder={tr.stock.fields.customUnitPlaceholder}
|
||||||
|
aria-label={tr.stock.fields.customUnit}
|
||||||
|
onChange={(e) => set("customUnit", e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.packageUnit)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{errors.packageUnit ? <FieldError>{errors.packageUnit}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{needsConversion ? (
|
||||||
|
<Field data-invalid={Boolean(errors.typeQuantity)}>
|
||||||
|
<FieldLabel htmlFor="pr-conv">{f.typeQuantity(typeUnit)}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pr-conv"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={v.typeQuantity}
|
||||||
|
onChange={(e) => set("typeQuantity", e.target.value)}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.typeQuantity)}
|
||||||
|
/>
|
||||||
|
<FieldDescription>{f.typeQuantityHint(pkgText, typeUnit)}</FieldDescription>
|
||||||
|
{errors.typeQuantity ? <FieldError>{errors.typeQuantity}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : autoValue !== null && packageUnit !== typeUnit ? (
|
||||||
|
<FieldDescription className="-mt-2">
|
||||||
|
{f.autoConversion(formatQuantity(autoValue, typeUnit))}
|
||||||
|
</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
<Field data-invalid={Boolean(errors.barcode)}>
|
||||||
|
<FieldLabel htmlFor="pr-barcode">{f.barcode}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="pr-barcode"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={v.barcode}
|
||||||
|
onChange={(e) => set("barcode", e.target.value)}
|
||||||
|
className="tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.barcode)}
|
||||||
|
/>
|
||||||
|
<FieldDescription>{f.barcodeHint}</FieldDescription>
|
||||||
|
{errors.barcode ? <FieldError>{errors.barcode}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.imageIds)}>
|
||||||
|
<FieldLabel>{f.images}</FieldLabel>
|
||||||
|
<AttachmentPicker
|
||||||
|
value={images}
|
||||||
|
onChange={setImages}
|
||||||
|
allowedKinds={["image"]}
|
||||||
|
onBusyChange={setUploading}
|
||||||
|
addLabel={f.addImage}
|
||||||
|
hint={f.imagesHint}
|
||||||
|
/>
|
||||||
|
{errors.imageIds ? <FieldError>{errors.imageIds}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="pr-note">{f.note}</FieldLabel>
|
||||||
|
<Input id="pr-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,188 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
ArchiveRestore,
|
||||||
|
AlertTriangle,
|
||||||
|
EllipsisVertical,
|
||||||
|
ImageIcon,
|
||||||
|
Pencil,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
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 {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { AttachmentThumb } from "@/features/attachments/components/attachment-strip";
|
||||||
|
import { AttachmentViewer } from "@/features/attachments/components/attachment-viewer";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { setProductArchivedAction } from "../actions";
|
||||||
|
import type { ProductTypeOption, ProductView } from "../service";
|
||||||
|
import { ProductFormDialog } from "./product-dialog";
|
||||||
|
|
||||||
|
const t = tr.products;
|
||||||
|
|
||||||
|
/** Ürün satırları: görsel, marka/tanım, paket, barkod; düzenle/arşivle. */
|
||||||
|
export function ProductList({
|
||||||
|
products,
|
||||||
|
types,
|
||||||
|
canWrite,
|
||||||
|
showType = false,
|
||||||
|
}: {
|
||||||
|
products: ProductView[];
|
||||||
|
types: ProductTypeOption[];
|
||||||
|
canWrite: boolean;
|
||||||
|
/** Katalogda ürün tipi bağlantısı. */
|
||||||
|
showType?: boolean;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState<ProductView | null>(null);
|
||||||
|
const [archiving, setArchiving] = useState<ProductView | null>(null);
|
||||||
|
const [viewer, setViewer] = useState<ProductView | null>(null);
|
||||||
|
|
||||||
|
async function setArchived(p: ProductView, archived: boolean) {
|
||||||
|
const r = await setProductArchivedAction(p.id, archived);
|
||||||
|
if (r.ok) toast.success(archived ? t.toast.archived : t.toast.unarchived);
|
||||||
|
else toast.error(r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{products.map((p) => {
|
||||||
|
const cover = p.images[0];
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={p.id}
|
||||||
|
className={cn("flex items-center gap-3 p-3", p.archived && "opacity-60")}
|
||||||
|
>
|
||||||
|
{cover ? (
|
||||||
|
<AttachmentThumb item={cover} onClick={() => setViewer(p)} className="size-14" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="flex size-14 shrink-0 items-center justify-center rounded-md border border-dashed bg-muted/40"
|
||||||
|
>
|
||||||
|
<ImageIcon className="size-5 text-muted-foreground/60" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">
|
||||||
|
<Link href={`/stok/urunler/${p.id}`} className="hover:underline">
|
||||||
|
{p.label}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-sm text-muted-foreground tabular-nums">
|
||||||
|
{p.packageLabel}
|
||||||
|
{showType ? (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<Link href={`/stok/${p.productTypeId}`} className="hover:underline">
|
||||||
|
{p.productTypeName}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
{p.barcode ? <span className="tabular-nums">{t.barcode(p.barcode)}</span> : null}
|
||||||
|
{p.images.length > 1 ? <span>{t.images(p.images.length)}</span> : null}
|
||||||
|
{p.archived ? <Badge variant="secondary">{t.archived}</Badge> : null}
|
||||||
|
{p.conversionMissing ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||||
|
title={t.conversionMissingHint}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-3" aria-hidden />
|
||||||
|
{t.conversionMissing}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canWrite ? (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`${p.label}: ${t.actions.more}`}
|
||||||
|
>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem disabled={p.archived} onSelect={() => setEditing(p)}>
|
||||||
|
<Pencil />
|
||||||
|
{t.actions.edit}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{p.archived ? (
|
||||||
|
<DropdownMenuItem onSelect={() => setArchived(p, false)}>
|
||||||
|
<ArchiveRestore />
|
||||||
|
{t.actions.unarchive}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem variant="destructive" onSelect={() => setArchiving(p)}>
|
||||||
|
<Archive />
|
||||||
|
{t.actions.archive}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<ProductFormDialog product={editing} types={types} onClose={() => setEditing(null)} />
|
||||||
|
) : null}
|
||||||
|
<AttachmentViewer
|
||||||
|
items={viewer?.images ?? []}
|
||||||
|
startIndex={0}
|
||||||
|
open={viewer !== null}
|
||||||
|
onOpenChange={(o) => !o && setViewer(null)}
|
||||||
|
/>
|
||||||
|
<AlertDialog open={archiving !== null} onOpenChange={(o) => !o && setArchiving(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{archiving ? t.archiveDescription(archiving.label) : null}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
const p = archiving;
|
||||||
|
setArchiving(null);
|
||||||
|
if (p) await setArchived(p, true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.actions.archive}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, Plus, Search } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { foldTr } from "@/shared/search";
|
||||||
|
import type { ProductTypeOption, ProductView } from "../service";
|
||||||
|
import { ProductFormDialog } from "./product-dialog";
|
||||||
|
import { ProductList } from "./product-list";
|
||||||
|
|
||||||
|
const t = tr.products;
|
||||||
|
|
||||||
|
/** /stok/urunler: tüm ürünler ürün tipine göre gruplu; marka, tanım veya barkodla arama. */
|
||||||
|
export function ProductsCatalog({
|
||||||
|
products,
|
||||||
|
types,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
products: ProductView[];
|
||||||
|
types: ProductTypeOption[];
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const q = foldTr(query.trim());
|
||||||
|
const digits = query.replace(/\s/g, "");
|
||||||
|
const match = (p: ProductView) =>
|
||||||
|
(showArchived || !p.archived) &&
|
||||||
|
(!q ||
|
||||||
|
foldTr(`${p.label} ${p.productTypeName}`).includes(q) ||
|
||||||
|
(/^\d+$/.test(digits) && (p.barcode ?? "").includes(digits)));
|
||||||
|
const by = new Map<string, { name: string; items: ProductView[] }>();
|
||||||
|
for (const p of products.filter(match)) {
|
||||||
|
const g = by.get(p.productTypeId) ?? { name: p.productTypeName, items: [] };
|
||||||
|
g.items.push(p);
|
||||||
|
by.set(p.productTypeId, g);
|
||||||
|
}
|
||||||
|
return [...by.entries()];
|
||||||
|
}, [products, query, showArchived]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href="/stok">
|
||||||
|
<ArrowLeft />
|
||||||
|
{t.back}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<PageHeader
|
||||||
|
title={t.title}
|
||||||
|
description={t.description}
|
||||||
|
actions={
|
||||||
|
canWrite && types.length ? (
|
||||||
|
<Button size="sm" onClick={() => setCreating(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.new}
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative min-w-48 flex-1">
|
||||||
|
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t.search}
|
||||||
|
aria-label={t.search}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{products.some((p) => p.archived) ? (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked={showArchived} onCheckedChange={(x) => setShowArchived(x === true)} />
|
||||||
|
{t.showArchived}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.empty}
|
||||||
|
</p>
|
||||||
|
) : groups.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.noMatch}</p>
|
||||||
|
) : (
|
||||||
|
groups.map(([typeId, g]) => (
|
||||||
|
<section key={typeId} className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground">
|
||||||
|
<Link href={`/stok/${typeId}`} className="hover:underline">
|
||||||
|
{g.name}
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
|
<ProductList products={g.items} types={types} canWrite={canWrite} />
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{creating ? (
|
||||||
|
<ProductFormDialog product={null} types={types} onClose={() => setCreating(false)} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import type { ProductTypeOption, ProductView } from "../service";
|
||||||
|
import { ProductFormDialog } from "./product-dialog";
|
||||||
|
import { ProductList } from "./product-list";
|
||||||
|
|
||||||
|
const t = tr.products;
|
||||||
|
|
||||||
|
/** /stok/:id içinde: bu ürün tipinin markaları ve paketleri. */
|
||||||
|
export function ProductsSection({
|
||||||
|
productTypeId,
|
||||||
|
products,
|
||||||
|
types,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
productTypeId: string;
|
||||||
|
products: ProductView[];
|
||||||
|
types: ProductTypeOption[];
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-2" aria-labelledby="products-title">
|
||||||
|
<div className="flex items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 id="products-title" className="text-base font-medium">
|
||||||
|
{t.sectionTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">{t.sectionDescription}</p>
|
||||||
|
</div>
|
||||||
|
{canWrite && types.some((x) => x.id === productTypeId) ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setCreating(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.new}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.emptyType}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ProductList products={products} types={types} canWrite={canWrite} />
|
||||||
|
)}
|
||||||
|
{creating ? (
|
||||||
|
<ProductFormDialog
|
||||||
|
product={null}
|
||||||
|
types={types}
|
||||||
|
defaultTypeId={productTypeId}
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { readAttachmentForUser, uploadAttachment } from "@/features/attachments/service";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { AttachmentModel } from "@/models/attachment";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { productFormSchema, type ProductFormInput } from "@/shared/schemas/product";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
createProduct,
|
||||||
|
listProducts,
|
||||||
|
listProductTypeOptions,
|
||||||
|
setProductArchived,
|
||||||
|
updateProduct,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await rm(env().UPLOAD_DIR, { recursive: true, force: true });
|
||||||
|
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(async () => {
|
||||||
|
await teardownDb();
|
||||||
|
await rm(env().UPLOAD_DIR, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeId = (ref: string) =>
|
||||||
|
ProductTypeModel.findOne({ seedRef: ref })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
|
||||||
|
const png = () =>
|
||||||
|
sharp({ create: { width: 40, height: 30, channels: 3, background: "#cc6633" } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
async function form(over: Partial<ProductFormInput> = {}) {
|
||||||
|
return productFormSchema.parse({
|
||||||
|
productTypeId: await typeId("toz-deterjan"),
|
||||||
|
brand: "Persil",
|
||||||
|
name: "Matik",
|
||||||
|
barcode: "",
|
||||||
|
packageQuantity: "4,5",
|
||||||
|
packageUnit: "kg",
|
||||||
|
typeQuantity: "30",
|
||||||
|
note: "",
|
||||||
|
imageIds: [],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ürünler (marka/paket)", () => {
|
||||||
|
it("seed: dönüşümler ve paket açıklaması; izolasyon", async () => {
|
||||||
|
const all = (await listProducts(ayse, cakir))!;
|
||||||
|
expect(all.products).toHaveLength(22);
|
||||||
|
const ariel = all.products.find((p) => p.brand === "Ariel")!;
|
||||||
|
expect(ariel).toMatchObject({
|
||||||
|
productTypeName: "Toz Deterjan",
|
||||||
|
typeUnit: "yıkama",
|
||||||
|
inTypeUnit: 33_000,
|
||||||
|
packageLabel: "5 kg · 33 yıkama",
|
||||||
|
conversionMissing: false,
|
||||||
|
});
|
||||||
|
const icim = all.products.find((p) => p.brand === "İçim")!;
|
||||||
|
expect(icim).toMatchObject({ typeQuantity: null, inTypeUnit: 500 });
|
||||||
|
const peynir = (await listProducts(ayse, cakir, {
|
||||||
|
productTypeId: await typeId("beyaz-peynir"),
|
||||||
|
}))!;
|
||||||
|
expect(peynir.products.map((p) => p.packageLabel)).toEqual(["500 g · 0,5 kg"]);
|
||||||
|
expect(await listProducts(fatma, cakir)).toBeNull();
|
||||||
|
expect((await listProductTypeOptions(ayse, cakir))!.length).toBe(19);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dönüşüm zorunlu; kendiliğinden çevrilebilende yok sayılır; barkod tek", async () => {
|
||||||
|
const missing = await createProduct(ayse, cakir, await form({ typeQuantity: "" }));
|
||||||
|
expect(missing).toMatchObject({ ok: false, fieldErrors: { typeQuantity: expect.any(String) } });
|
||||||
|
|
||||||
|
const ok = await createProduct(ayse, cakir, await form({ barcode: "8690 0000 0099 9" }));
|
||||||
|
expect(ok.ok).toBe(true);
|
||||||
|
const sut = await createProduct(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({
|
||||||
|
productTypeId: await typeId("sut"),
|
||||||
|
brand: "Torku",
|
||||||
|
packageQuantity: "1000",
|
||||||
|
packageUnit: "ml",
|
||||||
|
typeQuantity: "7",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(sut.ok).toBe(true);
|
||||||
|
const list = (await listProducts(ayse, cakir))!.products;
|
||||||
|
expect(list.find((p) => p.brand === "Persil")).toMatchObject({
|
||||||
|
barcode: "8690000000999",
|
||||||
|
inTypeUnit: 30_000,
|
||||||
|
});
|
||||||
|
expect(list.find((p) => p.brand === "Torku")).toMatchObject({
|
||||||
|
typeQuantity: null,
|
||||||
|
inTypeUnit: 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dup = await createProduct(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({ brand: "Başka", barcode: "8690000000999" }),
|
||||||
|
);
|
||||||
|
expect(dup).toMatchObject({ ok: false, fieldErrors: { barcode: expect.any(String) } });
|
||||||
|
expect(productFormSchema.safeParse({ ...(await form()), barcode: "12a" }).success).toBe(false);
|
||||||
|
expect((await createProduct(zeynep, cakir, await form({ brand: "İzleyici" }))).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("görseller: bağlanır, sıralanır, çıkarılır; okuma hane üyeleriyle sınırlı", async () => {
|
||||||
|
const a1 = await uploadAttachment(ayse, cakir, { name: "on.png", bytes: await png() });
|
||||||
|
const a2 = await uploadAttachment(ayse, cakir, { name: "arka.png", bytes: await png() });
|
||||||
|
const id1 = a1.ok ? a1.data.id : "";
|
||||||
|
const id2 = a2.ok ? a2.data.id : "";
|
||||||
|
const r = await createProduct(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({ brand: "Peros", imageIds: [id2, id1] }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const pid = r.ok ? r.data.id : "";
|
||||||
|
let p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||||
|
expect(p.images.map((i) => i.id)).toEqual([id2, id1]);
|
||||||
|
|
||||||
|
// Aynı hanenin başka üyesi görür; başka hane görmez.
|
||||||
|
expect(await readAttachmentForUser(emre, id1, "thumb")).not.toBeNull();
|
||||||
|
expect(await readAttachmentForUser(fatma, id1, "full")).toBeNull();
|
||||||
|
|
||||||
|
// Başkasının yüklediği bekleyen görsel bağlanamaz.
|
||||||
|
const foreign = await uploadAttachment(emre, cakir, { name: "x.png", bytes: await png() });
|
||||||
|
const bad = await updateProduct(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
pid,
|
||||||
|
await form({ brand: "Peros", imageIds: [id1, foreign.ok ? foreign.data.id : ""] }),
|
||||||
|
);
|
||||||
|
expect(bad.ok).toBe(false);
|
||||||
|
p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||||
|
expect(p.images).toHaveLength(2);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await updateProduct(ayse, cakir, pid, await form({ brand: "Peros", imageIds: [id1] }))).ok,
|
||||||
|
).toBe(true);
|
||||||
|
expect(await AttachmentModel.exists({ _id: id2, deletedAt: { $ne: null } })).toBeTruthy();
|
||||||
|
p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||||
|
expect(p.images.map((i) => i.id)).toEqual([id1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("arşiv: listeden çıkar, düzenlenemez, barkod çakışırsa geri alınamaz", async () => {
|
||||||
|
const r = await createProduct(ayse, cakir, await form({ brand: "Arşiv", barcode: "12345678" }));
|
||||||
|
const id = r.ok ? r.data.id : "";
|
||||||
|
expect((await setProductArchived(ayse, cakir, id, true)).ok).toBe(true);
|
||||||
|
expect((await listProducts(ayse, cakir))!.products.some((p) => p.id === id)).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await listProducts(ayse, cakir, { includeArchived: true }))!.products.find(
|
||||||
|
(p) => p.id === id,
|
||||||
|
)!.archived,
|
||||||
|
).toBe(true);
|
||||||
|
expect((await updateProduct(ayse, cakir, id, await form({ brand: "Arşiv" }))).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await createProduct(ayse, cakir, await form({ brand: "Yeni", barcode: "12345678" }))).ok,
|
||||||
|
).toBe(true);
|
||||||
|
expect((await setProductArchived(ayse, cakir, id, false)).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import {
|
||||||
|
syncOwnerAttachments,
|
||||||
|
toAttachmentInfo,
|
||||||
|
type AttachmentInfo,
|
||||||
|
} from "@/features/attachments/service";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { writeAudit } from "@/lib/audit";
|
||||||
|
import { connectMongoose } from "@/lib/db/mongoose";
|
||||||
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
import { AttachmentModel } from "@/models/attachment";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { autoConvertible, describePackage, packageInTypeUnit } from "@/shared/products";
|
||||||
|
import type { ProductForm } from "@/shared/schemas/product";
|
||||||
|
import { compareTr, foldTr } from "@/shared/search";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürünler (marka/varyant, Bölüm 6.11). Hane bazlı, hane içinde ortak. Okuma: viewer; yazma:
|
||||||
|
* member. Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa dönüşüm zorunludur.
|
||||||
|
* Görseller ek dosya altyapısıyla (ownerType "product", yalnızca görsel).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.products;
|
||||||
|
|
||||||
|
type ProductLean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
productTypeId: Types.ObjectId;
|
||||||
|
brand: string;
|
||||||
|
name: string;
|
||||||
|
barcode: string | null;
|
||||||
|
packageQuantity: number;
|
||||||
|
packageUnit: string;
|
||||||
|
typeQuantity: number | null;
|
||||||
|
note: string;
|
||||||
|
imageCount: number;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TypeLean = { _id: Types.ObjectId; name: string; unit: string; archivedAt: Date | null };
|
||||||
|
|
||||||
|
export type ProductView = {
|
||||||
|
id: string;
|
||||||
|
productTypeId: string;
|
||||||
|
productTypeName: string;
|
||||||
|
typeUnit: string;
|
||||||
|
brand: string;
|
||||||
|
name: string;
|
||||||
|
/** "Ariel Matik Dağ Esintisi". */
|
||||||
|
label: string;
|
||||||
|
barcode: string | null;
|
||||||
|
packageQuantity: number;
|
||||||
|
packageUnit: string;
|
||||||
|
/** Elle girilen dönüşüm (kendiliğinden çevrilebiliyorsa null). */
|
||||||
|
typeQuantity: number | null;
|
||||||
|
/** Bir paketin ürün tipi birimindeki karşılığı (çevrilemiyorsa null). */
|
||||||
|
inTypeUnit: number | null;
|
||||||
|
/** Ürün tipinin birimi sonradan değişti ve dönüşüm yok: fiyat karşılaştırmasına katılamaz. */
|
||||||
|
conversionMissing: boolean;
|
||||||
|
/** "5 kg · 50 yıkama". */
|
||||||
|
packageLabel: string;
|
||||||
|
note: string;
|
||||||
|
archived: boolean;
|
||||||
|
images: AttachmentInfo[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductTypeOption = { id: string; name: string; unit: string };
|
||||||
|
|
||||||
|
export const productLabel = (p: { brand: string; name: string }) =>
|
||||||
|
p.name ? `${p.brand} ${p.name}` : p.brand;
|
||||||
|
|
||||||
|
function toView(p: ProductLean, type: TypeLean | undefined, images: AttachmentInfo[]): ProductView {
|
||||||
|
const typeUnit = type?.unit ?? "";
|
||||||
|
const conv = {
|
||||||
|
packageQuantity: p.packageQuantity,
|
||||||
|
packageUnit: p.packageUnit,
|
||||||
|
typeUnit,
|
||||||
|
typeQuantity: p.typeQuantity,
|
||||||
|
};
|
||||||
|
const inTypeUnit = packageInTypeUnit(conv);
|
||||||
|
return {
|
||||||
|
id: String(p._id),
|
||||||
|
productTypeId: String(p.productTypeId),
|
||||||
|
productTypeName: type?.name ?? "",
|
||||||
|
typeUnit,
|
||||||
|
brand: p.brand,
|
||||||
|
name: p.name,
|
||||||
|
label: productLabel(p),
|
||||||
|
barcode: p.barcode,
|
||||||
|
packageQuantity: p.packageQuantity,
|
||||||
|
packageUnit: p.packageUnit,
|
||||||
|
typeQuantity: p.typeQuantity,
|
||||||
|
inTypeUnit,
|
||||||
|
conversionMissing: inTypeUnit === null,
|
||||||
|
packageLabel: describePackage(conv),
|
||||||
|
note: p.note,
|
||||||
|
archived: Boolean(p.archivedAt),
|
||||||
|
images,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImages(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
|
||||||
|
const rows = await AttachmentModel.find({
|
||||||
|
householdId,
|
||||||
|
ownerType: "product",
|
||||||
|
ownerId: { $in: productIds },
|
||||||
|
deletedAt: null,
|
||||||
|
})
|
||||||
|
.sort({ order: 1, createdAt: 1 })
|
||||||
|
.lean();
|
||||||
|
const by = new Map<string, AttachmentInfo[]>();
|
||||||
|
for (const a of rows) {
|
||||||
|
const key = String(a.ownerId);
|
||||||
|
by.set(key, [
|
||||||
|
...(by.get(key) ?? []),
|
||||||
|
toAttachmentInfo(a as Parameters<typeof toAttachmentInfo>[0]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return by;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün listesi: ürün tipine göre (verilirse) veya tüm katalog. Sıra: ürün tipi adı, marka,
|
||||||
|
* tanım. Arşivliler `includeArchived` ile.
|
||||||
|
*/
|
||||||
|
export async function listProducts(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
opts: { productTypeId?: string; includeArchived?: boolean } = {},
|
||||||
|
): Promise<{ products: ProductView[]; canWrite: boolean } | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const typeId = opts.productTypeId ? toObjectId(opts.productTypeId) : null;
|
||||||
|
if (opts.productTypeId && !typeId) return null;
|
||||||
|
const rows = await ProductModel.find({
|
||||||
|
householdId: m.householdId,
|
||||||
|
...(typeId ? { productTypeId: typeId } : {}),
|
||||||
|
...(opts.includeArchived ? {} : { archivedAt: null }),
|
||||||
|
}).lean<ProductLean[]>();
|
||||||
|
const types = await ProductTypeModel.find({
|
||||||
|
householdId: m.householdId,
|
||||||
|
_id: {
|
||||||
|
$in: [...new Set(rows.map((r) => String(r.productTypeId)))].map(
|
||||||
|
(id) => new Types.ObjectId(id),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.select({ name: 1, unit: 1, archivedAt: 1 })
|
||||||
|
.lean<TypeLean[]>();
|
||||||
|
const typeById = new Map(types.map((x) => [String(x._id), x]));
|
||||||
|
const images = await loadImages(
|
||||||
|
m.householdId,
|
||||||
|
rows.filter((r) => r.imageCount > 0).map((r) => r._id),
|
||||||
|
);
|
||||||
|
const products = rows
|
||||||
|
.map((r) => toView(r, typeById.get(String(r.productTypeId)), images.get(String(r._id)) ?? []))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
compareTr(a.productTypeName, b.productTypeName) ||
|
||||||
|
compareTr(a.brand, b.brand) ||
|
||||||
|
compareTr(a.name, b.name),
|
||||||
|
);
|
||||||
|
return { products, canWrite: hasHouseholdRole(m.role, "member") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün formu için aktif ürün tipleri (ada göre). */
|
||||||
|
export async function listProductTypeOptions(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<ProductTypeOption[] | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
const rows = await ProductTypeModel.find({ householdId: m.householdId, archivedAt: null })
|
||||||
|
.select({ name: 1, unit: 1 })
|
||||||
|
.lean<TypeLean[]>();
|
||||||
|
return rows
|
||||||
|
.map((r) => ({ id: String(r._id), name: r.name, unit: r.unit }))
|
||||||
|
.sort((a, b) => compareTr(a.name, b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün tipi, dönüşüm ve barkod denetimi; geçerliyse kaydedilecek dönüşümü döner. */
|
||||||
|
async function checkInput(
|
||||||
|
m: Membership,
|
||||||
|
input: ProductForm,
|
||||||
|
exceptId?: Types.ObjectId,
|
||||||
|
): Promise<{ error: ActionResult<never> } | { error: null; typeQuantity: number | null }> {
|
||||||
|
const type = await ProductTypeModel.findOne({
|
||||||
|
_id: new Types.ObjectId(input.productTypeId),
|
||||||
|
householdId: m.householdId,
|
||||||
|
}).lean<TypeLean>();
|
||||||
|
if (!type || type.archivedAt)
|
||||||
|
return { error: fail(t.validation.productType, { productTypeId: t.validation.productType }) };
|
||||||
|
const auto = autoConvertible(input.packageUnit, type.unit);
|
||||||
|
if (!auto && !input.typeQuantity) {
|
||||||
|
const msg = t.validation.conversionRequired(input.packageUnit, type.unit);
|
||||||
|
return { error: fail(msg, { typeQuantity: msg }) };
|
||||||
|
}
|
||||||
|
if (input.barcode) {
|
||||||
|
const dup = await ProductModel.exists({
|
||||||
|
householdId: m.householdId,
|
||||||
|
barcode: input.barcode,
|
||||||
|
archivedAt: null,
|
||||||
|
...(exceptId ? { _id: { $ne: exceptId } } : {}),
|
||||||
|
});
|
||||||
|
if (dup)
|
||||||
|
return { error: fail(t.validation.barcodeTaken, { barcode: t.validation.barcodeTaken }) };
|
||||||
|
}
|
||||||
|
return { error: null, typeQuantity: auto ? null : input.typeQuantity };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ek senkronizasyonu başarısızsa transaction'ı geri almak için fırlatılır. */
|
||||||
|
class ImageError extends Error {
|
||||||
|
constructor(readonly result: ActionResult<never>) {
|
||||||
|
super(result.ok ? "" : result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(
|
||||||
|
m: Membership,
|
||||||
|
actorId: string,
|
||||||
|
input: ProductForm,
|
||||||
|
typeQuantity: number | null,
|
||||||
|
existing: Types.ObjectId | null,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const fields = {
|
||||||
|
productTypeId: new Types.ObjectId(input.productTypeId),
|
||||||
|
brand: input.brand,
|
||||||
|
name: input.name,
|
||||||
|
searchKey: foldTr(`${input.brand} ${input.name}`),
|
||||||
|
barcode: input.barcode,
|
||||||
|
packageQuantity: input.packageQuantity,
|
||||||
|
packageUnit: input.packageUnit,
|
||||||
|
typeQuantity,
|
||||||
|
note: input.note,
|
||||||
|
updatedBy: actor,
|
||||||
|
};
|
||||||
|
const mongoose = await connectMongoose();
|
||||||
|
let id = existing;
|
||||||
|
try {
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
if (existing) {
|
||||||
|
await ProductModel.updateOne(
|
||||||
|
{ _id: existing, householdId: m.householdId },
|
||||||
|
{ $set: fields },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const [doc] = await ProductModel.create(
|
||||||
|
[{ householdId: m.householdId, ...fields, createdBy: actor }],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
id = doc!._id;
|
||||||
|
}
|
||||||
|
const synced = await syncOwnerAttachments({
|
||||||
|
householdId: m.householdId,
|
||||||
|
actorId,
|
||||||
|
ownerType: "product",
|
||||||
|
ownerId: id!,
|
||||||
|
ids: input.imageIds,
|
||||||
|
allowedKinds: ["image"],
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
if (!synced.ok) throw new ImageError(synced);
|
||||||
|
await ProductModel.updateOne(
|
||||||
|
{ _id: id!, householdId: m.householdId },
|
||||||
|
{ $set: { imageCount: synced.data } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ImageError) return error.result;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return ok({ id: String(id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProduct(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: ProductForm,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const c = await checkInput(m, input);
|
||||||
|
if (c.error) return c.error;
|
||||||
|
return save(m, actorId, input, c.typeQuantity, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProduct(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
productId: string,
|
||||||
|
input: ProductForm,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const id = toObjectId(productId);
|
||||||
|
const current = id
|
||||||
|
? await ProductModel.findOne({ _id: id, householdId: m.householdId }).lean<ProductLean>()
|
||||||
|
: null;
|
||||||
|
if (!current) return fail(tr.common.notFound);
|
||||||
|
if (current.archivedAt) return fail(t.validation.archived);
|
||||||
|
const c = await checkInput(m, input, current._id);
|
||||||
|
if (c.error) return c.error;
|
||||||
|
return save(m, actorId, input, c.typeQuantity, current._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setProductArchived(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
productId: string,
|
||||||
|
archived: boolean,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const id = toObjectId(productId);
|
||||||
|
const current = id
|
||||||
|
? await ProductModel.findOne({ _id: id, householdId: m.householdId }).lean<ProductLean>()
|
||||||
|
: null;
|
||||||
|
if (!current) return fail(tr.common.notFound);
|
||||||
|
if (!archived) {
|
||||||
|
const type = await ProductTypeModel.exists({
|
||||||
|
_id: current.productTypeId,
|
||||||
|
householdId: m.householdId,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
if (!type) return fail(t.validation.productType);
|
||||||
|
if (
|
||||||
|
current.barcode &&
|
||||||
|
(await ProductModel.exists({
|
||||||
|
householdId: m.householdId,
|
||||||
|
barcode: current.barcode,
|
||||||
|
archivedAt: null,
|
||||||
|
_id: { $ne: current._id },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return fail(t.validation.barcodeTaken);
|
||||||
|
}
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await ProductModel.updateOne(
|
||||||
|
{ _id: current._id, householdId: m.householdId },
|
||||||
|
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
|
||||||
|
);
|
||||||
|
await writeAudit({
|
||||||
|
action: archived ? "product.archive" : "product.unarchive",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: m.householdId,
|
||||||
|
targetType: "product",
|
||||||
|
targetId: productId,
|
||||||
|
diff: { label: productLabel(current) },
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import type { BudgetReport } from "../service";
|
||||||
|
import { monthLabel, monthShort } from "./report-frame";
|
||||||
|
|
||||||
|
const t = tr.reports.budget;
|
||||||
|
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||||
|
const ratio = (bp: number | null) => (bp === null ? "—" : `%${Math.round(bp / 100)}`);
|
||||||
|
const STATUS_TONE: Record<string, string> = {
|
||||||
|
ok: "bg-income",
|
||||||
|
warning: "bg-amber-500",
|
||||||
|
over: "bg-expense",
|
||||||
|
none: "bg-muted-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
limit: { label: t.limit, color: "var(--budget)" },
|
||||||
|
actual: { label: t.actual, color: "var(--expense)" },
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
export function BudgetReportView({ data }: { data: BudgetReport }) {
|
||||||
|
const chart = data.history.map((h) => ({
|
||||||
|
month: h.month,
|
||||||
|
label: monthShort(h.month),
|
||||||
|
limit: h.limitMinor === null ? null : h.limitMinor / 100,
|
||||||
|
actual: h.actualMinor / 100,
|
||||||
|
}));
|
||||||
|
const { totals, hasBudget } = data.budget;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{!hasBudget ? <p className="text-sm text-muted-foreground">{t.noBudget}</p> : null}
|
||||||
|
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full min-w-[34rem] text-sm tabular-nums">
|
||||||
|
<thead className="border-b text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-normal">{t.category}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.limit}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.actual}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.remaining}</th>
|
||||||
|
<th className="w-40 px-3 py-2 text-left font-normal">{t.ratio}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{data.lines.map((l) => (
|
||||||
|
<tr key={l.categoryId} className={cn(l.depth === 0 && "font-medium")}>
|
||||||
|
<td className="py-1.5 pr-3" style={{ paddingLeft: `${0.75 + l.depth * 1.25}rem` }}>
|
||||||
|
{l.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">
|
||||||
|
{l.limitMinor === null ? (
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
|
{l.covered ? t.covered : "—"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
formatMoney(l.limitMinor)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">{formatMoney(l.actualMinor)}</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 text-right",
|
||||||
|
(l.remainingMinor ?? 0) < 0 && "text-expense",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{l.remainingMinor === null
|
||||||
|
? "—"
|
||||||
|
: `${l.remainingMinor < 0 ? "−" : ""}${formatMoney(Math.abs(l.remainingMinor))}`}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
{l.ratioBp === null ? (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn("block h-full rounded-full", STATUS_TONE[l.status])}
|
||||||
|
style={{ width: `${Math.min(100, l.ratioBp / 100)}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="w-12 text-right text-xs">{ratio(l.ratioBp)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="border-t font-medium">
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-2">{tr.reports.categories.total}</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{hasBudget ? formatMoney(totals.limitMinor) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">{formatMoney(totals.actualMinor)}</td>
|
||||||
|
<td
|
||||||
|
className={cn("px-3 py-2 text-right", totals.remainingMinor < 0 && "text-expense")}
|
||||||
|
>
|
||||||
|
{hasBudget
|
||||||
|
? `${totals.remainingMinor < 0 ? "−" : ""}${formatMoney(Math.abs(totals.remainingMinor))}`
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-left text-xs">{ratio(totals.ratioBp)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-xl border bg-card p-4" aria-labelledby="bh-h">
|
||||||
|
<h2 id="bh-h" className="mb-2 text-sm font-medium">
|
||||||
|
{t.historyTitle}
|
||||||
|
</h2>
|
||||||
|
<ChartContainer
|
||||||
|
config={config}
|
||||||
|
className="aspect-auto h-56 w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label={t.historyChartLabel}
|
||||||
|
>
|
||||||
|
<BarChart data={chart} margin={{ left: 4, right: 8, top: 8 }} barGap={2}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={48}
|
||||||
|
tickFormatter={(v: number) => compact.format(v)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const m = payload?.[0]?.payload?.month as string | undefined;
|
||||||
|
return m ? monthLabel(m) : "";
|
||||||
|
}}
|
||||||
|
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 />} />
|
||||||
|
<Bar
|
||||||
|
dataKey="limit"
|
||||||
|
fill="var(--color-limit)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="actual"
|
||||||
|
fill="var(--color-actual)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
<table className="mt-3 w-full text-sm tabular-nums">
|
||||||
|
<thead className="text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 text-left font-normal">{t.month}</th>
|
||||||
|
<th className="py-1 text-right font-normal">{t.limit}</th>
|
||||||
|
<th className="py-1 text-right font-normal">{t.actual}</th>
|
||||||
|
<th className="py-1 text-right font-normal">{t.ratio}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{data.history.map((h) => (
|
||||||
|
<tr key={h.month}>
|
||||||
|
<td className="py-1 capitalize">{monthLabel(h.month)}</td>
|
||||||
|
<td className="py-1 text-right">
|
||||||
|
{h.limitMinor === null ? t.noLimit : formatMoney(h.limitMinor)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right">{formatMoney(h.actualMinor)}</td>
|
||||||
|
<td className="py-1 text-right">{ratio(h.ratioBp)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Cell, Pie, PieChart } from "recharts";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import { IconBadge } from "@/components/appearance/swatch";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import type { CategoryReport } from "../service";
|
||||||
|
import { Change, formatPct } from "./report-frame";
|
||||||
|
|
||||||
|
const t = tr.reports.categories;
|
||||||
|
/** Halkada en fazla bu kadar dilim; kalanlar "Diğer". */
|
||||||
|
const MAX_SLICES = 7;
|
||||||
|
|
||||||
|
export function CategoriesView({ data }: { data: CategoryReport }) {
|
||||||
|
const { breakdown, appearance } = data;
|
||||||
|
const positive = breakdown.rows.filter((r) => r.amountMinor > 0);
|
||||||
|
const head = positive.slice(0, MAX_SLICES);
|
||||||
|
const rest = positive.slice(MAX_SLICES);
|
||||||
|
const slices = [
|
||||||
|
...head.map((r) => ({
|
||||||
|
key: r.id,
|
||||||
|
name: r.name,
|
||||||
|
value: r.amountMinor / 100,
|
||||||
|
share: r.sharePct,
|
||||||
|
color: `var(--swatch-${appearance[r.id]?.color ?? "slate"})`,
|
||||||
|
})),
|
||||||
|
...(rest.length
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "__other__",
|
||||||
|
name: t.other(rest.length),
|
||||||
|
value: rest.reduce((a, r) => a + r.amountMinor, 0) / 100,
|
||||||
|
share: Math.round(rest.reduce((a, r) => a + r.sharePct, 0) * 10) / 10,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
const config = Object.fromEntries(
|
||||||
|
slices.map((s) => [s.key, { label: s.name, color: s.color }]),
|
||||||
|
) satisfies ChartConfig;
|
||||||
|
|
||||||
|
if (breakdown.totalMinor === 0 && breakdown.rows.length === 0)
|
||||||
|
return (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{tr.reports.noData}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:items-start">
|
||||||
|
<div className="rounded-xl border bg-card p-4">
|
||||||
|
<div className="relative">
|
||||||
|
<ChartContainer
|
||||||
|
config={config}
|
||||||
|
className="mx-auto aspect-square w-full max-w-64"
|
||||||
|
role="img"
|
||||||
|
aria-label={t.chartLabel}
|
||||||
|
>
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
hideLabel
|
||||||
|
nameKey="key"
|
||||||
|
formatter={(value, _name, item) => (
|
||||||
|
<div className="flex w-full items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">{item.payload.name}</span>
|
||||||
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
|
{formatMoney(Math.round(Number(value) * 100))} ·{" "}
|
||||||
|
{formatPct(item.payload.share)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Pie
|
||||||
|
data={slices}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="key"
|
||||||
|
innerRadius="62%"
|
||||||
|
outerRadius="100%"
|
||||||
|
stroke="var(--card)"
|
||||||
|
strokeWidth={2}
|
||||||
|
isAnimationActive={false}
|
||||||
|
>
|
||||||
|
{slices.map((s) => (
|
||||||
|
<Cell key={s.key} fill={s.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-xs text-muted-foreground">{t.total}</span>
|
||||||
|
<span className="text-lg font-semibold tabular-nums">
|
||||||
|
{formatMoney(breakdown.totalMinor)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-4 flex flex-col gap-1.5 text-sm">
|
||||||
|
{slices.map((s) => (
|
||||||
|
<li key={s.key} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="size-2.5 shrink-0 rounded-sm"
|
||||||
|
style={{ background: s.color }}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{s.name}</span>
|
||||||
|
<span className="text-muted-foreground tabular-nums">{formatPct(s.share)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full min-w-[34rem] text-sm">
|
||||||
|
<thead className="border-b text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-normal">{t.category}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.amount}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.share}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.previous}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.change}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{breakdown.rows.map((r) => {
|
||||||
|
const a = appearance[r.id];
|
||||||
|
return [
|
||||||
|
<tr key={r.id} className="border-t bg-muted/20 font-medium">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{a ? <IconBadge icon={a.icon} color={a.color} size="sm" /> : null}
|
||||||
|
{r.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">
|
||||||
|
{formatMoney(r.amountMinor)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">{formatPct(r.sharePct)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-muted-foreground tabular-nums">
|
||||||
|
{formatMoney(r.prevMinor)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<Change minor={r.changeMinor} pct={r.changePct} format={formatMoney} />
|
||||||
|
</td>
|
||||||
|
</tr>,
|
||||||
|
...(r.children.length && r.ownMinor
|
||||||
|
? [
|
||||||
|
<tr key={`${r.id}-own`} className="text-muted-foreground">
|
||||||
|
<td className="py-1.5 pr-3 pl-10 italic">{t.own}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">
|
||||||
|
{formatMoney(r.ownMinor)}
|
||||||
|
</td>
|
||||||
|
<td colSpan={3} />
|
||||||
|
</tr>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...r.children.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td className={cn("py-1.5 pr-3", c.depth > 1 ? "pl-14" : "pl-10")}>{c.name}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">
|
||||||
|
{formatMoney(c.amountMinor)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right text-muted-foreground tabular-nums">
|
||||||
|
{formatPct(c.sharePct)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right text-muted-foreground tabular-nums">
|
||||||
|
{formatMoney(c.prevMinor)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">
|
||||||
|
<Change minor={c.changeMinor} pct={c.changePct} format={formatMoney} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="border-t font-medium">
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-2">{t.total}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">
|
||||||
|
{formatMoney(breakdown.totalMinor)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">%100</td>
|
||||||
|
<td className="px-3 py-2 text-right text-muted-foreground tabular-nums">
|
||||||
|
{formatMoney(breakdown.prevTotalMinor)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<Change
|
||||||
|
minor={breakdown.totalMinor - breakdown.prevTotalMinor}
|
||||||
|
pct={
|
||||||
|
breakdown.prevTotalMinor
|
||||||
|
? Math.round(
|
||||||
|
((breakdown.totalMinor - breakdown.prevTotalMinor) * 1000) /
|
||||||
|
breakdown.prevTotalMinor,
|
||||||
|
) / 10
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
format={formatMoney}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { formatAssetQuantity } from "@/shared/assets";
|
||||||
|
import type { AssetsReport, ContactsReport } from "../extra";
|
||||||
|
import { dayLabel } from "./report-frame";
|
||||||
|
|
||||||
|
const signed = (n: number) => `${n > 0 ? "+" : n < 0 ? "−" : ""}${formatMoney(Math.abs(n))}`;
|
||||||
|
const bp = (n: number | null) =>
|
||||||
|
n === null
|
||||||
|
? ""
|
||||||
|
: ` (${n < 0 ? "−" : "+"}%${(Math.abs(n) / 100).toLocaleString("tr-TR", { maximumFractionDigits: 1 })})`;
|
||||||
|
|
||||||
|
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className={cn("text-lg font-semibold tabular-nums", tone)}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactsReportView({ data }: { data: ContactsReport }) {
|
||||||
|
const t = tr.reports.contacts;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<Stat label={t.owesMe} value={formatMoney(data.totals.owesMeMinor)} tone="text-income" />
|
||||||
|
<Stat label={t.iOwe} value={formatMoney(data.totals.iOweMinor)} tone="text-expense" />
|
||||||
|
<Stat
|
||||||
|
label={`${t.receivable} (${t.value.toLocaleLowerCase("tr-TR")})`}
|
||||||
|
value={formatMoney(data.inKindTotals.receivableMinor)}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={`${t.debt} (${t.value.toLocaleLowerCase("tr-TR")})`}
|
||||||
|
value={formatMoney(data.inKindTotals.debtMinor)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section aria-labelledby="cari-h" className="flex flex-col gap-2">
|
||||||
|
<h2 id="cari-h" className="font-medium">
|
||||||
|
{t.contact}
|
||||||
|
</h2>
|
||||||
|
{data.contacts.length ? (
|
||||||
|
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full min-w-[28rem] text-sm tabular-nums">
|
||||||
|
<thead className="border-b text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-normal">{t.contact}</th>
|
||||||
|
<th className="px-3 py-2 text-left font-normal">{t.direction}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.balance}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.due}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{data.contacts.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<Link href={`/cari/${c.id}`} className="hover:underline">
|
||||||
|
{c.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">{c.balanceMinor > 0 ? t.owesMe : t.iOwe}</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 text-right font-medium",
|
||||||
|
c.balanceMinor > 0 ? "text-income" : "text-expense",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMoney(Math.abs(c.balanceMinor))}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">
|
||||||
|
{c.dueDate ? dayLabel(c.dueDate) : "—"}
|
||||||
|
{t.dueStates[c.due] ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"ml-2",
|
||||||
|
c.due === "overdue" && "border-expense/50 text-expense",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.dueStates[c.due]}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.noContacts}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="ayni-h" className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 id="ayni-h" className="font-medium">
|
||||||
|
{t.inKindTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">{t.inKindHint}</p>
|
||||||
|
</div>
|
||||||
|
{data.inKind.length ? (
|
||||||
|
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full min-w-[32rem] text-sm tabular-nums">
|
||||||
|
<thead className="border-b text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-normal">{t.contact}</th>
|
||||||
|
<th className="px-3 py-2 text-left font-normal">{t.direction}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.remaining}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.value}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.since}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{data.inKind.map((d) => (
|
||||||
|
<tr key={d.id}>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<Link href={`/cari/${d.contactId}`} className="hover:underline">
|
||||||
|
{d.contactName}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
{d.direction === "receivable" ? t.receivable : t.debt}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">{d.remainingLabel}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">
|
||||||
|
{d.valueMinor === null ? "—" : formatMoney(d.valueMinor)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">{dayLabel(d.date)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.noInKind}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data.inKindTotals.unpriced ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.unpriced(data.inKindTotals.unpriced)}</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetsReportView({ data }: { data: AssetsReport }) {
|
||||||
|
const t = tr.reports.assets;
|
||||||
|
const p = data.period;
|
||||||
|
const tone = (n: number | null) =>
|
||||||
|
n === null ? "" : n < 0 ? "text-expense" : n > 0 ? "text-income" : "";
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<section aria-labelledby="period-h" className="flex flex-col gap-2">
|
||||||
|
<h2 id="period-h" className="font-medium">
|
||||||
|
{t.periodTitle}: {dayLabel(p.from)} – {dayLabel(p.to)}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||||
|
<Stat
|
||||||
|
label={t.startValue}
|
||||||
|
value={p.startValueMinor === null ? "—" : formatMoney(p.startValueMinor)}
|
||||||
|
/>
|
||||||
|
<Stat label={t.invested} value={formatMoney(p.investedMinor)} />
|
||||||
|
<Stat label={t.proceeds} value={formatMoney(p.proceedsMinor)} />
|
||||||
|
<Stat
|
||||||
|
label={t.endValue}
|
||||||
|
value={p.endValueMinor === null ? "—" : formatMoney(p.endValueMinor)}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={t.profit}
|
||||||
|
value={p.profitMinor === null ? "—" : `${signed(p.profitMinor)}${bp(p.profitBp)}`}
|
||||||
|
tone={tone(p.profitMinor)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{p.excluded ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.excluded(p.excluded)}</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{data.items.length ? (
|
||||||
|
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full min-w-[40rem] text-sm tabular-nums">
|
||||||
|
<thead className="border-b text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-normal">{t.asset}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.quantity}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.cost}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.value}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.unrealized}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.realized}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{data.items.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<Link href={`/birikim/${a.id}`} className="hover:underline">
|
||||||
|
{a.name || a.label}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">
|
||||||
|
{formatAssetQuantity(a.assetKey, a.summary.quantity)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">{formatMoney(a.summary.costMinor)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">
|
||||||
|
{a.summary.currentValueMinor === null
|
||||||
|
? t.noPrice
|
||||||
|
: formatMoney(a.summary.currentValueMinor)}
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-3 py-1.5 text-right", tone(a.summary.unrealizedMinor))}>
|
||||||
|
{a.summary.unrealizedMinor === null
|
||||||
|
? "—"
|
||||||
|
: `${signed(a.summary.unrealizedMinor)}${bp(a.summary.unrealizedBp)}`}
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-3 py-1.5 text-right", tone(a.summary.realizedMinor))}>
|
||||||
|
{signed(a.summary.realizedMinor)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="border-t font-medium">
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-2">{t.total}</td>
|
||||||
|
<td />
|
||||||
|
<td className="px-3 py-2 text-right">{formatMoney(data.totals.costMinor)}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{formatMoney(data.totals.valueMinor)}</td>
|
||||||
|
<td className={cn("px-3 py-2 text-right", tone(data.totals.unrealizedMinor))}>
|
||||||
|
{signed(data.totals.unrealizedMinor)}
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-3 py-2 text-right", tone(data.totals.realizedMinor))}>
|
||||||
|
{signed(data.totals.realizedMinor)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.empty}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import type { PeopleAccountsReport } from "../service";
|
||||||
|
import { formatPct } from "./report-frame";
|
||||||
|
|
||||||
|
const t = tr.reports.people;
|
||||||
|
const signed = (n: number) => `${n > 0 ? "+" : n < 0 ? "−" : ""}${formatMoney(Math.abs(n))}`;
|
||||||
|
|
||||||
|
/** Kişi bazlı harcama (pay çubuğu + tablo) ve hesap bazlı hareket tablosu. */
|
||||||
|
export function PeopleView({ data }: { data: PeopleAccountsReport }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<section aria-labelledby="people-h" className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 id="people-h" className="font-medium">
|
||||||
|
{t.peopleTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">{t.peopleHint}</p>
|
||||||
|
</div>
|
||||||
|
{data.people.length ? (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{data.people.map((p) => (
|
||||||
|
<li key={p.userId} className="flex flex-col gap-1.5 p-3 text-sm">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium">{p.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{p.count} {t.count.toLocaleLowerCase("tr-TR")}
|
||||||
|
</span>
|
||||||
|
<span className="w-24 text-right tabular-nums">
|
||||||
|
{formatMoney(p.expenseMinor)}
|
||||||
|
</span>
|
||||||
|
<span className="w-14 text-right text-muted-foreground tabular-nums">
|
||||||
|
{formatPct(p.sharePct)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-expense"
|
||||||
|
style={{ width: `${p.sharePct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="flex justify-between p-3 text-sm font-medium">
|
||||||
|
<span>{tr.reports.categories.total}</span>
|
||||||
|
<span className="tabular-nums">{formatMoney(data.peopleTotalMinor)}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{tr.reports.noData}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="acc-h" className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 id="acc-h" className="font-medium">
|
||||||
|
{t.accountsTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">{t.accountsHint}</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full min-w-[40rem] text-sm tabular-nums">
|
||||||
|
<thead className="border-b text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-normal">{t.account}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.income}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.expense}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.transferIn}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.transferOut}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.net}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.count}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{data.accounts.map((a) => (
|
||||||
|
<tr key={a.id} className={cn(a.count === 0 && "text-muted-foreground")}>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{a.name}
|
||||||
|
{a.archived ? <Badge variant="secondary">{t.archived}</Badge> : null}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">{formatMoney(a.incomeMinor)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">{formatMoney(a.expenseMinor)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">{formatMoney(a.transferInMinor)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">{formatMoney(a.transferOutMinor)}</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 text-right font-medium",
|
||||||
|
a.netMinor < 0 ? "text-expense" : a.netMinor > 0 ? "text-income" : "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{signed(a.netMinor)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">{a.count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { ArrowLeft, ChevronLeft, ChevronRight, Download } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { shiftMonth } from "@/shared/budget";
|
||||||
|
|
||||||
|
const t = tr.reports;
|
||||||
|
|
||||||
|
const monthFmt = new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" });
|
||||||
|
export const monthLabel = (month: string) =>
|
||||||
|
monthFmt.format(new Date(`${month}-15T12:00:00+03:00`));
|
||||||
|
const monthShortFmt = new Intl.DateTimeFormat("tr-TR", { month: "short", year: "2-digit" });
|
||||||
|
export const monthShort = (month: string) =>
|
||||||
|
monthShortFmt.format(new Date(`${month}-15T12:00:00+03:00`));
|
||||||
|
|
||||||
|
/** 14,3 → "%14,3"; −20 → "−%20"; null → "—". */
|
||||||
|
export function formatPct(n: number | null): string {
|
||||||
|
if (n === null) return "—";
|
||||||
|
const abs = Math.abs(n).toLocaleString("tr-TR", { maximumFractionDigits: 1 });
|
||||||
|
return n < 0 ? `−%${abs}` : `%${abs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Önceki / sonraki bütçe ayı (?ay=). Gelecek aya gidilmez. */
|
||||||
|
export function MonthNav({
|
||||||
|
month,
|
||||||
|
currentMonth,
|
||||||
|
path,
|
||||||
|
}: {
|
||||||
|
month: string;
|
||||||
|
currentMonth: string;
|
||||||
|
path: string;
|
||||||
|
}) {
|
||||||
|
const next = shiftMonth(month, 1);
|
||||||
|
return (
|
||||||
|
<nav aria-label={t.currentMonth} className="flex items-center gap-1">
|
||||||
|
<Button variant="outline" size="icon-sm" asChild>
|
||||||
|
<Link href={`${path}?ay=${shiftMonth(month, -1)}`} aria-label={t.prevMonth}>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-36 text-center text-sm font-medium capitalize">
|
||||||
|
{monthLabel(month)}
|
||||||
|
</span>
|
||||||
|
{next <= currentMonth ? (
|
||||||
|
<Button variant="outline" size="icon-sm" asChild>
|
||||||
|
<Link href={`${path}?ay=${next}`} aria-label={t.nextMonth}>
|
||||||
|
<ChevronRight />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="icon-sm" disabled aria-label={t.nextMonth}>
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{month !== currentMonth ? (
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={path}>{t.currentMonth}</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rapor sayfası çerçevesi: geri bağlantısı, başlık, araçlar (ay seçici, CSV). */
|
||||||
|
export function ReportFrame({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
tools,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: React.ReactNode;
|
||||||
|
tools?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href="/raporlar">
|
||||||
|
<ArrowLeft />
|
||||||
|
{t.back}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<PageHeader title={title} description={description} />
|
||||||
|
{tools ? <div className="flex flex-wrap items-center gap-2">{tools}</div> : null}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CsvButton({ href, label }: { href: string; label?: string }) {
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={href} download>
|
||||||
|
<Download />
|
||||||
|
{label ?? t.csv}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Araç çubuğunun sağ tarafı (CSV düğmeleri). */
|
||||||
|
export function ToolsEnd({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="ml-auto flex flex-wrap gap-2">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** İşaretli tutar ve yüzde değişim; renk tek başına anlam taşımaz (işaret metinde). */
|
||||||
|
export function Change({
|
||||||
|
minor,
|
||||||
|
pct,
|
||||||
|
format,
|
||||||
|
}: {
|
||||||
|
minor: number;
|
||||||
|
pct: number | null;
|
||||||
|
format: (n: number) => string;
|
||||||
|
}) {
|
||||||
|
if (minor === 0) return <span className="text-muted-foreground tabular-nums">0</span>;
|
||||||
|
const up = minor > 0;
|
||||||
|
return (
|
||||||
|
<span className={`tabular-nums ${up ? "text-expense" : "text-income"}`}>
|
||||||
|
{up ? "+" : "−"}
|
||||||
|
{format(Math.abs(minor))}
|
||||||
|
{pct !== null ? ` (${up ? "+" : "−"}%${Math.abs(pct).toLocaleString("tr-TR")})` : ""}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dönem seçici (?donem=…): seçenekler bağlantı, seçili olan vurgulu. */
|
||||||
|
export function RangeNav({
|
||||||
|
path,
|
||||||
|
param = "donem",
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
param?: string;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<nav aria-label={label} className="flex rounded-lg bg-muted p-0.5">
|
||||||
|
{options.map((o) => (
|
||||||
|
<Link
|
||||||
|
key={o.value}
|
||||||
|
href={`${path}?${param}=${o.value}`}
|
||||||
|
aria-current={o.value === value ? "page" : undefined}
|
||||||
|
className={
|
||||||
|
o.value === value
|
||||||
|
? "rounded-md bg-background px-2.5 py-1 text-sm font-medium shadow-sm"
|
||||||
|
: "rounded-md px-2.5 py-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
export const dayLabel = (ymd: string) => dayFmt.format(new Date(`${ymd}T12:00:00+03:00`));
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { WASTE_REASONS } from "@/shared/stock";
|
||||||
|
import { formatStock } from "@/shared/stock";
|
||||||
|
import type { ShoppingReport, WasteReport } from "../extra";
|
||||||
|
import { dayLabel, formatPct } from "./report-frame";
|
||||||
|
|
||||||
|
const signed = (n: number) => `${n > 0 ? "+" : n < 0 ? "−" : ""}${formatMoney(Math.abs(n))}`;
|
||||||
|
const empty = (text: string) => (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function ShoppingReportView({ data }: { data: ShoppingReport }) {
|
||||||
|
const t = tr.reports.shopping;
|
||||||
|
const reasons = tr.shopping.report.reasons;
|
||||||
|
const reasonRows = (
|
||||||
|
["price", "quantity", "substitute", "skipped", "extra", "unplanned"] as const
|
||||||
|
).filter((k) => data.listTotals[k] !== 0);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<section aria-labelledby="lists-h" className="flex flex-col gap-2">
|
||||||
|
<h2 id="lists-h" className="font-medium">
|
||||||
|
{t.listsTitle}
|
||||||
|
</h2>
|
||||||
|
{data.lists.length ? (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full min-w-[36rem] text-sm tabular-nums">
|
||||||
|
<thead className="border-b text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-normal">{t.date}</th>
|
||||||
|
<th className="px-3 py-2 text-left font-normal">{t.list}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.planned}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.actual}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.diff}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{data.lists.map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td className="px-3 py-1.5 whitespace-nowrap">{dayLabel(l.date)}</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<Link href={`/alisveris/${l.id}`} className="font-medium hover:underline">
|
||||||
|
{l.name}
|
||||||
|
</Link>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{t.counts(
|
||||||
|
l.summary.counts.same,
|
||||||
|
l.summary.counts.substitute,
|
||||||
|
l.summary.counts.skipped,
|
||||||
|
l.summary.counts.extra,
|
||||||
|
)}
|
||||||
|
{l.stores.length ? ` · ${l.stores.join(", ")}` : ""}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">
|
||||||
|
{formatMoney(l.summary.plannedMinor)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">
|
||||||
|
{formatMoney(l.summary.actualMinor)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 text-right",
|
||||||
|
l.summary.diffMinor > 0
|
||||||
|
? "text-expense"
|
||||||
|
: l.summary.diffMinor < 0
|
||||||
|
? "text-income"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{signed(l.summary.diffMinor)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="border-t font-medium">
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-2" colSpan={2}>
|
||||||
|
{t.total}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{formatMoney(data.listTotals.plannedMinor)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{formatMoney(data.listTotals.actualMinor)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">{signed(data.listTotals.diffMinor)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{reasonRows.length ? (
|
||||||
|
<ul className="flex flex-col gap-1 rounded-xl border bg-card p-3 text-sm">
|
||||||
|
{reasonRows.map((k) => (
|
||||||
|
<li key={k} className="flex justify-between gap-3">
|
||||||
|
<span>{reasons[k]}</span>
|
||||||
|
<span className="tabular-nums">{signed(data.listTotals[k])}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
empty(t.listsEmpty)
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="stores-h" className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 id="stores-h" className="font-medium">
|
||||||
|
{t.storesTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">{t.storesHint}</p>
|
||||||
|
</div>
|
||||||
|
{data.stores.length ? (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{data.stores.map((s) => (
|
||||||
|
<li key={s.name} className="flex flex-col gap-1.5 p-3 text-sm">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium">{s.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{s.count} {t.count.toLocaleLowerCase("tr-TR")}
|
||||||
|
{s.fromListsMinor ? ` · ${t.fromLists} ${formatMoney(s.fromListsMinor)}` : ""}
|
||||||
|
</span>
|
||||||
|
<span className="w-28 text-right tabular-nums">{formatMoney(s.amountMinor)}</span>
|
||||||
|
<span className="w-14 text-right text-muted-foreground tabular-nums">
|
||||||
|
{formatPct(s.sharePct)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-budget"
|
||||||
|
style={{ width: `${s.sharePct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="flex justify-between p-3 text-sm font-medium">
|
||||||
|
<span>{t.total}</span>
|
||||||
|
<span className="tabular-nums">{formatMoney(data.storesTotalMinor)}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
empty(t.storesEmpty)
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WasteReportView({ data }: { data: WasteReport }) {
|
||||||
|
const t = tr.reports.waste;
|
||||||
|
const reasonText = tr.stock.wasteReasons;
|
||||||
|
if (!data.rows.length) return empty(t.empty);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
|
||||||
|
<div className="rounded-xl border bg-card p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">{t.total}</p>
|
||||||
|
<p className="text-lg font-semibold text-expense tabular-nums">
|
||||||
|
{formatMoney(data.totalCostMinor)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t.costHint}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border bg-card p-3">
|
||||||
|
<h2 className="mb-1 text-xs text-muted-foreground">{t.reasonsTitle}</h2>
|
||||||
|
<ul className="flex flex-col gap-1 text-sm">
|
||||||
|
{WASTE_REASONS.filter((r) => data.reasons[r].count).map((r) => (
|
||||||
|
<li key={r} className="flex justify-between gap-3">
|
||||||
|
<span>
|
||||||
|
{reasonText[r]}{" "}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({t.count(data.reasons[r].count)})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">{formatMoney(data.reasons[r].costMinor)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full min-w-[36rem] text-sm tabular-nums">
|
||||||
|
<thead className="border-b text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-normal">{t.product}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.wasted}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.purchased}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.ratio}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.cost}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{data.rows.map((r) => (
|
||||||
|
<tr key={r.productTypeId}>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<Link href={`/stok/${r.productTypeId}`} className="hover:underline">
|
||||||
|
{r.name}
|
||||||
|
</Link>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{WASTE_REASONS.filter((x) => r.byReason[x])
|
||||||
|
.map((x) => `${reasonText[x]}: ${formatStock(r.byReason[x], r.unit)}`)
|
||||||
|
.join(" · ")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">{formatStock(r.wasted, r.unit)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">{formatStock(r.purchased, r.unit)}</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 text-right",
|
||||||
|
(r.wastePct ?? 0) >= 15 && "font-medium text-expense",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatPct(r.wastePct)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">
|
||||||
|
{r.costMinor === null ? "—" : formatMoney(r.costMinor)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{data.unpriced ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.unpriced(data.unpriced)}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ReferenceLine,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import type { TrendReport } from "../service";
|
||||||
|
import { monthLabel, monthShort, formatPct } from "./report-frame";
|
||||||
|
|
||||||
|
const t = tr.reports.trend;
|
||||||
|
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||||
|
|
||||||
|
const barConfig = {
|
||||||
|
income: { label: t.income, color: "var(--income)" },
|
||||||
|
expense: { label: t.expense, color: "var(--expense)" },
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
const rateConfig = { rate: { label: t.savings, color: "var(--budget)" } } satisfies ChartConfig;
|
||||||
|
|
||||||
|
export function TrendView({ data }: { data: TrendReport }) {
|
||||||
|
const chart = data.months.map((m) => ({
|
||||||
|
month: m.month,
|
||||||
|
label: monthShort(m.month),
|
||||||
|
income: m.incomeMinor / 100,
|
||||||
|
expense: m.expenseMinor / 100,
|
||||||
|
// Devam eden ayın oranı (maaş henüz yatmamış olabilir) ölçeği bozmasın: grafikte yok.
|
||||||
|
rate: m.month === data.currentMonth ? null : m.savingsPct,
|
||||||
|
}));
|
||||||
|
const moneyTip = (
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const m = payload?.[0]?.payload?.month as string | undefined;
|
||||||
|
return m ? monthLabel(m) : "";
|
||||||
|
}}
|
||||||
|
formatter={(value, name) => (
|
||||||
|
<div className="flex w-full items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{barConfig[name as keyof typeof barConfig]?.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
|
{formatMoney(Math.round(Number(value) * 100))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-xl border bg-card p-4">
|
||||||
|
<ChartContainer
|
||||||
|
config={barConfig}
|
||||||
|
className="aspect-auto h-64 w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label={t.chartLabel}
|
||||||
|
>
|
||||||
|
<BarChart data={chart} margin={{ left: 4, right: 8, top: 8 }} barGap={2}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={48}
|
||||||
|
tickFormatter={(v: number) => compact.format(v)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={moneyTip} />
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
<Bar
|
||||||
|
dataKey="income"
|
||||||
|
fill="var(--color-income)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="expense"
|
||||||
|
fill="var(--color-expense)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border bg-card p-4">
|
||||||
|
<h2 className="text-sm font-medium">{t.rateTitle}</h2>
|
||||||
|
<p className="mb-2 text-xs text-muted-foreground">
|
||||||
|
{t.rateHint} {t.partialHint}
|
||||||
|
</p>
|
||||||
|
<ChartContainer
|
||||||
|
config={rateConfig}
|
||||||
|
className="aspect-auto h-40 w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label={t.rateChartLabel}
|
||||||
|
>
|
||||||
|
<LineChart data={chart} margin={{ left: 4, right: 8, top: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={40}
|
||||||
|
tickFormatter={(v: number) => `%${v}`}
|
||||||
|
/>
|
||||||
|
<ReferenceLine y={0} stroke="var(--border)" />
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const m = payload?.[0]?.payload?.month as string | undefined;
|
||||||
|
return m ? monthLabel(m) : "";
|
||||||
|
}}
|
||||||
|
formatter={(value) => (
|
||||||
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
|
{formatPct(Number(value))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="rate"
|
||||||
|
stroke="var(--color-rate)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full min-w-[32rem] text-sm tabular-nums">
|
||||||
|
<thead className="border-b text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-normal">{t.month}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.income}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.expense}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.net}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-normal">{t.savings}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{[...data.months].reverse().map((m) => (
|
||||||
|
<tr key={m.month} className={cn(m.month === data.currentMonth && "bg-muted/40")}>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<span className="capitalize">{monthLabel(m.month)}</span>
|
||||||
|
{m.month === data.currentMonth ? (
|
||||||
|
<span className="ml-1 text-xs text-muted-foreground">({t.ongoing})</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">{formatMoney(m.incomeMinor)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">{formatMoney(m.expenseMinor)}</td>
|
||||||
|
<td className={cn("px-3 py-1.5 text-right", m.netMinor < 0 && "text-expense")}>
|
||||||
|
{m.netMinor < 0 ? "−" : ""}
|
||||||
|
{formatMoney(Math.abs(m.netMinor))}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right">{formatPct(m.savingsPct)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="border-t font-medium">
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-2">{t.total}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{formatMoney(data.totals.incomeMinor)}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{formatMoney(data.totals.expenseMinor)}</td>
|
||||||
|
<td
|
||||||
|
className={cn("px-3 py-2 text-right", data.totals.netMinor < 0 && "text-expense")}
|
||||||
|
>
|
||||||
|
{data.totals.netMinor < 0 ? "−" : ""}
|
||||||
|
{formatMoney(Math.abs(data.totals.netMinor))}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">{formatPct(data.totals.savingsPct)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { formatUnitPrice, unitPriceBase } from "@/shared/prices";
|
||||||
|
import type { UnitPriceReport } from "../extra";
|
||||||
|
import { dayLabel, monthLabel, monthShort } from "./report-frame";
|
||||||
|
|
||||||
|
const t = tr.reports.unitPrice;
|
||||||
|
const config = {
|
||||||
|
min: { label: t.min, color: "var(--income)" },
|
||||||
|
avg: { label: t.avg, color: "var(--budget)" },
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
/** Ürün tipi seçimi (URL'de) ve aylık en düşük / ortalama birim fiyat. */
|
||||||
|
export function UnitPriceView({ data }: { data: UnitPriceReport }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const sel = data.selected;
|
||||||
|
if (!sel)
|
||||||
|
return (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.noTypes}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
const base = unitPriceBase(sel.unit);
|
||||||
|
// Gösterim biriminde TL (g → kg, ml → lt).
|
||||||
|
const tl = (minor: number | null) => (minor === null ? null : (minor * base.factor) / 100);
|
||||||
|
const chart = sel.points.map((p) => ({
|
||||||
|
month: p.month,
|
||||||
|
label: monthShort(p.month),
|
||||||
|
min: tl(p.minMinor),
|
||||||
|
avg: tl(p.avgMinor),
|
||||||
|
}));
|
||||||
|
const fmt = (minor: number | null) => (minor === null ? "—" : formatUnitPrice(minor, sel.unit));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="up-type" className="text-sm text-muted-foreground">
|
||||||
|
{t.type}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={sel.id}
|
||||||
|
onValueChange={(id) => router.replace(`/raporlar/birim-fiyat?tip=${id}`)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="up-type" className="w-64">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{data.types.map((x) => (
|
||||||
|
<SelectItem key={x.id} value={x.id}>
|
||||||
|
{x.name}{" "}
|
||||||
|
<span className="text-xs text-muted-foreground">({t.records(x.records)})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border bg-card p-4">
|
||||||
|
<ChartContainer
|
||||||
|
config={config}
|
||||||
|
className="aspect-auto h-60 w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label={t.chartLabel(sel.name)}
|
||||||
|
>
|
||||||
|
<LineChart data={chart} margin={{ left: 4, right: 8, top: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={56}
|
||||||
|
tickFormatter={(v: number) =>
|
||||||
|
`₺${v.toLocaleString("tr-TR", { maximumFractionDigits: 2 })}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const m = payload?.[0]?.payload?.month as string | undefined;
|
||||||
|
return m ? monthLabel(m) : "";
|
||||||
|
}}
|
||||||
|
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">
|
||||||
|
{formatUnitPrice((Number(value) * 100) / base.factor, sel.unit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
{(["min", "avg"] as const).map((k) => (
|
||||||
|
<Line
|
||||||
|
key={k}
|
||||||
|
dataKey={k}
|
||||||
|
stroke={`var(--color-${k})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray={k === "avg" ? "6 3" : undefined}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
connectNulls
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
<table className="mt-3 w-full text-sm tabular-nums">
|
||||||
|
<thead className="text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 text-left font-normal">{t.month}</th>
|
||||||
|
<th className="py-1 text-right font-normal">{t.min}</th>
|
||||||
|
<th className="py-1 text-right font-normal">{t.avg}</th>
|
||||||
|
<th className="py-1 text-right font-normal">{t.count}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{[...sel.points].reverse().map((p) => (
|
||||||
|
<tr key={p.month}>
|
||||||
|
<td className="py-1 capitalize">{monthLabel(p.month)}</td>
|
||||||
|
<td className="py-1 text-right">{fmt(p.minMinor)}</td>
|
||||||
|
<td className="py-1 text-right">{fmt(p.avgMinor)}</td>
|
||||||
|
<td className="py-1 text-right">{p.count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sel.stores.length ? (
|
||||||
|
<section className="rounded-xl border bg-card p-4" aria-labelledby="up-stores">
|
||||||
|
<h2 id="up-stores" className="mb-2 text-sm font-medium">
|
||||||
|
{t.storesTitle}
|
||||||
|
</h2>
|
||||||
|
<table className="w-full text-sm tabular-nums">
|
||||||
|
<thead className="text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 text-left font-normal">{t.store}</th>
|
||||||
|
<th className="py-1 text-right font-normal">{t.min}</th>
|
||||||
|
<th className="py-1 text-right font-normal">{t.last}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{sel.stores.map((s) => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td className="py-1">{s.label}</td>
|
||||||
|
<td className="py-1 text-right">{fmt(s.minMinor)}</td>
|
||||||
|
<td className="py-1 text-right">{dayLabel(s.lastDate)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { getHouseholdContext } from "@/lib/dal";
|
||||||
|
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||||
|
|
||||||
|
/** CSV rotaları için ortak: oturum + aktif hane, ?ay= doğrulaması ve yanıt üretimi. */
|
||||||
|
export async function csvContext(request: Request) {
|
||||||
|
const ctx = await getHouseholdContext();
|
||||||
|
if (!ctx?.household) return null;
|
||||||
|
const params = new URL(request.url).searchParams;
|
||||||
|
const ay = budgetMonthSchema.safeParse(params.get("ay"));
|
||||||
|
return {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
householdId: ctx.household.id,
|
||||||
|
month: ay.success ? ay.data : undefined,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const unauthorized = () => new Response(tr.common.unauthorized, { status: 401 });
|
||||||
|
export const forbidden = () => new Response(tr.common.unauthorized, { status: 403 });
|
||||||
|
|
||||||
|
export function csvResponse(csv: string, filename: string) {
|
||||||
|
return new Response(csv, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/csv; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 14,3 → "14,3" (CSV ondalık virgül). */
|
||||||
|
export const csvPct = (n: number | null) => (n === null ? "" : String(n).replace(".", ","));
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { completeShopping, getCompletionSheet } from "@/features/shopping/completion";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import { shoppingCompletionSchema } from "@/shared/schemas/shopping";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
getAssetsReport,
|
||||||
|
getContactsReport,
|
||||||
|
getShoppingReport,
|
||||||
|
getUnitPriceReport,
|
||||||
|
getWasteReport,
|
||||||
|
} from "./extra";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
describe("cari ve aynî", () => {
|
||||||
|
it("bakiyeli cariler büyükten küçüğe; toplamlar; açık aynî borçlar; başka hane göremez", async () => {
|
||||||
|
const r = (await getContactsReport(ayse, cakir))!;
|
||||||
|
expect(r.contacts.length).toBeGreaterThan(0);
|
||||||
|
expect(r.contacts.every((c) => c.balanceMinor !== 0 && !c.archived)).toBe(true);
|
||||||
|
const abs = r.contacts.map((c) => Math.abs(c.balanceMinor));
|
||||||
|
expect(abs).toEqual([...abs].sort((a, b) => b - a));
|
||||||
|
expect(r.inKind.length).toBeGreaterThan(0);
|
||||||
|
expect(r.inKind.every((d) => d.remainingQuantity > 0)).toBe(true);
|
||||||
|
expect(await getContactsReport(fatma, cakir)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("birikim", () => {
|
||||||
|
it("varlık kâr/zarar ve dönem raporu", async () => {
|
||||||
|
const r = (await getAssetsReport(ayse, cakir, "6"))!;
|
||||||
|
expect(r.items.length).toBeGreaterThan(0);
|
||||||
|
expect(r.period.from < r.period.to).toBe(true);
|
||||||
|
expect(r.range).toBe("6");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("alışveriş", () => {
|
||||||
|
it("mağaza bazlı harcama alıcı adıyla eşleşir; tamamlanan liste plan/gerçek satırı verir", async () => {
|
||||||
|
const before = (await getShoppingReport(ayse, cakir, 6))!;
|
||||||
|
expect(before.lists).toEqual([]);
|
||||||
|
expect(before.stores.map((s) => s.name)).toEqual(expect.arrayContaining(["Migros", "BİM"]));
|
||||||
|
expect(before.stores.reduce((a, s) => a + s.amountMinor, 0)).toBe(before.storesTotalMinor);
|
||||||
|
|
||||||
|
// Haftalık marketten yalnızca süt alınır, kalanlar bırakılır.
|
||||||
|
const weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id);
|
||||||
|
const sheet = (await getCompletionSheet(ayse, cakir, weekly))!;
|
||||||
|
const milk = sheet.items.find((i) => i.label === "Süt")!;
|
||||||
|
const plan = sheet.planned[milk.id]!;
|
||||||
|
const account = String((await AccountModel.findOne({ name: "Ziraat Maaş" }).lean())!._id);
|
||||||
|
const done = await completeShopping(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
weekly,
|
||||||
|
shoppingCompletionSchema.parse({
|
||||||
|
date: todayIstanbul(),
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
itemId: milk.id,
|
||||||
|
productId: plan.productId,
|
||||||
|
storeId: plan.storeId,
|
||||||
|
packages: String(plan.packages),
|
||||||
|
amount: formatMoneyInput(plan.priceMinor * plan.packages),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stores: [{ storeId: plan.storeId, accountId: account, attachmentIds: [] }],
|
||||||
|
leftover: { action: "drop" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(done.ok).toBe(true);
|
||||||
|
|
||||||
|
const after = (await getShoppingReport(ayse, cakir, 3))!;
|
||||||
|
expect(after.lists).toHaveLength(1);
|
||||||
|
const row = after.lists[0]!;
|
||||||
|
expect(row.summary.counts.same).toBe(1);
|
||||||
|
expect(row.summary.actualMinor).toBe(plan.priceMinor * plan.packages);
|
||||||
|
expect(after.listTotals.diffMinor).toBe(row.summary.diffMinor);
|
||||||
|
const store = after.stores.find((s) => s.fromListsMinor > 0)!;
|
||||||
|
expect(store.fromListsMinor).toBe(plan.priceMinor * plan.packages);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("birim fiyat geçmişi", () => {
|
||||||
|
it("fiyatı olan tipler; seçilen tipte 12 ay noktası ve mağaza en düşükleri", async () => {
|
||||||
|
const r = (await getUnitPriceReport(ayse, cakir))!;
|
||||||
|
expect(r.types.length).toBeGreaterThan(5);
|
||||||
|
expect(r.months).toHaveLength(12);
|
||||||
|
const toz = String((await ProductTypeModel.findOne({ seedRef: "toz-deterjan" }).lean())!._id);
|
||||||
|
const d = (await getUnitPriceReport(ayse, cakir, toz))!;
|
||||||
|
expect(d.selected!.id).toBe(toz);
|
||||||
|
expect(d.selected!.points).toHaveLength(12);
|
||||||
|
const priced = d.selected!.points.filter((p) => p.count > 0);
|
||||||
|
expect(priced.length).toBeGreaterThan(3);
|
||||||
|
for (const p of priced) expect(p.minMinor!).toBeLessThanOrEqual(p.avgMinor!);
|
||||||
|
const mins = d.selected!.stores.map((s) => s.minMinor);
|
||||||
|
expect(mins).toEqual([...mins].sort((a, b) => a - b));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fire / zarar", () => {
|
||||||
|
it("fire satırları, oran ve nedenler; izleyici de okuyabilir", async () => {
|
||||||
|
const r = (await getWasteReport(zeynep, cakir, 12))!;
|
||||||
|
expect(r.rows.length).toBeGreaterThan(0);
|
||||||
|
for (const row of r.rows) {
|
||||||
|
const sum = Object.values(row.byReason).reduce((a, b) => a + b, 0);
|
||||||
|
expect(sum).toBe(row.wasted);
|
||||||
|
if (row.purchased > 0) expect(row.wastePct).not.toBeNull();
|
||||||
|
}
|
||||||
|
const priced = r.rows.reduce((a, x) => a + (x.costMinor ?? 0), 0);
|
||||||
|
expect(priced).toBe(r.totalCostMinor);
|
||||||
|
expect(await getWasteReport(fatma, cakir, 12)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import {
|
||||||
|
getPeriodReport,
|
||||||
|
listAssets,
|
||||||
|
type AssetView,
|
||||||
|
type ReportRange,
|
||||||
|
} from "@/features/assets/service";
|
||||||
|
import { listContacts, type ContactView } from "@/features/contacts/service";
|
||||||
|
import { listInKind } from "@/features/in-kind/service";
|
||||||
|
import { latestTypeUnitPrices, loadTypePriceHistory } from "@/features/prices/service";
|
||||||
|
import { storeLabel } from "@/features/stores/service";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import { StockMovementModel } from "@/models/stock-movement";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import { formatAssetQuantity, type AssetKey, type InKindDirection } from "@/shared/assets";
|
||||||
|
import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget";
|
||||||
|
import { istanbulDayStart } from "@/shared/dates";
|
||||||
|
import type { PeriodReport } from "@/shared/portfolio";
|
||||||
|
import {
|
||||||
|
matchStoreName,
|
||||||
|
monthlyUnitPrices,
|
||||||
|
sharePct,
|
||||||
|
type ReportPeriod,
|
||||||
|
type UnitPricePoint,
|
||||||
|
} from "@/shared/reports";
|
||||||
|
import { foldTr, compareTr } from "@/shared/search";
|
||||||
|
import type { ShoppingCompletion } from "@/shared/shopping";
|
||||||
|
import { compareShopping, type ComparisonSummary } from "@/shared/shopping-compare";
|
||||||
|
import { WASTE_REASONS, type WasteReason } from "@/shared/stock";
|
||||||
|
import { base, type Base } from "./service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Faz 14b raporları: 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. Okuma: viewer; gizlilik kuralları
|
||||||
|
* kaynak servislerle aynı (gizli cari/varlık/hesap görünmez).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
/** İçinde bulunulan dahil son N bütçe ayı ve tarih aralığı. */
|
||||||
|
function periodRange(b: Base, months: number) {
|
||||||
|
const list = Array.from({ length: months }, (_, i) => shiftMonth(b.currentMonth, i - months + 1));
|
||||||
|
return {
|
||||||
|
months: list,
|
||||||
|
from: budgetPeriod(list[0]!, b.s.startDay).from,
|
||||||
|
to: budgetPeriod(list.at(-1)!, b.s.startDay).to,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cari özet ve aynî borçlar ---
|
||||||
|
|
||||||
|
export type InKindRow = {
|
||||||
|
id: string;
|
||||||
|
contactId: string;
|
||||||
|
contactName: string;
|
||||||
|
direction: InKindDirection;
|
||||||
|
assetKey: AssetKey;
|
||||||
|
/** Kalan miktar ("12,5 gr 22 ayar"). */
|
||||||
|
remainingLabel: string;
|
||||||
|
remainingQuantity: number;
|
||||||
|
valueMinor: number | null;
|
||||||
|
date: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContactsReport = {
|
||||||
|
contacts: ContactView[];
|
||||||
|
totals: { owesMeMinor: number; iOweMinor: number };
|
||||||
|
inKind: InKindRow[];
|
||||||
|
inKindTotals: { receivableMinor: number; debtMinor: number; unpriced: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getContactsReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<ContactsReport | null> {
|
||||||
|
const [list, inKind] = await Promise.all([
|
||||||
|
listContacts(userId, householdId),
|
||||||
|
listInKind(userId, householdId),
|
||||||
|
]);
|
||||||
|
if (!list || !inKind) return null;
|
||||||
|
const open = inKind.filter((d) => !d.closedAt && d.progress.remainingQuantity > 0);
|
||||||
|
const totals = { receivableMinor: 0, debtMinor: 0, unpriced: 0 };
|
||||||
|
for (const d of open) {
|
||||||
|
const v = d.progress.currentValueMinor;
|
||||||
|
if (v === null) totals.unpriced++;
|
||||||
|
else if (d.direction === "receivable") totals.receivableMinor += v;
|
||||||
|
else totals.debtMinor += v;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
contacts: list.contacts
|
||||||
|
.filter((c) => !c.archived && c.balanceMinor !== 0)
|
||||||
|
.sort((a, b) => Math.abs(b.balanceMinor) - Math.abs(a.balanceMinor)),
|
||||||
|
totals: list.totals,
|
||||||
|
inKind: open.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
contactId: d.contactId,
|
||||||
|
contactName: d.contactName,
|
||||||
|
direction: d.direction,
|
||||||
|
assetKey: d.assetKey,
|
||||||
|
remainingLabel: formatAssetQuantity(d.assetKey, d.progress.remainingQuantity),
|
||||||
|
remainingQuantity: d.progress.remainingQuantity,
|
||||||
|
valueMinor: d.progress.currentValueMinor,
|
||||||
|
date: d.date,
|
||||||
|
})),
|
||||||
|
inKindTotals: totals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Birikim kâr/zarar ---
|
||||||
|
|
||||||
|
export type AssetsReport = {
|
||||||
|
items: AssetView[];
|
||||||
|
totals: {
|
||||||
|
valueMinor: number;
|
||||||
|
costMinor: number;
|
||||||
|
unrealizedMinor: number;
|
||||||
|
realizedMinor: number;
|
||||||
|
missingPrice: number;
|
||||||
|
};
|
||||||
|
period: PeriodReport & { excluded: number };
|
||||||
|
range: ReportRange;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAssetsReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
range: ReportRange,
|
||||||
|
): Promise<AssetsReport | null> {
|
||||||
|
const [assets, period] = await Promise.all([
|
||||||
|
listAssets(userId, householdId),
|
||||||
|
getPeriodReport(userId, householdId, range),
|
||||||
|
]);
|
||||||
|
if (!assets || !period) return null;
|
||||||
|
return {
|
||||||
|
items: assets.items.filter((a) => a.summary.quantity > 0 || a.summary.realizedMinor !== 0),
|
||||||
|
totals: assets.totals,
|
||||||
|
period,
|
||||||
|
range,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Alışveriş: liste vs. gerçekleşen, mağaza bazlı harcama ---
|
||||||
|
|
||||||
|
export type CompletedListRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
date: string;
|
||||||
|
summary: ComparisonSummary;
|
||||||
|
stores: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreSpendRow = {
|
||||||
|
name: string;
|
||||||
|
amountMinor: number;
|
||||||
|
count: number;
|
||||||
|
/** Alışverişi tamamla ile gelen kısım. */
|
||||||
|
fromListsMinor: number;
|
||||||
|
sharePct: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingReport = {
|
||||||
|
period: ReportPeriod;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
lists: CompletedListRow[];
|
||||||
|
listTotals: ComparisonSummary["reasons"] & {
|
||||||
|
plannedMinor: number;
|
||||||
|
actualMinor: number;
|
||||||
|
diffMinor: number;
|
||||||
|
};
|
||||||
|
stores: StoreSpendRow[];
|
||||||
|
storesTotalMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getShoppingReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
period: ReportPeriod,
|
||||||
|
): Promise<ShoppingReport | null> {
|
||||||
|
const b = await base(userId, householdId);
|
||||||
|
if (!b) return null;
|
||||||
|
const range = periodRange(b, period);
|
||||||
|
const hid = b.ctx.m.householdId;
|
||||||
|
const [lists, storeDocs, txs] = await Promise.all([
|
||||||
|
ShoppingListModel.find({
|
||||||
|
householdId: hid,
|
||||||
|
deletedAt: null,
|
||||||
|
status: "completed",
|
||||||
|
"completion.date": { $gte: range.from, $lte: range.to },
|
||||||
|
})
|
||||||
|
.select({ name: 1, completion: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; name: string; completion: ShoppingCompletion }[]>(),
|
||||||
|
StoreModel.find({ householdId: hid })
|
||||||
|
.select({ name: 1, branch: 1 })
|
||||||
|
.lean<{ name: string; branch: string }[]>(),
|
||||||
|
TransactionModel.find({
|
||||||
|
householdId: hid,
|
||||||
|
type: "expense",
|
||||||
|
status: "posted",
|
||||||
|
deletedAt: null,
|
||||||
|
date: {
|
||||||
|
$gte: istanbulDayStart(range.from)!,
|
||||||
|
$lt: new Date(istanbulDayStart(range.to)!.getTime() + DAY_MS),
|
||||||
|
},
|
||||||
|
...(!b.s.includePrivate && b.ctx.hiddenAccountIds.length
|
||||||
|
? { accountId: { $nin: b.ctx.hiddenAccountIds } }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
.select({ payeeName: 1, amountMinor: 1, shoppingListId: 1 })
|
||||||
|
.lean<{ payeeName: string; amountMinor: number; shoppingListId: Types.ObjectId | null }[]>(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rows: CompletedListRow[] = lists
|
||||||
|
.map((l) => ({
|
||||||
|
id: String(l._id),
|
||||||
|
name: l.name,
|
||||||
|
date: l.completion.date,
|
||||||
|
stores: l.completion.stores.map((s) => s.label),
|
||||||
|
summary: compareShopping(
|
||||||
|
l.completion.rows.map((r) => ({
|
||||||
|
match: r.match,
|
||||||
|
plan: r.plan,
|
||||||
|
actual:
|
||||||
|
r.amountMinor === null
|
||||||
|
? null
|
||||||
|
: { productId: r.productId, packages: r.packages, amountMinor: r.amountMinor },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.sort((a, c) => (a.date < c.date ? 1 : -1));
|
||||||
|
const listTotals = {
|
||||||
|
plannedMinor: 0,
|
||||||
|
actualMinor: 0,
|
||||||
|
diffMinor: 0,
|
||||||
|
price: 0,
|
||||||
|
quantity: 0,
|
||||||
|
substitute: 0,
|
||||||
|
skipped: 0,
|
||||||
|
extra: 0,
|
||||||
|
unplanned: 0,
|
||||||
|
};
|
||||||
|
for (const r of rows) {
|
||||||
|
listTotals.plannedMinor += r.summary.plannedMinor;
|
||||||
|
listTotals.actualMinor += r.summary.actualMinor;
|
||||||
|
listTotals.diffMinor += r.summary.diffMinor;
|
||||||
|
for (const k of ["price", "quantity", "substitute", "skipped", "extra", "unplanned"] as const)
|
||||||
|
listTotals[k] += r.summary.reasons[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mağaza adı bazında (şubeler birlikte): gider alıcısı mağaza adıyla başlıyorsa.
|
||||||
|
const names = [...new Set(storeDocs.map((s) => s.name))];
|
||||||
|
const byStore = new Map<string, StoreSpendRow>();
|
||||||
|
for (const t of txs) {
|
||||||
|
const name = matchStoreName(t.payeeName, names, foldTr);
|
||||||
|
if (!name) continue;
|
||||||
|
const row = byStore.get(name) ?? {
|
||||||
|
name,
|
||||||
|
amountMinor: 0,
|
||||||
|
count: 0,
|
||||||
|
fromListsMinor: 0,
|
||||||
|
sharePct: 0,
|
||||||
|
};
|
||||||
|
row.amountMinor += t.amountMinor;
|
||||||
|
row.count++;
|
||||||
|
if (t.shoppingListId) row.fromListsMinor += t.amountMinor;
|
||||||
|
byStore.set(name, row);
|
||||||
|
}
|
||||||
|
const storesTotal = [...byStore.values()].reduce((a, r) => a + r.amountMinor, 0);
|
||||||
|
const stores = [...byStore.values()]
|
||||||
|
.map((r) => ({ ...r, sharePct: sharePct(r.amountMinor, storesTotal) }))
|
||||||
|
.sort((a, c) => c.amountMinor - a.amountMinor);
|
||||||
|
return {
|
||||||
|
period,
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
lists: rows,
|
||||||
|
listTotals,
|
||||||
|
stores,
|
||||||
|
storesTotalMinor: storesTotal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Birim fiyat geçmişi ---
|
||||||
|
|
||||||
|
export type UnitPriceReport = {
|
||||||
|
types: { id: string; name: string; unit: string; records: number }[];
|
||||||
|
selected: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
points: UnitPricePoint[];
|
||||||
|
/** Mağaza başına dönemdeki en düşük birim fiyat. */
|
||||||
|
stores: { id: string; label: string; minMinor: number; lastDate: string }[];
|
||||||
|
} | null;
|
||||||
|
months: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getUnitPriceReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
typeId?: string,
|
||||||
|
): Promise<UnitPriceReport | null> {
|
||||||
|
const b = await base(userId, householdId);
|
||||||
|
if (!b) return null;
|
||||||
|
const hid = b.ctx.m.householdId;
|
||||||
|
const range = periodRange(b, 12);
|
||||||
|
const [types, counts] = await Promise.all([
|
||||||
|
ProductTypeModel.find({ householdId: hid, archivedAt: null })
|
||||||
|
.select({ name: 1, unit: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; name: string; unit: string }[]>(),
|
||||||
|
ProductPriceModel.aggregate<{ _id: Types.ObjectId; n: number }>([
|
||||||
|
{ $match: { householdId: hid, deletedAt: null } },
|
||||||
|
{ $lookup: { from: "products", localField: "productId", foreignField: "_id", as: "p" } },
|
||||||
|
{ $unwind: "$p" },
|
||||||
|
{ $group: { _id: "$p.productTypeId", n: { $sum: 1 } } },
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
const count = new Map(counts.map((c) => [String(c._id), c.n]));
|
||||||
|
const options = types
|
||||||
|
.map((x) => ({
|
||||||
|
id: String(x._id),
|
||||||
|
name: x.name,
|
||||||
|
unit: x.unit,
|
||||||
|
records: count.get(String(x._id)) ?? 0,
|
||||||
|
}))
|
||||||
|
.filter((x) => x.records > 0)
|
||||||
|
.sort((a, c) => compareTr(a.name, c.name));
|
||||||
|
const chosen = options.find((x) => x.id === typeId) ?? options[0];
|
||||||
|
if (!chosen) return { types: options, selected: null, months: range.months };
|
||||||
|
|
||||||
|
const [history, storeDocs] = await Promise.all([
|
||||||
|
loadTypePriceHistory(hid, new Types.ObjectId(chosen.id)),
|
||||||
|
StoreModel.find({ householdId: hid })
|
||||||
|
.select({ name: 1, branch: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; name: string; branch: string }[]>(),
|
||||||
|
]);
|
||||||
|
const inRange = history.filter((h) => h.date >= range.from && h.date <= range.to);
|
||||||
|
const points = monthlyUnitPrices(
|
||||||
|
inRange.map((h) => ({
|
||||||
|
month: periodOfDate(h.date, b.s.startDay),
|
||||||
|
unitPriceMinor: h.unitPriceMinor,
|
||||||
|
})),
|
||||||
|
range.months,
|
||||||
|
);
|
||||||
|
const labelOf = new Map(storeDocs.map((s) => [String(s._id), storeLabel(s)]));
|
||||||
|
const perStore = new Map<string, { minMinor: number; lastDate: string }>();
|
||||||
|
for (const h of inRange) {
|
||||||
|
const cur = perStore.get(h.storeId);
|
||||||
|
perStore.set(h.storeId, {
|
||||||
|
minMinor: cur ? Math.min(cur.minMinor, h.unitPriceMinor) : h.unitPriceMinor,
|
||||||
|
lastDate: cur && cur.lastDate > h.date ? cur.lastDate : h.date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
types: options,
|
||||||
|
months: range.months,
|
||||||
|
selected: {
|
||||||
|
id: chosen.id,
|
||||||
|
name: chosen.name,
|
||||||
|
unit: chosen.unit,
|
||||||
|
points,
|
||||||
|
stores: [...perStore.entries()]
|
||||||
|
.map(([id, v]) => ({ id, label: labelOf.get(id) ?? "—", ...v }))
|
||||||
|
.sort((a, c) => a.minMinor - c.minMinor),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fire / zarar ---
|
||||||
|
|
||||||
|
export type WasteRow = {
|
||||||
|
productTypeId: string;
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
wasted: number;
|
||||||
|
purchased: number;
|
||||||
|
/** Fire / alım (yüzde); alım yoksa null. */
|
||||||
|
wastePct: number | null;
|
||||||
|
byReason: Record<WasteReason, number>;
|
||||||
|
/** Tahmini maliyet: fire × son bilinen birim fiyat (fiyat yoksa null). */
|
||||||
|
costMinor: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WasteReport = {
|
||||||
|
period: ReportPeriod;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
rows: WasteRow[];
|
||||||
|
/** Neden başına fire satırı sayısı ve tahmini maliyet. */
|
||||||
|
reasons: Record<WasteReason, { count: number; costMinor: number }>;
|
||||||
|
totalCostMinor: number;
|
||||||
|
unpriced: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getWasteReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
period: ReportPeriod,
|
||||||
|
): Promise<WasteReport | null> {
|
||||||
|
const b = await base(userId, householdId);
|
||||||
|
if (!b) return null;
|
||||||
|
const range = periodRange(b, period);
|
||||||
|
const hid = b.ctx.m.householdId;
|
||||||
|
const moves = await StockMovementModel.find({
|
||||||
|
householdId: hid,
|
||||||
|
deletedAt: null,
|
||||||
|
kind: { $in: ["waste", "purchase"] },
|
||||||
|
date: { $gte: range.from, $lte: range.to },
|
||||||
|
})
|
||||||
|
.select({ productTypeId: 1, kind: 1, quantity: 1, wasteReason: 1 })
|
||||||
|
.lean<
|
||||||
|
{
|
||||||
|
productTypeId: Types.ObjectId;
|
||||||
|
kind: "waste" | "purchase";
|
||||||
|
quantity: number;
|
||||||
|
wasteReason: WasteReason | null;
|
||||||
|
}[]
|
||||||
|
>();
|
||||||
|
const wasteTypeIds = [
|
||||||
|
...new Set(moves.filter((m) => m.kind === "waste").map((m) => String(m.productTypeId))),
|
||||||
|
].map((id) => new Types.ObjectId(id));
|
||||||
|
const [types, prices] = await Promise.all([
|
||||||
|
ProductTypeModel.find({ householdId: hid, _id: { $in: wasteTypeIds } })
|
||||||
|
.select({ name: 1, unit: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; name: string; unit: string }[]>(),
|
||||||
|
latestTypeUnitPrices(hid, wasteTypeIds),
|
||||||
|
]);
|
||||||
|
const emptyReasons = () =>
|
||||||
|
Object.fromEntries(WASTE_REASONS.map((r) => [r, 0])) as Record<WasteReason, number>;
|
||||||
|
const rows = new Map<string, WasteRow>();
|
||||||
|
for (const t of types)
|
||||||
|
rows.set(String(t._id), {
|
||||||
|
productTypeId: String(t._id),
|
||||||
|
name: t.name,
|
||||||
|
unit: t.unit,
|
||||||
|
wasted: 0,
|
||||||
|
purchased: 0,
|
||||||
|
wastePct: null,
|
||||||
|
byReason: emptyReasons(),
|
||||||
|
costMinor: null,
|
||||||
|
});
|
||||||
|
const reasons = Object.fromEntries(
|
||||||
|
WASTE_REASONS.map((r) => [r, { count: 0, costMinor: 0 }]),
|
||||||
|
) as WasteReport["reasons"];
|
||||||
|
for (const m of moves) {
|
||||||
|
const row = rows.get(String(m.productTypeId));
|
||||||
|
if (!row) continue;
|
||||||
|
if (m.kind === "purchase") {
|
||||||
|
row.purchased += m.quantity;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const reason = m.wasteReason ?? "other";
|
||||||
|
row.wasted += m.quantity;
|
||||||
|
row.byReason[reason] += m.quantity;
|
||||||
|
reasons[reason].count++;
|
||||||
|
const price = prices.get(row.productTypeId);
|
||||||
|
if (price) reasons[reason].costMinor += Math.round((m.quantity * price.unitPriceMinor) / 1000);
|
||||||
|
}
|
||||||
|
let total = 0;
|
||||||
|
let unpriced = 0;
|
||||||
|
const list = [...rows.values()].map((r) => {
|
||||||
|
const price = prices.get(r.productTypeId);
|
||||||
|
const costMinor = price ? Math.round((r.wasted * price.unitPriceMinor) / 1000) : null;
|
||||||
|
if (costMinor === null) unpriced++;
|
||||||
|
else total += costMinor;
|
||||||
|
return { ...r, costMinor, wastePct: r.purchased > 0 ? sharePct(r.wasted, r.purchased) : null };
|
||||||
|
});
|
||||||
|
list.sort((a, c) => (c.costMinor ?? -1) - (a.costMinor ?? -1) || compareTr(a.name, c.name));
|
||||||
|
return {
|
||||||
|
period,
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
rows: list,
|
||||||
|
reasons,
|
||||||
|
totalCostMinor: total,
|
||||||
|
unpriced,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { getBudget } from "@/features/budgets/service";
|
||||||
|
import { getOverview } from "@/features/dashboard/service";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
getBudgetReport,
|
||||||
|
getCategoryReport,
|
||||||
|
getPeopleAccountsReport,
|
||||||
|
getTrendReport,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
describe("kategori dağılımı", () => {
|
||||||
|
it("toplam bütçenin gerçekleşeniyle aynı; oranlar ~%100; başka hane göremez", async () => {
|
||||||
|
const r = (await getCategoryReport(ayse, cakir))!;
|
||||||
|
const budget = (await getBudget(ayse, cakir))!;
|
||||||
|
expect(r.breakdown.totalMinor).toBe(budget.totals.actualMinor);
|
||||||
|
expect(r.breakdown.totalMinor).toBeGreaterThan(0);
|
||||||
|
const sum = r.breakdown.rows.reduce((a, x) => a + x.amountMinor, 0);
|
||||||
|
expect(sum).toBe(r.breakdown.totalMinor);
|
||||||
|
const pct = r.breakdown.rows.reduce((a, x) => a + x.sharePct, 0);
|
||||||
|
expect(Math.abs(pct - 100)).toBeLessThan(1);
|
||||||
|
// Alt kategori kırılımı: çocukların ve doğrudan tutarın toplamı üst satıra eşit.
|
||||||
|
for (const row of r.breakdown.rows) {
|
||||||
|
const kids = row.children.reduce((a, c) => a + c.amountMinor, 0);
|
||||||
|
expect(kids + row.ownMinor).toBe(row.amountMinor);
|
||||||
|
}
|
||||||
|
expect(r.appearance[r.breakdown.rows[0]!.id]).toBeDefined();
|
||||||
|
expect(await getCategoryReport(fatma, cakir)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("önceki ay seçilebilir", async () => {
|
||||||
|
const cur = (await getCategoryReport(ayse, cakir))!;
|
||||||
|
const prev = (await getCategoryReport(ayse, cakir, cur.previous))!;
|
||||||
|
expect(prev.current).toBe(false);
|
||||||
|
expect(prev.breakdown.totalMinor).toBe(cur.breakdown.prevTotalMinor);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("aylık trend", () => {
|
||||||
|
it("12 ay; içinde bulunulan ayın gideri bütçeyle aynı; tasarruf oranı tutarlı", async () => {
|
||||||
|
const r = (await getTrendReport(ayse, cakir))!;
|
||||||
|
expect(r.months).toHaveLength(12);
|
||||||
|
const last = r.months.at(-1)!;
|
||||||
|
expect(last.month).toBe(r.currentMonth);
|
||||||
|
expect(last.expenseMinor).toBe((await getBudget(ayse, cakir))!.totals.actualMinor);
|
||||||
|
for (const m of r.months) {
|
||||||
|
expect(m.netMinor).toBe(m.incomeMinor - m.expenseMinor);
|
||||||
|
if (m.incomeMinor === 0) expect(m.savingsPct).toBeNull();
|
||||||
|
}
|
||||||
|
expect(r.months.filter((m) => m.incomeMinor > 0).length).toBeGreaterThan(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("kişi ve hesap bazlı", () => {
|
||||||
|
it("kişi toplamları ve oranları; gizli hesap yalnızca sahibine görünür", async () => {
|
||||||
|
const own = (await getPeopleAccountsReport(ayse, cakir))!;
|
||||||
|
expect(own.people.reduce((a, p) => a + p.expenseMinor, 0)).toBe(own.peopleTotalMinor);
|
||||||
|
expect(own.people.every((p) => p.name !== "—")).toBe(true);
|
||||||
|
expect(own.accounts.map((a) => a.name)).toContain("Ayşe — Pluxee");
|
||||||
|
for (const a of own.accounts)
|
||||||
|
expect(a.netMinor).toBe(
|
||||||
|
a.incomeMinor + a.transferInMinor - a.expenseMinor - a.transferOutMinor,
|
||||||
|
);
|
||||||
|
const other = (await getPeopleAccountsReport(zeynep, cakir))!;
|
||||||
|
expect(other.accounts.map((a) => a.name)).not.toContain("Ayşe — Pluxee");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bütçe ve gerçekleşen", () => {
|
||||||
|
it("son 6 ay; son satır seçilen ay", async () => {
|
||||||
|
const r = (await getBudgetReport(ayse, cakir))!;
|
||||||
|
expect(r.history).toHaveLength(6);
|
||||||
|
expect(r.history.at(-1)!.month).toBe(r.month);
|
||||||
|
expect(r.history.at(-1)!.actualMinor).toBe(r.budget.totals.actualMinor);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dashboard ana grafik", () => {
|
||||||
|
it("periyotlar: hafta 7 gün, ay günleri, 6 ve 12 ay; gelecek günlerde gerçekleşen boş", async () => {
|
||||||
|
const week = (await getOverview(ayse, cakir, "week"))!;
|
||||||
|
expect(week.points).toHaveLength(7);
|
||||||
|
expect(week.points.filter((p) => p.key > week.today).every((p) => p.actual === null)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const month = (await getOverview(ayse, cakir, "month"))!;
|
||||||
|
const budget = (await getBudget(ayse, cakir))!;
|
||||||
|
const actual = month.points.reduce((a, p) => a + (p.actual ?? 0), 0);
|
||||||
|
expect(actual).toBe(budget.totals.actualMinor);
|
||||||
|
if (budget.hasBudget) {
|
||||||
|
const planned = month.points.reduce((a, p) => a + (p.budget ?? 0), 0);
|
||||||
|
expect(planned).toBe(budget.totals.limitMinor);
|
||||||
|
}
|
||||||
|
const half = (await getOverview(ayse, cakir, "half"))!;
|
||||||
|
expect(half.points).toHaveLength(6);
|
||||||
|
expect(half.points.at(-1)!.actual).toBe(budget.totals.actualMinor);
|
||||||
|
expect((await getOverview(ayse, cakir, "year"))!.points).toHaveLength(12);
|
||||||
|
expect(await getOverview(fatma, cakir, "month")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import { incomePlan } from "@/features/budgets/plan";
|
||||||
|
import {
|
||||||
|
budgetFor,
|
||||||
|
expenseAmounts,
|
||||||
|
settingsOf,
|
||||||
|
type BudgetLineView,
|
||||||
|
type BudgetView,
|
||||||
|
type Settings,
|
||||||
|
} from "@/features/budgets/service";
|
||||||
|
import { loadContext, userNames, type Ctx } from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { AccountType } from "@/shared/accounts";
|
||||||
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
|
import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import { categoryBreakdown, savingsRate, sharePct, type CategoryBreakdown } from "@/shared/reports";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raporlar (Bölüm 6.14). Okuma: viewer. "Ay" bütçe ayıdır (hane ayarı); gider kuralları bütçeyle
|
||||||
|
* aynı: gerçekleşmiş işlemler, bölünmüş işlemler kalem bazında, taksit modu ve başkasının
|
||||||
|
* "yalnızca ben" hesabındaki giderler hane ayarına göre (yalnızca tutar). Başkasının gizli
|
||||||
|
* hesabı hesap raporunda hiç görünmez.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
const ORPHAN = "__none__";
|
||||||
|
|
||||||
|
export type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string };
|
||||||
|
|
||||||
|
export async function base(userId: string, householdId: string): Promise<Base | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const s = await settingsOf(ctx.m.householdId);
|
||||||
|
const today = todayIstanbul();
|
||||||
|
return { ctx, s, today, currentMonth: periodOfDate(today, s.startDay) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kategori bazında gerçekleşen gider (kendi tutarı); kategorisiz/bilinmeyen tek satırda. */
|
||||||
|
async function actualByCategory(b: Base, month: string) {
|
||||||
|
const { from, to } = budgetPeriod(month, b.s.startDay);
|
||||||
|
const amounts = await expenseAmounts(b.ctx, b.s, from, to);
|
||||||
|
const out = new Map<string, number>();
|
||||||
|
for (const [id, a] of amounts) {
|
||||||
|
const v = a.fixedMinor + a.variableMinor;
|
||||||
|
if (!v) continue;
|
||||||
|
const known = id && b.ctx.categoryMap.get(id)?.kind === "expense";
|
||||||
|
const key = known ? id : ORPHAN;
|
||||||
|
out.set(key, (out.get(key) ?? 0) + v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MonthInfo = {
|
||||||
|
month: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
current: boolean;
|
||||||
|
currentMonth: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const monthInfo = (b: Base, month: string): MonthInfo => ({
|
||||||
|
month,
|
||||||
|
...budgetPeriod(month, b.s.startDay),
|
||||||
|
current: month === b.currentMonth,
|
||||||
|
currentMonth: b.currentMonth,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Kategori dağılımı ---
|
||||||
|
|
||||||
|
export type CategoryReport = MonthInfo & {
|
||||||
|
previous: string;
|
||||||
|
breakdown: CategoryBreakdown;
|
||||||
|
appearance: Record<string, { icon: IconKey; color: Swatch; path: string }>;
|
||||||
|
includesHidden: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCategoryReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
month?: string,
|
||||||
|
): Promise<CategoryReport | null> {
|
||||||
|
const b = await base(userId, householdId);
|
||||||
|
if (!b) return null;
|
||||||
|
const m = month ?? b.currentMonth;
|
||||||
|
const prev = shiftMonth(m, -1);
|
||||||
|
const [current, previous] = await Promise.all([
|
||||||
|
actualByCategory(b, m),
|
||||||
|
actualByCategory(b, prev),
|
||||||
|
]);
|
||||||
|
const cats = b.ctx.flatCategories
|
||||||
|
.filter((c) => c.kind === "expense")
|
||||||
|
.map((c) => ({ id: c.id, parentId: c.parentId, name: c.name, order: c.order }));
|
||||||
|
cats.push({ id: ORPHAN, parentId: null, name: tr.reports.uncategorized, order: 1e9 });
|
||||||
|
const appearance: CategoryReport["appearance"] = {
|
||||||
|
[ORPHAN]: { icon: "tag", color: "slate", path: tr.reports.uncategorized },
|
||||||
|
};
|
||||||
|
for (const c of b.ctx.categoryMap.values())
|
||||||
|
if (c.kind === "expense")
|
||||||
|
appearance[String(c.id)] = { icon: c.icon, color: c.color, path: c.path };
|
||||||
|
return {
|
||||||
|
...monthInfo(b, m),
|
||||||
|
previous: prev,
|
||||||
|
breakdown: categoryBreakdown(cats, current, previous),
|
||||||
|
appearance,
|
||||||
|
includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Aylık trend ---
|
||||||
|
|
||||||
|
export type TrendRow = {
|
||||||
|
month: string;
|
||||||
|
incomeMinor: number;
|
||||||
|
expenseMinor: number;
|
||||||
|
netMinor: number;
|
||||||
|
savingsPct: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrendReport = {
|
||||||
|
months: TrendRow[];
|
||||||
|
totals: {
|
||||||
|
incomeMinor: number;
|
||||||
|
expenseMinor: number;
|
||||||
|
netMinor: number;
|
||||||
|
savingsPct: number | null;
|
||||||
|
};
|
||||||
|
currentMonth: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Son `count` bütçe ayı (içinde bulunulan dahil): gerçekleşen gelir, gider, net, tasarruf oranı. */
|
||||||
|
export async function getTrendReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
count = 12,
|
||||||
|
): Promise<TrendReport | null> {
|
||||||
|
const b = await base(userId, householdId);
|
||||||
|
if (!b) return null;
|
||||||
|
const months = Array.from({ length: count }, (_, i) => shiftMonth(b.currentMonth, i - count + 1));
|
||||||
|
const rows = await Promise.all(
|
||||||
|
months.map(async (month) => {
|
||||||
|
const [income, expense] = await Promise.all([
|
||||||
|
incomePlan(b.ctx, b.s, month),
|
||||||
|
actualByCategory(b, month),
|
||||||
|
]);
|
||||||
|
const incomeMinor = income.actualMinor;
|
||||||
|
const expenseMinor = [...expense.values()].reduce((a, v) => a + v, 0);
|
||||||
|
return {
|
||||||
|
month,
|
||||||
|
incomeMinor,
|
||||||
|
expenseMinor,
|
||||||
|
netMinor: incomeMinor - expenseMinor,
|
||||||
|
savingsPct: savingsRate(incomeMinor, expenseMinor),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const incomeMinor = rows.reduce((a, r) => a + r.incomeMinor, 0);
|
||||||
|
const expenseMinor = rows.reduce((a, r) => a + r.expenseMinor, 0);
|
||||||
|
return {
|
||||||
|
months: rows,
|
||||||
|
totals: {
|
||||||
|
incomeMinor,
|
||||||
|
expenseMinor,
|
||||||
|
netMinor: incomeMinor - expenseMinor,
|
||||||
|
savingsPct: savingsRate(incomeMinor, expenseMinor),
|
||||||
|
},
|
||||||
|
currentMonth: b.currentMonth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kişi ve hesap bazlı ---
|
||||||
|
|
||||||
|
export type PersonRow = {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
expenseMinor: number;
|
||||||
|
count: number;
|
||||||
|
sharePct: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: AccountType;
|
||||||
|
archived: boolean;
|
||||||
|
incomeMinor: number;
|
||||||
|
expenseMinor: number;
|
||||||
|
transferInMinor: number;
|
||||||
|
transferOutMinor: number;
|
||||||
|
netMinor: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeopleAccountsReport = MonthInfo & {
|
||||||
|
people: PersonRow[];
|
||||||
|
peopleTotalMinor: number;
|
||||||
|
accounts: AccountRow[];
|
||||||
|
includesHidden: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TxLean = {
|
||||||
|
type: "income" | "expense" | "transfer";
|
||||||
|
amountMinor: number;
|
||||||
|
accountId: Types.ObjectId | null;
|
||||||
|
toAccountId: Types.ObjectId | null;
|
||||||
|
createdBy: Types.ObjectId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getPeopleAccountsReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
month?: string,
|
||||||
|
): Promise<PeopleAccountsReport | null> {
|
||||||
|
const b = await base(userId, householdId);
|
||||||
|
if (!b) return null;
|
||||||
|
const m = month ?? b.currentMonth;
|
||||||
|
const info = monthInfo(b, m);
|
||||||
|
const docs = await TransactionModel.find({
|
||||||
|
householdId: b.ctx.m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
status: "posted",
|
||||||
|
date: {
|
||||||
|
$gte: istanbulDayStart(info.from)!,
|
||||||
|
$lt: new Date(istanbulDayStart(info.to)!.getTime() + DAY_MS),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.select({ type: 1, amountMinor: 1, accountId: 1, toAccountId: 1, createdBy: 1 })
|
||||||
|
.lean<TxLean[]>();
|
||||||
|
const hidden = new Set(b.ctx.hiddenAccountIds.map(String));
|
||||||
|
|
||||||
|
// Kişi: kaydı giren üye; gizli hesap giderleri hane ayarına göre (yalnızca tutar).
|
||||||
|
const people = new Map<string, { expense: number; count: number }>();
|
||||||
|
for (const d of docs) {
|
||||||
|
if (d.type !== "expense") continue;
|
||||||
|
if (d.accountId && hidden.has(String(d.accountId)) && !b.s.includePrivate) continue;
|
||||||
|
const key = String(d.createdBy);
|
||||||
|
const p = people.get(key) ?? { expense: 0, count: 0 };
|
||||||
|
p.expense += d.amountMinor;
|
||||||
|
p.count++;
|
||||||
|
people.set(key, p);
|
||||||
|
}
|
||||||
|
const names = await userNames([...people.keys()].map((id) => new Types.ObjectId(id)));
|
||||||
|
const peopleTotal = [...people.values()].reduce((a, p) => a + p.expense, 0);
|
||||||
|
|
||||||
|
// Hesap: başkasının gizli hesabı hiç görünmez.
|
||||||
|
const acc = new Map<string, Omit<AccountRow, "id" | "name" | "type" | "archived" | "netMinor">>();
|
||||||
|
const bump = (
|
||||||
|
id: Types.ObjectId | null,
|
||||||
|
k: "incomeMinor" | "expenseMinor" | "transferInMinor" | "transferOutMinor",
|
||||||
|
v: number,
|
||||||
|
) => {
|
||||||
|
if (!id || hidden.has(String(id))) return;
|
||||||
|
const key = String(id);
|
||||||
|
const row = acc.get(key) ?? {
|
||||||
|
incomeMinor: 0,
|
||||||
|
expenseMinor: 0,
|
||||||
|
transferInMinor: 0,
|
||||||
|
transferOutMinor: 0,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
row[k] += v;
|
||||||
|
row.count++;
|
||||||
|
acc.set(key, row);
|
||||||
|
};
|
||||||
|
for (const d of docs) {
|
||||||
|
if (d.type === "income") bump(d.accountId, "incomeMinor", d.amountMinor);
|
||||||
|
else if (d.type === "expense") bump(d.accountId, "expenseMinor", d.amountMinor);
|
||||||
|
else {
|
||||||
|
bump(d.accountId, "transferOutMinor", d.amountMinor);
|
||||||
|
bump(d.toAccountId, "transferInMinor", d.amountMinor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const accounts: AccountRow[] = [];
|
||||||
|
for (const a of b.ctx.accountMap.values()) {
|
||||||
|
if (a.hidden) continue;
|
||||||
|
const row = acc.get(String(a.id));
|
||||||
|
if (!row && a.archived) continue;
|
||||||
|
const r = row ?? {
|
||||||
|
incomeMinor: 0,
|
||||||
|
expenseMinor: 0,
|
||||||
|
transferInMinor: 0,
|
||||||
|
transferOutMinor: 0,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
accounts.push({
|
||||||
|
id: String(a.id),
|
||||||
|
name: a.name,
|
||||||
|
type: a.type,
|
||||||
|
archived: a.archived,
|
||||||
|
...r,
|
||||||
|
netMinor: r.incomeMinor + r.transferInMinor - r.expenseMinor - r.transferOutMinor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
accounts.sort((x, y) => y.count - x.count || x.name.localeCompare(y.name, "tr"));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...info,
|
||||||
|
people: [...people.entries()]
|
||||||
|
.map(([id, p]) => ({
|
||||||
|
userId: id,
|
||||||
|
name: names.get(id) ?? "—",
|
||||||
|
expenseMinor: p.expense,
|
||||||
|
count: p.count,
|
||||||
|
sharePct: sharePct(p.expense, peopleTotal),
|
||||||
|
}))
|
||||||
|
.sort((x, y) => y.expenseMinor - x.expenseMinor),
|
||||||
|
peopleTotalMinor: peopleTotal,
|
||||||
|
accounts,
|
||||||
|
includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bütçe vs. gerçekleşen ---
|
||||||
|
|
||||||
|
export type BudgetHistoryRow = {
|
||||||
|
month: string;
|
||||||
|
limitMinor: number | null;
|
||||||
|
actualMinor: number;
|
||||||
|
ratioBp: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BudgetReport = MonthInfo & {
|
||||||
|
budget: BudgetView;
|
||||||
|
lines: BudgetLineView[];
|
||||||
|
history: BudgetHistoryRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Seçilen ayın kategori bazında bütçe/gerçekleşen tablosu ve son 6 ayın toplamları. */
|
||||||
|
export async function getBudgetReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
month?: string,
|
||||||
|
): Promise<BudgetReport | null> {
|
||||||
|
const b = await base(userId, householdId);
|
||||||
|
if (!b) return null;
|
||||||
|
const m = month ?? b.currentMonth;
|
||||||
|
const months = Array.from({ length: 6 }, (_, i) => shiftMonth(m, i - 5));
|
||||||
|
const budgets = await Promise.all(months.map((x) => budgetFor(b.ctx, b.s, x)));
|
||||||
|
const budget = budgets.at(-1)!;
|
||||||
|
return {
|
||||||
|
...monthInfo(b, m),
|
||||||
|
budget,
|
||||||
|
lines: budget.lines,
|
||||||
|
history: budgets.map((x) => ({
|
||||||
|
month: x.month,
|
||||||
|
limitMinor: x.hasBudget ? x.totals.limitMinor : null,
|
||||||
|
actualMinor: x.totals.actualMinor,
|
||||||
|
ratioBp: x.hasBudget ? x.totals.ratioBp : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"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 {
|
||||||
|
shoppingCompletionSchema,
|
||||||
|
shoppingItemFormSchema,
|
||||||
|
shoppingItemUpdateSchema,
|
||||||
|
shoppingListFormSchema,
|
||||||
|
} from "@/shared/schemas/shopping";
|
||||||
|
import { completeShopping } from "./completion";
|
||||||
|
import {
|
||||||
|
addShoppingItem,
|
||||||
|
approveSuggestions,
|
||||||
|
createShoppingList,
|
||||||
|
deleteShoppingList,
|
||||||
|
dismissSuggestion,
|
||||||
|
moveShoppingItem,
|
||||||
|
removeShoppingItem,
|
||||||
|
setShoppingItemChecked,
|
||||||
|
updateShoppingItem,
|
||||||
|
updateShoppingList,
|
||||||
|
} 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, R>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
input: unknown,
|
||||||
|
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
|
||||||
|
): Promise<ActionResult<R>> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = (...keys: string[]) => z.object(Object.fromEntries(keys.map((k) => [k, idSchema])));
|
||||||
|
|
||||||
|
export async function saveShoppingListAction(listId: string | null, input: unknown) {
|
||||||
|
if (listId === null)
|
||||||
|
return run(shoppingListFormSchema, input, (u, h, d) => createShoppingList(u, h, d));
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingListFormSchema, input, (u, h, d) => updateShoppingList(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteShoppingListAction(listId: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => deleteShoppingList(u, h, id.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addShoppingItemAction(listId: unknown, input: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingItemFormSchema, input, (u, h, d) => addShoppingItem(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShoppingItemAction(listId: unknown, itemId: unknown, input: unknown) {
|
||||||
|
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||||
|
if (!ref.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingItemUpdateSchema, input, (u, h, d) =>
|
||||||
|
updateShoppingItem(u, h, ref.data.listId!, ref.data.itemId!, d),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeShoppingItemAction(listId: unknown, itemId: unknown) {
|
||||||
|
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||||
|
if (!ref.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) =>
|
||||||
|
removeShoppingItem(u, h, ref.data.listId!, ref.data.itemId!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setShoppingItemCheckedAction(
|
||||||
|
listId: unknown,
|
||||||
|
itemId: unknown,
|
||||||
|
checked: unknown,
|
||||||
|
) {
|
||||||
|
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||||
|
if (!ref.success || typeof checked !== "boolean") return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) =>
|
||||||
|
setShoppingItemChecked(u, h, ref.data.listId!, ref.data.itemId!, checked),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveShoppingItemAction(listId: unknown, itemId: unknown, target: unknown) {
|
||||||
|
const ref = ids("listId", "itemId", "target").safeParse({ listId, itemId, target });
|
||||||
|
if (!ref.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) =>
|
||||||
|
moveShoppingItem(u, h, ref.data.listId!, ref.data.itemId!, ref.data.target!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveSuggestionsAction(listId: unknown, productTypeIds: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
const types = z.array(idSchema).min(1).max(200).safeParse(productTypeIds);
|
||||||
|
if (!id.success || !types.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => approveSuggestions(u, h, id.data, types.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissSuggestionAction(productTypeId: unknown) {
|
||||||
|
const id = idSchema.safeParse(productTypeId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => dismissSuggestion(u, h, id.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeShoppingAction(listId: unknown, input: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingCompletionSchema, input, (u, h, d) => completeShopping(u, h, id.data, d));
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import { StockMovementModel } from "@/models/stock-movement";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { shoppingCompletionSchema } from "@/shared/schemas/shopping";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { completeShopping, getCompletionSheet } from "./completion";
|
||||||
|
import { getShoppingList } from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let weekly = "";
|
||||||
|
let monthEnd = "";
|
||||||
|
const today = todayIstanbul();
|
||||||
|
|
||||||
|
const bySeed = (model: typeof ProductModel | typeof StoreModel, seedRef: string) =>
|
||||||
|
(model as typeof ProductModel)
|
||||||
|
.findOne({ seedRef })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
const accountId = (name: string) =>
|
||||||
|
AccountModel.findOne({ name })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id);
|
||||||
|
monthEnd = String((await ShoppingListModel.findOne({ seedRef: "ay-sonu" }).lean())!._id);
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
async function scenario() {
|
||||||
|
const sheet = (await getCompletionSheet(ayse, cakir, weekly))!;
|
||||||
|
const item = (label: string) => sheet.items.find((i) => i.label === label)!;
|
||||||
|
const milk = item("Süt");
|
||||||
|
const eggs = item("Yumurta");
|
||||||
|
const detergent = item("Toz Deterjan");
|
||||||
|
const candle = item("Doğum günü mumu");
|
||||||
|
const tomato = item("Domates");
|
||||||
|
const sok = await bySeed(StoreModel, "sok");
|
||||||
|
const migros = await bySeed(StoreModel, "migros");
|
||||||
|
const a101 = await bySeed(StoreModel, "a101");
|
||||||
|
const milkPlan = sheet.planned[milk.id]!;
|
||||||
|
const detPlan = sheet.planned[detergent.id]!;
|
||||||
|
const market = (await CategoryModel.findOne({
|
||||||
|
defaultKey: "market",
|
||||||
|
householdId: cakir,
|
||||||
|
}).lean())!;
|
||||||
|
const accounts = {
|
||||||
|
bank: await accountId("Ziraat Maaş"),
|
||||||
|
meal: await accountId("Ayşe — Pluxee"),
|
||||||
|
card: await accountId("Garanti Bonus"),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
sheet,
|
||||||
|
milk,
|
||||||
|
eggs,
|
||||||
|
detergent,
|
||||||
|
candle,
|
||||||
|
tomato,
|
||||||
|
stores: { sok, migros, a101 },
|
||||||
|
form: (over: Record<string, unknown> = {}) =>
|
||||||
|
shoppingCompletionSchema.parse({
|
||||||
|
date: today,
|
||||||
|
lines: [
|
||||||
|
// Planlanan ürün, planlanan mağaza; paket fiyatı plandan 1 TL fazla.
|
||||||
|
{
|
||||||
|
itemId: milk.id,
|
||||||
|
productId: milkPlan.productId,
|
||||||
|
storeId: milkPlan.storeId,
|
||||||
|
packages: String(milkPlan.packages),
|
||||||
|
amount: formatMoneyInput((milkPlan.priceMinor + 100) * milkPlan.packages),
|
||||||
|
},
|
||||||
|
// Muadil: 15'li köy yumurtası, A101.
|
||||||
|
{
|
||||||
|
itemId: eggs.id,
|
||||||
|
productId: sheet.products.find((p) => p.label.includes("Köy"))!.id,
|
||||||
|
storeId: a101,
|
||||||
|
packages: "2",
|
||||||
|
amount: "189,80",
|
||||||
|
},
|
||||||
|
// Planlanan ürün başka mağazada (Migros, yemek kartı).
|
||||||
|
{
|
||||||
|
itemId: detergent.id,
|
||||||
|
productId: detPlan.productId,
|
||||||
|
storeId: migros,
|
||||||
|
packages: "1",
|
||||||
|
amount: "549,00",
|
||||||
|
},
|
||||||
|
// Markasız ürün tipi (pazardan domates): 2,5 kg, fiyat kaydı yok.
|
||||||
|
{ itemId: tomato.id, productId: "", storeId: sok, packages: "2,5", amount: "60" },
|
||||||
|
// Serbest metin kalemi: yedek gider kategorisi.
|
||||||
|
{ itemId: candle.id, productId: "", storeId: sok, packages: "1", amount: "25" },
|
||||||
|
// Plansız ekstra.
|
||||||
|
{
|
||||||
|
itemId: "",
|
||||||
|
productId: sheet.products.find((p) => p.label.startsWith("Fairy"))!.id,
|
||||||
|
storeId: sok,
|
||||||
|
packages: "1",
|
||||||
|
amount: "84,50",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stores: [
|
||||||
|
{ storeId: sok, accountId: accounts.bank, attachmentIds: [] },
|
||||||
|
{ storeId: migros, accountId: accounts.meal, attachmentIds: [] },
|
||||||
|
{ storeId: a101, accountId: accounts.card, attachmentIds: [] },
|
||||||
|
],
|
||||||
|
fallbackCategoryId: String(market._id),
|
||||||
|
leftover: { action: "move", targetListId: monthEnd, newListName: "" },
|
||||||
|
...over,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tamamlama ekranı", () => {
|
||||||
|
it("plan, fiyatlar, hesaplar ve eşleme bilgisi; izleyici açamaz", async () => {
|
||||||
|
const s = await scenario();
|
||||||
|
expect(s.sheet.items).toHaveLength(9);
|
||||||
|
expect(Object.keys(s.sheet.planned).length).toBe(7);
|
||||||
|
expect(Object.keys(s.sheet.prices).length).toBeGreaterThan(20);
|
||||||
|
expect(s.sheet.accounts.map((a) => a.name)).toContain("Ayşe — Pluxee");
|
||||||
|
expect(s.sheet.defaultFallbackCategoryId).not.toBeNull();
|
||||||
|
expect(s.sheet.otherLists.map((l) => l.name)).toEqual(["Ay sonu toptan"]);
|
||||||
|
expect(await getCompletionSheet(zeynep, cakir, weekly)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("alışverişi tamamla", () => {
|
||||||
|
it("doğrulama: muadil aynı tipten, gelecek tarih, izleyici, şema kuralları", async () => {
|
||||||
|
const s = await scenario();
|
||||||
|
const wrongType = s.form();
|
||||||
|
wrongType.lines[0]!.productId = await bySeed(ProductModel, "ariel-5kg");
|
||||||
|
expect((await completeShopping(ayse, cakir, weekly, wrongType)).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await completeShopping(ayse, cakir, weekly, s.form({ date: addDays(today, 1) }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect((await completeShopping(zeynep, cakir, weekly, s.form())).ok).toBe(false);
|
||||||
|
// Mağazası için ödeme hesabı girilmemiş satır.
|
||||||
|
expect(
|
||||||
|
shoppingCompletionSchema.safeParse({
|
||||||
|
date: today,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
itemId: "",
|
||||||
|
productId: s.milk.productTypeId,
|
||||||
|
storeId: s.stores.sok,
|
||||||
|
packages: "1",
|
||||||
|
amount: "1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stores: [],
|
||||||
|
leftover: { action: "drop" },
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ek dosya hatasında hiçbir şey yazılmaz (transaction geri alınır)", async () => {
|
||||||
|
const s = await scenario();
|
||||||
|
const form = s.form();
|
||||||
|
form.stores[0]!.attachmentIds = [String(new Types.ObjectId())];
|
||||||
|
expect((await completeShopping(ayse, cakir, weekly, form)).ok).toBe(false);
|
||||||
|
expect(await TransactionModel.countDocuments({ shoppingListId: { $ne: null } })).toBe(0);
|
||||||
|
expect(await StockMovementModel.countDocuments({ shoppingListId: { $ne: null } })).toBe(0);
|
||||||
|
expect(await ProductPriceModel.countDocuments({ source: "receipt" })).toBe(0);
|
||||||
|
expect((await ShoppingListModel.findById(weekly).lean())!.status).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stok, fiyat, mağaza başına bölünmüş gider, kalanların taşınması ve rapor", async () => {
|
||||||
|
const s = await scenario();
|
||||||
|
const monthEndBefore = (await ShoppingListModel.findById(monthEnd).lean())!.items.length;
|
||||||
|
|
||||||
|
const r = await completeShopping(ayse, cakir, weekly, s.form());
|
||||||
|
expect(r).toMatchObject({ ok: true, data: { transactions: 3 } });
|
||||||
|
|
||||||
|
// Mağaza başına gider; Şok: gıda + temizlik + yedek (mum) bölünmüş.
|
||||||
|
const txs = await TransactionModel.find({ shoppingListId: new Types.ObjectId(weekly) }).lean();
|
||||||
|
expect(txs).toHaveLength(3);
|
||||||
|
const sokTx = txs.find((t) => t.payeeName.startsWith("Şok"))!;
|
||||||
|
expect(sokTx.splits).toHaveLength(3);
|
||||||
|
expect(sokTx.splits.reduce((a, x) => a + x.amountMinor, 0)).toBe(sokTx.amountMinor);
|
||||||
|
const migrosTx = txs.find((t) => t.payeeName.startsWith("Migros"))!;
|
||||||
|
expect(migrosTx).toMatchObject({ amountMinor: 549_00, splits: [] });
|
||||||
|
expect(String(migrosTx.accountId)).toBe(await accountId("Ayşe — Pluxee"));
|
||||||
|
|
||||||
|
// Stok alımları (serbest metin hariç 5 kalem) ve fiş kaynaklı fiyatlar (markasız domates hariç 4).
|
||||||
|
expect(
|
||||||
|
await StockMovementModel.countDocuments({
|
||||||
|
shoppingListId: new Types.ObjectId(weekly),
|
||||||
|
kind: "purchase",
|
||||||
|
}),
|
||||||
|
).toBe(5);
|
||||||
|
expect(await ProductPriceModel.countDocuments({ source: "receipt" })).toBe(4);
|
||||||
|
const eggPrice = await ProductPriceModel.findOne({ source: "receipt", priceMinor: 94_90 });
|
||||||
|
expect(eggPrice).not.toBeNull();
|
||||||
|
|
||||||
|
// Liste kapandı; alınmayan 5 kalem "Ay sonu toptan"a taşındı.
|
||||||
|
const after = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
expect(after.list.status).toBe("completed");
|
||||||
|
expect(after.canWrite).toBe(false);
|
||||||
|
expect(after.report!.completion.movedTo?.name).toBe("Ay sonu toptan");
|
||||||
|
expect((await ShoppingListModel.findById(monthEnd).lean())!.items.length).toBe(
|
||||||
|
monthEndBefore + 4,
|
||||||
|
);
|
||||||
|
// Süt alımı: paket × 1 lt.
|
||||||
|
const milkMove = await StockMovementModel.findOne({
|
||||||
|
shoppingListId: new Types.ObjectId(weekly),
|
||||||
|
productTypeId: new Types.ObjectId(s.milk.productTypeId!),
|
||||||
|
}).lean();
|
||||||
|
expect(milkMove!.quantity).toBe(s.sheet.planned[s.milk.id]!.packages * 1000);
|
||||||
|
// Markasız domates: tipin biriminde 2,5 kg.
|
||||||
|
const tomatoMove = await StockMovementModel.findOne({
|
||||||
|
shoppingListId: new Types.ObjectId(weekly),
|
||||||
|
productTypeId: new Types.ObjectId(s.tomato.productTypeId!),
|
||||||
|
}).lean();
|
||||||
|
expect(tomatoMove!.quantity).toBe(2500);
|
||||||
|
|
||||||
|
// Rapor: aynı/muadil/alınmadı/ekstra; nedenlerin toplamı farka eşit.
|
||||||
|
const { summary, completion } = after.report!;
|
||||||
|
expect(summary.counts).toEqual({ same: 4, substitute: 1, skipped: 4, extra: 1 });
|
||||||
|
const sum = Object.values(summary.reasons).reduce((a, b) => a + b, 0);
|
||||||
|
expect(sum).toBe(summary.diffMinor);
|
||||||
|
// Süt: paket başına +1 TL; deterjan: aynı ürün başka mağazada (Migros) farklı fiyat.
|
||||||
|
expect(summary.reasons.price).toBe(
|
||||||
|
100 * s.sheet.planned[s.milk.id]!.packages +
|
||||||
|
(549_00 - s.sheet.planned[s.detergent.id]!.priceMinor),
|
||||||
|
);
|
||||||
|
expect(summary.reasons.extra).toBe(84_50);
|
||||||
|
expect(summary.reasons.unplanned).toBe(25_00 + 60_00);
|
||||||
|
expect(summary.reasons.skipped).toBeLessThan(0);
|
||||||
|
expect(completion.stores).toHaveLength(3);
|
||||||
|
|
||||||
|
// Tamamlanan liste değiştirilemez, ikinci kez tamamlanamaz.
|
||||||
|
expect((await completeShopping(ayse, cakir, weekly, s.form())).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import { syncOwnerAttachments } from "@/features/attachments/service";
|
||||||
|
import type { CategoryOption } from "@/features/categories/tree";
|
||||||
|
import { buildTree, toOptions } from "@/features/categories/tree";
|
||||||
|
import { loadTypeOffers } from "@/features/prices/service";
|
||||||
|
import { listProducts, type ProductView } from "@/features/products/service";
|
||||||
|
import { getStock } from "@/features/stock/service";
|
||||||
|
import { buildDoc, loadContext } from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { connectMongoose } from "@/lib/db/mongoose";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import { StockMovementModel } from "@/models/stock-movement";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { allowedAttachmentKinds } from "@/shared/attachments";
|
||||||
|
import type { MealCardProvider } from "@/shared/accounts";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { formatQuantity, QUANTITY_SCALE } from "@/shared/quantity";
|
||||||
|
import { formatStock } from "@/shared/stock";
|
||||||
|
import type { ShoppingCompletionForm } from "@/shared/schemas/shopping";
|
||||||
|
import type { CompletionRow, CompletionStore, ShoppingCompletion } from "@/shared/shopping";
|
||||||
|
import { matchKind, type PlannedPurchase } from "@/shared/shopping-compare";
|
||||||
|
import { MAX_SPLITS } from "@/shared/transactions";
|
||||||
|
import type { TransactionForm } from "@/shared/schemas/transaction";
|
||||||
|
import {
|
||||||
|
getShoppingList,
|
||||||
|
writableList,
|
||||||
|
type ItemLean,
|
||||||
|
type ShoppingItemView,
|
||||||
|
type ShoppingListSummary,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Alışverişi tamamla" (Bölüm 6.12). Tek MongoDB transaction içinde: stok alım hareketleri, fiyat
|
||||||
|
* kayıtları (kaynak: fiş), mağaza başına bölünmüş gider (stok → gider kategorisi eşlemesi), fiş
|
||||||
|
* görselleri ve alınmayan kalemlerin taşınması; liste kapanır ve karşılaştırma görüntüsü saklanır.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.shopping;
|
||||||
|
const v = t.validation;
|
||||||
|
|
||||||
|
export type CompletionSheet = {
|
||||||
|
list: ShoppingListSummary;
|
||||||
|
items: ShoppingItemView[];
|
||||||
|
/** Kalem başına akıllı önerinin planı (önerilen dağılım). */
|
||||||
|
planned: Record<
|
||||||
|
string,
|
||||||
|
{ productId: string; storeId: string; packages: number; priceMinor: number }
|
||||||
|
>;
|
||||||
|
products: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
packageLabel: string;
|
||||||
|
productTypeId: string;
|
||||||
|
typeName: string;
|
||||||
|
}[];
|
||||||
|
stores: { id: string; label: string; slot: number; mealCard: boolean; providers: string[] }[];
|
||||||
|
/** Geçerli paket fiyatları: `${productId}:${storeId}` → kuruş (tutar ön doldurması için). */
|
||||||
|
prices: Record<string, number>;
|
||||||
|
accounts: { id: string; name: string; type: string; mealCardProvider: MealCardProvider | null }[];
|
||||||
|
expenseCategories: CategoryOption[];
|
||||||
|
/** Gider kategorisine eşlenmemiş ürün tipleri (yedek kategori gerekir). */
|
||||||
|
unmappedTypeIds: string[];
|
||||||
|
defaultFallbackCategoryId: string | null;
|
||||||
|
otherLists: { id: string; name: string }[];
|
||||||
|
today: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function expenseCategoryOptions(householdId: Types.ObjectId) {
|
||||||
|
const rows = await CategoryModel.find({ householdId, kind: "expense" }).lean<
|
||||||
|
{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
parentId: Types.ObjectId | null;
|
||||||
|
order: number;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
defaultKey: string | null;
|
||||||
|
}[]
|
||||||
|
>();
|
||||||
|
const options = toOptions(
|
||||||
|
buildTree(
|
||||||
|
rows.map((c) => ({
|
||||||
|
id: String(c._id),
|
||||||
|
kind: "expense" as const,
|
||||||
|
name: c.name,
|
||||||
|
parentId: c.parentId ? String(c.parentId) : null,
|
||||||
|
order: c.order,
|
||||||
|
archived: Boolean(c.archivedAt),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const market = rows.find((c) => c.defaultKey === "market" && !c.archivedAt);
|
||||||
|
return { options, marketId: market ? String(market._id) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ürün tipi → etkin gider kategorisi (stok kategorisinden, üstten devralınarak). */
|
||||||
|
async function typeExpenseMap(userId: string, householdId: string) {
|
||||||
|
const stock = await getStock(userId, householdId);
|
||||||
|
if (!stock) return null;
|
||||||
|
const catExpense = new Map(stock.categories.map((c) => [c.id, c.expense?.id ?? null]));
|
||||||
|
return new Map(stock.items.map((i) => [i.id, catExpense.get(i.categoryId) ?? null]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /alisveris/:id/tamamla — alınanları girme ekranının verisi. */
|
||||||
|
export async function getCompletionSheet(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
): Promise<CompletionSheet | null> {
|
||||||
|
const detail = await getShoppingList(userId, householdId, listId);
|
||||||
|
if (!detail || !detail.canWrite) return null;
|
||||||
|
const ctx = await loadContext(userId, householdId, "member");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const [productList, expense, categories, accounts] = await Promise.all([
|
||||||
|
listProducts(userId, householdId),
|
||||||
|
typeExpenseMap(userId, householdId),
|
||||||
|
expenseCategoryOptions(ctx.m.householdId),
|
||||||
|
AccountModel.find({ householdId: ctx.m.householdId, archivedAt: null })
|
||||||
|
.select({ name: 1, type: 1, order: 1, isPrivate: 1, ownerUserId: 1, mealCardProvider: 1 })
|
||||||
|
.sort({ order: 1 })
|
||||||
|
.lean<
|
||||||
|
{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
ownerUserId: Types.ObjectId | null;
|
||||||
|
mealCardProvider: MealCardProvider | null;
|
||||||
|
}[]
|
||||||
|
>(),
|
||||||
|
]);
|
||||||
|
if (!productList || !expense) return null;
|
||||||
|
const products = productList.products.filter((p) => p.inTypeUnit !== null);
|
||||||
|
const offers = await loadTypeOffers(
|
||||||
|
ctx.m.householdId,
|
||||||
|
[...new Set(products.map((p) => p.productTypeId))].map((x) => new Types.ObjectId(x)),
|
||||||
|
{ activeOnly: true },
|
||||||
|
);
|
||||||
|
const planned: CompletionSheet["planned"] = {};
|
||||||
|
for (const g of detail.plan.recommended?.stores ?? [])
|
||||||
|
for (const l of g.lines)
|
||||||
|
planned[l.itemId] = {
|
||||||
|
productId: l.productId,
|
||||||
|
storeId: l.storeId,
|
||||||
|
packages: l.packages,
|
||||||
|
priceMinor: l.priceMinor,
|
||||||
|
};
|
||||||
|
const { stores: storeRefs } = detail.plan;
|
||||||
|
const storeRows = await StoreModel.find({ householdId: ctx.m.householdId, archivedAt: null })
|
||||||
|
.select({ mealCardProviders: 1, mealCardAccepted: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; mealCardAccepted: boolean; mealCardProviders: string[] }[]>();
|
||||||
|
return {
|
||||||
|
list: detail.list,
|
||||||
|
items: detail.items,
|
||||||
|
planned,
|
||||||
|
products: products.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
packageLabel: p.packageLabel,
|
||||||
|
productTypeId: p.productTypeId,
|
||||||
|
typeName: p.productTypeName,
|
||||||
|
})),
|
||||||
|
stores: storeRows
|
||||||
|
.map((s) => {
|
||||||
|
const ref = storeRefs[String(s._id)]!;
|
||||||
|
return {
|
||||||
|
id: ref.id,
|
||||||
|
label: ref.label,
|
||||||
|
slot: ref.slot,
|
||||||
|
mealCard: s.mealCardAccepted,
|
||||||
|
providers: s.mealCardAccepted ? s.mealCardProviders : [],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, "tr")),
|
||||||
|
prices: Object.fromEntries(offers.map((o) => [`${o.productId}:${o.storeId}`, o.priceMinor])),
|
||||||
|
accounts: accounts
|
||||||
|
.filter((a) => !a.isPrivate || String(a.ownerUserId) === userId)
|
||||||
|
.map((a) => ({
|
||||||
|
id: String(a._id),
|
||||||
|
name: a.name,
|
||||||
|
type: a.type,
|
||||||
|
mealCardProvider: a.mealCardProvider ?? null,
|
||||||
|
})),
|
||||||
|
expenseCategories: categories.options,
|
||||||
|
unmappedTypeIds: [...expense.entries()].filter(([, e]) => !e).map(([id]) => id),
|
||||||
|
defaultFallbackCategoryId: categories.marketId,
|
||||||
|
otherLists: detail.otherLists,
|
||||||
|
today: todayIstanbul(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transaction içindeki doğrulama hatası: geri alıp kullanıcıya döndürmek için. */
|
||||||
|
class CompletionError extends Error {
|
||||||
|
constructor(readonly result: ActionResult<never>) {
|
||||||
|
super(result.ok ? "" : result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolvedLine = {
|
||||||
|
item: ItemLean | null;
|
||||||
|
product: ProductView | null;
|
||||||
|
storeId: string;
|
||||||
|
/** Üründe paket adedi; markasız ürün tipi kaleminde tipin birimindeki miktar (binde bir). */
|
||||||
|
packages: number;
|
||||||
|
amountMinor: number;
|
||||||
|
categoryId: string;
|
||||||
|
/** Stoğa işlenecek ürün tipi ve miktar (serbest metinde null). */
|
||||||
|
stock: { productTypeId: string; quantity: number } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function completeShopping(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
listId: string,
|
||||||
|
input: ShoppingCompletionForm,
|
||||||
|
): Promise<ActionResult<{ transactions: number }>> {
|
||||||
|
const r = await writableList(actorId, householdId, listId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const { m, list, actor } = r;
|
||||||
|
if (input.date > todayIstanbul()) return fail(v.futureDate, { date: v.futureDate });
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
|
||||||
|
const [productList, expense, stockData, activeStores] = await Promise.all([
|
||||||
|
listProducts(actorId, householdId, { includeArchived: true }),
|
||||||
|
typeExpenseMap(actorId, householdId),
|
||||||
|
getShoppingList(actorId, householdId, listId),
|
||||||
|
StoreModel.find({ householdId: m.householdId, archivedAt: null })
|
||||||
|
.select({ _id: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId }[]>(),
|
||||||
|
]);
|
||||||
|
const activeStoreIds = new Set(activeStores.map((x) => String(x._id)));
|
||||||
|
if (!productList || !expense || !stockData) return fail(tr.common.notFound);
|
||||||
|
const products = new Map(productList.products.map((p) => [p.id, p]));
|
||||||
|
const views = new Map(stockData.items.map((i) => [i.id, i]));
|
||||||
|
const storeRefs = stockData.plan.stores;
|
||||||
|
const itemById = new Map(list.items.map((i) => [String(i._id), i]));
|
||||||
|
|
||||||
|
// Yedek gider kategorisi (eşlenmemiş ve serbest metin kalemleri için).
|
||||||
|
const fallback = input.fallbackCategoryId;
|
||||||
|
const fallbackOk =
|
||||||
|
fallback &&
|
||||||
|
ctx.categoryMap.get(fallback)?.kind === "expense" &&
|
||||||
|
!ctx.categoryMap.get(fallback)?.archived;
|
||||||
|
|
||||||
|
// --- Satırları doğrula ---
|
||||||
|
const lines: ResolvedLine[] = [];
|
||||||
|
for (const [i, l] of input.lines.entries()) {
|
||||||
|
const at = (field: string) => `lines.${i}.${field}`;
|
||||||
|
const item = l.itemId ? (itemById.get(l.itemId) ?? null) : null;
|
||||||
|
if (l.itemId && !item) return fail(v.itemGone);
|
||||||
|
if (!activeStoreIds.has(l.storeId))
|
||||||
|
return fail(tr.productPrices.validation.store, {
|
||||||
|
[at("storeId")]: tr.productPrices.validation.store,
|
||||||
|
});
|
||||||
|
|
||||||
|
let product: ProductView | null = null;
|
||||||
|
let typeId: string | null = null;
|
||||||
|
if (item?.kind === "text") {
|
||||||
|
if (l.productId) return fail(v.textNoProduct, { [at("productId")]: v.textNoProduct });
|
||||||
|
} else if (item?.kind === "type" && !l.productId) {
|
||||||
|
// Markasız (ör. pazardan domates): miktar tipin biriminde, fiyat kaydı yok.
|
||||||
|
typeId = String(item.productTypeId);
|
||||||
|
} else {
|
||||||
|
product = l.productId ? (products.get(l.productId) ?? null) : null;
|
||||||
|
if (!product || product.archived) return fail(v.product, { [at("productId")]: v.product });
|
||||||
|
if (product.inTypeUnit === null)
|
||||||
|
return fail(v.conversion(product.label), {
|
||||||
|
[at("productId")]: v.conversion(product.label),
|
||||||
|
});
|
||||||
|
const expectedType =
|
||||||
|
item?.kind === "type"
|
||||||
|
? String(item.productTypeId)
|
||||||
|
: item?.kind === "product"
|
||||||
|
? (products.get(String(item.productId))?.productTypeId ?? null)
|
||||||
|
: null;
|
||||||
|
if (expectedType && product.productTypeId !== expectedType)
|
||||||
|
return fail(v.substituteType, { [at("productId")]: v.substituteType });
|
||||||
|
typeId = product.productTypeId;
|
||||||
|
}
|
||||||
|
const mapped = typeId ? (expense.get(typeId) ?? null) : null;
|
||||||
|
const categoryId = mapped ?? (fallbackOk ? fallback : null);
|
||||||
|
if (!categoryId) return fail(v.fallbackCategory, { fallbackCategoryId: v.fallbackCategory });
|
||||||
|
lines.push({
|
||||||
|
item,
|
||||||
|
product,
|
||||||
|
storeId: l.storeId,
|
||||||
|
packages: l.packages,
|
||||||
|
amountMinor: l.amount,
|
||||||
|
categoryId,
|
||||||
|
stock: typeId
|
||||||
|
? {
|
||||||
|
productTypeId: typeId,
|
||||||
|
quantity: product
|
||||||
|
? Math.max(1, Math.round((l.packages * product.inTypeUnit!) / QUANTITY_SCALE))
|
||||||
|
: l.packages,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mağaza başına gider işlemleri (doğrulama buildDoc ile) ---
|
||||||
|
const accountOf = new Map(input.stores.map((s) => [s.storeId, s]));
|
||||||
|
const byStore = new Map<string, ResolvedLine[]>();
|
||||||
|
for (const l of lines) byStore.set(l.storeId, [...(byStore.get(l.storeId) ?? []), l]);
|
||||||
|
const txDocs: { storeId: string; doc: Record<string, unknown>; attachmentIds: string[] }[] = [];
|
||||||
|
for (const [storeId, group] of byStore) {
|
||||||
|
const entry = accountOf.get(storeId)!;
|
||||||
|
const amount = group.reduce((a, l) => a + l.amountMinor, 0);
|
||||||
|
const perCategory = new Map<string, { amount: number; count: number }>();
|
||||||
|
for (const l of group) {
|
||||||
|
const c = perCategory.get(l.categoryId) ?? { amount: 0, count: 0 };
|
||||||
|
c.amount += l.amountMinor;
|
||||||
|
c.count++;
|
||||||
|
perCategory.set(l.categoryId, c);
|
||||||
|
}
|
||||||
|
if (perCategory.size > MAX_SPLITS) return fail(v.tooManySplits);
|
||||||
|
const split = perCategory.size >= 2;
|
||||||
|
const form: TransactionForm = {
|
||||||
|
type: "expense",
|
||||||
|
amount,
|
||||||
|
date: input.date,
|
||||||
|
accountId: entry.accountId,
|
||||||
|
toAccountId: null,
|
||||||
|
categoryId: split ? null : [...perCategory.keys()][0]!,
|
||||||
|
splits: split
|
||||||
|
? [...perCategory.entries()].map(([categoryId, c]) => ({
|
||||||
|
categoryId,
|
||||||
|
amount: c.amount,
|
||||||
|
note: t.complete.splitNote(c.count),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
payee: storeRefs[storeId]!.label,
|
||||||
|
description: t.complete.txDescription(list.name),
|
||||||
|
tags: [],
|
||||||
|
installments: 1,
|
||||||
|
attachmentIds: entry.attachmentIds,
|
||||||
|
};
|
||||||
|
const built = buildDoc(ctx, form);
|
||||||
|
if (!built.ok) {
|
||||||
|
const msg = built.error;
|
||||||
|
return fail(msg, { [`stores.${input.stores.indexOf(entry)}.accountId`]: msg });
|
||||||
|
}
|
||||||
|
txDocs.push({ storeId, doc: built.data, attachmentIds: entry.attachmentIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Alınmayanlar ---
|
||||||
|
const boughtIds = new Set(lines.flatMap((l) => (l.item ? [String(l.item._id)] : [])));
|
||||||
|
const leftover = list.items.filter((i) => !boughtIds.has(String(i._id)));
|
||||||
|
let target: { _id: Types.ObjectId; name: string; items: ItemLean[] } | null = null;
|
||||||
|
if (leftover.length && input.leftover.action === "move") {
|
||||||
|
const tl = await writableList(actorId, householdId, input.leftover.targetListId!);
|
||||||
|
if (tl.error || tl.list._id.equals(list._id))
|
||||||
|
return fail(v.targetList, { "leftover.targetListId": v.targetList });
|
||||||
|
target = tl.list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Plan ve karşılaştırma görüntüsü ---
|
||||||
|
// Kullanıcının gördüğü akıllı öneri (önerilen dağılım).
|
||||||
|
const plan = stockData.plan;
|
||||||
|
const planned = new Map<string, PlannedPurchase & { storeId: string }>();
|
||||||
|
for (const g of plan.recommended?.stores ?? [])
|
||||||
|
for (const l of g.lines)
|
||||||
|
planned.set(l.itemId, {
|
||||||
|
productId: l.productId,
|
||||||
|
storeId: l.storeId,
|
||||||
|
packages: l.packages * QUANTITY_SCALE,
|
||||||
|
priceMinor: l.priceMinor,
|
||||||
|
costMinor: l.costMinor,
|
||||||
|
});
|
||||||
|
const label = (id: string) => products.get(id)?.label ?? "—";
|
||||||
|
const pkgLabel = (id: string) => {
|
||||||
|
const p = products.get(id);
|
||||||
|
return p ? `${p.label} · ${p.packageLabel}` : "—";
|
||||||
|
};
|
||||||
|
const planOf = (itemId: string): CompletionRow["plan"] => {
|
||||||
|
const p = planned.get(itemId);
|
||||||
|
return p
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
productLabel: pkgLabel(p.productId),
|
||||||
|
storeLabel: storeRefs[p.storeId]?.label ?? "—",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
const rows: CompletionRow[] = [];
|
||||||
|
const lineByItem = new Map(lines.flatMap((l) => (l.item ? [[String(l.item._id), l]] : [])));
|
||||||
|
for (const item of list.items) {
|
||||||
|
const id = String(item._id);
|
||||||
|
const l = lineByItem.get(id) ?? null;
|
||||||
|
const p = planned.get(id) ?? null;
|
||||||
|
rows.push({
|
||||||
|
itemId: id,
|
||||||
|
match: matchKind(
|
||||||
|
{ kind: item.kind, productId: item.productId ? String(item.productId) : null },
|
||||||
|
p,
|
||||||
|
l
|
||||||
|
? { productId: l.product?.id ?? null, packages: l.packages, amountMinor: l.amountMinor }
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
label: views.get(id)?.label ?? item.text,
|
||||||
|
productId: l?.product?.id ?? null,
|
||||||
|
productLabel: l?.product ? pkgLabel(l.product.id) : "",
|
||||||
|
storeId: l?.storeId ?? null,
|
||||||
|
storeLabel: l ? (storeRefs[l.storeId]?.label ?? "—") : "",
|
||||||
|
packages: l?.packages ?? 0,
|
||||||
|
quantityLabel: !l
|
||||||
|
? ""
|
||||||
|
: l.product
|
||||||
|
? `${formatQuantity(l.packages)} ${t.packageUnit}`
|
||||||
|
: l.stock
|
||||||
|
? formatStock(l.packages, views.get(id)?.unit ?? "")
|
||||||
|
: "",
|
||||||
|
amountMinor: l?.amountMinor ?? null,
|
||||||
|
plan: planOf(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const l of lines.filter((x) => !x.item))
|
||||||
|
rows.push({
|
||||||
|
itemId: null,
|
||||||
|
match: "extra",
|
||||||
|
label: label(l.product!.id),
|
||||||
|
productId: l.product!.id,
|
||||||
|
productLabel: pkgLabel(l.product!.id),
|
||||||
|
storeId: l.storeId,
|
||||||
|
storeLabel: storeRefs[l.storeId]?.label ?? "—",
|
||||||
|
packages: l.packages,
|
||||||
|
quantityLabel: `${formatQuantity(l.packages)} ${t.packageUnit}`,
|
||||||
|
amountMinor: l.amountMinor,
|
||||||
|
plan: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Yaz (tek transaction) ---
|
||||||
|
const mongoose = await connectMongoose();
|
||||||
|
const accountNames = new Map([...ctx.accountMap.values()].map((a) => [String(a.id), a.name]));
|
||||||
|
try {
|
||||||
|
const stores: CompletionStore[] = [];
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
stores.length = 0;
|
||||||
|
for (const { storeId, doc, attachmentIds } of txDocs) {
|
||||||
|
const [tx] = await TransactionModel.create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...doc,
|
||||||
|
householdId: m.householdId,
|
||||||
|
status: "posted",
|
||||||
|
shoppingListId: list._id,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
const synced = await syncOwnerAttachments({
|
||||||
|
householdId: m.householdId,
|
||||||
|
actorId,
|
||||||
|
ownerType: "transaction",
|
||||||
|
ownerId: tx!._id,
|
||||||
|
ids: attachmentIds,
|
||||||
|
allowedKinds: allowedAttachmentKinds("expense"),
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
if (!synced.ok) throw new CompletionError(synced);
|
||||||
|
await TransactionModel.updateOne(
|
||||||
|
{ _id: tx!._id },
|
||||||
|
{ $set: { attachmentCount: synced.data } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
const accountId = String(doc.accountId);
|
||||||
|
stores.push({
|
||||||
|
storeId,
|
||||||
|
label: storeRefs[storeId]!.label,
|
||||||
|
accountId,
|
||||||
|
accountName: accountNames.get(accountId) ?? "—",
|
||||||
|
transactionId: String(tx!._id),
|
||||||
|
amountMinor: doc.amountMinor as number,
|
||||||
|
attachmentCount: synced.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stockLines = lines.filter((l) => l.stock);
|
||||||
|
if (stockLines.length)
|
||||||
|
await StockMovementModel.insertMany(
|
||||||
|
stockLines.map((l) => ({
|
||||||
|
householdId: m.householdId,
|
||||||
|
productTypeId: new Types.ObjectId(l.stock!.productTypeId),
|
||||||
|
kind: "purchase",
|
||||||
|
date: input.date,
|
||||||
|
quantity: l.stock!.quantity,
|
||||||
|
note: t.complete.txDescription(list.name),
|
||||||
|
shoppingListId: list._id,
|
||||||
|
createdBy: actor,
|
||||||
|
})),
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
const productLines = lines.filter((l) => l.product);
|
||||||
|
if (productLines.length) {
|
||||||
|
// Fiyat kaydı: ürün × mağaza başına tek kayıt; paket fiyatı = tutar / paket adedi.
|
||||||
|
const prices = new Map<string, ResolvedLine[]>();
|
||||||
|
for (const l of productLines) {
|
||||||
|
const key = `${l.product!.id}:${l.storeId}`;
|
||||||
|
prices.set(key, [...(prices.get(key) ?? []), l]);
|
||||||
|
}
|
||||||
|
await ProductPriceModel.insertMany(
|
||||||
|
[...prices.values()].map((group) => {
|
||||||
|
const packages = group.reduce((a, l) => a + l.packages, 0);
|
||||||
|
const amount = group.reduce((a, l) => a + l.amountMinor, 0);
|
||||||
|
return {
|
||||||
|
householdId: m.householdId,
|
||||||
|
productId: new Types.ObjectId(group[0]!.product!.id),
|
||||||
|
storeId: new Types.ObjectId(group[0]!.storeId),
|
||||||
|
date: input.date,
|
||||||
|
priceMinor: Math.max(1, Math.round((amount * QUANTITY_SCALE) / packages)),
|
||||||
|
isPromo: false,
|
||||||
|
promoUntil: null,
|
||||||
|
source: "receipt",
|
||||||
|
note: "",
|
||||||
|
createdBy: actor,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let movedTo: ShoppingCompletion["movedTo"] = null;
|
||||||
|
const carry = leftover.map((i) => ({
|
||||||
|
...i,
|
||||||
|
checked: false,
|
||||||
|
checkedBy: null,
|
||||||
|
checkedAt: null,
|
||||||
|
}));
|
||||||
|
if (leftover.length && input.leftover.action === "move" && target) {
|
||||||
|
await ShoppingListModel.updateOne(
|
||||||
|
{ _id: target._id },
|
||||||
|
{ $push: { items: { $each: carry } }, $set: { updatedBy: actor } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
movedTo = { listId: String(target._id), name: target.name };
|
||||||
|
} else if (leftover.length && input.leftover.action === "new") {
|
||||||
|
const [created] = await ShoppingListModel.create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
householdId: m.householdId,
|
||||||
|
name: input.leftover.newListName,
|
||||||
|
items: carry,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
movedTo = { listId: String(created!._id), name: input.leftover.newListName };
|
||||||
|
}
|
||||||
|
|
||||||
|
const completion: ShoppingCompletion = {
|
||||||
|
date: input.date,
|
||||||
|
completedBy: actorId,
|
||||||
|
rows,
|
||||||
|
stores,
|
||||||
|
movedTo,
|
||||||
|
};
|
||||||
|
const res = await ShoppingListModel.updateOne(
|
||||||
|
{ _id: list._id, status: "active", deletedAt: null },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
status: "completed",
|
||||||
|
completedAt: new Date(),
|
||||||
|
completion,
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
if (!res.modifiedCount) throw new CompletionError(fail(v.notActive));
|
||||||
|
});
|
||||||
|
return ok({ transactions: txDocs.length });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CompletionError) return error.result;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { AttachmentPicker } from "@/features/attachments/components/attachment-picker";
|
||||||
|
import type { AttachmentInfo } from "@/features/attachments/service";
|
||||||
|
import { StoreDot } from "@/features/prices/components/offers-list";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { formatMoneyInput, parseMoney } from "@/shared/money";
|
||||||
|
import { parseQuantity } from "@/shared/quantity";
|
||||||
|
import type { LeftoverAction } from "@/shared/shopping";
|
||||||
|
import { quantityInput } from "@/shared/stock";
|
||||||
|
import { completeShoppingAction } from "../actions";
|
||||||
|
import type { CompletionSheet } from "../completion";
|
||||||
|
import type { ShoppingItemView } from "../service";
|
||||||
|
|
||||||
|
const t = tr.shopping.complete;
|
||||||
|
/** Ürün tipi kaleminde marka belirtmeden alım (Radix Select boş değer kabul etmez). */
|
||||||
|
const GENERIC = "__generic__";
|
||||||
|
|
||||||
|
type Line = {
|
||||||
|
productId: string;
|
||||||
|
storeId: string;
|
||||||
|
packages: string;
|
||||||
|
amount: string;
|
||||||
|
/** Kullanıcı tutarı elle değiştirdi: fiyattan yeniden hesaplanmaz. */
|
||||||
|
touched: boolean;
|
||||||
|
};
|
||||||
|
type ItemLine = Line & { bought: boolean };
|
||||||
|
type Extra = Line & { key: number };
|
||||||
|
|
||||||
|
/** Paket fiyatı biliniyorsa tutar = fiyat × paket. */
|
||||||
|
function priceAmount(sheet: CompletionSheet, l: Pick<Line, "productId" | "storeId" | "packages">) {
|
||||||
|
const price = sheet.prices[`${l.productId}:${l.storeId}`];
|
||||||
|
const q = parseQuantity(l.packages);
|
||||||
|
return price && q ? formatMoneyInput(Math.round((price * q) / 1000)) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function withPrice(sheet: CompletionSheet, l: Line, patch: Partial<Line>): Line {
|
||||||
|
const next = { ...l, ...patch };
|
||||||
|
if ("amount" in patch) return { ...next, touched: true };
|
||||||
|
if (next.touched) return next;
|
||||||
|
return { ...next, amount: priceAmount(sheet, next) || next.amount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialLine(sheet: CompletionSheet, item: ShoppingItemView): Line {
|
||||||
|
const plan = sheet.planned[item.id];
|
||||||
|
const typeOf = (id: string | null) => sheet.products.find((p) => p.id === id)?.productTypeId;
|
||||||
|
const typeId = item.kind === "product" ? typeOf(item.productId) : item.productTypeId;
|
||||||
|
const productId =
|
||||||
|
item.kind === "text"
|
||||||
|
? ""
|
||||||
|
: (plan?.productId ??
|
||||||
|
item.productId ??
|
||||||
|
sheet.products.find((p) => p.productTypeId === typeId)?.id ??
|
||||||
|
GENERIC);
|
||||||
|
const packages = plan
|
||||||
|
? String(plan.packages)
|
||||||
|
: item.quantity && (item.kind === "product" || productId === GENERIC)
|
||||||
|
? quantityInput(item.quantity)
|
||||||
|
: "1";
|
||||||
|
const base = {
|
||||||
|
productId,
|
||||||
|
storeId: plan?.storeId ?? defaultStore(sheet),
|
||||||
|
packages,
|
||||||
|
amount: "",
|
||||||
|
touched: false,
|
||||||
|
};
|
||||||
|
return { ...base, amount: priceAmount(sheet, base) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Planda en çok kalemin gittiği mağaza; plan yoksa ilk mağaza. */
|
||||||
|
function defaultStore(sheet: CompletionSheet) {
|
||||||
|
const count = new Map<string, number>();
|
||||||
|
for (const p of Object.values(sheet.planned))
|
||||||
|
count.set(p.storeId, (count.get(p.storeId) ?? 0) + 1);
|
||||||
|
return [...count.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? sheet.stores[0]?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mağazaya uygun varsayılan hesap: geçerli yemek kartı, yoksa banka/nakit, yoksa ilk hesap. */
|
||||||
|
function defaultAccount(sheet: CompletionSheet, storeId: string) {
|
||||||
|
const store = sheet.stores.find((s) => s.id === storeId);
|
||||||
|
const meal = sheet.accounts.find(
|
||||||
|
(a) =>
|
||||||
|
a.type === "meal_card" && a.mealCardProvider && store?.providers.includes(a.mealCardProvider),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
meal?.id ??
|
||||||
|
sheet.accounts.find((a) => a.type === "bank" || a.type === "cash")?.id ??
|
||||||
|
sheet.accounts[0]?.id ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineFields({
|
||||||
|
sheet,
|
||||||
|
line,
|
||||||
|
products,
|
||||||
|
text,
|
||||||
|
onChange,
|
||||||
|
errors,
|
||||||
|
idPrefix,
|
||||||
|
genericUnit,
|
||||||
|
}: {
|
||||||
|
sheet: CompletionSheet;
|
||||||
|
line: Line;
|
||||||
|
products: CompletionSheet["products"];
|
||||||
|
text: boolean;
|
||||||
|
onChange: (patch: Partial<Line>) => void;
|
||||||
|
errors: Record<string, string | undefined>;
|
||||||
|
idPrefix: string;
|
||||||
|
/** Ürün tipi kalemi: markasız seçeneği ve tipin birimi. */
|
||||||
|
genericUnit?: string;
|
||||||
|
}) {
|
||||||
|
const generic = line.productId === GENERIC;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)_5rem_7rem]">
|
||||||
|
{text ? (
|
||||||
|
<p className="col-span-2 self-center text-xs text-muted-foreground sm:col-span-1">
|
||||||
|
{tr.shopping.complete.textItem}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Field data-invalid={Boolean(errors.productId)} className="col-span-2 sm:col-span-1">
|
||||||
|
<FieldLabel htmlFor={`${idPrefix}-p`} className="text-xs">
|
||||||
|
{t.product}
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={line.productId} onValueChange={(x) => onChange({ productId: x })}>
|
||||||
|
<SelectTrigger id={`${idPrefix}-p`} className="w-full">
|
||||||
|
<SelectValue placeholder={tr.shopping.validation.product} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{genericUnit ? (
|
||||||
|
<SelectItem value={GENERIC}>{t.generic(genericUnit)}</SelectItem>
|
||||||
|
) : null}
|
||||||
|
{products.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.label}{" "}
|
||||||
|
<span className="text-xs text-muted-foreground">({p.packageLabel})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.productId ? <FieldError>{errors.productId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field data-invalid={Boolean(errors.storeId)} className="col-span-2 sm:col-span-1">
|
||||||
|
<FieldLabel htmlFor={`${idPrefix}-s`} className="text-xs">
|
||||||
|
{t.store}
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={line.storeId} onValueChange={(x) => onChange({ storeId: x })}>
|
||||||
|
<SelectTrigger id={`${idPrefix}-s`} className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{sheet.stores.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
<StoreDot slot={s.slot} />
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
{text ? (
|
||||||
|
<span className="hidden sm:block" />
|
||||||
|
) : (
|
||||||
|
<Field data-invalid={Boolean(errors.packages)}>
|
||||||
|
<FieldLabel htmlFor={`${idPrefix}-q`} className="text-xs">
|
||||||
|
{generic && genericUnit ? `${t.quantity} (${genericUnit})` : t.packages}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`${idPrefix}-q`}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={line.packages}
|
||||||
|
onChange={(e) => onChange({ packages: e.target.value })}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.packages)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field
|
||||||
|
data-invalid={Boolean(errors.amount)}
|
||||||
|
className={text ? "col-span-2 sm:col-span-1" : ""}
|
||||||
|
>
|
||||||
|
<FieldLabel htmlFor={`${idPrefix}-a`} className="text-xs">
|
||||||
|
{t.amount}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`${idPrefix}-a`}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={line.amount}
|
||||||
|
onChange={(e) => onChange({ amount: e.target.value })}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.amount)}
|
||||||
|
/>
|
||||||
|
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /alisveris/:id/tamamla — alınanları gir, mağaza başına hesap ve fiş, alınmayanlar. */
|
||||||
|
export function CompletionForm({ sheet }: { sheet: CompletionSheet }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const anyChecked = sheet.items.some((i) => i.checked);
|
||||||
|
const [date, setDate] = useState(sheet.today);
|
||||||
|
const [rows, setRows] = useState<Record<string, ItemLine>>(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
sheet.items.map((i) => [
|
||||||
|
i.id,
|
||||||
|
{ ...initialLine(sheet, i), bought: anyChecked ? i.checked : !i.archived },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [extras, setExtras] = useState<Extra[]>([]);
|
||||||
|
const [accounts, setAccounts] = useState<Record<string, string>>({});
|
||||||
|
const [receipts, setReceipts] = useState<Record<string, AttachmentInfo[]>>({});
|
||||||
|
const [uploading, setUploading] = useState(0);
|
||||||
|
const [fallback, setFallback] = useState(sheet.defaultFallbackCategoryId ?? "");
|
||||||
|
const [leftover, setLeftover] = useState<{
|
||||||
|
action: LeftoverAction;
|
||||||
|
target: string;
|
||||||
|
name: string;
|
||||||
|
}>({
|
||||||
|
action: sheet.otherLists.length ? "move" : "new",
|
||||||
|
target: sheet.otherLists[0]?.id ?? "",
|
||||||
|
name: t.newListDefault(sheet.list.name),
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const typeProducts = (item: ShoppingItemView) => {
|
||||||
|
const typeId =
|
||||||
|
item.kind === "product"
|
||||||
|
? sheet.products.find((p) => p.id === item.productId)?.productTypeId
|
||||||
|
: item.productTypeId;
|
||||||
|
return sheet.products.filter((p) => p.productTypeId === typeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bought = sheet.items.filter((i) => rows[i.id]!.bought);
|
||||||
|
const unbought = sheet.items.filter((i) => !rows[i.id]!.bought);
|
||||||
|
const allLines: (Line & { itemId: string; text: boolean })[] = [
|
||||||
|
...bought.map((i) => ({ ...rows[i.id]!, itemId: i.id, text: i.kind === "text" })),
|
||||||
|
...extras.map((e) => ({ ...e, itemId: "", text: false })),
|
||||||
|
];
|
||||||
|
const storeIds = [...new Set(allLines.map((l) => l.storeId).filter(Boolean))];
|
||||||
|
const unmapped = new Set(sheet.unmappedTypeIds);
|
||||||
|
const needsFallback = allLines.some(
|
||||||
|
(l) =>
|
||||||
|
l.text || unmapped.has(sheet.products.find((p) => p.id === l.productId)?.productTypeId ?? ""),
|
||||||
|
);
|
||||||
|
const total = allLines.reduce((a, l) => a + (parseMoney(l.amount) ?? 0), 0);
|
||||||
|
const plannedTotal = useMemo(
|
||||||
|
() => Object.values(sheet.planned).reduce((a, p) => a + p.priceMinor * p.packages, 0),
|
||||||
|
[sheet.planned],
|
||||||
|
);
|
||||||
|
|
||||||
|
function setRow(id: string, patch: Partial<ItemLine>) {
|
||||||
|
setRows((s) => {
|
||||||
|
const cur = s[id]!;
|
||||||
|
const { bought: b, ...rest } = patch;
|
||||||
|
const next = Object.keys(rest).length ? { ...cur, ...withPrice(sheet, cur, rest) } : cur;
|
||||||
|
return { ...s, [id]: { ...next, bought: b ?? cur.bought } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true);
|
||||||
|
const lineKeys = allLines.map((l, i) =>
|
||||||
|
l.itemId ? `item:${l.itemId}` : `extra:${extras[i - bought.length]?.key}`,
|
||||||
|
);
|
||||||
|
const r = await completeShoppingAction(sheet.list.id, {
|
||||||
|
date,
|
||||||
|
lines: allLines.map((l) => ({
|
||||||
|
itemId: l.itemId,
|
||||||
|
productId: l.text || l.productId === GENERIC ? "" : l.productId,
|
||||||
|
storeId: l.storeId,
|
||||||
|
packages: l.text ? "1" : l.packages,
|
||||||
|
amount: l.amount,
|
||||||
|
})),
|
||||||
|
stores: storeIds.map((id) => ({
|
||||||
|
storeId: id,
|
||||||
|
accountId: accounts[id] ?? defaultAccount(sheet, id),
|
||||||
|
attachmentIds: (receipts[id] ?? []).map((a) => a.id),
|
||||||
|
})),
|
||||||
|
fallbackCategoryId: needsFallback ? fallback : "",
|
||||||
|
leftover: unbought.length
|
||||||
|
? { action: leftover.action, targetListId: leftover.target, newListName: leftover.name }
|
||||||
|
: { action: "drop" },
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.saved);
|
||||||
|
router.push(`/alisveris/${sheet.list.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Satır hatalarını satır anahtarına çevir.
|
||||||
|
const mapped: Record<string, string | undefined> = {};
|
||||||
|
for (const [k, msg] of Object.entries(r.fieldErrors ?? {})) {
|
||||||
|
const m = /^lines\.(\d+)\.(\w+)$/.exec(k);
|
||||||
|
if (m) mapped[`${lineKeys[Number(m[1])]}.${m[2]}`] = msg;
|
||||||
|
else {
|
||||||
|
const s = /^stores\.(\d+)\.(\w+)$/.exec(k);
|
||||||
|
mapped[s ? `store:${storeIds[Number(s[1])]}` : k] = msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setErrors({ ...mapped, root: r.error });
|
||||||
|
toast.error(r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errs = (key: string) => ({
|
||||||
|
productId: errors[`${key}.productId`],
|
||||||
|
storeId: errors[`${key}.storeId`],
|
||||||
|
packages: errors[`${key}.packages`],
|
||||||
|
amount: errors[`${key}.amount`],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href={`/alisveris/${sheet.list.id}`}>
|
||||||
|
<ArrowLeft />
|
||||||
|
{tr.shopping.actions.backToList}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
{t.title}: {sheet.list.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t.description}</p>
|
||||||
|
</div>
|
||||||
|
{sheet.stores.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm">{t.noStores}</p>
|
||||||
|
) : null}
|
||||||
|
<Field className="max-w-48" data-invalid={Boolean(errors.date)}>
|
||||||
|
<FieldLabel htmlFor="sc-date">{t.date}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="sc-date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
max={sheet.today}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<section aria-labelledby="sc-items" className="flex flex-col gap-2">
|
||||||
|
<h2 id="sc-items" className="font-medium">
|
||||||
|
{t.itemsTitle}
|
||||||
|
</h2>
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{sheet.items.map((item) => {
|
||||||
|
const row = rows[item.id]!;
|
||||||
|
const plan = sheet.planned[item.id];
|
||||||
|
const planProduct = plan ? sheet.products.find((p) => p.id === plan.productId) : null;
|
||||||
|
const planStore = plan ? sheet.stores.find((s) => s.id === plan.storeId) : null;
|
||||||
|
const substitute =
|
||||||
|
row.bought &&
|
||||||
|
item.kind !== "text" &&
|
||||||
|
(item.kind === "product"
|
||||||
|
? row.productId !== item.productId
|
||||||
|
: plan && row.productId !== plan.productId);
|
||||||
|
const key = `item:${item.id}`;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={cn("flex flex-col gap-3 p-3", !row.bought && "bg-muted/30")}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id={`b-${item.id}`}
|
||||||
|
checked={row.bought}
|
||||||
|
onCheckedChange={(x) => setRow(item.id, { bought: x === true })}
|
||||||
|
className="mt-0.5 size-5"
|
||||||
|
/>
|
||||||
|
<label htmlFor={`b-${item.id}`} className="min-w-0 flex-1 cursor-pointer">
|
||||||
|
<span
|
||||||
|
className={cn("block font-medium", !row.bought && "text-muted-foreground")}
|
||||||
|
>
|
||||||
|
{item.label}{" "}
|
||||||
|
{item.quantityLabel ? (
|
||||||
|
<span className="text-sm font-normal text-muted-foreground tabular-nums">
|
||||||
|
· {item.quantityLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{plan && planProduct && planStore
|
||||||
|
? t.planned(
|
||||||
|
`${planProduct.label} · ${planStore.label} · ${formatMoney(plan.priceMinor * plan.packages)}`,
|
||||||
|
)
|
||||||
|
: item.kind === "text"
|
||||||
|
? null
|
||||||
|
: t.noPlan}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{substitute ? <Badge variant="outline">{t.substitute}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
{row.bought ? (
|
||||||
|
<LineFields
|
||||||
|
sheet={sheet}
|
||||||
|
line={row}
|
||||||
|
products={typeProducts(item)}
|
||||||
|
text={item.kind === "text"}
|
||||||
|
genericUnit={item.kind === "type" ? item.unit : undefined}
|
||||||
|
onChange={(patch) => setRow(item.id, patch)}
|
||||||
|
errors={errs(key)}
|
||||||
|
idPrefix={`l-${item.id}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="sc-extras" className="flex flex-col gap-2">
|
||||||
|
<h2 id="sc-extras" className="font-medium">
|
||||||
|
{t.extrasTitle}
|
||||||
|
</h2>
|
||||||
|
{extras.length ? (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{extras.map((e) => (
|
||||||
|
<li key={e.key} className="flex items-start gap-2 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<LineFields
|
||||||
|
sheet={sheet}
|
||||||
|
line={e}
|
||||||
|
products={sheet.products}
|
||||||
|
text={false}
|
||||||
|
onChange={(patch) =>
|
||||||
|
setExtras((s) =>
|
||||||
|
s.map((x) =>
|
||||||
|
x.key === e.key ? { ...x, ...withPrice(sheet, x, patch) } : x,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
errors={errs(`extra:${e.key}`)}
|
||||||
|
idPrefix={`e-${e.key}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="mt-6"
|
||||||
|
aria-label={t.removeExtra}
|
||||||
|
onClick={() => setExtras((s) => s.filter((x) => x.key !== e.key))}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
onClick={() =>
|
||||||
|
setExtras((s) => [
|
||||||
|
...s,
|
||||||
|
{
|
||||||
|
key: Date.now(),
|
||||||
|
productId: "",
|
||||||
|
storeId: storeIds[0] ?? defaultStore(sheet),
|
||||||
|
packages: "1",
|
||||||
|
amount: "",
|
||||||
|
touched: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
{t.addExtra}
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{storeIds.length ? (
|
||||||
|
<section aria-labelledby="sc-stores" className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 id="sc-stores" className="font-medium">
|
||||||
|
{t.storesTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t.storesDescription}</p>
|
||||||
|
</div>
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
|
{storeIds.map((id) => {
|
||||||
|
const store = sheet.stores.find((s) => s.id === id);
|
||||||
|
const lines = allLines.filter((l) => l.storeId === id);
|
||||||
|
const sum = lines.reduce((a, l) => a + (parseMoney(l.amount) ?? 0), 0);
|
||||||
|
return (
|
||||||
|
<li key={id} className="flex flex-col gap-3 rounded-xl border bg-card p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex min-w-0 items-center font-medium">
|
||||||
|
<StoreDot slot={store?.slot ?? 1} />
|
||||||
|
<span className="truncate">{store?.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-sm tabular-nums">
|
||||||
|
{t.subtotal(lines.length)} · {formatMoney(sum)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Field data-invalid={Boolean(errors[`store:${id}`])}>
|
||||||
|
<FieldLabel htmlFor={`acc-${id}`}>{t.account}</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={accounts[id] ?? defaultAccount(sheet, id)}
|
||||||
|
onValueChange={(x) => setAccounts((s) => ({ ...s, [id]: x }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`acc-${id}`} className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sheet.accounts.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors[`store:${id}`] ? (
|
||||||
|
<FieldError>{errors[`store:${id}`]}</FieldError>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{t.receipts}</FieldLabel>
|
||||||
|
<AttachmentPicker
|
||||||
|
value={receipts[id] ?? []}
|
||||||
|
onChange={(items) => setReceipts((s) => ({ ...s, [id]: items }))}
|
||||||
|
allowedKinds={["image"]}
|
||||||
|
onBusyChange={(b) => setUploading((n) => n + (b ? 1 : -1))}
|
||||||
|
addLabel={t.addReceipt}
|
||||||
|
hint={t.receiptHint}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{needsFallback ? (
|
||||||
|
<Field data-invalid={Boolean(errors.fallbackCategoryId)} className="max-w-md">
|
||||||
|
<FieldLabel htmlFor="sc-fallback">{t.fallbackCategory}</FieldLabel>
|
||||||
|
<Select value={fallback} onValueChange={setFallback}>
|
||||||
|
<SelectTrigger id="sc-fallback" className="w-full">
|
||||||
|
<SelectValue placeholder={t.fallbackCategory} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{sheet.expenseCategories.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.path}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldDescription>{t.fallbackHint}</FieldDescription>
|
||||||
|
{errors.fallbackCategoryId ? <FieldError>{errors.fallbackCategoryId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{unbought.length ? (
|
||||||
|
<section aria-labelledby="sc-left" className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 id="sc-left" className="font-medium">
|
||||||
|
{t.leftoverTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t.leftoverDescription(unbought.length)} {unbought.map((i) => i.label).join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div role="radiogroup" aria-labelledby="sc-left" className="flex flex-col gap-2">
|
||||||
|
{(["move", "new", "drop"] as const)
|
||||||
|
.filter((a) => a !== "move" || sheet.otherLists.length)
|
||||||
|
.map((a) => (
|
||||||
|
<label key={a} className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="leftover"
|
||||||
|
value={a}
|
||||||
|
checked={leftover.action === a}
|
||||||
|
onChange={() => setLeftover((s) => ({ ...s, action: a }))}
|
||||||
|
className="size-4 accent-primary"
|
||||||
|
/>
|
||||||
|
{t.leftoverActions[a]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{leftover.action === "move" ? (
|
||||||
|
<Select
|
||||||
|
value={leftover.target}
|
||||||
|
onValueChange={(x) => setLeftover((s) => ({ ...s, target: x }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full max-w-md" aria-label={tr.shopping.moveTarget}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sheet.otherLists.map((l) => (
|
||||||
|
<SelectItem key={l.id} value={l.id}>
|
||||||
|
{l.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : leftover.action === "new" ? (
|
||||||
|
<Input
|
||||||
|
aria-label={t.newListName}
|
||||||
|
value={leftover.name}
|
||||||
|
onChange={(e) => setLeftover((s) => ({ ...s, name: e.target.value }))}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{errors["leftover.targetListId"] || errors["leftover.newListName"] ? (
|
||||||
|
<FieldError>
|
||||||
|
{errors["leftover.targetListId"] ?? errors["leftover.newListName"]}
|
||||||
|
</FieldError>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="sticky bottom-[calc(4rem+env(safe-area-inset-bottom))] z-10 -mx-4 border-t bg-background/95 backdrop-blur md:bottom-0 md:mx-0 md:rounded-xl md:border">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<div className="min-w-0 flex-1 text-sm">
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{t.total}: {formatMoney(total)}
|
||||||
|
</span>
|
||||||
|
{plannedTotal ? (
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{" · "}
|
||||||
|
{t.plannedTotal}: {formatMoney(plannedTotal)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{errors.root ? (
|
||||||
|
<span className="block text-xs text-destructive">{errors.root}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button onClick={submit} disabled={busy || uploading > 0 || allLines.length === 0}>
|
||||||
|
{t.submit}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user