Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e7e374778 | |||
| 6cc698f70b | |||
| 07ba531d31 | |||
| 814bdbbc2a | |||
| 5e0f1b5901 | |||
| 868bb23fd9 | |||
| d9de29c18d | |||
| abf2ea6dd7 | |||
| 0c5d959058 | |||
| d8f442c1aa | |||
| 39aa19d168 | |||
| 21b3715d35 | |||
| b5078d9251 | |||
| a274d29728 | |||
| 62023ef35d | |||
| 3fa1e949e2 | |||
| 6de6d07c66 | |||
| 660c6d5f2d | |||
| c53fe80241 | |||
| b022c1c3b0 | |||
| 9a60085d14 | |||
| 20e5a90df4 | |||
| b8306b3b5d | |||
| a818c110cf | |||
| 890ba9f521 | |||
| baec70d324 | |||
| 7a6414df7a | |||
| 571bf7690d | |||
| 380c9b3647 | |||
| 3cd8d98324 | |||
| 9781cd9ed2 | |||
| 1062b2d96c | |||
| 015906392c | |||
| 5648eafd7c | |||
| b6c6be5c97 | |||
| 5d370a062e | |||
| dfb83d6fd4 |
+139
-1
@@ -6,6 +6,138 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
||||
|
||||
## [Yayınlanmamış]
|
||||
|
||||
## [0.11.0] - 2026-11-06
|
||||
|
||||
Faz 11: ev stoğu (11a ve 11b alt fazları).
|
||||
|
||||
### Eklendi — Faz 11b: periyodik sayım, fire ayarlı öneri, tüketim analizi
|
||||
|
||||
- Sayım oturumları (`stockCounts`): her sayımda ürün başına beklenen, sayılan ve fireye ayrılan miktar; fark sıfır olsa da kayıt tutulur ve oluşan hareketler oturuma bağlanır (`countId`). Ürün detayındaki tek ürün sayımı da oturum oluşturur.
|
||||
- Periyodik sayım ekranı (`/stok/sayim?kategori=…`): kategori (alt ağacıyla) veya tüm stok; liste hâlinde beklenen → sayılan, anlık fark; eksiğin bir kısmı nedenle fire olarak işaretlenebilir (fire, eksikten fazla olamaz), kalan fark sayım düzeltmesi olur. "Yalnızca sayımı gelenler", "Beklenenle doldur", tek transaction'da kayıt.
|
||||
- Sayım takvimi: sıklık ürün tipinden, yoksa stok kategorisinden (üstten devralınır); son sayımdan (hiç sayılmadıysa ilk hareketten) n gün sonra sayım gelir. `/stok`'ta "Sayımı gelenler" kartı (kategori, ürün sayısı, gecikme) ve dashboard hatırlatması.
|
||||
- Önerilen alım miktarı motoru: hedef − mevcut; son 3 alımda fire varsa (fireler FIFO ile hangi alımdan düştüyse ona yazılır, ≥ %5) fire oranı kadar düşürülür; haftalık tüketim adımdan büyükse haftalık tüketimin katına, değilse adıma yuvarlanır; hedefi aşmaz. Gerekçe gösterilir ("Son 2 alımda %30 fire, miktar azaltıldı.", "Haftalık tüketime (0,3 kg) göre yuvarlandı.", "Mevcutla birlikte yaklaşık 46 gün yeter.").
|
||||
- Tüketim hızı (son 90 gün; tüketim + sayımda çıkan eksik, fire hariç) ve periyodik sayım durumu ürün detayında.
|
||||
- Tüketim analizi (`/stok/analiz`): ürün tipi başına son 6 ayın tüketim/fire çubukları, aylık ortalama tüketim ve fire, fire oranı (≥ %15 vurgulu), bu ayın tüketimi. Maliyet ürün fiyatlarıyla (Faz 12) eklenecek.
|
||||
- Migration `012-stock-counts`: index'ler; Faz 11a'da oturumsuz kaydedilen sayım düzeltmeleri, beklenen miktar yeniden hesaplanarak sayım oturumlarına bağlanır.
|
||||
- Seed: Çakır Ailesi'nde Mutfak 15, Genel Temizlik 30 günde bir sayılır; sayım oturumları ve ara sıra sayım farkı; Mutfak'ın sayımı gecikmiş.
|
||||
- Testler: FIFO fire dağılımı, son alımların fire oranı, öneri (fire indirimi, tüketim hızına yuvarlama, hedef sınırı), sayım takvimi, aylık kullanım (birim); seed sayım takvimi, toplu sayım (fire + düzeltme, oturum, sıfır fark, yetki), tüketim analizi (entegrasyon).
|
||||
|
||||
### Değişti — Faz 11b
|
||||
|
||||
- Dashboard stok kartı "Stok uyarıları" oldu: azalan ürünler, SKT yaklaşan partiler ve sayımı gelen kategoriler.
|
||||
- Seed'deki sayım farkları artık periyodik sayım oturumlarında oluşur.
|
||||
|
||||
### Eklendi — Faz 11a: ev stoğu
|
||||
|
||||
- Stok kategorileri (`stockCategories`): gider kategorilerinden ayrı, sınırsız derinlikte ağaç; varsayılan set (Mutfak › Manav, Şarküteri, Bakliyat; Genel Temizlik › Bulaşık, Çamaşır, Genel; Kişisel Temizlik › Şampuan, Tıraş, Kişisel Bakım) yeni hanede ve migration ile mevcut hanelere yüklenir. Her kategori bir gider kategorisine eşlenebilir (alt kategoriler üstten devralır; Faz 13'te alışveriş gideri için) ve sayım sıklığı tutar. Taşıma, arşiv (içinde aktif ürün yoksa) ve arşivden çıkarma; `/stok/kategoriler`.
|
||||
- Ürün tipleri (`productTypes`): kategori, temel ölçü birimi (kg, lt, adet … veya özel birim: "yıkama", "tablet"), hedef stok, kritik eşik yüzdesi (varsayılan %20), saklama yeri, "1 azalt" miktarı, not; ilk kurulumda evdeki başlangıç miktarı.
|
||||
- Stok hareketleri (`stockMovements`): alım (isteğe bağlı SKT), tüketim, fire/zarar (nedenli) ve sayım düzeltmesi (gerçek miktar girilir, fark işlenir). Mevcut miktar hareketlerden türetilir; stokta olandan fazla tüketim/fire reddedilir. Hareket silme denetim kaydına yazılır.
|
||||
- SKT takibi FIFO partileriyle: çıkışlar en eski girişten düşer; evde kalan miktarın SKT'si 7 gün içinde dolan veya geçmiş partiler uyarılır.
|
||||
- `/stok`: özet, "Alınması önerilenler" (eşiğin altındakiler ve hedefe tamamlayan miktar), SKT uyarıları, kategoriye göre ürün listesi (mevcut/hedef çubuğu ve eşik çizgisi, arama, "yalnızca azalanlar"), tek dokunuşla "1 azalt". `/stok/:id`: partiler ve hareket geçmişi.
|
||||
- Dashboard "Azalan stoklar" kartı.
|
||||
- Seed: Çakır Ailesi için 19 ürün tipi ve son 6 ayın haftalık alım/tüketim/fire/sayım simülasyonu.
|
||||
- Testler: mevcut miktar, FIFO partileri, SKT durumu, eşik ve öneri (birim); görünüm, izolasyon, hareket kuralları, sayım, kategori kuralları (entegrasyon).
|
||||
|
||||
## [0.10.0] - 2026-11-06
|
||||
|
||||
Faz 10: aylık bütçe, gelir planı ve günlük plan (10a ve 10b alt fazları).
|
||||
|
||||
### Eklendi — Faz 10b: gelir planı ve günlük plan
|
||||
|
||||
- Gelir planı (`/butce` altında): gerçekleşen gelirler, henüz gerçekleşmemiş planlı (düzenli) gelirler ve elle eklenen beklenen gelirler (`budgets.incomeItems`, ör. prim); beklenen toplam. "Plana göre elde kalacak" (beklenen gelir − bütçe) ve "Bu tempoda elde kalacak" (beklenen gelir − ay sonu tahmini gider).
|
||||
- Günlük plan (dashboard): ayın bütçesi günlere yayılır; tarihe bağlı giderler (düzenli işlemler, taksitler) kendi günlerine, kalan değişken bütçe günlere eşit veya hane ayarına göre önceki ayın günlük örüntüsüyle (kuruşu kuruşuna toplam korunur). "Bugün ayın %X'i geçti, bütçenin %Y'si harcandı", bugün için harcanabilir tutar (önceki günlerden kalan/aşan dahil), bugüne kadar planlanan ve harcanan, ay sonunda elde kalacak.
|
||||
- Kümülatif planlanan / gerçekleşen gider grafiği, beklenen gelir referans çizgisi ve gün tablosu; sonraki ayın beklenen geliri ile bütçesi karşılaştırması.
|
||||
- Dashboard özet kartları: "Bu ay gelir", "Bu ay gider" ve "Kalan" gerçek veriyle (bütçe ayına göre).
|
||||
- Testler: dağıtım ve kuruş yuvarlama, örüntü uyarlama, günlük plan ve bugünkü pay, taban hızlı tahmin (birim); gelir planı, gizli hesap ayarı, elle kalemler, yetki, günlük plan tutarlılığı ve örüntü modu (entegrasyon).
|
||||
|
||||
### Değişti — Faz 10b
|
||||
|
||||
- Ay sonu tahmini: değişken giderin günlük hızı, bu ayın hızı ile önceki dönemin değişken harcama hızının karışımı (ay ilerledikçe bu ayın ağırlığı artar); ay başındaki tek bir büyük harcama artık tüm aya yayılmıyor. Geçmişi olmayan hanede yalnızca bu ayın hızı.
|
||||
|
||||
### Düzeltildi — Faz 10b
|
||||
|
||||
- Bütçede "sabit gider" (düzenli işlem / taksit) ayrımı her işlemi sabit sayıyordu (`installment` boşken alan yolu eksik değer verir ve `null`'a eşit sayılmaz); ay sonu tahmini değişken gideri hiç yansıtmıyordu. `$ifNull` ile düzeltildi, regresyon testi eklendi.
|
||||
|
||||
### Eklendi — Faz 10a: aylık bütçe
|
||||
|
||||
- Bütçe sayfası (`/butce?ay=YYYY-MM`): ay gezintisi, dönem sınırları hane ayarı `budgetMonthStartDay`'e göre (ör. 15'i → 15 Kasım – 14 Aralık).
|
||||
- Kategori limitleri (`budgets`): her gider kategorisine limit; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır ve toplam bütçede iki kez sayılmaz. Limitsiz alt kategoriler "Üst kategori limitinde" olarak gösterilir; hiçbir limitin kapsamadığı harcama "Bütçesiz harcama" toplamında.
|
||||
- Gerçekleşen / limit / kalan, yüzde çubuğu (ayın geçen kısmı işaretli), %80'de uyarı, %100'de "Limit doldu", aşımda "Limit aşıldı".
|
||||
- Ay sonu tahmini: sabit giderler (düzenli işlem, taksit) olduğu gibi + kalan planlı giderler + değişken giderin günlük hızıyla ay sonuna yansıtılması; tahmin limiti aşıyorsa işaret.
|
||||
- Bölünmüş işlemler kalem bazında; taksitler hane ayarına göre her biri kendi ayına (varsayılan) veya tamamı alış ayına. Başkasının "yalnızca ben" hesabından giderler hane ayarına göre yalnızca tutar olarak sayılır.
|
||||
- "Önceki aydan kopyala" ve "Önceki ayın harcamalarından oluştur" (ana kategori bazında, 100 TL'ye yukarı yuvarlanır); mevcut limitler varsa onay istenir.
|
||||
- Limit düzenleyici: tüm etkin gider kategorileri ağaç sırasıyla, anlık toplam bütçe.
|
||||
- Dashboard: %80'i geçilen veya aşılan kategori sayısıyla uygulama içi bütçe uyarısı.
|
||||
- Seed: Çakır Ailesi'nde önceki ve bu ay için harcamalardan oluşturulmuş bütçeler (bu ay Market limiti düşük, Elektrik alt limiti).
|
||||
- `010` migration'ı: `budgets` index'i (hane × ay benzersiz).
|
||||
- Testler: dönem sınırları, eşikler, tahmin, üst/alt limit toplama, bütçesiz harcama, sıra (birim); limitler, bölünmüş işlem, planlı, gizli hesap ayarı, taksit modları, başlangıç günü, kopyala/oluştur, yetki (entegrasyon).
|
||||
|
||||
## [0.9.0] - 2026-11-06
|
||||
|
||||
Faz 9: birikim ve varlık takibi, fiyat tablosu (elle), kâr/zarar, net varlık (9a ve 9b alt fazları).
|
||||
|
||||
### Eklendi — Faz 9b: dönemsel rapor ve net varlık
|
||||
|
||||
- Dönemsel rapor (`/birikim` toplam, `/birikim/:id` varlık bazında): 3 ay / 6 ay / 12 ay / tümü (`?donem=`); dönem başı değer, yatırılan TL, dönem sonu değer, satış geliri, gelen/çıkan hediye ve dönem kâr/zararı (TL ve %, dönemde bağlanan sermayeye oranla). Formül sayfada yazılıdır.
|
||||
- Aylık değer grafiği (shadcn charts / Recharts): güncel değer (düz çizgi) ve maliyet (kesikli, basamaklı), lejant, ipucu ve tablo görünümü. Fiyatı olmayan varlık toplam rapora katılmaz ve belirtilir.
|
||||
- Net varlık (`/birikim` üstünde): hesap bakiyeleri + birikim (alış fiyatı) + cari alacaklar + aynî alacaklar (alış) − kart/KMH borçları − cari borçlar − aynî borçlar (satış). Gizli varlık ve cariler yalnızca sahibinin net varlığına girer; gizli hesap bakiyeleri hane ayarına göre.
|
||||
- Dashboard: "Toplam hesap bakiyesi", "Kart borçları" ve "Net varlık" kartları gerçek veriyle (gelir/gider kartları Faz 14).
|
||||
- Aynî kayıt ↔ varlık bağlantısı: "Ödeme gerçekleşti → Aynı varlıkla" diyaloğunda isteğe bağlı kutucuk. Borç ödemesi seçilen varlıktan ortalama maliyetle düşer; alacak tahsilatı varlığa borç tarihindeki birim fiyatla eklenir. Ödeme ve varlık hareketi tek transaction'da; varlık yetersizse hiçbir şey yazılmaz. Bağlı hareket Birikim'den tek başına silinemez; aynî kayıt silinince bağ çözülür.
|
||||
- Fiyat okuma yardımcıları ayrı modülde (`features/in-kind/prices.ts`).
|
||||
- Testler: dönem noktaları, örnek senaryo ("10 gr'ı 25.000'e aldım, her ay ekledim"), başlangıç değeri, fiyat yokluğu, toplam (birim); dönem raporu, gizli varlık ve fiyatsız varlık, net varlık formülü ve değişmezleri (makas, cari borç), aynî borç/alacak bağlantısı, yetersiz varlıkta geri alma, tür uyuşmazlığı (entegrasyon).
|
||||
|
||||
### Değişti — Faz 9b
|
||||
|
||||
- "Aynı varlıkla" alacak tahsilatında anlamsız olan "varlığı almak için harcanan" tutar alanı kaldırıldı (yalnızca borç ödemesinde gösterilir).
|
||||
- `recharts` bağımlılığı ve `components/ui/chart.tsx` eklendi.
|
||||
- CHANGELOG sürüm karşılaştırma bağlantıları 0.6.0–0.9.0 için tamamlandı.
|
||||
|
||||
### Eklendi — Faz 9a: varlık lotları ve kâr/zarar
|
||||
|
||||
- Birikim sayfası (`/birikim`): altın, döviz ve ziynet varlıkları (`assets`); toplam güncel değer, toplam maliyet, gerçekleşmemiş ve gerçekleşmiş kâr/zarar; elde kalmayan varlıklar ayrı grupta. Fiyatı olmayan varlık toplam değere katılmaz ve belirtilir.
|
||||
- Varlık hareketleri (`assetMovements`): alım, gelen hediye/miras, satış, hediye/çıkış; tarih, miktar, tutar veya birim fiyat (ikisi de boşsa fiyat tablosundan: alımda satış, satış ve hediyede alış fiyatı), hesap, saklama yeri, not. Geçmiş tarihli satış/çıkış o tarihteki miktarla sınırlıdır.
|
||||
- Hesap bağlantısı: alım hesaptan varlığa, satış varlıktan hesaba transfer olarak kaydedilir (bakiye değişir, gelir/gider raporlarına girmez). İşlem listesinde varlık adıyla görünür; bağlı işlem tek başına düzenlenemez/silinemez, hareket silinince birlikte silinir.
|
||||
- Hesap motoru (`shared/portfolio.ts`): ağırlıklı ortalama maliyet, satışta ortalama maliyetle gerçekleşmiş K/Z, çıkışta K/Z yazılmaz, gelen hediyenin maliyeti o günkü değer (yatırım sayılmaz); güncel değer alış fiyatıyla (Faz 0), gerçekleşmemiş K/Z TL ve %.
|
||||
- `/birikim/:id`: özet (eldeki miktar, ortalama maliyet, maliyet, güncel değer, K/Z, yatırılan, satış geliri), hareket geçmişi (kalan miktar, satış K/Z'si), hareket silme (sonraki çıkışı eksiye düşürecekse reddedilir).
|
||||
- "Yalnızca ben" varlık: varlık, hareketleri ve bağlı hesap işlemleri yalnızca sahibine görünür.
|
||||
- Seed: aylık alımlı ve bir satışlı gram altın birikimi, düğün ziynetleri (hediye giriş ve çıkış), Emre'nin "yalnızca ben" dolar varlığı, Annemler'de fiyatsız bilezikler.
|
||||
- `009` migration'ı: `assets`, `assetMovements` index'leri, işlemlerde `assetId`. Denetim kaydı: `asset.delete`, `asset.movement.delete`.
|
||||
- Testler: ortalama maliyet, satış/çıkış, hediye, eksi miktar, oran biçimi (birim); alım/satış transferleri ve bakiye, fiyat tablosundan tutar, gerçekleşmiş K/Z, kilitli işlem, hareket silme, gizli varlık, izleyici ve başka hane (entegrasyon).
|
||||
|
||||
## [0.8.0] - 2026-11-05
|
||||
|
||||
Faz 8: cari hesaplar ve aynî borç/alacak (8a ve 8b alt fazları).
|
||||
|
||||
### Eklendi — Faz 8b: aynî borç/alacak ve fiyat tablosu
|
||||
|
||||
- Varlık türleri: gram altın, çeyrek, yarım, tam, Cumhuriyet altını, 22 ayar bilezik, gümüş, USD, EUR, GBP (birim: gr / adet / birim; miktarlar binde bir hassasiyetle).
|
||||
- Fiyat tablosu (`/fiyatlar`, `assetPrices`): varlık × tarih × alış/satış, elle giriş; tek fiyat girilirse ikisine de yazılır; gün başına tek kayıt; son fiyatlar ve geçmiş. Faz 9 varlık takibi aynı tabloyu kullanacak.
|
||||
- Aynî borç/alacak (`inKindDebts`), cari sayfasında: cari, yön (ben borçluyum / bana borçlu), varlık, miktar, borç tarihi; o tarihteki birim fiyat ve TL karşılığı not olarak saklanır (boşsa fiyat tablosundan). Değerleme: borç satış, alacak alış fiyatıyla.
|
||||
- Borca bağlı birikim planı: birikim hesabı ve aylık tutar; "Birikim ekle" kaynak hesaptan birikim hesabına transfer oluşturur, borç kapanmaz. Kartta güncel değer, biriken, kalan ihtiyaç ve tahmini kapanış ayı.
|
||||
- "Ödeme gerçekleşti": aynı varlıkla veya TL karşılığıyla; tarih, miktar/tutar, hesap. TL ödemede miktar boşsa güncel fiyattan hesaplanır; kısmi ödeme miktarı düşürür, kalanı aşan ödeme kalana sınırlanır, tam ödemede kayıt kapanır. Borç ödemesi hesaptan çıkış, alacak tahsilatı hesaba giriş işlemi olarak aynı transaction'da oluşturulur; denetim kaydı (`inkind.payment`, `inkind.close`).
|
||||
- Cari listesinde açık aynî kayıtlar rozet olarak.
|
||||
- Aynî borç hareketleri cari TL bakiyesine ve gelir/gider toplamlarına girmez; "yalnızca ben" carinin aynî kayıtları ve hareketleri başkasına görünmez (hesap ekstresinde ad ve açıklama gizlenir). Ödeme işlemi tek başına silinemez.
|
||||
- Seed: son 6 ayın örnek altın/döviz fiyatları (gerçek piyasa verisi değildir), birikim planlı 5 gr altın borcu (iki katkıyla), USD alacağı, Annemler'de çeyrek altın alacağı.
|
||||
- `008` migration'ı: `assetPrices`, `inKindDebts` index'leri, işlemlerde aynî borç alanları.
|
||||
- Testler: değer/miktar karşılığı, değerleme fiyatı, ilerleme ve tahmini kapanış (birim); fiyat tablosu, tarih fiyatı, birikim katkısı, kısmi/tam ödeme, kapanış, alacak tahsilatı, cari bakiye ve gelir/gider ayrımı, gizli cari ve yetki (entegrasyon).
|
||||
|
||||
### Eklendi — Faz 8a: cari hesaplar
|
||||
|
||||
- Cari kişiler (`contacts`): ad, yakınlık, telefon, not, açılış bakiyesi (+ bana borçlu / − ben borçluyum), vade tarihi ve hatırlatma günü; arşivleme; aynı adda iki aktif cari engellenir (Türkçe normalize).
|
||||
- "Yalnızca ben" cari: kişi ve hareketleri yalnızca sahibine görünür; ortak hesabın ekstresinde bu hareketler görünür ama kişi adı "Gizli cari" olarak maskelenir.
|
||||
- Cari hareketleri: "Borç verdim", "Borç ödedim" (hesaptan cariye), "Borç aldım", "Tahsilat" (cariden hesaba). Hepsi transferdir: hesap bakiyesi değişir, gelir/gider toplamlarına girmez. Kısmi ödeme bakiyeyle doğal olarak desteklenir.
|
||||
- `/cari`: bana borçlu / ben borçluyum / net özeti, cari listesi (bakiye, son hareket, vade rozeti), yeni cari, hareket ekle, düzenle, arşivle.
|
||||
- `/cari/:id`: cari ekstresi (tarih sıralı hareketler ve yürüyen bakiye, açılış satırı); hareketi düzenleme ve geri alınabilir silme.
|
||||
- Vade uyarısı (uygulama içi): vade geçtiyse veya hatırlatma penceresindeyse cari listesinde rozet ve dashboard'da uyarı.
|
||||
- İşlem listesi ve ayrıntısında cari hareketleri kişi adı ve hareket türüyle; ayrıntıdan cariye bağlantı. CSV'de kategori sütununda "Cari: Ad (tür)".
|
||||
- Seed: 5 örnek cari (vadesi yaklaşan alacak, kısmen ödenen borç, vadesi geçmiş alacak, "yalnızca ben" cari, Annemler hanesinde bir borç).
|
||||
- `007` migration'ı: `contacts` index'leri, işlemlerde cari alanları. Denetim kaydı: cari arşivleme.
|
||||
- Testler: yön/bakiye ve vade durumu (birim); defter, bakiye ve gelir/gider ayrımı, açılış bakiyesi, ekstre, tür değiştirme, vade uyarısı, çift ad, arşiv, yetki, başka hanenin hesabı ve gizli cari izolasyonu (entegrasyon).
|
||||
|
||||
### Değişti — Faz 8a
|
||||
|
||||
- İşlemlerde kaynak hesap (`accountId`) artık boş olabilir: cariden girişte para yalnızca hedef hesaba (`toAccountId`) girer. Bakiye, ekstre, kart ve KMH hesapları ek kural olmadan doğru çalışır.
|
||||
|
||||
## [0.7.0] - 2026-11-05
|
||||
|
||||
Faz 7: planlı/düzenli işlemler, kredi kartı ve KMH dönemleri, taksitler (7a ve 7b alt fazları).
|
||||
@@ -218,7 +350,13 @@ Faz 1: Framework, klasör yapısı ve altyapı.
|
||||
- `.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`.
|
||||
|
||||
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.5.0...HEAD
|
||||
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.11.0...HEAD
|
||||
[0.11.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.10.0...v0.11.0
|
||||
[0.10.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.9.0...v0.10.0
|
||||
[0.9.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.8.0...v0.9.0
|
||||
[0.8.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.7.0...v0.8.0
|
||||
[0.7.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.6.0...v0.7.0
|
||||
[0.6.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.5.0...v0.6.0
|
||||
[0.5.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0
|
||||
|
||||
@@ -319,6 +319,10 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
||||
- **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı).
|
||||
- **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır.
|
||||
- **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir.
|
||||
- **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir.
|
||||
- **Faz 9 kararları (2026-11-05):** 9a varlık lotları ve kâr/zarar, 9b dönemsel rapor ve net varlık; `v0.9.0` 9b sonunda. Varlıklar hane ortaktır; "yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür. Varlık alım/satışı hesaba bağlıysa transfer olarak kaydedilir (gelir/gider raporlarına girmez). Aynî kayıt "aynı varlıkla" kapanırken isteğe bağlı olarak varlıktan düşülür / varlığa eklenir.
|
||||
- **Faz 10 kararları (2026-11-06):** 10a aylık bütçe, 10b gelir planı ve günlük plan; `v0.10.0` 10b sonunda. Limit her gider kategorisine verilebilir; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır (toplamda iki kez sayılmaz). Başkasının "yalnızca ben" hesabından yapılan harcama hane ayarına (gizli hesapları toplamlara kat) göre yalnızca tutar olarak bütçeye girer.
|
||||
- **Faz 11 kararları (2026-11-06):** 11a stok kategorileri, ürün tipleri, hareketler, SKT ve %20 eşik; 11b periyodik sayım, fire ayarlı öneri ve tüketim analizi; `v0.11.0` 11b sonunda. Eşiğin altındaki ürünler Faz 11'de "Alınması önerilenler" listesinde gösterilir (koleksiyon yok); Faz 13'te aktif alışveriş listesine onayla/reddet önerisi olarak bağlanır. SKT alım hareketinde (isteğe bağlı) girilir; tüketim ve fire FIFO ile en eski alımdan düşülür.
|
||||
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
||||
|
||||
---
|
||||
|
||||
Generated
+372
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.7.0",
|
||||
"version": "0.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "butcem",
|
||||
"version": "0.7.0",
|
||||
"version": "0.11.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^7.0.2",
|
||||
"@tanstack/react-table": "^9.3.1",
|
||||
@@ -24,6 +24,7 @@
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"recharts": "^3.8.0",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "^4.23.0",
|
||||
"sharp": "^0.35.5",
|
||||
@@ -4051,6 +4052,46 @@
|
||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.14.0.tgz",
|
||||
"integrity": "sha512-m/PxMwRe0xLL6XUsAgy0YjInklHcfi9uMwFhJk+QINqjxqPvyc89T5cMjszA5bDmTE0gwGc7zr9LRO4gL+mhaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.2.9",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.2.9",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.2.9.tgz",
|
||||
"integrity": "sha512-IQ4UEtXjZjyefyWjtLEDXUSrF6T2hwjKPi6BIJl3bY/2XXW24d6cNGdXJSBOA/+KNnuiXkZY6DR0C3efolhitA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
|
||||
@@ -4964,6 +5005,69 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -5022,6 +5126,12 @@
|
||||
"@types/react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/validate-npm-package-name": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
@@ -6573,6 +6683,127 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.5.tgz",
|
||||
"integrity": "sha512-Th8qGXoBalmWsstpkuhmBuTsBQIFqYOJtjlBvNVvBfJ8E3uYusBUwUR8HU20U0qKKQkgCvKyR1YjiozWadk5QA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.1.tgz",
|
||||
"integrity": "sha512-lfab8x0Bj0UO7SNsY8dZSbNIA6ipbkE3X0NG9nqjCu6CfCHZseuDnOSJY7hidKrjpP++i1M2qT+FlAdPKYRilA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-B5znKqRmKROAN4oHWQ50W/qisVjA9GXh2WMsVKdFVp4DxWmcrOsweqgUOUVsv5th96kwaZlokU6SlV4J3gak1w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.3.tgz",
|
||||
"integrity": "sha512-+F+/49swqmGuQJmSKnVMIDH/QkEFV5li9QRj0xm/KhyAUBv099O5PrvpQFxBp24PYph17Mnvt9mjKQV0ii0nMA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.2.tgz",
|
||||
"integrity": "sha512-IRzGU62DtxhvsnSTWMQIdnoZ6gA+BCWwBon1EJI6SjoN595799b6z/QRBzXfFhE8iupUPp3042yBNo0STPqnzQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-jsbgV8zIT38ReB9O7X7gcLndTQRONSqNNd+oGIaTBdUug4r5jFmp0N6RY9R5GWbdUDucn0qoTGyfjapT4tDhcg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.3.tgz",
|
||||
"integrity": "sha512-66cWobCNzcMvlfoaDHCxuIv9TBUWG1BLOWdv9kHHFuCr1JdqAWPvTmsaEYDD5NRMyWZ5+7u0GqiCDfMXMqh3hg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.1.tgz",
|
||||
"integrity": "sha512-dm2o+lbKdzg9tY7GEag/dB9hcbodLfwkE/DkvqbELJXaMz0yKzWWhZqhbI7CKubukA7HnRyz0avdtvi7EPxPvw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.1.tgz",
|
||||
"integrity": "sha512-Ze7rjrhMzdeulh0/hjcfFj7XLAHOfowtEMfsoG6DU866HPynQNW7Wfen6hzXcqKTv8XzxvF6Hzr5XD6bMHr2ug==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.1.tgz",
|
||||
"integrity": "sha512-22XaqjyCjpqgda20erhsOjGmC3vvYwTNYniKAd45W5UWLDF43moeOuV9qYpRqobIlsnK0UhnKZjUUJm3VgORUA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-1Il1aliroVSErrv5aulRBxAY+7+DnaoLIWGd1C6SsBTWDeobWiP/V69vwb8vT6abR1kKwaNe1196pmaQ1fK90A==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -6666,6 +6897,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@@ -7140,6 +7377,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.53.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.1.tgz",
|
||||
"integrity": "sha512-VhMc6IXmexu8iYFfL2aaAww6IvJTdHlqBLruLoEUs33YD4IcP5DdZcLOybMsMmmLsiy1c8PsiLsrM0DdIuuRUA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks",
|
||||
"tests/types",
|
||||
"tests/browser-compat"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
@@ -7658,6 +7907,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
||||
@@ -8282,6 +8537,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -8324,6 +8589,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.6",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz",
|
||||
@@ -10715,9 +10989,31 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.1.tgz",
|
||||
"integrity": "sha512-vxMLT0lalUjbZPkoTepWyKAZbjIIFssNx/CEL9lTfwQBU6XpkGSmCNdcCIrkOBUgB68uftZMnbXVb6DwGnhRKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
@@ -10803,6 +11099,51 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -10856,6 +11197,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "2.0.0-next.7",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||
@@ -12405,6 +12752,28 @@
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.4.1",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.4.1.tgz",
|
||||
"integrity": "sha512-geV4p2GVfVM9lgm9ZRS3pvsbAPS+BUGhM9W9nPzPHTKi+/+KjNrFXdgP6KkG+XspNZuvcXmchcrtklfuQ4gniw==",
|
||||
"license": "MIT AND ISC AND BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.7.0",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||
"engines": {
|
||||
@@ -42,6 +42,7 @@
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"recharts": "^3.8.0",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "^4.23.0",
|
||||
"sharp": "^0.35.5",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ContactModel } from "@/models/contact";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "007-contacts",
|
||||
description: "contacts index'leri; işlemlerde cari alanları ve cari index'i",
|
||||
async up(db) {
|
||||
await db
|
||||
.collection("transactions")
|
||||
.updateMany({ contactKind: { $exists: false } }, { $set: { contactKind: null } });
|
||||
await ContactModel.createIndexes();
|
||||
await TransactionModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "008-in-kind",
|
||||
description: "assetPrices ve inKindDebts index'leri; işlemlerde aynî borç alanları",
|
||||
async up(db) {
|
||||
await db
|
||||
.collection("transactions")
|
||||
.updateMany(
|
||||
{ inKindDebtId: { $exists: false } },
|
||||
{ $set: { inKindDebtId: null, inKindRole: null } },
|
||||
);
|
||||
await AssetPriceModel.createIndexes();
|
||||
await InKindDebtModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AssetModel } from "@/models/asset";
|
||||
import { AssetMovementModel } from "@/models/asset-movement";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "009-assets",
|
||||
description: "assets ve assetMovements index'leri; işlemlerde varlık alanları",
|
||||
async up(db) {
|
||||
await db
|
||||
.collection("transactions")
|
||||
.updateMany({ assetId: { $exists: false } }, { $set: { assetId: null } });
|
||||
await AssetModel.createIndexes();
|
||||
await AssetMovementModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { BudgetModel } from "@/models/budget";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "010-budgets",
|
||||
description: "budgets index'i (hane × ay benzersiz)",
|
||||
async up() {
|
||||
await BudgetModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { installDefaultStockCategories } from "@/features/stock/service";
|
||||
import { HouseholdModel } from "@/models/household";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { StockCategoryModel } from "@/models/stock-category";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "011-stock",
|
||||
description: "stok koleksiyonları index'leri; mevcut hanelere varsayılan stok kategorileri",
|
||||
async up() {
|
||||
await StockCategoryModel.createIndexes();
|
||||
await ProductTypeModel.createIndexes();
|
||||
await StockMovementModel.createIndexes();
|
||||
const households = await HouseholdModel.find({}).select({ createdBy: 1 }).lean();
|
||||
for (const h of households)
|
||||
await installDefaultStockCategories(h._id as Types.ObjectId, h.createdBy as Types.ObjectId);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Types } from "mongoose";
|
||||
import { StockCountModel } from "@/models/stock-count";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { signedDelta, type StockMovementKind } from "@/shared/stock";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
type Move = {
|
||||
_id: Types.ObjectId;
|
||||
householdId: Types.ObjectId;
|
||||
productTypeId: Types.ObjectId;
|
||||
kind: StockMovementKind;
|
||||
date: string;
|
||||
quantity: number;
|
||||
initial: boolean;
|
||||
countId: Types.ObjectId | null;
|
||||
createdBy: Types.ObjectId;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Faz 11b: sayım oturumları. Faz 11a'da tek ürün sayımıyla oluşan (oturumsuz) sayım
|
||||
* düzeltmeleri, o ana kadarki kayıtlardan beklenen miktar hesaplanarak hane + tarih + kişi
|
||||
* başına bir sayım oturumuna bağlanır. Başlangıç miktarları sayım sayılmaz.
|
||||
*/
|
||||
export const migration: Migration = {
|
||||
name: "012-stock-counts",
|
||||
description: "stockCounts index'leri; oturumsuz sayım düzeltmelerini sayım oturumuna bağla",
|
||||
async up() {
|
||||
await StockCountModel.createIndexes();
|
||||
const orphans = await StockMovementModel.find({
|
||||
kind: "count_adjust",
|
||||
initial: { $ne: true },
|
||||
countId: null,
|
||||
deletedAt: null,
|
||||
}).lean<Move[]>();
|
||||
if (orphans.length === 0) return;
|
||||
const products = [...new Set(orphans.map((o) => String(o.productTypeId)))];
|
||||
const all = await StockMovementModel.find({
|
||||
productTypeId: { $in: products.map((id) => new Types.ObjectId(id)) },
|
||||
deletedAt: null,
|
||||
}).lean<Move[]>();
|
||||
const byProduct = new Map<string, Move[]>();
|
||||
for (const m of all) {
|
||||
const list = byProduct.get(String(m.productTypeId)) ?? [];
|
||||
list.push(m);
|
||||
byProduct.set(String(m.productTypeId), list);
|
||||
}
|
||||
// Her düzeltme için beklenen = önceki hareketlerin toplamı (tarih, oluşturulma sırası).
|
||||
const expectedOf = new Map<string, number>();
|
||||
for (const list of byProduct.values()) {
|
||||
list.sort((a, b) =>
|
||||
a.date === b.date
|
||||
? a.createdAt.getTime() - b.createdAt.getTime()
|
||||
: a.date < b.date
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
let running = 0;
|
||||
for (const m of list) {
|
||||
expectedOf.set(String(m._id), running);
|
||||
running += signedDelta(m);
|
||||
}
|
||||
}
|
||||
const sessions = new Map<string, Move[]>();
|
||||
for (const o of orphans) {
|
||||
const key = `${o.householdId}:${o.date}:${o.createdBy}`;
|
||||
sessions.set(key, [...(sessions.get(key) ?? []), o]);
|
||||
}
|
||||
for (const moves of sessions.values()) {
|
||||
const first = moves[0]!;
|
||||
const count = await StockCountModel.create({
|
||||
householdId: first.householdId,
|
||||
date: first.date,
|
||||
categoryId: null,
|
||||
items: moves.map((m) => {
|
||||
const expected = expectedOf.get(String(m._id)) ?? 0;
|
||||
return {
|
||||
productTypeId: m.productTypeId,
|
||||
expected,
|
||||
actual: expected + m.quantity,
|
||||
wasted: 0,
|
||||
};
|
||||
}),
|
||||
createdBy: first.createdBy,
|
||||
});
|
||||
await StockMovementModel.updateMany(
|
||||
{ _id: { $in: moves.map((m) => m._id) } },
|
||||
{ $set: { countId: count._id } },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -6,10 +6,29 @@ import { migration as m003 } from "./003-transactions";
|
||||
import { migration as m004 } from "./004-attachments-reconciliation";
|
||||
import { migration as m005 } from "./005-recurring";
|
||||
import { migration as m006 } from "./006-card-statements";
|
||||
import { migration as m007 } from "./007-contacts";
|
||||
import { migration as m008 } from "./008-in-kind";
|
||||
import { migration as m009 } from "./009-assets";
|
||||
import { migration as m010 } from "./010-budgets";
|
||||
import { migration as m011 } from "./011-stock";
|
||||
import { migration as m012 } from "./012-stock-counts";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006];
|
||||
export const MIGRATIONS: Migration[] = [
|
||||
m001,
|
||||
m002,
|
||||
m003,
|
||||
m004,
|
||||
m005,
|
||||
m006,
|
||||
m007,
|
||||
m008,
|
||||
m009,
|
||||
m010,
|
||||
m011,
|
||||
m012,
|
||||
];
|
||||
|
||||
const COLLECTION = "migrations";
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { addMovement, createAsset } from "@/features/assets/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AssetModel } from "@/models/asset";
|
||||
import { AssetMovementModel } from "@/models/asset-movement";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import type { MovementKind } from "@/shared/portfolio";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||
|
||||
/**
|
||||
* Örnek varlıklar (Faz 9a). Tutarı boş alımlar fiyat tablosundaki örnek fiyatlardan (seedInKind
|
||||
* önce çalışır) hesaplanır. İdempotent: varlık `seedRef` ile bir kez oluşturulur.
|
||||
*/
|
||||
|
||||
type SeedMovement = {
|
||||
kind: MovementKind;
|
||||
daysAgo: number;
|
||||
quantity: string;
|
||||
amount?: string;
|
||||
account?: string;
|
||||
storage?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
type SeedAsset = {
|
||||
ref: string;
|
||||
household: string;
|
||||
by: string;
|
||||
assetKey: AssetKey;
|
||||
name: string;
|
||||
isPrivate?: boolean;
|
||||
note?: string;
|
||||
movements: SeedMovement[];
|
||||
};
|
||||
|
||||
const monthly = (from: number, to: number): SeedMovement[] =>
|
||||
Array.from({ length: to - from + 1 }, (_, i) => ({
|
||||
kind: "buy" as const,
|
||||
daysAgo: (to - i) * 30 + 2,
|
||||
quantity: "1",
|
||||
account: "Ziraat Maaş",
|
||||
storage: "Ev kasası",
|
||||
}));
|
||||
|
||||
const ITEMS: SeedAsset[] = [
|
||||
{
|
||||
ref: "gram-altin",
|
||||
household: "Çakır Ailesi",
|
||||
by: "ayse",
|
||||
assetKey: "gram_gold",
|
||||
name: "Gram altın birikimi",
|
||||
note: "Her ay maaştan 1 gr",
|
||||
movements: [
|
||||
{
|
||||
kind: "buy",
|
||||
daysAgo: 178,
|
||||
quantity: "4",
|
||||
account: "Birikim (Vadeli)",
|
||||
storage: "Ev kasası",
|
||||
note: "İlk alım",
|
||||
},
|
||||
...monthly(1, 5),
|
||||
{ kind: "sell", daysAgo: 12, quantity: "2", account: "Birikim (Vadeli)", note: "Tatil için" },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "ziynet",
|
||||
household: "Çakır Ailesi",
|
||||
by: "admin",
|
||||
assetKey: "quarter_gold",
|
||||
name: "Düğün ziynetleri",
|
||||
movements: [
|
||||
{
|
||||
kind: "gift_in",
|
||||
daysAgo: 175,
|
||||
quantity: "6",
|
||||
storage: "Banka kiralık kasa",
|
||||
note: "Düğün takıları",
|
||||
},
|
||||
{ kind: "gift_out", daysAgo: 40, quantity: "1", note: "Yeğenin düğünü" },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "emre-dolar",
|
||||
household: "Çakır Ailesi",
|
||||
by: "emre",
|
||||
assetKey: "usd",
|
||||
name: "Dolar",
|
||||
isPrivate: true,
|
||||
movements: [
|
||||
{ kind: "buy", daysAgo: 120, quantity: "200", account: "Ziraat Maaş", storage: "Cüzdan" },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "bilezik",
|
||||
household: "Annemler",
|
||||
by: "fatma",
|
||||
assetKey: "bracelet_22k",
|
||||
name: "Bilezikler",
|
||||
note: "Fiyat tablosunda bilezik fiyatı yok (örnek: fiyatsız varlık)",
|
||||
movements: [{ kind: "gift_in", daysAgo: 160, quantity: "40", amount: "120.000" }],
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedAssets(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const today = todayIstanbul();
|
||||
let created = 0;
|
||||
for (const x of ITEMS) {
|
||||
const householdId = householdIds.get(x.household)!;
|
||||
const seedRef = `asset:${x.ref}`;
|
||||
if (await AssetModel.exists({ householdId, seedRef })) continue;
|
||||
const by = userIds.get(x.by)!;
|
||||
const hh = String(householdId);
|
||||
const r = await createAsset(
|
||||
by,
|
||||
hh,
|
||||
assetFormSchema.parse({
|
||||
assetKey: x.assetKey,
|
||||
name: x.name,
|
||||
note: x.note ?? "",
|
||||
isPrivate: x.isPrivate ?? false,
|
||||
}),
|
||||
);
|
||||
if (!r.ok) throw new Error(`Seed varlığı oluşturulamadı (${x.ref}): ${r.error}`);
|
||||
await AssetModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
|
||||
for (const [i, m] of x.movements.entries()) {
|
||||
const account = m.account
|
||||
? String((await AccountModel.findOne({ householdId, name: m.account }).lean())!._id)
|
||||
: "";
|
||||
const res = await addMovement(
|
||||
by,
|
||||
hh,
|
||||
r.data.id,
|
||||
movementFormSchema.parse({
|
||||
kind: m.kind,
|
||||
date: addDays(today, -m.daysAgo),
|
||||
quantity: m.quantity,
|
||||
amount: m.amount ?? "",
|
||||
accountId: account,
|
||||
storage: m.storage ?? "",
|
||||
note: m.note ?? "",
|
||||
}),
|
||||
);
|
||||
if (!res.ok) throw new Error(`Seed varlık hareketi eklenemedi (${x.ref}): ${res.error}`);
|
||||
const ref = `${seedRef}:${i}`;
|
||||
const mv = await AssetMovementModel.findOneAndUpdate(
|
||||
{ _id: res.data.id },
|
||||
{ $set: { seedRef: ref } },
|
||||
).lean();
|
||||
if (mv?.transactionId)
|
||||
await TransactionModel.updateOne({ _id: mv.transactionId }, { $set: { seedRef: ref } });
|
||||
}
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { budgetFromActuals, saveBudget } from "@/features/budgets/service";
|
||||
import { BudgetModel } from "@/models/budget";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { HouseholdModel } from "@/models/household";
|
||||
import { periodOfDate, shiftMonth } from "@/shared/budget";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { budgetLimitsSchema } from "@/shared/schemas/budget";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
|
||||
/**
|
||||
* Örnek bütçeler (Faz 10a): Çakır Ailesi'nde önceki ve bu ay, önceki ayların harcamalarından
|
||||
* oluşturulur. Bu ay "Market" limiti biraz düşürülür ve "Elektrik" alt limiti eklenir; böylece
|
||||
* uyarı ve alt limit örnekleri görünür. İdempotent: bütçesi olan aya dokunulmaz.
|
||||
*/
|
||||
export async function seedBudgets(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||
const by = userIds.get("ayse")!;
|
||||
const hh = String(householdId);
|
||||
const h = await HouseholdModel.findById(householdId).select({ settings: 1 }).lean();
|
||||
const current = periodOfDate(todayIstanbul(), h?.settings?.budgetMonthStartDay ?? 1);
|
||||
let created = 0;
|
||||
for (const month of [shiftMonth(current, -1), current]) {
|
||||
if (await BudgetModel.exists({ householdId, month })) continue;
|
||||
const r = await budgetFromActuals(by, hh, month);
|
||||
if (!r.ok) continue;
|
||||
created++;
|
||||
}
|
||||
if (created && (await BudgetModel.exists({ householdId, month: current }))) {
|
||||
const doc = (await BudgetModel.findOne({ householdId, month: current }).lean())!;
|
||||
const cats = await CategoryModel.find({
|
||||
householdId,
|
||||
defaultKey: { $in: ["market", "faturalar.elektrik"] },
|
||||
}).lean();
|
||||
const id = (key: string) => String(cats.find((c) => c.defaultKey === key)?._id ?? "");
|
||||
const limits = doc.limits.map((l) => ({
|
||||
categoryId: String(l.categoryId),
|
||||
amount: formatMoneyInput(
|
||||
String(l.categoryId) === id("market") ? Math.round(l.amountMinor * 0.7) : l.amountMinor,
|
||||
),
|
||||
}));
|
||||
if (id("faturalar.elektrik"))
|
||||
limits.push({ categoryId: id("faturalar.elektrik"), amount: "900" });
|
||||
await saveBudget(by, hh, budgetLimitsSchema.parse({ month: current, limits }));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { createContact, createContactEntry } from "@/features/contacts/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { ContactModel } from "@/models/contact";
|
||||
import type { ContactEntryKind, ContactRelation } from "@/shared/contacts";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact";
|
||||
|
||||
/**
|
||||
* Örnek cariler (Faz 8a): vadesi yaklaşan alacak, kısmen ödenen borç, vadesi geçmiş küçük alacak
|
||||
* ve "yalnızca ben" cari. İdempotent: cari `seedRef` ile; hareketler yalnızca cari ilk
|
||||
* oluşturulduğunda eklenir.
|
||||
*/
|
||||
type SeedContact = {
|
||||
ref: string;
|
||||
household: string;
|
||||
by: string;
|
||||
name: string;
|
||||
relation: ContactRelation;
|
||||
phone?: string;
|
||||
isPrivate?: boolean;
|
||||
dueInDays?: number;
|
||||
reminderDays?: number;
|
||||
entries: {
|
||||
kind: ContactEntryKind;
|
||||
account: string;
|
||||
amountMinor: number;
|
||||
daysAgo: number;
|
||||
description?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
const CONTACTS: SeedContact[] = [
|
||||
{
|
||||
ref: "ahmet",
|
||||
household: "Çakır Ailesi",
|
||||
by: "admin",
|
||||
name: "Ahmet Yılmaz",
|
||||
relation: "friend",
|
||||
phone: "0532 000 00 01",
|
||||
dueInDays: 5,
|
||||
reminderDays: 7,
|
||||
entries: [
|
||||
{
|
||||
kind: "lend",
|
||||
account: "Ziraat Maaş",
|
||||
amountMinor: 5_000_00,
|
||||
daysAgo: 70,
|
||||
description: "Araba tamiri için",
|
||||
},
|
||||
{ kind: "collect", account: "Cüzdan", amountMinor: 2_000_00, daysAgo: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "hasan",
|
||||
household: "Çakır Ailesi",
|
||||
by: "admin",
|
||||
name: "Hasan Çakır",
|
||||
relation: "family",
|
||||
dueInDays: 60,
|
||||
entries: [
|
||||
{
|
||||
kind: "borrow",
|
||||
account: "Ziraat Maaş",
|
||||
amountMinor: 20_000_00,
|
||||
daysAgo: 120,
|
||||
description: "Ev eşyası için",
|
||||
},
|
||||
{ kind: "repay", account: "Ziraat Maaş", amountMinor: 5_000_00, daysAgo: 60 },
|
||||
{ kind: "repay", account: "Ziraat Maaş", amountMinor: 5_000_00, daysAgo: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "elif",
|
||||
household: "Çakır Ailesi",
|
||||
by: "ayse",
|
||||
name: "Elif (komşu)",
|
||||
relation: "neighbor",
|
||||
dueInDays: -2,
|
||||
entries: [
|
||||
{
|
||||
kind: "lend",
|
||||
account: "Cüzdan",
|
||||
amountMinor: 300_00,
|
||||
daysAgo: 12,
|
||||
description: "Pazar alışverişi",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "selin",
|
||||
household: "Çakır Ailesi",
|
||||
by: "ayse",
|
||||
name: "Selin (iş arkadaşı)",
|
||||
relation: "work",
|
||||
isPrivate: true,
|
||||
entries: [{ kind: "lend", account: "Cüzdan", amountMinor: 1_000_00, daysAgo: 8 }],
|
||||
},
|
||||
{
|
||||
ref: "ali",
|
||||
household: "Annemler",
|
||||
by: "fatma",
|
||||
name: "Ali Demir",
|
||||
relation: "family",
|
||||
dueInDays: 45,
|
||||
entries: [{ kind: "borrow", account: "Vakıfbank Emekli", amountMinor: 3_000_00, daysAgo: 25 }],
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedContacts(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const today = todayIstanbul();
|
||||
let created = 0;
|
||||
for (const c of CONTACTS) {
|
||||
const householdId = householdIds.get(c.household)!;
|
||||
const seedRef = `contact:${c.ref}`;
|
||||
if (await ContactModel.exists({ householdId, seedRef })) continue;
|
||||
const by = userIds.get(c.by)!;
|
||||
const oldest = Math.max(...c.entries.map((e) => e.daysAgo));
|
||||
const r = await createContact(
|
||||
by,
|
||||
String(householdId),
|
||||
contactFormSchema.parse({
|
||||
name: c.name,
|
||||
relation: c.relation,
|
||||
phone: c.phone ?? "",
|
||||
openingDate: addDays(today, -oldest - 1),
|
||||
isPrivate: c.isPrivate ?? false,
|
||||
dueDate: c.dueInDays !== undefined ? addDays(today, c.dueInDays) : null,
|
||||
reminderDays: c.reminderDays ?? 3,
|
||||
}),
|
||||
);
|
||||
if (!r.ok) throw new Error(`Seed carisi oluşturulamadı (${c.name}): ${r.error}`);
|
||||
await ContactModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
|
||||
for (const e of c.entries) {
|
||||
const account = await AccountModel.findOne({ householdId, name: e.account }).lean();
|
||||
const x = await createContactEntry(
|
||||
by,
|
||||
String(householdId),
|
||||
r.data.id,
|
||||
contactEntrySchema.parse({
|
||||
kind: e.kind,
|
||||
accountId: String(account!._id),
|
||||
amount: formatMoneyInput(e.amountMinor),
|
||||
date: addDays(today, -e.daysAgo),
|
||||
description: e.description ?? "",
|
||||
}),
|
||||
);
|
||||
if (!x.ok) throw new Error(`Seed cari hareketi oluşturulamadı (${c.name}): ${x.error}`);
|
||||
}
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { addContribution, createInKind, savePrice } from "@/features/in-kind/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { ContactModel } from "@/models/contact";
|
||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import type { AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { addDays, addMonths } from "@/shared/recurrence";
|
||||
import {
|
||||
assetPriceFormSchema,
|
||||
contributionSchema,
|
||||
inKindFormSchema,
|
||||
} from "@/shared/schemas/in-kind";
|
||||
|
||||
/**
|
||||
* Örnek fiyat tablosu ve aynî kayıtlar (Faz 8b). Fiyatlar örnek amaçlıdır (gerçek piyasa verisi
|
||||
* değildir): son 6 ay, ayda bir kayıt, doğrusal artış. İdempotent.
|
||||
*/
|
||||
|
||||
/** [6 ay önceki alış, bugünkü alış, satış farkı] — birim başına kuruş. */
|
||||
const PRICE_PATH: Partial<Record<AssetKey, [number, number, number]>> = {
|
||||
gram_gold: [4_900_00, 5_600_00, 100_00],
|
||||
quarter_gold: [8_050_00, 9_200_00, 250_00],
|
||||
usd: [40_20, 42_80, 30],
|
||||
eur: [46_90, 49_50, 40],
|
||||
};
|
||||
|
||||
async function seedPrices(householdId: Types.ObjectId, actorId: string, today: string) {
|
||||
for (const [k, [from, to, spread]] of Object.entries(PRICE_PATH)) {
|
||||
const key = k as AssetKey;
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = i === 0 ? today : addMonths(today, -i);
|
||||
if (await AssetPriceModel.exists({ householdId, assetKey: key, date })) continue;
|
||||
const buy = Math.round(to - ((to - from) * i) / 6);
|
||||
await savePrice(
|
||||
actorId,
|
||||
String(householdId),
|
||||
assetPriceFormSchema.parse({
|
||||
assetKey: key,
|
||||
date,
|
||||
buy: formatMoneyInput(buy),
|
||||
sell: formatMoneyInput(buy + spread),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SeedInKind = {
|
||||
ref: string;
|
||||
household: string;
|
||||
contact: string;
|
||||
by: string;
|
||||
direction: "debt" | "receivable";
|
||||
assetKey: AssetKey;
|
||||
quantity: string;
|
||||
daysAgo: number;
|
||||
note?: string;
|
||||
savings?: string;
|
||||
monthlyPlanMinor?: number;
|
||||
contributions?: { from: string; amountMinor: number; daysAgo: number }[];
|
||||
};
|
||||
|
||||
const ITEMS: SeedInKind[] = [
|
||||
{
|
||||
ref: "hasan-altin",
|
||||
household: "Çakır Ailesi",
|
||||
contact: "contact:hasan",
|
||||
by: "admin",
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "5",
|
||||
daysAgo: 95,
|
||||
note: "Düğün için ödünç alınan altın",
|
||||
savings: "Birikim (Vadeli)",
|
||||
monthlyPlanMinor: 4_000_00,
|
||||
contributions: [
|
||||
{ from: "Ziraat Maaş", amountMinor: 4_000_00, daysAgo: 60 },
|
||||
{ from: "Ziraat Maaş", amountMinor: 4_000_00, daysAgo: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "elif-usd",
|
||||
household: "Çakır Ailesi",
|
||||
contact: "contact:elif",
|
||||
by: "ayse",
|
||||
direction: "receivable",
|
||||
assetKey: "usd",
|
||||
quantity: "100",
|
||||
daysAgo: 40,
|
||||
},
|
||||
{
|
||||
ref: "ali-ceyrek",
|
||||
household: "Annemler",
|
||||
contact: "contact:ali",
|
||||
by: "fatma",
|
||||
direction: "receivable",
|
||||
assetKey: "quarter_gold",
|
||||
quantity: "2",
|
||||
daysAgo: 150,
|
||||
note: "Torun doğumunda verilen çeyrekler",
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedInKind(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const today = todayIstanbul();
|
||||
await seedPrices(householdIds.get("Çakır Ailesi")!, userIds.get("admin")!, today);
|
||||
await seedPrices(householdIds.get("Annemler")!, userIds.get("fatma")!, today);
|
||||
let created = 0;
|
||||
for (const x of ITEMS) {
|
||||
const householdId = householdIds.get(x.household)!;
|
||||
const seedRef = `inkind:${x.ref}`;
|
||||
if (await InKindDebtModel.exists({ householdId, seedRef })) continue;
|
||||
const contact = await ContactModel.findOne({ householdId, seedRef: x.contact }).lean();
|
||||
if (!contact) continue;
|
||||
const account = async (name: string) =>
|
||||
String((await AccountModel.findOne({ householdId, name }).lean())!._id);
|
||||
const by = userIds.get(x.by)!;
|
||||
const r = await createInKind(
|
||||
by,
|
||||
String(householdId),
|
||||
String(contact._id),
|
||||
inKindFormSchema.parse({
|
||||
direction: x.direction,
|
||||
assetKey: x.assetKey,
|
||||
quantity: x.quantity,
|
||||
date: addDays(today, -x.daysAgo),
|
||||
note: x.note ?? "",
|
||||
savingsAccountId: x.savings ? await account(x.savings) : "",
|
||||
monthlyPlan: x.monthlyPlanMinor ? formatMoneyInput(x.monthlyPlanMinor) : "",
|
||||
}),
|
||||
);
|
||||
if (!r.ok) throw new Error(`Seed aynî kaydı oluşturulamadı (${x.ref}): ${r.error}`);
|
||||
await InKindDebtModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
|
||||
for (const c of x.contributions ?? []) {
|
||||
const res = await addContribution(
|
||||
by,
|
||||
String(householdId),
|
||||
r.data.id,
|
||||
contributionSchema.parse({
|
||||
fromAccountId: await account(c.from),
|
||||
amount: formatMoneyInput(c.amountMinor),
|
||||
date: addDays(today, -c.daysAgo),
|
||||
}),
|
||||
);
|
||||
if (!res.ok) throw new Error(`Seed birikimi oluşturulamadı (${x.ref}): ${res.error}`);
|
||||
}
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -32,6 +32,9 @@ describe("seed", () => {
|
||||
expect(summary.created.categories).toBeGreaterThan(40);
|
||||
expect(summary.created.accounts).toBe(7);
|
||||
expect(summary.created.transactions).toBeGreaterThan(150);
|
||||
expect(summary.created.assets).toBe(4);
|
||||
expect(summary.created.budgets).toBe(2);
|
||||
expect(summary.created.stock).toBe(19);
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
||||
});
|
||||
@@ -47,6 +50,11 @@ describe("seed", () => {
|
||||
transactions: 0,
|
||||
recurringRules: 0,
|
||||
installmentPlans: 0,
|
||||
contacts: 0,
|
||||
inKind: 0,
|
||||
assets: 0,
|
||||
budgets: 0,
|
||||
stock: 0,
|
||||
});
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||
|
||||
@@ -10,6 +10,11 @@ import { HouseholdModel } from "@/models/household";
|
||||
import { HouseholdMemberModel } from "@/models/household-member";
|
||||
import { storedEmail } from "@/shared/account-email";
|
||||
import { runMigrations } from "../migrations";
|
||||
import { seedContacts } from "./contacts";
|
||||
import { seedAssets } from "./assets";
|
||||
import { seedBudgets } from "./budgets";
|
||||
import { seedStock } from "./stock";
|
||||
import { seedInKind } from "./in-kind";
|
||||
import { seedInstallments } from "./installments";
|
||||
import { seedRecurring } from "./recurring";
|
||||
import { seedTransactions } from "./transactions";
|
||||
@@ -47,6 +52,11 @@ export type SeedSummary = {
|
||||
transactions: number;
|
||||
recurringRules: number;
|
||||
installmentPlans: number;
|
||||
contacts: number;
|
||||
inKind: number;
|
||||
assets: number;
|
||||
budgets: number;
|
||||
stock: number;
|
||||
};
|
||||
accounts: SeedAccountRow[];
|
||||
};
|
||||
@@ -64,6 +74,16 @@ const DOMAIN_COLLECTIONS = [
|
||||
"reconciliations",
|
||||
"recurringRules",
|
||||
"statements",
|
||||
"contacts",
|
||||
"assetPrices",
|
||||
"inKindDebts",
|
||||
"assets",
|
||||
"assetMovements",
|
||||
"budgets",
|
||||
"stockCategories",
|
||||
"productTypes",
|
||||
"stockMovements",
|
||||
"stockCounts",
|
||||
];
|
||||
|
||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||
@@ -115,6 +135,11 @@ export async function runSeed({
|
||||
transactions: 0,
|
||||
recurringRules: 0,
|
||||
installmentPlans: 0,
|
||||
contacts: 0,
|
||||
inKind: 0,
|
||||
assets: 0,
|
||||
budgets: 0,
|
||||
stock: 0,
|
||||
},
|
||||
accounts: [],
|
||||
};
|
||||
@@ -289,6 +314,16 @@ export async function runSeed({
|
||||
);
|
||||
if (summary.created.installmentPlans)
|
||||
log(`✓ Örnek taksitli alışverişler: ${summary.created.installmentPlans}`);
|
||||
summary.created.contacts = await seedContacts(householdIds, userIdMap);
|
||||
if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`);
|
||||
summary.created.inKind = await seedInKind(householdIds, userIdMap);
|
||||
if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`);
|
||||
summary.created.assets = await seedAssets(householdIds, userIdMap);
|
||||
if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`);
|
||||
summary.created.budgets = await seedBudgets(householdIds, userIdMap);
|
||||
if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`);
|
||||
summary.created.stock = await seedStock(householdIds, userIdMap);
|
||||
if (summary.created.stock) log(`✓ Örnek stok ürün tipleri: ${summary.created.stock}`);
|
||||
|
||||
// 7) Geliştirme hesapları tablosu
|
||||
const all = [
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
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;
|
||||
/** 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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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 })) 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,
|
||||
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,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AssetDetailView } from "@/features/assets/components/asset-detail-view";
|
||||
import { PeriodReportView } from "@/features/assets/components/period-report";
|
||||
import {
|
||||
getAsset,
|
||||
getPeriodReport,
|
||||
REPORT_RANGES,
|
||||
type ReportRange,
|
||||
} from "@/features/assets/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.assets.history };
|
||||
|
||||
/** /birikim/:id — varlık hareketleri ve dönemsel rapor (gizli varlık yalnızca sahibine; aksi 404). */
|
||||
export default async function AssetPage({ params, searchParams }: PageProps<"/birikim/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const { id } = await params;
|
||||
const donem = (await searchParams).donem;
|
||||
const range: ReportRange = REPORT_RANGES.includes(donem as ReportRange)
|
||||
? (donem as ReportRange)
|
||||
: "6";
|
||||
const [data, report] = await Promise.all([
|
||||
getAsset(user.id, household.id, id),
|
||||
getPeriodReport(user.id, household.id, range, id),
|
||||
]);
|
||||
if (!data || !report) notFound();
|
||||
return (
|
||||
<AssetDetailView
|
||||
key={id}
|
||||
data={data}
|
||||
report={<PeriodReportView report={report} range={range} basePath={`/birikim/${id}`} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { AssetsView } from "@/features/assets/components/assets-view";
|
||||
import { PeriodReportView } from "@/features/assets/components/period-report";
|
||||
import {
|
||||
getPeriodReport,
|
||||
listAssets,
|
||||
REPORT_RANGES,
|
||||
type ReportRange,
|
||||
} from "@/features/assets/service";
|
||||
import { NetWorthCard } from "@/features/net-worth/components/net-worth-card";
|
||||
import { getNetWorth } from "@/features/net-worth/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.assets.title };
|
||||
|
||||
function parseRange(value: string | string[] | undefined): ReportRange {
|
||||
return REPORT_RANGES.includes(value as ReportRange) ? (value as ReportRange) : "6";
|
||||
}
|
||||
|
||||
/** /birikim — net varlık, varlıklar (ortalama maliyet, K/Z) ve dönemsel rapor. */
|
||||
export default async function AssetsPage({ searchParams }: PageProps<"/birikim">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const range = parseRange((await searchParams).donem);
|
||||
const [data, report, netWorth] = await Promise.all([
|
||||
listAssets(user.id, household.id),
|
||||
getPeriodReport(user.id, household.id, range),
|
||||
getNetWorth(user.id, household.id),
|
||||
]);
|
||||
if (!data || !report || !netWorth) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.assets.title} description={tr.assets.description} />
|
||||
<section aria-labelledby="net-worth-title" className="flex flex-col gap-2">
|
||||
<h2 id="net-worth-title" className="text-base font-medium">
|
||||
{tr.assets.netWorth.title}
|
||||
</h2>
|
||||
<NetWorthCard data={netWorth} />
|
||||
</section>
|
||||
<AssetsView items={data.items} totals={data.totals} canWrite={data.canWrite} />
|
||||
<PeriodReportView report={report} range={range} basePath="/birikim" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContactStatementView } from "@/features/contacts/components/contact-statement-view";
|
||||
import { getContactStatement } from "@/features/contacts/service";
|
||||
import { listInKind } from "@/features/in-kind/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.contacts.statement };
|
||||
|
||||
/** /cari/:id — cari ekstresi (gizli cari yalnızca sahibine; aksi 404). */
|
||||
export default async function ContactPage({ params }: PageProps<"/cari/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const { id } = await params;
|
||||
const [data, inKind] = await Promise.all([
|
||||
getContactStatement(user.id, household.id, id),
|
||||
listInKind(user.id, household.id, id),
|
||||
]);
|
||||
if (!data) notFound();
|
||||
return <ContactStatementView key={id} data={data} inKind={inKind ?? []} />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { ContactsView } from "@/features/contacts/components/contacts-view";
|
||||
import { listContacts } from "@/features/contacts/service";
|
||||
import { openInKindByContact } from "@/features/in-kind/service";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.contacts.title };
|
||||
|
||||
export default async function ContactsPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [data, inKind] = await Promise.all([
|
||||
listContacts(user.id, household.id),
|
||||
openInKindByContact(user.id, household.id),
|
||||
]);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.contacts.title} description={tr.contacts.description} />
|
||||
<ContactsView
|
||||
data={data}
|
||||
inKind={Object.fromEntries(inKind)}
|
||||
canWrite={hasHouseholdRole(household.role, "member")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||
import { DailyPlanCard } from "@/features/budgets/components/daily-plan-card";
|
||||
import { BudgetWarningBanner } from "@/features/budgets/components/warning-banner";
|
||||
import { getIncomePlan } from "@/features/budgets/plan";
|
||||
import { getBudget } from "@/features/budgets/service";
|
||||
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
||||
import { getNetWorth } from "@/features/net-worth/service";
|
||||
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
||||
import { LowStockCard } from "@/features/stock/components/low-stock-card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -15,22 +23,37 @@ type SummaryCard = {
|
||||
key: keyof typeof tr.dashboard.cards;
|
||||
tone?: string;
|
||||
amountMinor: number | null;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
/** FAZ 14: gerçek özet verisi (gelir, gider, bakiye, kart borcu, net varlık). */
|
||||
async function getSummary(): Promise<SummaryCard[]> {
|
||||
/**
|
||||
* Özet kartları (içinde bulunulan bütçe ayı): gerçekleşen gelir, gider ve farkı; hesap bakiyesi,
|
||||
* kart borcu ve net varlık.
|
||||
*/
|
||||
async function getSummary(userId: string, householdId: string): Promise<SummaryCard[]> {
|
||||
const [nw, budget] = await Promise.all([
|
||||
getNetWorth(userId, householdId),
|
||||
getBudget(userId, householdId),
|
||||
]);
|
||||
const income = budget ? await getIncomePlan(userId, householdId, budget.month) : null;
|
||||
const inc = income?.actualMinor ?? null;
|
||||
const exp = budget?.totals.actualMinor ?? null;
|
||||
return [
|
||||
{ key: "monthIncome", tone: "text-income", amountMinor: null },
|
||||
{ key: "monthExpense", tone: "text-expense", amountMinor: null },
|
||||
{ key: "monthRemaining", tone: "text-budget", amountMinor: null },
|
||||
{ key: "totalBalance", amountMinor: null },
|
||||
{ key: "cardDebt", tone: "text-expense", amountMinor: null },
|
||||
{ key: "netWorth", amountMinor: null },
|
||||
{ key: "monthIncome", tone: "text-income", amountMinor: inc, href: "/butce" },
|
||||
{ key: "monthExpense", tone: "text-expense", amountMinor: exp, href: "/butce" },
|
||||
{
|
||||
key: "monthRemaining",
|
||||
tone: "text-budget",
|
||||
amountMinor: inc !== null && exp !== null ? inc - exp : null,
|
||||
},
|
||||
{ key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" },
|
||||
{ key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null },
|
||||
{ key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" },
|
||||
];
|
||||
}
|
||||
|
||||
async function SummaryCards() {
|
||||
const cards = await getSummary();
|
||||
async function SummaryCards({ userId, householdId }: { userId: string; householdId: string }) {
|
||||
const cards = await getSummary(userId, householdId);
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{cards.map((c) => (
|
||||
@@ -38,7 +61,15 @@ async function SummaryCards() {
|
||||
<CardHeader>
|
||||
<CardDescription>{tr.dashboard.cards[c.key]}</CardDescription>
|
||||
<CardTitle className={cn("text-lg tabular-nums", c.tone)}>
|
||||
{c.amountMinor === null ? "—" : formatMoney(c.amountMinor)}
|
||||
{c.amountMinor === null ? (
|
||||
"—"
|
||||
) : c.href ? (
|
||||
<Link href={c.href} className="hover:underline">
|
||||
{formatMoney(c.amountMinor)}
|
||||
</Link>
|
||||
) : (
|
||||
formatMoney(c.amountMinor)
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{c.amountMinor === null ? (
|
||||
@@ -63,8 +94,20 @@ export default async function DashboardPage() {
|
||||
<Suspense fallback={null}>
|
||||
<OverdueBanner userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<BudgetWarningBanner userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<ContactsDueBanner userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||
<SummaryCards />
|
||||
<SummaryCards userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<DailyPlanCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<LowStockCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { PricesView } from "@/features/in-kind/components/prices-view";
|
||||
import { listPrices } from "@/features/in-kind/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.prices.title };
|
||||
|
||||
/** /fiyatlar — altın ve döviz fiyat tablosu (elle; Faz 9 varlık takibi de kullanır). */
|
||||
export default async function PricesPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await listPrices(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.prices.title} description={tr.prices.description} />
|
||||
<PricesView rows={data.rows} canWrite={data.canWrite} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
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] = await Promise.all([
|
||||
getProductType(user.id, household.id, id),
|
||||
getStockOptions(user.id, household.id),
|
||||
]);
|
||||
if (!data || !options) notFound();
|
||||
return <ProductDetailView data={data} categories={options.categories} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export type NavItem = {
|
||||
|
||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||
export const CURRENT_PHASE = 7.2;
|
||||
export const CURRENT_PHASE = 11.2;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
@@ -37,10 +37,10 @@ export const MAIN_NAV: NavItem[] = [
|
||||
{ href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 },
|
||||
{ href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 },
|
||||
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 },
|
||||
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 },
|
||||
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 },
|
||||
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
|
||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
|
||||
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
|
||||
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 },
|
||||
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 },
|
||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
|
||||
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
|
||||
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
|
||||
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
import type { TooltipValueType } from "recharts";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
|
||||
type TooltipNameType = number | string;
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>;
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||
initialDimension?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>,
|
||||
"accessibilityLayer"
|
||||
>) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string" ? (config[label]?.label ?? label) : itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||
import {
|
||||
addMovement,
|
||||
createAsset,
|
||||
deleteAsset,
|
||||
deleteMovement,
|
||||
listAssets,
|
||||
updateAsset,
|
||||
} from "./service";
|
||||
|
||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
|
||||
function fieldErrors(error: z.ZodError) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function run<T>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<unknown>>,
|
||||
): Promise<ActionResult<unknown>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const parsed = schema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||
if (result.ok) revalidatePath("/", "layout");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Yeni varlık (assetId boş) veya mevcut varlığı düzenle. */
|
||||
export async function saveAssetAction(assetId: unknown, input: unknown) {
|
||||
if (assetId) {
|
||||
const id = idSchema.safeParse(assetId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(assetFormSchema, input, (u, h, d) => updateAsset(u, h, id.data, d));
|
||||
}
|
||||
return run(assetFormSchema, input, createAsset);
|
||||
}
|
||||
|
||||
export async function deleteAssetAction(assetId: unknown) {
|
||||
const id = idSchema.safeParse(assetId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.unknown(), null, (u, h) => deleteAsset(u, h, id.data));
|
||||
}
|
||||
|
||||
export async function addMovementAction(assetId: unknown, input: unknown) {
|
||||
const id = idSchema.safeParse(assetId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(movementFormSchema, input, (u, h, d) => addMovement(u, h, id.data, d));
|
||||
}
|
||||
|
||||
export async function deleteMovementAction(movementId: unknown) {
|
||||
const id = idSchema.safeParse(movementId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.unknown(), null, (u, h) => deleteMovement(u, h, id.data));
|
||||
}
|
||||
|
||||
export type AssetOption = { id: string; name: string; assetKey: string; quantity: number };
|
||||
|
||||
/** Aynî ödeme diyaloğu için görünür varlıklar (ad, tür, eldeki miktar). */
|
||||
export async function getAssetOptionsAction(): Promise<ActionResult<AssetOption[]>> {
|
||||
const ctx = await getHouseholdActionContext("viewer");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const data = await listAssets(ctx.user.id, ctx.household.id);
|
||||
if (!data) return fail(tr.common.unauthorized);
|
||||
return ok(
|
||||
data.items.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
assetKey: a.assetKey,
|
||||
quantity: a.summary.quantity,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
EllipsisVertical,
|
||||
Gift,
|
||||
HandCoins,
|
||||
Lock,
|
||||
Pencil,
|
||||
ShoppingBag,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { ASSETS, formatAssetQuantity } from "@/shared/assets";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { isInflow, type MovementKind } from "@/shared/portfolio";
|
||||
import { deleteAssetAction, deleteMovementAction } from "../actions";
|
||||
import type { AssetDetail, MovementRow } from "../service";
|
||||
import { AssetFormDialog, MovementDialog } from "./asset-dialogs";
|
||||
import { ProfitLoss, Stat } from "./assets-view";
|
||||
|
||||
const t = tr.assets;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
|
||||
type Pending =
|
||||
| { kind: "edit" }
|
||||
| { kind: "movement"; movement: MovementKind }
|
||||
| { kind: "deleteMovement"; row: MovementRow }
|
||||
| { kind: "deleteAsset" }
|
||||
| null;
|
||||
|
||||
/** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */
|
||||
export function AssetDetailView({
|
||||
data,
|
||||
report,
|
||||
}: {
|
||||
data: AssetDetail;
|
||||
/** Dönemsel rapor (sunucuda hazırlanır). */
|
||||
report?: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const close = () => setPending(null);
|
||||
const s = data.summary;
|
||||
const unit = ASSETS[data.assetKey].unit;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/birikim">
|
||||
<ArrowLeft />
|
||||
{t.back}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader
|
||||
title={data.name}
|
||||
description={ASSETS[data.assetKey].label}
|
||||
actions={
|
||||
data.canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="outline" aria-label={tr.recurring.actions.more}>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => setPending({ kind: "edit" })}>
|
||||
<Pencil />
|
||||
{tr.common.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={data.movementCount > 0}
|
||||
onSelect={() => setPending({ kind: "deleteAsset" })}
|
||||
>
|
||||
<Trash2 />
|
||||
{t.delete}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{data.isPrivate ? (
|
||||
<Badge variant="outline" className="-mt-2 w-fit gap-1">
|
||||
<Lock className="size-3" />
|
||||
{t.privateBadge}
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
<Card size="sm">
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label={t.stats.quantity}>{formatAssetQuantity(data.assetKey, s.quantity)}</Stat>
|
||||
<Stat label={t.stats.avgCost}>
|
||||
{s.avgCostMinor !== null
|
||||
? `${formatMoney(s.avgCostMinor)} ${t.stats.perUnit(unit)}`
|
||||
: "—"}
|
||||
</Stat>
|
||||
<Stat label={t.stats.cost}>{formatMoney(s.costMinor)}</Stat>
|
||||
<Stat label={t.stats.currentValue}>
|
||||
{s.currentValueMinor !== null ? formatMoney(s.currentValueMinor) : t.stats.noPrice}
|
||||
</Stat>
|
||||
<Stat label={t.stats.unrealized}>
|
||||
<ProfitLoss minor={s.unrealizedMinor} bp={s.unrealizedBp} />
|
||||
</Stat>
|
||||
<Stat label={t.stats.realized}>
|
||||
<ProfitLoss minor={s.realizedMinor} />
|
||||
</Stat>
|
||||
<Stat label={t.stats.invested}>{formatMoney(s.investedMinor)}</Stat>
|
||||
<Stat label={t.stats.proceeds}>{formatMoney(s.proceedsMinor)}</Stat>
|
||||
</div>
|
||||
{data.price ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.stats.price(formatMoney(data.price.buyMinor), d(data.price.date))} ·{" "}
|
||||
<Link href="/fiyatlar" className="underline-offset-2 hover:underline">
|
||||
{t.prices}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
{data.storage ? (
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">{t.movement.storage}:</span> {data.storage}
|
||||
</p>
|
||||
) : null}
|
||||
{data.note ? <p className="text-sm text-muted-foreground">{data.note}</p> : null}
|
||||
{data.canWrite ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPending({ kind: "movement", movement: "gift_in" })}
|
||||
>
|
||||
<Gift />
|
||||
{t.kinds.gift_in}
|
||||
</Button>
|
||||
{s.quantity > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPending({ kind: "movement", movement: "sell" })}
|
||||
>
|
||||
<HandCoins />
|
||||
{t.kinds.sell}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" onClick={() => setPending({ kind: "movement", movement: "buy" })}>
|
||||
<ShoppingBag />
|
||||
{t.kinds.buy}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{report}
|
||||
|
||||
<section className="flex flex-col gap-2" aria-labelledby="movements-title">
|
||||
<h2 id="movements-title" className="text-base font-medium">
|
||||
{t.history}
|
||||
</h2>
|
||||
{data.movements.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.noMovements}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{data.movements.map((row) => (
|
||||
<MovementItem
|
||||
key={row.id}
|
||||
row={row}
|
||||
data={data}
|
||||
canWrite={data.canWrite}
|
||||
onDelete={() => setPending({ kind: "deleteMovement", row })}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{pending?.kind === "edit" ? <AssetFormDialog item={data} onClose={close} /> : null}
|
||||
{pending?.kind === "movement" ? (
|
||||
<MovementDialog item={data} initialKind={pending.movement} onClose={close} />
|
||||
) : null}
|
||||
<AlertDialog
|
||||
open={pending?.kind === "deleteMovement" || pending?.kind === "deleteAsset"}
|
||||
onOpenChange={(o) => !o && close()}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{pending?.kind === "deleteAsset" ? t.deleteTitle : t.deleteMovementTitle}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pending?.kind === "deleteAsset" ? t.deleteDescription : t.deleteMovementDescription}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (pending?.kind === "deleteAsset") {
|
||||
const r = await deleteAssetAction(data.id);
|
||||
close();
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.deleted);
|
||||
router.push("/birikim");
|
||||
} else toast.error(r.error);
|
||||
} else if (pending?.kind === "deleteMovement") {
|
||||
const r = await deleteMovementAction(pending.row.id);
|
||||
close();
|
||||
if (r.ok) toast.success(t.toast.movementDeleted);
|
||||
else toast.error(r.error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pending?.kind === "deleteAsset" ? t.delete : t.deleteMovement}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MovementItem({
|
||||
row,
|
||||
data,
|
||||
canWrite,
|
||||
onDelete,
|
||||
}: {
|
||||
row: MovementRow;
|
||||
data: AssetDetail;
|
||||
canWrite: boolean;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const inflow = isInflow(row.kind);
|
||||
const qty = formatAssetQuantity(data.assetKey, row.quantity);
|
||||
const unit = ASSETS[data.assetKey].unit;
|
||||
return (
|
||||
<li className="flex items-start gap-3 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-x-2 text-sm font-medium">
|
||||
<span>{t.kinds[row.kind]}</span>
|
||||
<span className={inflow ? "text-income" : "text-expense"}>
|
||||
{inflow ? "+" : "−"}
|
||||
{qty}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{d(row.date)}
|
||||
{row.unitPriceMinor !== null
|
||||
? ` · ${formatMoney(row.unitPriceMinor)} ${t.stats.perUnit(unit)}`
|
||||
: ""}
|
||||
{row.account ? ` · ${row.account}` : ""}
|
||||
{row.storage ? ` · ${row.storage}` : ""}
|
||||
</p>
|
||||
{row.note ? <p className="text-xs text-muted-foreground">{row.note}</p> : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.table.balance}: {formatAssetQuantity(data.assetKey, row.quantityAfter)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end text-sm">
|
||||
{row.kind !== "gift_out" ? (
|
||||
<span className="tabular-nums">{formatMoney(row.amountMinor)}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{t.stats.cost} {formatMoney(row.costOutMinor)}
|
||||
</span>
|
||||
)}
|
||||
{row.kind === "sell" ? (
|
||||
<span className="text-xs">
|
||||
{t.table.result}: <ProfitLoss minor={row.realizedMinor} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{canWrite && !row.fromInKind ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t.deleteMovement}
|
||||
onClick={onDelete}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { MoneyInput } from "@/components/money-input";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useAccounts } from "@/features/in-kind/components/in-kind-section";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { ASSET_KEYS, ASSETS, formatAssetQuantity, type AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { MOVEMENT_KINDS, type MovementKind } from "@/shared/portfolio";
|
||||
import { addMovementAction, saveAssetAction } from "../actions";
|
||||
import type { AssetView } from "../service";
|
||||
|
||||
const t = tr.assets;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
const NONE = "__none__";
|
||||
|
||||
function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Yeni varlık / varlığı düzenle. */
|
||||
export function AssetFormDialog({
|
||||
item,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
item: AssetView | null;
|
||||
onClose: () => void;
|
||||
onCreated?: (id: string) => void;
|
||||
}) {
|
||||
const [v, setV] = useState({
|
||||
assetKey: (item?.assetKey ?? "gram_gold") as AssetKey,
|
||||
name: item?.name ?? "",
|
||||
note: item?.note ?? "",
|
||||
isPrivate: item?.isPrivate ?? false,
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
const locked = Boolean(item?.movementCount);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveAssetAction(item?.id ?? null, {
|
||||
...v,
|
||||
name: v.name.trim() || ASSETS[v.assetKey].label,
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.saved);
|
||||
onClose();
|
||||
if (!item) onCreated?.((r.data as { id: string }).id);
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={item ? t.editTitle : t.createTitle}
|
||||
footer={<Footer busy={busy} formId="asset-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="asset-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.assetKey)}>
|
||||
<FieldLabel htmlFor="as-key">{f.asset}</FieldLabel>
|
||||
<Select
|
||||
value={v.assetKey}
|
||||
onValueChange={(x) => set("assetKey", x as AssetKey)}
|
||||
disabled={locked}
|
||||
>
|
||||
<SelectTrigger id="as-key" className="w-full">
|
||||
<SelectValue>{ASSETS[v.assetKey].label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASSET_KEYS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{ASSETS[k].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.assetKey ? <FieldError>{errors.assetKey}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.name)}>
|
||||
<FieldLabel htmlFor="as-name">{f.name}</FieldLabel>
|
||||
<Input
|
||||
id="as-name"
|
||||
value={v.name}
|
||||
placeholder={ASSETS[v.assetKey].label}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="as-note">{f.note}</FieldLabel>
|
||||
<Input id="as-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={v.isPrivate}
|
||||
onCheckedChange={(x) => set("isPrivate", x === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{f.isPrivate}
|
||||
<span className="block text-xs text-muted-foreground">{f.isPrivateHint}</span>
|
||||
</span>
|
||||
</label>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Alım, gelen hediye, satış veya çıkış hareketi. */
|
||||
export function MovementDialog({
|
||||
item,
|
||||
initialKind,
|
||||
onClose,
|
||||
}: {
|
||||
item: AssetView;
|
||||
initialKind: MovementKind;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const accounts = useAccounts();
|
||||
const [kind, setKind] = useState<MovementKind>(initialKind);
|
||||
const [v, setV] = useState({
|
||||
date: todayIstanbul(),
|
||||
quantity: "",
|
||||
amount: "",
|
||||
unitPrice: "",
|
||||
accountId: "",
|
||||
storage: item.storage,
|
||||
note: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
const withAccount = kind === "buy" || kind === "sell";
|
||||
const priced = kind !== "gift_out";
|
||||
const inflow = kind === "buy" || kind === "gift_in";
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await addMovementAction(item.id, {
|
||||
kind,
|
||||
date: v.date,
|
||||
quantity: v.quantity,
|
||||
amount: priced ? v.amount : "",
|
||||
unitPrice: priced ? v.unitPrice : "",
|
||||
accountId: withAccount ? v.accountId : "",
|
||||
storage: inflow ? v.storage : "",
|
||||
note: v.note,
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.movement);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
const m = t.movement;
|
||||
const unit = ASSETS[item.assetKey].unit;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={m.title(t.kinds[kind], item.name)}
|
||||
description={m.held(formatAssetQuantity(item.assetKey, item.summary.quantity))}
|
||||
footer={<Footer busy={busy} formId="movement-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="movement-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Tabs
|
||||
value={kind}
|
||||
onValueChange={(x) => {
|
||||
setKind(x as MovementKind);
|
||||
setErrors({});
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid h-auto w-full grid-cols-2 sm:grid-cols-4">
|
||||
{MOVEMENT_KINDS.map((k) => (
|
||||
<TabsTrigger key={k} value={k}>
|
||||
{t.kinds[k]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<p className="-mt-2 text-xs text-muted-foreground">{t.kindHints[kind]}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.quantity)}>
|
||||
<FieldLabel htmlFor="mv-qty">
|
||||
{m.quantity} ({unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="mv-qty"
|
||||
inputMode="decimal"
|
||||
value={v.quantity}
|
||||
onChange={(e) => set("quantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.quantity)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="mv-date">{m.date}</FieldLabel>
|
||||
<Input
|
||||
id="mv-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => set("date", e.target.value)}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
{priced ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.amount)}>
|
||||
<FieldLabel htmlFor="mv-amount">{m.amount}</FieldLabel>
|
||||
<MoneyInput id="mv-amount" value={v.amount} onChange={(x) => set("amount", x)} />
|
||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.unitPrice)}>
|
||||
<FieldLabel htmlFor="mv-unit">{m.unitPrice}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="mv-unit"
|
||||
value={v.unitPrice}
|
||||
onChange={(x) => set("unitPrice", x)}
|
||||
/>
|
||||
{errors.unitPrice ? <FieldError>{errors.unitPrice}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">
|
||||
{kind === "gift_in" ? m.giftValueHint : m.priceHint}
|
||||
</FieldDescription>
|
||||
</>
|
||||
) : null}
|
||||
{withAccount ? (
|
||||
<Field data-invalid={Boolean(errors.accountId)}>
|
||||
<FieldLabel htmlFor="mv-account">{m.account}</FieldLabel>
|
||||
<Select
|
||||
value={v.accountId || NONE}
|
||||
onValueChange={(x) => set("accountId", x === NONE ? "" : x)}
|
||||
>
|
||||
<SelectTrigger id="mv-account" className="w-full">
|
||||
<SelectValue>
|
||||
{accounts?.find((a) => a.id === v.accountId)?.name ?? m.accountNone}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
<SelectItem value={NONE}>{m.accountNone}</SelectItem>
|
||||
{(accounts ?? []).map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>{m.accountHint}</FieldDescription>
|
||||
{errors.accountId ? <FieldError>{errors.accountId}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
{inflow ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="mv-storage">{m.storage}</FieldLabel>
|
||||
<Input
|
||||
id="mv-storage"
|
||||
value={v.storage}
|
||||
placeholder={m.storagePlaceholder}
|
||||
onChange={(e) => set("storage", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="mv-note">{m.note}</FieldLabel>
|
||||
<Input id="mv-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, Coins, Lock, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import { formatBp } from "@/shared/portfolio";
|
||||
import type { AssetTotals, AssetView } from "../service";
|
||||
import { AssetFormDialog } from "./asset-dialogs";
|
||||
|
||||
const t = tr.assets;
|
||||
|
||||
/** Kâr/zarar tutarı: + yeşil, − kırmızı. */
|
||||
export function ProfitLoss({
|
||||
minor,
|
||||
bp,
|
||||
compact,
|
||||
}: {
|
||||
minor: number | null;
|
||||
bp?: number | null;
|
||||
/** Mobilde oranı gizle (dar satırlar). */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (minor === null) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold whitespace-nowrap tabular-nums",
|
||||
minor > 0 ? "text-income" : minor < 0 ? "text-expense" : undefined,
|
||||
)}
|
||||
>
|
||||
{minor > 0 ? "+" : minor < 0 ? "−" : ""}
|
||||
{formatMoney(Math.abs(minor))}
|
||||
{bp !== undefined && bp !== null ? (
|
||||
<span className={cn("ml-1 text-xs font-normal", compact && "hidden sm:inline")}>
|
||||
({formatBp(bp)})
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stat({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<div className="font-semibold tabular-nums">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** /birikim: toplamlar ve varlık kartları. */
|
||||
export function AssetsView({
|
||||
items,
|
||||
totals,
|
||||
canWrite,
|
||||
}: {
|
||||
items: AssetView[];
|
||||
totals: AssetTotals;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const held = items.filter((i) => i.summary.quantity > 0 || i.movementCount === 0);
|
||||
const closed = items.filter((i) => i.summary.quantity === 0 && i.movementCount > 0);
|
||||
const unrealizedBp =
|
||||
totals.costMinor > 0 ? Math.round((totals.unrealizedMinor * 10_000) / totals.costMinor) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/fiyatlar">
|
||||
<Coins />
|
||||
{t.prices}
|
||||
</Link>
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus />
|
||||
{t.new}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Card size="sm">
|
||||
<CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label={t.totals.value}>{formatMoney(totals.valueMinor)}</Stat>
|
||||
<Stat label={t.totals.cost}>{formatMoney(totals.costMinor)}</Stat>
|
||||
<Stat label={t.totals.unrealized}>
|
||||
<ProfitLoss minor={totals.unrealizedMinor} bp={unrealizedBp} />
|
||||
</Stat>
|
||||
<Stat label={t.totals.realized}>
|
||||
<ProfitLoss minor={totals.realizedMinor} />
|
||||
</Stat>
|
||||
{totals.missingPrice ? (
|
||||
<p className="col-span-full text-xs text-muted-foreground">
|
||||
{t.totals.missingPrice(totals.missingPrice)}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.empty}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<ul className="flex flex-col gap-2">
|
||||
{held.map((item) => (
|
||||
<AssetRow key={item.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
{closed.length ? (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">{t.closedGroup}</h2>
|
||||
<ul className="flex flex-col gap-2 opacity-70">
|
||||
{closed.map((item) => (
|
||||
<AssetRow key={item.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{creating ? (
|
||||
<AssetFormDialog
|
||||
item={null}
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={(id) => router.push(`/birikim/${id}`)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetRow({ item }: { item: AssetView }) {
|
||||
const s = item.summary;
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={`/birikim/${item.id}`}
|
||||
className="flex items-center gap-3 rounded-xl border bg-card p-3 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
|
||||
<Coins className="size-4.5" />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="flex min-w-0 items-center gap-1.5 font-medium">
|
||||
<span className="truncate">{item.name}</span>
|
||||
{item.isPrivate ? (
|
||||
<Badge variant="outline" className="shrink-0 gap-1" title={t.privateBadge}>
|
||||
<Lock className="size-3" />
|
||||
<span className="hidden sm:inline">{t.privateBadge}</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
</p>
|
||||
<span className="shrink-0 text-sm font-semibold tabular-nums">
|
||||
{s.currentValueMinor !== null ? formatMoney(s.currentValueMinor) : t.stats.noPrice}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<p className="min-w-0 truncate text-muted-foreground">
|
||||
{item.label}
|
||||
{s.avgCostMinor !== null
|
||||
? ` · ${t.stats.avgCost} ${formatMoney(s.avgCostMinor)}`
|
||||
: ""}
|
||||
</p>
|
||||
<span className="shrink-0">
|
||||
{s.quantity > 0 ? (
|
||||
<ProfitLoss minor={s.unrealizedMinor} bp={s.unrealizedBp} compact />
|
||||
) : (
|
||||
<>
|
||||
{t.stats.realized}: <ProfitLoss minor={s.realizedMinor} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import type { PeriodReport } from "@/shared/portfolio";
|
||||
import type { ReportRange } from "../service";
|
||||
import { ProfitLoss, Stat } from "./assets-view";
|
||||
|
||||
const t = tr.assets.report;
|
||||
const RANGES: ReportRange[] = ["3", "6", "12", "tum"];
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
const tickFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: "Europe/Istanbul",
|
||||
});
|
||||
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||
|
||||
const config = {
|
||||
value: { label: t.value, color: "var(--budget)" },
|
||||
cost: { label: t.cost, color: "var(--muted-foreground)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
/** Dönem seçici + özet + aylık değer/maliyet grafiği + tablo görünümü. */
|
||||
export function PeriodReportView({
|
||||
report,
|
||||
range,
|
||||
basePath,
|
||||
}: {
|
||||
report: PeriodReport & { excluded: number };
|
||||
range: ReportRange;
|
||||
basePath: string;
|
||||
}) {
|
||||
const data = report.points.map((p) => ({
|
||||
date: p.date,
|
||||
label: tickFmt.format(new Date(`${p.date}T12:00:00+03:00`)),
|
||||
// Kuruş → TL (eksen ve ipucu TL gösterir).
|
||||
value: p.valueMinor === null ? null : p.valueMinor / 100,
|
||||
cost: p.costMinor / 100,
|
||||
}));
|
||||
const hasData = report.points.some((p) => p.costMinor > 0 || (p.valueMinor ?? 0) > 0);
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||
<CardDescription>{t.period(d(report.from), d(report.to))}</CardDescription>
|
||||
</div>
|
||||
<nav aria-label={t.rangeLabel} className="flex rounded-lg bg-muted p-0.5 text-sm">
|
||||
{RANGES.map((r) => (
|
||||
<Link
|
||||
key={r}
|
||||
href={`${basePath}?donem=${r}`}
|
||||
scroll={false}
|
||||
aria-current={r === range ? "page" : undefined}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-muted-foreground transition-colors",
|
||||
r === range && "bg-background font-medium text-foreground shadow-sm",
|
||||
)}
|
||||
>
|
||||
{t.ranges[r]}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label={t.startValue}>
|
||||
{report.startValueMinor !== null ? formatMoney(report.startValueMinor) : t.noPrice}
|
||||
</Stat>
|
||||
<Stat label={t.invested}>{formatMoney(report.investedMinor)}</Stat>
|
||||
<Stat label={t.endValue}>
|
||||
{report.endValueMinor !== null ? formatMoney(report.endValueMinor) : t.noPrice}
|
||||
</Stat>
|
||||
<Stat label={t.profit}>
|
||||
<ProfitLoss minor={report.profitMinor} bp={report.profitBp} />
|
||||
</Stat>
|
||||
{report.proceedsMinor ? (
|
||||
<Stat label={t.proceeds}>{formatMoney(report.proceedsMinor)}</Stat>
|
||||
) : null}
|
||||
{report.giftInMinor ? (
|
||||
<Stat label={t.giftIn}>{formatMoney(report.giftInMinor)}</Stat>
|
||||
) : null}
|
||||
{report.giftOutMinor ? (
|
||||
<Stat label={t.giftOut}>{formatMoney(report.giftOutMinor)}</Stat>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t.formula}</p>
|
||||
{report.excluded ? (
|
||||
<p className="text-xs text-muted-foreground">{t.excluded(report.excluded)}</p>
|
||||
) : null}
|
||||
|
||||
{hasData ? (
|
||||
<>
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="aspect-auto h-56 w-full sm:h-64"
|
||||
aria-label={t.chartLabel}
|
||||
role="img"
|
||||
>
|
||||
<LineChart data={data} margin={{ left: 4, right: 12, top: 8 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={16}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickFormatter={(v: number) => compact.format(v)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const date = payload?.[0]?.payload?.date as string | undefined;
|
||||
return date ? d(date) : "";
|
||||
}}
|
||||
formatter={(value, name) => (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{config[name as keyof typeof config]?.label}
|
||||
</span>
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{formatMoney(Math.round(Number(value) * 100))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<Line
|
||||
dataKey="cost"
|
||||
type="stepAfter"
|
||||
stroke="var(--color-cost)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 4"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="value"
|
||||
type="linear"
|
||||
stroke="var(--color-value)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground">{t.table}</summary>
|
||||
<table className="mt-2 w-full text-right tabular-nums">
|
||||
<thead className="text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-1 text-left font-normal">{t.date}</th>
|
||||
<th className="py-1 font-normal">{t.value}</th>
|
||||
<th className="py-1 font-normal">{t.cost}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{report.points.map((p) => (
|
||||
<tr key={p.date}>
|
||||
<td className="py-1 text-left">{d(p.date)}</td>
|
||||
<td className="py-1">
|
||||
{p.valueMinor !== null ? formatMoney(p.valueMinor) : t.noPrice}
|
||||
</td>
|
||||
<td className="py-1">{formatMoney(p.costMinor)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t.empty}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { listAccounts } from "@/features/accounts/service";
|
||||
import { savePrice } from "@/features/in-kind/service";
|
||||
import {
|
||||
listTransactions,
|
||||
setTransactionDeleted,
|
||||
updateTransaction,
|
||||
} from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AssetModel } from "@/models/asset";
|
||||
import { AssetMovementModel } from "@/models/asset-movement";
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||
import { assetPriceFormSchema } from "@/shared/schemas/in-kind";
|
||||
import { transactionFilterSchema, transactionFormSchema } from "@/shared/schemas/transaction";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
addMovement,
|
||||
createAsset,
|
||||
deleteAsset,
|
||||
deleteMovement,
|
||||
getAsset,
|
||||
listAssets,
|
||||
updateAsset,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let emre = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
const today = todayIstanbul();
|
||||
const daysAgo = (n: number) => addDays(today, -n);
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
emre = ids.user("emre");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
// Seed örnekleri bu testin senaryolarını etkilemesin.
|
||||
await Promise.all([
|
||||
AssetPriceModel.deleteMany({}),
|
||||
AssetModel.deleteMany({}),
|
||||
AssetMovementModel.deleteMany({}),
|
||||
TransactionModel.deleteMany({ assetId: { $ne: null } }),
|
||||
]);
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
const acc = (name: string) =>
|
||||
AccountModel.findOne({ householdId: cakir, name })
|
||||
.lean()
|
||||
.then((a) => String(a!._id));
|
||||
async function balance(accountId: string) {
|
||||
return (await listAccounts(ayse, cakir))!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
|
||||
}
|
||||
const mv = (input: Record<string, unknown>) => movementFormSchema.parse({ date: today, ...input });
|
||||
async function asset(name: string, assetKey = "gram_gold", isPrivate = false, by = ayse) {
|
||||
const r = await createAsset(by, cakir, assetFormSchema.parse({ assetKey, name, isPrivate }));
|
||||
return r.ok ? r.data.id : "";
|
||||
}
|
||||
|
||||
describe("varlık lotları ve ortalama maliyet", () => {
|
||||
let gold = "";
|
||||
|
||||
it("alım hesaptan transfer olarak çıkar; gelir/gider toplamları değişmez", async () => {
|
||||
gold = await asset("Altın birikimi");
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const z0 = await balance(ziraat);
|
||||
const sums0 = (await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums;
|
||||
const r = await addMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
mv({
|
||||
kind: "buy",
|
||||
date: daysAgo(60),
|
||||
quantity: "10",
|
||||
amount: "25.000",
|
||||
accountId: ziraat,
|
||||
storage: "Ev kasası",
|
||||
}),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(await balance(ziraat)).toBe(z0 - 25_000_00);
|
||||
expect((await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums).toEqual(
|
||||
sums0,
|
||||
);
|
||||
// İşlem listesinde varlık adıyla görünür.
|
||||
const page = (await listTransactions(
|
||||
ayse,
|
||||
cakir,
|
||||
transactionFilterSchema.parse({ q: "Varlık alımı" }),
|
||||
))!;
|
||||
expect(page.items[0]).toMatchObject({ asset: { id: gold, name: "Altın birikimi" } });
|
||||
});
|
||||
|
||||
it("birim fiyatla alım ve fiyat tablosundan (satış fiyatı) alım; özet", async () => {
|
||||
await addMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
mv({ kind: "buy", date: daysAgo(30), quantity: "2", unitPrice: "2.600" }),
|
||||
);
|
||||
// Fiyat yok → reddedilir.
|
||||
expect(
|
||||
(await addMovement(ayse, cakir, gold, mv({ kind: "buy", date: daysAgo(10), quantity: "2" })))
|
||||
.ok,
|
||||
).toBe(false);
|
||||
await savePrice(
|
||||
ayse,
|
||||
cakir,
|
||||
assetPriceFormSchema.parse({
|
||||
assetKey: "gram_gold",
|
||||
date: daysAgo(10),
|
||||
buy: "2.650",
|
||||
sell: "2.700",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
(await addMovement(ayse, cakir, gold, mv({ kind: "buy", date: daysAgo(10), quantity: "2" })))
|
||||
.ok,
|
||||
).toBe(true);
|
||||
await savePrice(
|
||||
ayse,
|
||||
cakir,
|
||||
assetPriceFormSchema.parse({
|
||||
assetKey: "gram_gold",
|
||||
date: today,
|
||||
buy: "3.000",
|
||||
sell: "3.100",
|
||||
}),
|
||||
);
|
||||
const v = (await getAsset(ayse, cakir, gold))!;
|
||||
// 14 gr, 25.000 + 5.200 + 5.400 = 35.600; değer alış 3.000 ile 42.000.
|
||||
expect(v.summary).toMatchObject({
|
||||
quantity: 14_000,
|
||||
costMinor: 35_600_00,
|
||||
investedMinor: 35_600_00,
|
||||
avgCostMinor: 2_542_86,
|
||||
currentValueMinor: 42_000_00,
|
||||
unrealizedMinor: 6_400_00,
|
||||
unrealizedBp: 1798,
|
||||
});
|
||||
expect(v.storage).toBe("Ev kasası");
|
||||
expect(v.movements[0]!.kind).toBe("buy");
|
||||
});
|
||||
|
||||
it("satış: hesaba girer, ortalama maliyetle gerçekleşmiş K/Z; eldekinden fazlası reddedilir", async () => {
|
||||
const cuzdan = await acc("Cüzdan");
|
||||
const c0 = await balance(cuzdan);
|
||||
expect(
|
||||
(await addMovement(ayse, cakir, gold, mv({ kind: "sell", quantity: "15", amount: "1" }))).ok,
|
||||
).toBe(false);
|
||||
// Geçmiş tarihli satış o tarihteki miktarla sınırlı (60 gün önce yalnızca 10 gr vardı).
|
||||
expect(
|
||||
(
|
||||
await addMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
mv({ kind: "sell", date: daysAgo(45), quantity: "11", amount: "1" }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
const r = await addMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
mv({ kind: "sell", quantity: "4", amount: "12.000", accountId: cuzdan }),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(await balance(cuzdan)).toBe(c0 + 12_000_00);
|
||||
const v = (await getAsset(ayse, cakir, gold))!;
|
||||
// 4 gr maliyeti: 35.600 × 4/14 = 10.171,43 → K/Z 1.828,57
|
||||
expect(v.movements[0]).toMatchObject({
|
||||
kind: "sell",
|
||||
costOutMinor: 10_171_43,
|
||||
realizedMinor: 1_828_57,
|
||||
quantityAfter: 10_000,
|
||||
});
|
||||
expect(v.summary.realizedMinor).toBe(1_828_57);
|
||||
expect(v.summary.proceedsMinor).toBe(12_000_00);
|
||||
});
|
||||
|
||||
it("hediye çıkışı K/Z yazmaz, hesap hareketi oluşturmaz", async () => {
|
||||
const before = (await getAsset(ayse, cakir, gold))!.summary;
|
||||
const r = await addMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
mv({ kind: "gift_out", quantity: "1", note: "Yeğene" }),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
const m = await AssetMovementModel.findById(r.ok ? r.data.id : "").lean();
|
||||
expect(m).toMatchObject({ amountMinor: 0, transactionId: null });
|
||||
const after = (await getAsset(ayse, cakir, gold))!.summary;
|
||||
expect(after.quantity).toBe(9_000);
|
||||
expect(after.realizedMinor).toBe(before.realizedMinor);
|
||||
expect(after.avgCostMinor).toBe(before.avgCostMinor);
|
||||
});
|
||||
|
||||
it("bağlı işlem tek başına düzenlenemez/silinemez; hareket silinince işlem de silinir", async () => {
|
||||
const tx = (await AssetMovementModel.findOne({ assetId: gold, kind: "sell" }).lean())!;
|
||||
const txId = String(tx.transactionId);
|
||||
const locked = { ok: false, error: tr.transactions.assetLocked };
|
||||
expect(await setTransactionDeleted(ayse, cakir, txId, true)).toMatchObject(locked);
|
||||
const edit = transactionFormSchema.parse({
|
||||
type: "transfer",
|
||||
date: today,
|
||||
amount: "1",
|
||||
accountId: await acc("Cüzdan"),
|
||||
toAccountId: await acc("Ziraat Maaş"),
|
||||
});
|
||||
expect(await updateTransaction(ayse, cakir, txId, edit)).toMatchObject(locked);
|
||||
|
||||
const cuzdan = await acc("Cüzdan");
|
||||
const c0 = await balance(cuzdan);
|
||||
expect((await deleteMovement(ayse, cakir, String(tx._id))).ok).toBe(true);
|
||||
expect(await balance(cuzdan)).toBe(c0 - 12_000_00);
|
||||
expect(
|
||||
await AuditLogModel.exists({ action: "asset.movement.delete", targetId: String(tx._id) }),
|
||||
).toBeTruthy();
|
||||
expect((await getAsset(ayse, cakir, gold))!.summary.realizedMinor).toBe(0);
|
||||
});
|
||||
|
||||
it("silinince sonraki çıkış eldekini aşacaksa reddedilir; hareketli varlık silinemez", async () => {
|
||||
const first = (await AssetMovementModel.findOne({ assetId: gold, kind: "buy" })
|
||||
.sort({ date: 1 })
|
||||
.lean())!;
|
||||
// Kalan: 10 + 2 + 2 alım, bugün 1 gr çıkış. Sonraki alımlar silinebilir; ilk alım silinirse
|
||||
// çıkış eldekini aşar → reddedilir.
|
||||
const buys = await AssetMovementModel.find({ assetId: gold, kind: "buy", deletedAt: null })
|
||||
.sort({ date: -1 })
|
||||
.lean();
|
||||
expect(buys.at(-1)!._id.equals(first._id)).toBe(true);
|
||||
for (const b of buys.slice(0, -1))
|
||||
expect((await deleteMovement(ayse, cakir, String(b._id))).ok).toBe(true);
|
||||
expect((await deleteMovement(ayse, cakir, String(first._id))).ok).toBe(false);
|
||||
expect((await deleteAsset(ayse, cakir, gold)).ok).toBe(false);
|
||||
expect(
|
||||
(await updateAsset(ayse, cakir, gold, assetFormSchema.parse({ assetKey: "usd", name: "X" })))
|
||||
.ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("gelen hediye: maliyet o günkü alış fiyatı, yatırım sayılmaz", async () => {
|
||||
const z = await asset("Düğün ziynetleri", "quarter_gold");
|
||||
await savePrice(
|
||||
ayse,
|
||||
cakir,
|
||||
assetPriceFormSchema.parse({ assetKey: "quarter_gold", date: daysAgo(100), buy: "5.000" }),
|
||||
);
|
||||
expect(
|
||||
movementFormSchema.safeParse({
|
||||
kind: "gift_in",
|
||||
date: today,
|
||||
quantity: "1",
|
||||
accountId: await acc("Cüzdan"),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
(await addMovement(ayse, cakir, z, mv({ kind: "gift_in", date: daysAgo(90), quantity: "4" })))
|
||||
.ok,
|
||||
).toBe(true);
|
||||
const v = (await getAsset(ayse, cakir, z))!;
|
||||
expect(v.summary).toMatchObject({ quantity: 4_000, costMinor: 20_000_00, investedMinor: 0 });
|
||||
// Güncel değer: bugünden önceki en yakın fiyat.
|
||||
expect(v.summary.currentValueMinor).toBe(20_000_00);
|
||||
});
|
||||
});
|
||||
|
||||
describe("görünürlük ve yetki", () => {
|
||||
it("gizli varlık ve bağlı işlemi başkasına görünmez; izleyici yazamaz; başka hane göremez", async () => {
|
||||
const secret = await asset("Emre dolar", "usd", true, emre);
|
||||
await savePrice(
|
||||
emre,
|
||||
cakir,
|
||||
assetPriceFormSchema.parse({ assetKey: "usd", date: today, buy: "40" }),
|
||||
);
|
||||
const r = await addMovement(
|
||||
emre,
|
||||
cakir,
|
||||
secret,
|
||||
mv({ kind: "buy", quantity: "100", amount: "4.000", accountId: await acc("Cüzdan") }),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
expect((await listAssets(emre, cakir))!.items.some((a) => a.id === secret)).toBe(true);
|
||||
expect((await listAssets(ayse, cakir))!.items.some((a) => a.id === secret)).toBe(false);
|
||||
expect(await getAsset(ayse, cakir, secret)).toBeNull();
|
||||
const ayseTx = (await listTransactions(
|
||||
ayse,
|
||||
cakir,
|
||||
transactionFilterSchema.parse({ q: "Emre dolar" }),
|
||||
))!;
|
||||
expect(ayseTx.total).toBe(0);
|
||||
expect(
|
||||
(await addMovement(ayse, cakir, secret, mv({ kind: "gift_out", quantity: "1" }))).ok,
|
||||
).toBe(false);
|
||||
|
||||
const shared = (await listAssets(ayse, cakir))!.items[0]!.id;
|
||||
expect(
|
||||
(await addMovement(zeynep, cakir, shared, mv({ kind: "gift_out", quantity: "1" }))).ok,
|
||||
).toBe(false);
|
||||
expect((await listAssets(zeynep, cakir))!.canWrite).toBe(false);
|
||||
expect(await listAssets(fatma, cakir)).toBeNull();
|
||||
expect(await getAsset(fatma, cakir, shared)).toBeNull();
|
||||
});
|
||||
|
||||
it("toplamlar: fiyatı olmayan varlık toplam değere katılmaz", async () => {
|
||||
const eur = await asset("Euro kumbara", "eur");
|
||||
await addMovement(ayse, cakir, eur, mv({ kind: "gift_in", quantity: "50", amount: "2.400" }));
|
||||
const data = (await listAssets(ayse, cakir))!;
|
||||
expect(data.totals.missingPrice).toBe(1);
|
||||
const withPrice = data.items.filter((i) => i.summary.currentValueMinor !== null);
|
||||
expect(data.totals.valueMinor).toBe(
|
||||
withPrice.reduce((a, i) => a + (i.summary.currentValueMinor ?? 0), 0),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,543 @@
|
||||
import "server-only";
|
||||
import mongoose, { Types } from "mongoose";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { buyPriceLookup, priceOn, type PriceQuote } from "@/features/in-kind/prices";
|
||||
import { loadContext, type Ctx } from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { AssetModel } from "@/models/asset";
|
||||
import { AssetMovementModel } from "@/models/asset-movement";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { formatAssetQuantity, valueOf, type AssetKey } from "@/shared/assets";
|
||||
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||
import {
|
||||
holdingSummary,
|
||||
periodReport,
|
||||
runMovements,
|
||||
sumReports,
|
||||
type HoldingSummary,
|
||||
type MovementKind,
|
||||
type PeriodReport,
|
||||
} from "@/shared/portfolio";
|
||||
import { addDays, addMonths } from "@/shared/recurrence";
|
||||
import { QUANTITY_SCALE } from "@/shared/quantity";
|
||||
import type { AssetForm, MovementForm } from "@/shared/schemas/asset";
|
||||
import { compareTr, foldTr } from "@/shared/search";
|
||||
|
||||
/**
|
||||
* Birikim ve varlık takibi (Bölüm 6.6). Okuma: viewer; yazma: member. Varlıklar hane ortaktır;
|
||||
* "yalnızca ben" işaretli varlık ve hareketleri (bağlı hesap işlemleri dahil) yalnızca sahibine
|
||||
* görünür. Değerleme alış fiyatıyla (Faz 0). Alım/satış hesaba bağlıysa transfer işlemi oluşur.
|
||||
*/
|
||||
|
||||
const t = tr.assets;
|
||||
|
||||
type AssetDoc = {
|
||||
_id: Types.ObjectId;
|
||||
assetKey: AssetKey;
|
||||
name: string;
|
||||
note: string;
|
||||
isPrivate: boolean;
|
||||
ownerUserId?: Types.ObjectId | null;
|
||||
};
|
||||
|
||||
type MovementDoc = {
|
||||
_id: Types.ObjectId;
|
||||
assetId: Types.ObjectId;
|
||||
kind: MovementKind;
|
||||
date: string;
|
||||
quantity: number;
|
||||
unitPriceMinor?: number | null;
|
||||
amountMinor: number;
|
||||
accountId?: Types.ObjectId | null;
|
||||
transactionId?: Types.ObjectId | null;
|
||||
storage: string;
|
||||
note: string;
|
||||
inKindDebtId?: Types.ObjectId | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type AssetView = {
|
||||
id: string;
|
||||
assetKey: AssetKey;
|
||||
name: string;
|
||||
note: string;
|
||||
isPrivate: boolean;
|
||||
label: string;
|
||||
price: PriceQuote | null;
|
||||
summary: HoldingSummary;
|
||||
/** Son alımın saklama yeri. */
|
||||
storage: string;
|
||||
movementCount: number;
|
||||
};
|
||||
|
||||
export type AssetTotals = {
|
||||
valueMinor: number;
|
||||
costMinor: number;
|
||||
unrealizedMinor: number;
|
||||
realizedMinor: number;
|
||||
/** Fiyatı olmadığı için toplam değere katılmayan (elde miktarı olan) varlık sayısı. */
|
||||
missingPrice: number;
|
||||
};
|
||||
|
||||
export type MovementRow = {
|
||||
id: string;
|
||||
kind: MovementKind;
|
||||
date: string;
|
||||
quantity: number;
|
||||
unitPriceMinor: number | null;
|
||||
amountMinor: number;
|
||||
account: string | null;
|
||||
storage: string;
|
||||
note: string;
|
||||
costOutMinor: number;
|
||||
realizedMinor: number;
|
||||
quantityAfter: number;
|
||||
fromInKind: boolean;
|
||||
};
|
||||
|
||||
export type AssetDetail = AssetView & { movements: MovementRow[]; canWrite: boolean };
|
||||
|
||||
const sortMovements = (a: MovementDoc, b: MovementDoc) =>
|
||||
a.date === b.date ? a.createdAt.getTime() - b.createdAt.getTime() : a.date < b.date ? -1 : 1;
|
||||
|
||||
function visibleAsset(ctx: Ctx, id: Types.ObjectId | string) {
|
||||
const a = ctx.assetMap.get(String(id));
|
||||
return a && !a.hidden && !a.deleted ? a : null;
|
||||
}
|
||||
|
||||
async function loadMovements(householdId: Types.ObjectId, assetIds: Types.ObjectId[]) {
|
||||
const docs = await AssetMovementModel.find({
|
||||
householdId,
|
||||
assetId: { $in: assetIds },
|
||||
deletedAt: null,
|
||||
}).lean<MovementDoc[]>();
|
||||
const by = new Map<string, MovementDoc[]>();
|
||||
for (const m of docs) {
|
||||
const list = by.get(String(m.assetId)) ?? [];
|
||||
list.push(m);
|
||||
by.set(String(m.assetId), list);
|
||||
}
|
||||
for (const list of by.values()) list.sort(sortMovements);
|
||||
return by;
|
||||
}
|
||||
|
||||
function toView(a: AssetDoc, movements: MovementDoc[], price: PriceQuote | null): AssetView {
|
||||
const run = runMovements(movements);
|
||||
const summary = holdingSummary(run, price?.buyMinor ?? null);
|
||||
const storage = [...movements].reverse().find((m) => m.storage)?.storage ?? "";
|
||||
return {
|
||||
id: String(a._id),
|
||||
assetKey: a.assetKey,
|
||||
name: a.name,
|
||||
note: a.note,
|
||||
isPrivate: a.isPrivate,
|
||||
label: formatAssetQuantity(a.assetKey, summary.quantity),
|
||||
price,
|
||||
summary,
|
||||
storage,
|
||||
movementCount: movements.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Görünür varlıklar ve toplamlar (net varlık ve dashboard da kullanır). */
|
||||
export async function listAssets(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<{ items: AssetView[]; totals: AssetTotals; canWrite: boolean } | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
return { ...(await assetViews(ctx)), canWrite: hasHouseholdRole(ctx.m.role, "member") };
|
||||
}
|
||||
|
||||
export async function assetViews(ctx: Ctx, date = todayIstanbul()) {
|
||||
const docs = (
|
||||
await AssetModel.find({ householdId: ctx.m.householdId, deletedAt: null }).lean<AssetDoc[]>()
|
||||
).filter((a) => visibleAsset(ctx, a._id));
|
||||
const movements = await loadMovements(
|
||||
ctx.m.householdId,
|
||||
docs.map((a) => a._id),
|
||||
);
|
||||
const prices = new Map<string, PriceQuote | null>();
|
||||
for (const key of new Set(docs.map((a) => a.assetKey)))
|
||||
prices.set(key, await priceOn(ctx.m.householdId, key, date));
|
||||
const items = docs
|
||||
.map((a) => toView(a, movements.get(String(a._id)) ?? [], prices.get(a.assetKey) ?? null))
|
||||
.sort(
|
||||
(x, y) =>
|
||||
Number(y.summary.quantity > 0) - Number(x.summary.quantity > 0) ||
|
||||
(y.summary.currentValueMinor ?? 0) - (x.summary.currentValueMinor ?? 0) ||
|
||||
compareTr(x.name, y.name),
|
||||
);
|
||||
const totals: AssetTotals = {
|
||||
valueMinor: 0,
|
||||
costMinor: 0,
|
||||
unrealizedMinor: 0,
|
||||
realizedMinor: 0,
|
||||
missingPrice: 0,
|
||||
};
|
||||
for (const v of items) {
|
||||
totals.realizedMinor += v.summary.realizedMinor;
|
||||
if (v.summary.quantity === 0) continue;
|
||||
if (v.summary.currentValueMinor === null) {
|
||||
totals.missingPrice++;
|
||||
continue;
|
||||
}
|
||||
totals.valueMinor += v.summary.currentValueMinor;
|
||||
totals.costMinor += v.summary.costMinor;
|
||||
totals.unrealizedMinor += v.summary.unrealizedMinor ?? 0;
|
||||
}
|
||||
return { items, totals };
|
||||
}
|
||||
|
||||
async function loadAsset(ctx: Ctx, assetId: string) {
|
||||
const id = toObjectId(assetId);
|
||||
if (!id || !visibleAsset(ctx, id)) return null;
|
||||
return AssetModel.findOne({
|
||||
_id: id,
|
||||
householdId: ctx.m.householdId,
|
||||
deletedAt: null,
|
||||
}).lean<AssetDoc>();
|
||||
}
|
||||
|
||||
export async function getAsset(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
assetId: string,
|
||||
): Promise<AssetDetail | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const a = await loadAsset(ctx, assetId);
|
||||
if (!a) return null;
|
||||
const movements = (await loadMovements(ctx.m.householdId, [a._id])).get(String(a._id)) ?? [];
|
||||
const price = await priceOn(ctx.m.householdId, a.assetKey, todayIstanbul());
|
||||
const run = runMovements(movements);
|
||||
const rows = movements.map((m, i): MovementRow => {
|
||||
const acc = m.accountId ? ctx.accountMap.get(String(m.accountId)) : undefined;
|
||||
const step = run.steps[i]!;
|
||||
return {
|
||||
id: String(m._id),
|
||||
kind: m.kind,
|
||||
date: m.date,
|
||||
quantity: m.quantity,
|
||||
unitPriceMinor: m.unitPriceMinor ?? null,
|
||||
amountMinor: m.amountMinor,
|
||||
account: acc ? (acc.hidden ? tr.transactions.list.hiddenAccount : acc.name) : null,
|
||||
storage: m.storage,
|
||||
note: m.note,
|
||||
costOutMinor: step.costOutMinor,
|
||||
realizedMinor: step.realizedMinor,
|
||||
quantityAfter: step.quantityAfter,
|
||||
fromInKind: Boolean(m.inKindDebtId),
|
||||
};
|
||||
});
|
||||
return {
|
||||
...toView(a, movements, price),
|
||||
movements: rows.reverse(),
|
||||
canWrite: hasHouseholdRole(ctx.m.role, "member"),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Varlık kaydı ---
|
||||
|
||||
export async function createAsset(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: AssetForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const doc = await AssetModel.create({
|
||||
householdId: ctx.m.householdId,
|
||||
assetKey: input.assetKey,
|
||||
name: input.name,
|
||||
note: input.note,
|
||||
isPrivate: input.isPrivate,
|
||||
ownerUserId: input.isPrivate ? actor : null,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
export async function updateAsset(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
assetId: string,
|
||||
input: AssetForm,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const a = await loadAsset(ctx, assetId);
|
||||
if (!a) return fail(tr.common.notFound);
|
||||
if (
|
||||
input.assetKey !== a.assetKey &&
|
||||
(await AssetMovementModel.exists({ assetId: a._id, deletedAt: null }))
|
||||
)
|
||||
return fail(t.errors.assetKeyLocked, { assetKey: t.errors.assetKeyLocked });
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const owner = a.ownerUserId ?? (input.isPrivate ? actor : null);
|
||||
await AssetModel.updateOne(
|
||||
{ _id: a._id, householdId: ctx.m.householdId },
|
||||
{
|
||||
$set: {
|
||||
assetKey: input.assetKey,
|
||||
name: input.name,
|
||||
note: input.note,
|
||||
isPrivate: input.isPrivate,
|
||||
ownerUserId: input.isPrivate ? owner : null,
|
||||
updatedBy: actor,
|
||||
},
|
||||
},
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
export async function deleteAsset(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
assetId: string,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const a = await loadAsset(ctx, assetId);
|
||||
if (!a) return fail(tr.common.notFound);
|
||||
if (await AssetMovementModel.exists({ assetId: a._id, deletedAt: null }))
|
||||
return fail(t.errors.hasMovements);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await AssetModel.updateOne({ _id: a._id }, { $set: { deletedAt: new Date(), updatedBy: actor } });
|
||||
await writeAudit({
|
||||
action: "asset.delete",
|
||||
actorId: actor,
|
||||
householdId: ctx.m.householdId,
|
||||
targetType: "asset",
|
||||
targetId: String(a._id),
|
||||
diff: { name: a.name, assetKey: a.assetKey },
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Hareketler ---
|
||||
|
||||
function checkAccount(ctx: Ctx, id: string | null) {
|
||||
if (!id) return null;
|
||||
const a = ctx.accountMap.get(id);
|
||||
return a && !a.hidden && !a.archived ? a : null;
|
||||
}
|
||||
|
||||
/** Tutar ve birim fiyatı tamamlar (bkz. movementFormSchema). */
|
||||
async function resolveAmount(
|
||||
householdId: Types.ObjectId,
|
||||
assetKey: AssetKey,
|
||||
input: MovementForm,
|
||||
): Promise<{ unitPriceMinor: number | null; amountMinor: number } | null> {
|
||||
if (input.kind === "gift_out") return { unitPriceMinor: null, amountMinor: 0 };
|
||||
if (input.amount !== null)
|
||||
return {
|
||||
amountMinor: input.amount,
|
||||
unitPriceMinor:
|
||||
input.unitPrice ?? Math.round((input.amount * QUANTITY_SCALE) / input.quantity),
|
||||
};
|
||||
let unit = input.unitPrice;
|
||||
if (unit === null) {
|
||||
const p = await priceOn(householdId, assetKey, input.date);
|
||||
// Alımda kurumun satış fiyatı ödenir; satış ve hediye değeri alış fiyatıyla.
|
||||
unit = p ? (input.kind === "buy" ? p.sellMinor : p.buyMinor) : null;
|
||||
}
|
||||
if (unit === null) return null;
|
||||
return { unitPriceMinor: unit, amountMinor: valueOf(input.quantity, unit) };
|
||||
}
|
||||
|
||||
export async function addMovement(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
assetId: string,
|
||||
input: MovementForm,
|
||||
opts: { inKindDebtId?: Types.ObjectId; session?: mongoose.ClientSession } = {},
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const a = await loadAsset(ctx, assetId);
|
||||
if (!a) return fail(tr.common.notFound);
|
||||
if (input.date > todayIstanbul()) return fail(t.errors.futureDate, { date: t.errors.futureDate });
|
||||
const date = istanbulDayStart(input.date);
|
||||
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
|
||||
const account = checkAccount(ctx, input.accountId);
|
||||
if (input.accountId && !account)
|
||||
return fail(tr.transactions.validation.accountInvalid, {
|
||||
accountId: tr.transactions.validation.accountInvalid,
|
||||
});
|
||||
const amount = await resolveAmount(ctx.m.householdId, a.assetKey, input);
|
||||
if (!amount) return fail(t.errors.noPrice, { amount: t.errors.noPrice });
|
||||
|
||||
// Eldeki miktar: yeni hareket aynı günün sonuna eklenmiş gibi doğrulanır.
|
||||
const existing = (await loadMovements(ctx.m.householdId, [a._id])).get(String(a._id)) ?? [];
|
||||
const now = new Date();
|
||||
const candidate = [
|
||||
...existing,
|
||||
{ kind: input.kind, date: input.date, quantity: input.quantity, createdAt: now } as MovementDoc,
|
||||
]
|
||||
.map((m) => ({ ...m, amountMinor: m.amountMinor ?? 0 }))
|
||||
.sort(sortMovements);
|
||||
if (runMovements(candidate).negativeAt !== null)
|
||||
return fail(t.errors.insufficient, { quantity: t.errors.insufficient });
|
||||
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const movementId = new Types.ObjectId();
|
||||
const label = formatAssetQuantity(a.assetKey, input.quantity);
|
||||
const write = async (session: mongoose.ClientSession) => {
|
||||
let transactionId: Types.ObjectId | null = null;
|
||||
if (account && amount.amountMinor > 0 && (input.kind === "buy" || input.kind === "sell")) {
|
||||
const out = input.kind === "buy";
|
||||
const description = t.txDescription[input.kind](label, a.name);
|
||||
const [tx] = await TransactionModel.create(
|
||||
[
|
||||
{
|
||||
householdId: ctx.m.householdId,
|
||||
type: "transfer",
|
||||
status: "posted",
|
||||
date,
|
||||
// Alım hesaptan çıkar (varlığa); satış hesaba girer (varlıktan).
|
||||
accountId: out ? account.id : null,
|
||||
toAccountId: out ? null : account.id,
|
||||
amountMinor: amount.amountMinor,
|
||||
description,
|
||||
searchText: foldTr(description),
|
||||
assetId: a._id,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
},
|
||||
],
|
||||
{ session },
|
||||
);
|
||||
transactionId = tx!._id;
|
||||
}
|
||||
await AssetMovementModel.create(
|
||||
[
|
||||
{
|
||||
_id: movementId,
|
||||
householdId: ctx.m.householdId,
|
||||
assetId: a._id,
|
||||
kind: input.kind,
|
||||
date: input.date,
|
||||
quantity: input.quantity,
|
||||
unitPriceMinor: amount.unitPriceMinor,
|
||||
amountMinor: amount.amountMinor,
|
||||
accountId: account && transactionId ? account.id : null,
|
||||
transactionId,
|
||||
storage: input.storage,
|
||||
note: input.note,
|
||||
inKindDebtId: opts.inKindDebtId ?? null,
|
||||
createdBy: actor,
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
{ session },
|
||||
);
|
||||
};
|
||||
if (opts.session) await write(opts.session);
|
||||
else await mongoose.connection.transaction(write);
|
||||
return ok({ id: String(movementId) });
|
||||
}
|
||||
|
||||
export async function deleteMovement(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
movementId: string,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(movementId);
|
||||
if (!id) return fail(tr.common.notFound);
|
||||
const m = await AssetMovementModel.findOne({
|
||||
_id: id,
|
||||
householdId: ctx.m.householdId,
|
||||
deletedAt: null,
|
||||
}).lean<MovementDoc>();
|
||||
if (!m || !visibleAsset(ctx, m.assetId)) return fail(tr.common.notFound);
|
||||
const rest = (
|
||||
(await loadMovements(ctx.m.householdId, [m.assetId])).get(String(m.assetId)) ?? []
|
||||
).filter((x) => !x._id.equals(m._id));
|
||||
if (runMovements(rest).negativeAt !== null) return fail(t.errors.deleteBreaks);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
const now = new Date();
|
||||
await AssetMovementModel.updateOne({ _id: m._id }, { $set: { deletedAt: now } }, { session });
|
||||
if (m.transactionId)
|
||||
await TransactionModel.updateOne(
|
||||
{ _id: m.transactionId, householdId: ctx.m.householdId },
|
||||
{ $set: { deletedAt: now, updatedBy: actor } },
|
||||
{ session },
|
||||
);
|
||||
await writeAudit(
|
||||
{
|
||||
action: "asset.movement.delete",
|
||||
actorId: actor,
|
||||
householdId: ctx.m.householdId,
|
||||
targetType: "assetMovement",
|
||||
targetId: String(m._id),
|
||||
diff: { kind: m.kind, quantity: m.quantity, amountMinor: m.amountMinor, date: m.date },
|
||||
},
|
||||
session,
|
||||
);
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Dönemsel rapor ---
|
||||
|
||||
export const REPORT_RANGES = ["3", "6", "12", "tum"] as const;
|
||||
export type ReportRange = (typeof REPORT_RANGES)[number];
|
||||
|
||||
/**
|
||||
* Seçilen aralıkta (son N ay veya ilk hareketten bugüne) yatırılan TL, dönem sonu değer,
|
||||
* kâr/zarar ve aylık değer noktaları. `assetId` verilirse yalnızca o varlık, yoksa tüm görünür
|
||||
* varlıkların toplamı.
|
||||
*/
|
||||
export async function getPeriodReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
range: ReportRange,
|
||||
assetId?: string,
|
||||
): Promise<(PeriodReport & { excluded: number }) | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
let docs: AssetDoc[];
|
||||
if (assetId) {
|
||||
const a = await loadAsset(ctx, assetId);
|
||||
if (!a) return null;
|
||||
docs = [a];
|
||||
} else {
|
||||
docs = (
|
||||
await AssetModel.find({ householdId: ctx.m.householdId, deletedAt: null }).lean<AssetDoc[]>()
|
||||
).filter((a) => visibleAsset(ctx, a._id));
|
||||
}
|
||||
const movements = await loadMovements(
|
||||
ctx.m.householdId,
|
||||
docs.map((a) => a._id),
|
||||
);
|
||||
const to = todayIstanbul();
|
||||
let from: string;
|
||||
if (range === "tum") {
|
||||
const first = [...movements.values()]
|
||||
.flat()
|
||||
.map((m) => m.date)
|
||||
.sort()[0];
|
||||
from = first && first < to ? first : addDays(to, -1);
|
||||
} else from = addMonths(to, -Number(range));
|
||||
const price = await buyPriceLookup(
|
||||
ctx.m.householdId,
|
||||
[...new Set(docs.map((a) => a.assetKey))],
|
||||
to,
|
||||
);
|
||||
const reports = docs.map((a) =>
|
||||
periodReport(movements.get(String(a._id)) ?? [], (date) => price(a.assetKey, date), from, to),
|
||||
);
|
||||
if (assetId) return { ...reports[0]!, excluded: 0 };
|
||||
// Toplamda fiyatı eksik varlıklar (kâr hesaplanamayan) dışarıda bırakılır ve sayılır.
|
||||
const priced = reports.filter((r) => r.profitMinor !== null);
|
||||
return { ...sumReports(priced, from, to), excluded: reports.length - priced.length };
|
||||
}
|
||||
@@ -245,7 +245,7 @@ describe("işleme bağlama", () => {
|
||||
cakir,
|
||||
txId,
|
||||
await form({
|
||||
accountId: detail!.account.id,
|
||||
accountId: detail!.account!.id,
|
||||
categoryId: detail!.categoryId,
|
||||
attachmentIds: [a3.id, a4.id],
|
||||
}),
|
||||
|
||||
@@ -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,257 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts";
|
||||
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 { 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 dayFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: "Europe/Istanbul",
|
||||
});
|
||||
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Istanbul",
|
||||
});
|
||||
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||
const signed = (minor: number) => `${minor < 0 ? "−" : ""}${formatMoney(Math.abs(minor))}`;
|
||||
|
||||
const config = {
|
||||
planned: { label: t.cumPlanned, color: "var(--budget)" },
|
||||
actual: { label: t.cumActual, color: "var(--expense)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
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ı, kümülatif grafik ve gelirle karşılaştırma. */
|
||||
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 chart = p.days.map((day, i) => ({
|
||||
date: day.date,
|
||||
label: dayFmt.format(new Date(`${day.date}T12:00:00+03:00`)),
|
||||
planned: day.cumPlannedMinor / 100,
|
||||
// Gerçekleşen yalnızca bugüne kadar çizilir.
|
||||
actual: p.todayIndex === -1 || i <= p.todayIndex ? day.cumActualMinor / 100 : null,
|
||||
}));
|
||||
const income = data.income.expectedMinor / 100;
|
||||
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>
|
||||
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="aspect-auto h-56 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={24}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickFormatter={(v: number) => compact.format(v)}
|
||||
/>
|
||||
{income > 0 ? (
|
||||
<ReferenceLine
|
||||
y={income}
|
||||
stroke="var(--income)"
|
||||
strokeDasharray="4 4"
|
||||
label={{
|
||||
value: `${t.income} ${compact.format(income)}`,
|
||||
position: "insideTopLeft",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const date = payload?.[0]?.payload?.date as string | undefined;
|
||||
return date ? d(date) : "";
|
||||
}}
|
||||
formatter={(value, name) => (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{config[name as keyof typeof config]?.label}
|
||||
</span>
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{formatMoney(Math.round(Number(value) * 100))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<Line
|
||||
dataKey="planned"
|
||||
type="linear"
|
||||
stroke="var(--color-planned)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="actual"
|
||||
type="linear"
|
||||
stroke="var(--color-actual)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
|
||||
<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,261 @@
|
||||
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 }[];
|
||||
};
|
||||
|
||||
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ü). */
|
||||
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 };
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/** 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, byDay, income, nextIncome, nextBudget] = await Promise.all([
|
||||
getBudget(userId, householdId, month),
|
||||
expensesByDay(ctx, s, period.from, period.to),
|
||||
incomePlan(ctx, s, month),
|
||||
incomePlan(ctx, s, nextMonth),
|
||||
getBudget(userId, householdId, nextMonth),
|
||||
]);
|
||||
if (!budget) return null;
|
||||
|
||||
let weights: number[] | undefined;
|
||||
if (mode === "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),
|
||||
);
|
||||
}
|
||||
const plan = dailyPlan({
|
||||
from: period.from,
|
||||
to: period.to,
|
||||
today,
|
||||
budgetMinor: budget.totals.limitMinor,
|
||||
fixedByDay: byDay.fixed,
|
||||
actualByDay: byDay.actual,
|
||||
weights,
|
||||
});
|
||||
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,413 @@
|
||||
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;
|
||||
const s = await settingsOf(ctx.m.householdId);
|
||||
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,88 @@
|
||||
"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 { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact";
|
||||
import {
|
||||
createContact,
|
||||
createContactEntry,
|
||||
setContactArchived,
|
||||
updateContact,
|
||||
updateContactEntry,
|
||||
} 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;
|
||||
}
|
||||
|
||||
function revalidate() {
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
export async function saveContactAction(
|
||||
contactId: unknown,
|
||||
input: unknown,
|
||||
): Promise<ActionResult<unknown>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const parsed = contactFormSchema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
let result: ActionResult<unknown>;
|
||||
if (contactId === null || contactId === undefined) {
|
||||
result = await createContact(ctx.user.id, ctx.household.id, parsed.data);
|
||||
} else {
|
||||
const id = idSchema.safeParse(contactId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
result = await updateContact(ctx.user.id, ctx.household.id, id.data, parsed.data);
|
||||
}
|
||||
if (result.ok) revalidate();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function setContactArchivedAction(
|
||||
contactId: unknown,
|
||||
archived: unknown,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const id = idSchema.safeParse(contactId);
|
||||
const a = z.boolean().safeParse(archived);
|
||||
if (!id.success || !a.success) return fail(tr.common.notFound);
|
||||
const result = await setContactArchived(ctx.user.id, ctx.household.id, id.data, a.data);
|
||||
if (result.ok) revalidate();
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Yeni hareket (transactionId boş) veya mevcut hareketi düzenle. */
|
||||
export async function saveContactEntryAction(
|
||||
contactId: unknown,
|
||||
transactionId: unknown,
|
||||
input: unknown,
|
||||
): Promise<ActionResult<unknown>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const parsed = contactEntrySchema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
let result: ActionResult<unknown>;
|
||||
if (transactionId) {
|
||||
const tid = idSchema.safeParse(transactionId);
|
||||
if (!tid.success) return fail(tr.common.notFound);
|
||||
result = await updateContactEntry(ctx.user.id, ctx.household.id, tid.data, parsed.data);
|
||||
} else {
|
||||
const cid = idSchema.safeParse(contactId);
|
||||
if (!cid.success) return fail(tr.common.notFound);
|
||||
result = await createContactEntry(ctx.user.id, ctx.household.id, cid.data, parsed.data);
|
||||
}
|
||||
if (result.ok) revalidate();
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { MoneyInput } from "@/components/money-input";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
getTransactionFormOptionsAction,
|
||||
setTransactionDeletedAction,
|
||||
} from "@/features/transactions/actions";
|
||||
import { AccountSelect } from "@/features/transactions/components/transaction-form";
|
||||
import type { TransactionFormOptions, TransactionListItem } from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import {
|
||||
CONTACT_ENTRY_KINDS,
|
||||
CONTACT_RELATIONS,
|
||||
isOutgoing,
|
||||
type ContactEntryKind,
|
||||
type ContactRelation,
|
||||
} from "@/shared/contacts";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { saveContactAction, saveContactEntryAction } from "../actions";
|
||||
import type { ContactView } from "../service";
|
||||
|
||||
const t = tr.contacts;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
|
||||
/** Cari oluştur / düzenle. */
|
||||
export function ContactFormDialog({
|
||||
open,
|
||||
contact,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
contact: ContactView | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
return open ? (
|
||||
<ContactForm key={contact?.id ?? "new"} contact={contact} onClose={() => onOpenChange(false)} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
function ContactForm({ contact, onClose }: { contact: ContactView | null; onClose: () => void }) {
|
||||
const [v, setV] = useState({
|
||||
name: contact?.name ?? "",
|
||||
relation: (contact?.relation ?? "friend") as ContactRelation,
|
||||
phone: contact?.phone ?? "",
|
||||
note: contact?.note ?? "",
|
||||
openingBalance: contact?.openingBalanceMinor
|
||||
? formatMoneyInput(contact.openingBalanceMinor)
|
||||
: "",
|
||||
openingDate: contact?.openingDate ?? todayIstanbul(),
|
||||
dueDate: contact?.dueDate ?? "",
|
||||
reminderDays: String(contact?.reminderDays ?? 3),
|
||||
isPrivate: contact?.isPrivate ?? false,
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, value: (typeof v)[K]) =>
|
||||
setV((x) => ({ ...x, [k]: value }));
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveContactAction(contact?.id ?? null, v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(contact ? t.toast.updated : t.toast.created);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={contact ? t.editTitle : t.createTitle}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="contact-form" disabled={busy}>
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="contact-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.name)}>
|
||||
<FieldLabel htmlFor="ct-name">{f.name}</FieldLabel>
|
||||
<Input
|
||||
id="ct-name"
|
||||
value={v.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ct-relation">{f.relation}</FieldLabel>
|
||||
<Select
|
||||
value={v.relation}
|
||||
onValueChange={(x) => set("relation", x as ContactRelation)}
|
||||
>
|
||||
<SelectTrigger id="ct-relation" className="w-full">
|
||||
<SelectValue>{t.relations[v.relation]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTACT_RELATIONS.map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{t.relations[r]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.phone)}>
|
||||
<FieldLabel htmlFor="ct-phone">{f.phone}</FieldLabel>
|
||||
<Input
|
||||
id="ct-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
value={v.phone}
|
||||
onChange={(e) => set("phone", e.target.value)}
|
||||
aria-invalid={Boolean(errors.phone)}
|
||||
/>
|
||||
{errors.phone ? <FieldError>{errors.phone}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.openingBalance)}>
|
||||
<FieldLabel htmlFor="ct-opening">{f.openingBalance}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="ct-opening"
|
||||
value={v.openingBalance}
|
||||
onChange={(x) => set("openingBalance", x)}
|
||||
allowNegative
|
||||
/>
|
||||
{errors.openingBalance ? <FieldError>{errors.openingBalance}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ct-opening-date">{tr.transactions.fields.date}</FieldLabel>
|
||||
<Input
|
||||
id="ct-opening-date"
|
||||
type="date"
|
||||
value={v.openingDate}
|
||||
onChange={(e) => set("openingDate", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">{f.openingHint}</FieldDescription>
|
||||
<DueFields
|
||||
dueDate={v.dueDate}
|
||||
reminderDays={v.reminderDays}
|
||||
onChange={(d, r) => setV((x) => ({ ...x, dueDate: d, reminderDays: r }))}
|
||||
errors={errors}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ct-note">{f.note}</FieldLabel>
|
||||
<Textarea
|
||||
id="ct-note"
|
||||
rows={2}
|
||||
value={v.note}
|
||||
onChange={(e) => set("note", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={v.isPrivate}
|
||||
onCheckedChange={(x) => set("isPrivate", x === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
{f.isPrivate}
|
||||
</label>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DueFields({
|
||||
dueDate,
|
||||
reminderDays,
|
||||
onChange,
|
||||
errors,
|
||||
}: {
|
||||
dueDate: string;
|
||||
reminderDays: string;
|
||||
onChange: (dueDate: string, reminderDays: string) => void;
|
||||
errors: Errors;
|
||||
}) {
|
||||
const f = t.fields;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.dueDate)}>
|
||||
<FieldLabel htmlFor="ct-due">{f.dueDate}</FieldLabel>
|
||||
<Input
|
||||
id="ct-due"
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => onChange(e.target.value, reminderDays)}
|
||||
aria-describedby="ct-due-hint"
|
||||
/>
|
||||
<FieldDescription id="ct-due-hint">{f.dueHint}</FieldDescription>
|
||||
{errors.dueDate ? <FieldError>{errors.dueDate}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.reminderDays)}>
|
||||
<FieldLabel htmlFor="ct-remind">{f.reminderDays}</FieldLabel>
|
||||
<Input
|
||||
id="ct-remind"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={60}
|
||||
value={reminderDays}
|
||||
onChange={(e) => onChange(dueDate, e.target.value)}
|
||||
disabled={!dueDate}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type EntryMode = {
|
||||
contact: ContactView;
|
||||
entry?: TransactionListItem;
|
||||
kind?: ContactEntryKind;
|
||||
};
|
||||
|
||||
/** Cari hareketi ekle / düzenle (düzenlemede sil). */
|
||||
export function ContactEntryDialog({
|
||||
mode,
|
||||
onClose,
|
||||
}: {
|
||||
mode: EntryMode | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return mode ? (
|
||||
<EntryForm key={mode.entry?.id ?? `new-${mode.kind ?? ""}`} mode={mode} onClose={onClose} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
function EntryForm({ mode, onClose }: { mode: EntryMode; onClose: () => void }) {
|
||||
const { contact, entry } = mode;
|
||||
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
|
||||
const [kind, setKind] = useState<ContactEntryKind>(
|
||||
entry?.contact?.kind ?? mode.kind ?? (contact.balanceMinor < 0 ? "repay" : "lend"),
|
||||
);
|
||||
const [v, setV] = useState({
|
||||
accountId: entry?.account?.id ?? entry?.toAccount?.id ?? "",
|
||||
amount: entry ? formatMoneyInput(entry.amountMinor) : "",
|
||||
date: entry?.date ?? todayIstanbul(),
|
||||
description: entry?.description ?? "",
|
||||
dueDate: contact.dueDate ?? "",
|
||||
reminderDays: String(contact.reminderDays),
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getTransactionFormOptionsAction().then((r) => {
|
||||
if (cancelled || !r.ok) return;
|
||||
setOptions(r.data);
|
||||
setV((x) => (x.accountId ? x : { ...x, accountId: r.data.accounts[0]?.id ?? "" }));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const accounts = (() => {
|
||||
const list = [...(options?.accounts ?? [])];
|
||||
const current = entry?.account ?? entry?.toAccount;
|
||||
if (current && !list.some((a) => a.id === current.id))
|
||||
list.push({ ...current, type: "bank", icon: "wallet", color: "slate" });
|
||||
return list;
|
||||
})();
|
||||
const createsDebt = kind === "lend" || kind === "borrow";
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const payload = {
|
||||
kind,
|
||||
accountId: v.accountId,
|
||||
amount: v.amount,
|
||||
date: v.date,
|
||||
description: v.description,
|
||||
// Vade yalnızca borç veren/alan harekette güncellenir.
|
||||
...(createsDebt ? { dueDate: v.dueDate, reminderDays: v.reminderDays } : {}),
|
||||
};
|
||||
const r = await saveContactEntryAction(contact.id, entry?.id ?? null, payload);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.entrySaved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!entry) return;
|
||||
const r = await setTransactionDeletedAction(entry.id, true);
|
||||
if (!r.ok) return void toast.error(r.error);
|
||||
onClose();
|
||||
toast.success(tr.transactions.toast.deleted, {
|
||||
duration: 10_000,
|
||||
action: {
|
||||
label: tr.transactions.actions.restore,
|
||||
onClick: async () => {
|
||||
const x = await setTransactionDeletedAction(entry.id, false);
|
||||
if (x.ok) toast.success(tr.transactions.toast.restored);
|
||||
else toast.error(x.error);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${entry ? t.editEntryTitle : t.entryTitle} · ${contact.name}`}
|
||||
footer={
|
||||
<>
|
||||
{entry ? (
|
||||
<Button variant="destructive" type="button" onClick={remove} className="sm:mr-auto">
|
||||
<Trash2 />
|
||||
{tr.transactions.actions.delete}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="contact-entry-form" disabled={busy || !options}>
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="contact-entry-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Tabs value={kind} onValueChange={(x) => setKind(x as ContactEntryKind)}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-2 sm:grid-cols-4">
|
||||
{CONTACT_ENTRY_KINDS.map((k) => (
|
||||
<TabsTrigger key={k} value={k}>
|
||||
{t.kinds[k]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<p className="-mt-2 text-xs text-muted-foreground">{t.kindHints[kind]}</p>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||
<Field data-invalid={Boolean(errors.amount)}>
|
||||
<FieldLabel htmlFor="ce-amount">{tr.transactions.fields.amount}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="ce-amount"
|
||||
value={v.amount}
|
||||
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
||||
aria-invalid={Boolean(errors.amount)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="ce-date">{tr.transactions.fields.date}</FieldLabel>
|
||||
<Input
|
||||
id="ce-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<Field data-invalid={Boolean(errors.accountId)}>
|
||||
<FieldLabel htmlFor="ce-account">
|
||||
{isOutgoing(kind) ? f.fromAccount : f.toAccount}
|
||||
</FieldLabel>
|
||||
<AccountSelect
|
||||
id="ce-account"
|
||||
value={v.accountId}
|
||||
onChange={(x) => setV((s) => ({ ...s, accountId: x }))}
|
||||
accounts={accounts}
|
||||
invalid={Boolean(errors.accountId)}
|
||||
/>
|
||||
{errors.accountId ? <FieldError>{errors.accountId}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ce-description">{tr.transactions.fields.description}</FieldLabel>
|
||||
<Input
|
||||
id="ce-description"
|
||||
value={v.description}
|
||||
onChange={(e) => setV((s) => ({ ...s, description: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
{createsDebt ? (
|
||||
<DueFields
|
||||
dueDate={v.dueDate}
|
||||
reminderDays={v.reminderDays}
|
||||
onChange={(d, r) => setV((s) => ({ ...s, dueDate: d, reminderDays: r }))}
|
||||
errors={errors}
|
||||
/>
|
||||
) : null}
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Lock, Pencil, Phone, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { InKindSection } from "@/features/in-kind/components/in-kind-section";
|
||||
import type { InKindView } from "@/features/in-kind/service";
|
||||
import type { ContactStatement } from "../service";
|
||||
import { ContactEntryDialog, ContactFormDialog, type EntryMode } from "./contact-dialogs";
|
||||
import { ContactBalance, DueBadge } from "./contacts-view";
|
||||
|
||||
const t = tr.contacts;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
|
||||
function Delta({ minor }: { minor: number }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold whitespace-nowrap tabular-nums",
|
||||
minor > 0 ? "text-income" : "text-expense",
|
||||
)}
|
||||
>
|
||||
{minor > 0 ? "+" : "−"}
|
||||
{formatMoney(Math.abs(minor))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cari ekstresi: hareketler (yeniden eskiye) ve yürüyen bakiye (+ bana borçlu, − ben borçluyum). */
|
||||
export function ContactStatementView({
|
||||
data,
|
||||
inKind,
|
||||
}: {
|
||||
data: ContactStatement;
|
||||
inKind: InKindView[];
|
||||
}) {
|
||||
const c = data.contact;
|
||||
const [entry, setEntry] = useState<EntryMode | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const canWrite = data.canWrite && !c.archived;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/cari">
|
||||
<ArrowLeft />
|
||||
{t.title}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader
|
||||
title={c.name}
|
||||
description={t.relations[c.relation]}
|
||||
actions={
|
||||
data.canWrite ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditing(true)}>
|
||||
<Pencil />
|
||||
<span className="hidden sm:inline">{tr.common.edit}</span>
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button onClick={() => setEntry({ contact: c })}>
|
||||
<Plus />
|
||||
{t.newEntry}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<DueBadge contact={c} />
|
||||
{c.phone ? (
|
||||
<a
|
||||
href={`tel:${c.phone}`}
|
||||
className="inline-flex items-center gap-1.5 hover:underline"
|
||||
>
|
||||
<Phone className="size-3.5" aria-hidden />
|
||||
{c.phone}
|
||||
</a>
|
||||
) : null}
|
||||
{c.isPrivate ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
{t.privateBadge}
|
||||
</span>
|
||||
) : null}
|
||||
{c.note ? <p className="text-muted-foreground">{c.note}</p> : null}
|
||||
</div>
|
||||
<ContactBalance minor={c.balanceMinor} className="text-lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<InKindSection contactId={c.id} items={inKind} canWrite={canWrite} />
|
||||
|
||||
<h2 className="text-base font-medium">{t.statement}</h2>
|
||||
{data.rows.length === 0 && c.openingBalanceMinor === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>{t.emptyEntries}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{data.rows.map((r) => {
|
||||
const content = (
|
||||
<>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{tr.contacts.kinds[r.item.contact!.kind]}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{d(r.item.date)} · {r.item.account?.name ?? r.item.toAccount?.name ?? "—"}
|
||||
{r.item.description ? ` · ${r.item.description}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex flex-col items-end">
|
||||
<Delta minor={r.deltaMinor} />
|
||||
<span
|
||||
className="text-xs text-muted-foreground tabular-nums"
|
||||
title={t.runningBalance}
|
||||
>
|
||||
{formatMoney(r.balanceMinor)}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const cls = "flex w-full items-center gap-3 p-3 text-left";
|
||||
return (
|
||||
<li key={r.item.id}>
|
||||
{canWrite ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(cls, "outline-none hover:bg-muted/50 focus-visible:bg-muted")}
|
||||
onClick={() => setEntry({ contact: c, entry: r.item })}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className={cls}>{content}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<li className="flex items-center gap-3 p-3 text-sm text-muted-foreground">
|
||||
<span className="flex-1">
|
||||
{t.opening} · {d(c.openingDate)}
|
||||
</span>
|
||||
<span className="tabular-nums">{formatMoney(c.openingBalanceMinor)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<ContactEntryDialog mode={entry} onClose={() => setEntry(null)} />
|
||||
<ContactFormDialog open={editing} contact={c} onOpenChange={setEditing} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ChevronDown,
|
||||
EllipsisVertical,
|
||||
Lock,
|
||||
Pencil,
|
||||
Plus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { setContactArchivedAction } from "../actions";
|
||||
import type { ContactList, ContactView } from "../service";
|
||||
import { ContactEntryDialog, ContactFormDialog, type EntryMode } from "./contact-dialogs";
|
||||
|
||||
const t = tr.contacts;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
|
||||
export function ContactBalance({ minor, className }: { minor: number; className?: string }) {
|
||||
const label = minor > 0 ? t.balance.owesMe : minor < 0 ? t.balance.iOwe : t.balance.settled;
|
||||
return (
|
||||
<span className={cn("flex flex-col items-end", className)}>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold whitespace-nowrap tabular-nums",
|
||||
minor > 0 && "text-income",
|
||||
minor < 0 && "text-expense",
|
||||
)}
|
||||
>
|
||||
{formatMoney(Math.abs(minor))}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DueBadge({ contact }: { contact: ContactView }) {
|
||||
if (!contact.dueDate || contact.balanceMinor === 0) return null;
|
||||
if (contact.due === "overdue")
|
||||
return <Badge variant="destructive">{t.due.overdue(d(contact.dueDate))}</Badge>;
|
||||
if (contact.due === "upcoming")
|
||||
return <Badge variant="secondary">{t.due.upcoming(d(contact.dueDate))}</Badge>;
|
||||
return <Badge variant="outline">{t.due.on(d(contact.dueDate))}</Badge>;
|
||||
}
|
||||
|
||||
export function ContactsView({
|
||||
data,
|
||||
inKind,
|
||||
canWrite,
|
||||
}: {
|
||||
data: ContactList;
|
||||
/** Açık aynî kayıtlar (cari kimliği → yön ve kalan miktar). */
|
||||
inKind: Record<string, { direction: "debt" | "receivable"; label: string }[]>;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [form, setForm] = useState<{ contact: ContactView | null } | null>(null);
|
||||
const [entry, setEntry] = useState<EntryMode | null>(null);
|
||||
const [archivedOpen, setArchivedOpen] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
const active = data.contacts.filter((c) => !c.archived);
|
||||
const archived = data.contacts.filter((c) => c.archived);
|
||||
|
||||
function archive(c: ContactView, value: boolean) {
|
||||
startTransition(async () => {
|
||||
const r = await setContactArchivedAction(c.id, value);
|
||||
if (r.ok) toast.success(value ? t.toast.archived : t.toast.unarchived);
|
||||
else toast.error(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
const row = (c: ContactView) => (
|
||||
<li key={c.id} className={cn("flex items-center gap-3 p-3", c.archived && "opacity-60")}>
|
||||
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Users className="size-4.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Link href={`/cari/${c.id}`} className="truncate font-medium hover:underline">
|
||||
{c.name}
|
||||
</Link>
|
||||
{c.isPrivate ? (
|
||||
<Lock className="size-3.5 shrink-0 text-muted-foreground" aria-label={t.privateBadge} />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{t.relations[c.relation]}</span>
|
||||
{c.lastActivity ? <span>· {t.lastActivity(d(c.lastActivity))}</span> : null}
|
||||
<DueBadge contact={c} />
|
||||
{(inKind[c.id] ?? []).map((x, i) => (
|
||||
<Badge key={i} variant={x.direction === "debt" ? "destructive" : "secondary"}>
|
||||
{x.label}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<ContactBalance minor={c.balanceMinor} />
|
||||
{canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" aria-label={tr.recurring.actions.more}>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!c.archived ? (
|
||||
<DropdownMenuItem onSelect={() => setEntry({ contact: c })}>
|
||||
<Plus />
|
||||
{t.newEntry}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={() => setForm({ contact: c })}>
|
||||
<Pencil />
|
||||
{tr.common.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => archive(c, !c.archived)}>
|
||||
{c.archived ? <ArchiveRestore /> : <Archive />}
|
||||
{c.archived ? t.unarchive : t.archive}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(
|
||||
[
|
||||
[t.summary.owesMe, data.totals.owesMeMinor, "text-income"],
|
||||
[t.summary.iOwe, data.totals.iOweMinor, "text-expense"],
|
||||
[t.summary.net, data.totals.owesMeMinor - data.totals.iOweMinor, ""],
|
||||
] as const
|
||||
).map(([label, value, tone]) => (
|
||||
<Card key={label} size="sm">
|
||||
<CardHeader>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className={cn("text-base tabular-nums", tone)}>
|
||||
{formatMoney(value)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setForm({ contact: null })}>
|
||||
<Plus />
|
||||
{t.new}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{active.length === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>{t.empty}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border bg-card">{active.map(row)}</ul>
|
||||
)}
|
||||
{archived.length ? (
|
||||
<Collapsible open={archivedOpen} onOpenChange={setArchivedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit">
|
||||
<ChevronDown className={cn("transition-transform", archivedOpen && "rotate-180")} />
|
||||
{t.archivedSection(archived.length)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<ul className="divide-y rounded-xl border bg-card">{archived.map(row)}</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<ContactFormDialog
|
||||
open={Boolean(form)}
|
||||
contact={form?.contact ?? null}
|
||||
onOpenChange={(o) => !o && setForm(null)}
|
||||
/>
|
||||
<ContactEntryDialog mode={entry} onClose={() => setEntry(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { HandCoins } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { countDueContacts } from "../service";
|
||||
|
||||
/** Dashboard: vadesi geçmiş veya yaklaşan cari borç/alacak uyarısı (Faz 8 kararı: uygulama içi). */
|
||||
export async function ContactsDueBanner({
|
||||
userId,
|
||||
householdId,
|
||||
}: {
|
||||
userId: string;
|
||||
householdId: string;
|
||||
}) {
|
||||
const n = await countDueContacts(userId, householdId);
|
||||
if (!n) return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-wrap items-center gap-3 rounded-xl border border-budget/30 bg-budget/10 p-3 text-sm"
|
||||
>
|
||||
<HandCoins className="size-4 shrink-0 text-budget" aria-hidden />
|
||||
<span className="flex-1">{tr.contacts.dueBanner(n)}</span>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href="/cari">{tr.contacts.dueLink}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { listAccounts } from "@/features/accounts/service";
|
||||
import {
|
||||
getTransaction,
|
||||
listTransactions,
|
||||
updateTransaction,
|
||||
} from "@/features/transactions/service";
|
||||
import { currentMonth, getAccountStatement } from "@/features/transactions/statement";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import {
|
||||
contactEntrySchema,
|
||||
contactFormSchema,
|
||||
type ContactEntryInput,
|
||||
type ContactFormInput,
|
||||
} from "@/shared/schemas/contact";
|
||||
import { transactionFilterSchema } from "@/shared/schemas/transaction";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
countDueContacts,
|
||||
createContact,
|
||||
createContactEntry,
|
||||
getContactStatement,
|
||||
listContacts,
|
||||
setContactArchived,
|
||||
updateContact,
|
||||
updateContactEntry,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let emre = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
const today = todayIstanbul();
|
||||
|
||||
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);
|
||||
|
||||
const acc = (name: string) =>
|
||||
AccountModel.findOne({ householdId: cakir, name })
|
||||
.lean()
|
||||
.then((a) => String(a!._id));
|
||||
const contactForm = (over: Partial<ContactFormInput> = {}) =>
|
||||
contactFormSchema.parse({ name: "Test Kişi", openingDate: today, ...over });
|
||||
const entry = (over: Partial<ContactEntryInput>) =>
|
||||
contactEntrySchema.parse({ kind: "lend", amount: "1000", date: today, ...over });
|
||||
|
||||
async function balance(userId: string, accountId: string) {
|
||||
return (await listAccounts(userId, cakir))!.accounts.find((a) => a.id === accountId)!
|
||||
.balanceMinor!;
|
||||
}
|
||||
async function contactBalance(userId: string, id: string) {
|
||||
return (await listContacts(userId, cakir))!.contacts.find((c) => c.id === id)!.balanceMinor;
|
||||
}
|
||||
const sums = async () =>
|
||||
(await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums;
|
||||
|
||||
describe("cari defteri", () => {
|
||||
let ahmet = "";
|
||||
let lendId = "";
|
||||
|
||||
it("borç verme ve tahsilat: hesap bakiyeleri değişir, gelir/gider toplamlarına girmez", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const cuzdan = await acc("Cüzdan");
|
||||
const [zBefore, cBefore, sBefore] = [
|
||||
await balance(ayse, ziraat),
|
||||
await balance(ayse, cuzdan),
|
||||
await sums(),
|
||||
];
|
||||
const c = await createContact(
|
||||
ayse,
|
||||
cakir,
|
||||
contactForm({ name: "Kemal Test", relation: "friend" }),
|
||||
);
|
||||
expect(c.ok).toBe(true);
|
||||
ahmet = c.ok ? c.data.id : "";
|
||||
|
||||
const l = await createContactEntry(
|
||||
ayse,
|
||||
cakir,
|
||||
ahmet,
|
||||
entry({ accountId: ziraat, amount: "5.000" }),
|
||||
);
|
||||
expect(l.ok).toBe(true);
|
||||
lendId = l.ok ? l.data.id : "";
|
||||
await createContactEntry(
|
||||
emre,
|
||||
cakir,
|
||||
ahmet,
|
||||
entry({ kind: "collect", accountId: cuzdan, amount: "2.000" }),
|
||||
);
|
||||
|
||||
expect(await balance(ayse, ziraat)).toBe(zBefore - 5_000_00);
|
||||
expect(await balance(ayse, cuzdan)).toBe(cBefore + 2_000_00);
|
||||
expect(await contactBalance(ayse, ahmet)).toBe(3_000_00);
|
||||
expect(await sums()).toEqual(sBefore);
|
||||
|
||||
const detail = (await getTransaction(ayse, cakir, lendId))!;
|
||||
expect(detail).toMatchObject({
|
||||
type: "transfer",
|
||||
contact: { kind: "lend", name: "Kemal Test" },
|
||||
});
|
||||
expect(detail.toAccount).toBeNull();
|
||||
});
|
||||
|
||||
it("borç alma ve geri ödeme; açılış bakiyesi; ekstre yürüyen bakiye", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const c = await createContact(
|
||||
ayse,
|
||||
cakir,
|
||||
contactForm({ name: "Hasan Amca", relation: "family", openingBalance: "-1.000" }),
|
||||
);
|
||||
const hasan = c.ok ? c.data.id : "";
|
||||
await createContactEntry(
|
||||
ayse,
|
||||
cakir,
|
||||
hasan,
|
||||
entry({ kind: "borrow", accountId: ziraat, amount: "10.000" }),
|
||||
);
|
||||
await createContactEntry(
|
||||
ayse,
|
||||
cakir,
|
||||
hasan,
|
||||
entry({ kind: "repay", accountId: ziraat, amount: "4.000" }),
|
||||
);
|
||||
expect(await contactBalance(ayse, hasan)).toBe(-7_000_00);
|
||||
|
||||
const s = (await getContactStatement(ayse, cakir, hasan))!;
|
||||
expect(s.rows).toHaveLength(2);
|
||||
expect(s.rows[0]!.balanceMinor).toBe(-7_000_00);
|
||||
expect(s.rows[1]!.balanceMinor).toBe(-11_000_00);
|
||||
expect(s.contact.balanceMinor).toBe(-7_000_00);
|
||||
|
||||
const list = (await listContacts(ayse, cakir))!;
|
||||
expect(list.totals.iOweMinor).toBeGreaterThanOrEqual(7_000_00);
|
||||
expect(list.totals.owesMeMinor).toBeGreaterThanOrEqual(3_000_00);
|
||||
});
|
||||
|
||||
it("hareket düzenleme: tür değişince yön ve bakiyeler değişir; işlem formuyla düzenlenemez", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const before = await balance(ayse, ziraat);
|
||||
const u = await updateContactEntry(
|
||||
ayse,
|
||||
cakir,
|
||||
lendId,
|
||||
entry({ kind: "borrow", accountId: ziraat, amount: "5.000" }),
|
||||
);
|
||||
expect(u.ok).toBe(true);
|
||||
expect(await balance(ayse, ziraat)).toBe(before + 10_000_00);
|
||||
expect(await contactBalance(ayse, ahmet)).toBe(-7_000_00);
|
||||
const d = (await getTransaction(ayse, cakir, lendId))!;
|
||||
expect(d.account).toBeNull();
|
||||
expect(d.toAccount?.id).toBe(ziraat);
|
||||
|
||||
const viaForm = await updateTransaction(ayse, cakir, lendId, {
|
||||
type: "transfer",
|
||||
amount: 1,
|
||||
date: today,
|
||||
accountId: ziraat,
|
||||
toAccountId: await acc("Cüzdan"),
|
||||
categoryId: null,
|
||||
splits: [],
|
||||
payee: "",
|
||||
description: "",
|
||||
tags: [],
|
||||
installments: 1,
|
||||
attachmentIds: [],
|
||||
});
|
||||
expect(viaForm.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("vade uyarısı: yaklaşan, geçmiş, kapalı bakiye", async () => {
|
||||
const c = await createContact(
|
||||
ayse,
|
||||
cakir,
|
||||
contactForm({
|
||||
name: "Vade Kişi",
|
||||
openingBalance: "500",
|
||||
dueDate: addDays(today, 2),
|
||||
reminderDays: 3,
|
||||
}),
|
||||
);
|
||||
const id = c.ok ? c.data.id : "";
|
||||
const before = await countDueContacts(ayse, cakir);
|
||||
let v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
|
||||
expect(v.due).toBe("upcoming");
|
||||
await updateContact(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
contactForm({ name: "Vade Kişi", openingBalance: "500", dueDate: addDays(today, -1) }),
|
||||
);
|
||||
v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
|
||||
expect(v.due).toBe("overdue");
|
||||
await updateContact(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
contactForm({ name: "Vade Kişi", openingBalance: "0", dueDate: addDays(today, -1) }),
|
||||
);
|
||||
v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
|
||||
expect(v.due).toBe("none");
|
||||
expect(await countDueContacts(ayse, cakir)).toBe(before - 1);
|
||||
});
|
||||
|
||||
it("aynı ad engellenir; arşivli cariye hareket eklenmez", async () => {
|
||||
expect((await createContact(ayse, cakir, contactForm({ name: "kemal TEST" }))).ok).toBe(false);
|
||||
expect((await setContactArchived(ayse, cakir, ahmet, true)).ok).toBe(true);
|
||||
expect(
|
||||
(await createContactEntry(ayse, cakir, ahmet, entry({ accountId: await acc("Cüzdan") }))).ok,
|
||||
).toBe(false);
|
||||
expect((await setContactArchived(ayse, cakir, ahmet, false)).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("yetki ve gizlilik", () => {
|
||||
it("izleyici yazamaz; hane dışı göremez; başka hanenin hesabı kullanılamaz", async () => {
|
||||
expect((await createContact(zeynep, cakir, contactForm({ name: "X" }))).ok).toBe(false);
|
||||
expect(await listContacts(fatma, cakir)).toBeNull();
|
||||
const c = await createContact(ayse, cakir, contactForm({ name: "Hesap Testi" }));
|
||||
const id = c.ok ? c.data.id : "";
|
||||
const otherHousehold = String(
|
||||
(await AccountModel.findOne({ name: "Vakıfbank Emekli" }).lean())!._id,
|
||||
);
|
||||
expect(
|
||||
(await createContactEntry(ayse, cakir, id, entry({ accountId: otherHousehold }))).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("yalnızca ben cari: başkası listede, ekstrede ve işlemlerde görmez; hesap ekstresinde ad gizlenir", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const c = await createContact(
|
||||
ayse,
|
||||
cakir,
|
||||
contactForm({ name: "Gizli Kişi", isPrivate: true }),
|
||||
);
|
||||
const id = c.ok ? c.data.id : "";
|
||||
const e = await createContactEntry(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
entry({ accountId: ziraat, amount: "750" }),
|
||||
);
|
||||
const txId = e.ok ? e.data.id : "";
|
||||
|
||||
expect((await listContacts(emre, cakir))!.contacts.some((x) => x.id === id)).toBe(false);
|
||||
expect(await getContactStatement(emre, cakir, id)).toBeNull();
|
||||
expect(await getTransaction(emre, cakir, txId)).toBeNull();
|
||||
expect((await createContactEntry(emre, cakir, id, entry({ accountId: ziraat }))).ok).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const st = (await getAccountStatement(emre, cakir, ziraat, currentMonth()))!;
|
||||
const row = st.rows.find((r) => r.item.id === txId)!;
|
||||
expect(row.restricted).toBe(true);
|
||||
expect(row.item.contact!.name).not.toBe("Gizli Kişi");
|
||||
// Ortak hesabın bakiyesi herkes için aynı.
|
||||
expect(await balance(emre, ziraat)).toBe(await balance(ayse, ziraat));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,444 @@
|
||||
import "server-only";
|
||||
import { Types, type PipelineStage } from "mongoose";
|
||||
import {
|
||||
loadContext,
|
||||
loadOwnTransaction,
|
||||
toItem,
|
||||
userNames,
|
||||
type Ctx,
|
||||
type LeanTx,
|
||||
type TransactionListItem,
|
||||
} from "@/features/transactions/service";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
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 { ContactModel } from "@/models/contact";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import {
|
||||
contactDelta,
|
||||
dueState,
|
||||
isOutgoing,
|
||||
type ContactRelation,
|
||||
type DueState,
|
||||
} from "@/shared/contacts";
|
||||
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import type { ContactEntry, ContactForm } from "@/shared/schemas/contact";
|
||||
import { compareTr, foldTr } from "@/shared/search";
|
||||
|
||||
/**
|
||||
* Cari hesaplar (Bölüm 6.4). Okuma: viewer; yazma: member. Hareketler `transactions` içinde
|
||||
* transferdir (`contactId`, `contactKind`): cariye çıkışta `accountId` dolu/`toAccountId` boş,
|
||||
* cariden girişte tersi. Böylece hesap bakiyesi, ekstre ve mutabakat ek kural gerektirmez;
|
||||
* gelir/gider raporlarına girmez.
|
||||
*/
|
||||
|
||||
const t = tr.contacts;
|
||||
|
||||
type ContactDoc = {
|
||||
_id: Types.ObjectId;
|
||||
householdId: Types.ObjectId;
|
||||
name: string;
|
||||
relation: ContactRelation;
|
||||
phone: string;
|
||||
note: string;
|
||||
openingBalanceMinor: number;
|
||||
openingDate: Date;
|
||||
dueDate?: string | null;
|
||||
reminderDays: number;
|
||||
isPrivate: boolean;
|
||||
ownerUserId?: Types.ObjectId | null;
|
||||
archivedAt?: Date | null;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
const visible = (c: ContactDoc, userId: string) => !c.isPrivate || String(c.ownerUserId) === userId;
|
||||
|
||||
/** Cari bazında hareket toplamı (+ çıkış, − giriş) ve son hareket tarihi. */
|
||||
async function contactTotals(householdId: Types.ObjectId, contactIds?: Types.ObjectId[]) {
|
||||
const pipeline: PipelineStage[] = [
|
||||
{
|
||||
$match: {
|
||||
householdId,
|
||||
status: "posted",
|
||||
deletedAt: null,
|
||||
contactId: contactIds ? { $in: contactIds } : { $type: "objectId" },
|
||||
// Aynî borç hareketleri cari TL bakiyesine girmez.
|
||||
contactKind: { $ne: null },
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$contactId",
|
||||
delta: {
|
||||
$sum: {
|
||||
$cond: [
|
||||
{ $in: ["$contactKind", ["lend", "repay"]] },
|
||||
"$amountMinor",
|
||||
{ $multiply: ["$amountMinor", -1] },
|
||||
],
|
||||
},
|
||||
},
|
||||
last: { $max: "$date" },
|
||||
},
|
||||
},
|
||||
];
|
||||
const rows = await TransactionModel.aggregate<{ _id: Types.ObjectId; delta: number; last: Date }>(
|
||||
pipeline,
|
||||
);
|
||||
return new Map(rows.map((r) => [String(r._id), r]));
|
||||
}
|
||||
|
||||
export type ContactView = {
|
||||
id: string;
|
||||
name: string;
|
||||
relation: ContactRelation;
|
||||
phone: string;
|
||||
note: string;
|
||||
/** + bana borçlu, − ben borçluyum. */
|
||||
balanceMinor: number;
|
||||
openingBalanceMinor: number;
|
||||
openingDate: string;
|
||||
dueDate: string | null;
|
||||
reminderDays: number;
|
||||
due: DueState;
|
||||
isPrivate: boolean;
|
||||
archived: boolean;
|
||||
lastActivity: string | null;
|
||||
};
|
||||
|
||||
function toView(
|
||||
c: ContactDoc,
|
||||
totals: Map<string, { delta: number; last: Date }>,
|
||||
today: string,
|
||||
): ContactView {
|
||||
const x = totals.get(String(c._id));
|
||||
const balanceMinor = c.openingBalanceMinor + (x?.delta ?? 0);
|
||||
return {
|
||||
id: String(c._id),
|
||||
name: c.name,
|
||||
relation: c.relation,
|
||||
phone: c.phone,
|
||||
note: c.note,
|
||||
balanceMinor,
|
||||
openingBalanceMinor: c.openingBalanceMinor,
|
||||
openingDate: toIstanbulYmd(c.openingDate),
|
||||
dueDate: c.dueDate ?? null,
|
||||
reminderDays: c.reminderDays,
|
||||
due: dueState(balanceMinor, c.dueDate ?? null, c.reminderDays, today, addDays),
|
||||
isPrivate: c.isPrivate,
|
||||
archived: Boolean(c.archivedAt),
|
||||
lastActivity: x?.last ? toIstanbulYmd(x.last) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export type ContactList = {
|
||||
contacts: ContactView[];
|
||||
totals: { owesMeMinor: number; iOweMinor: number };
|
||||
};
|
||||
|
||||
export async function listContacts(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<ContactList | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const docs = (await ContactModel.find({ householdId: ctx.m.householdId }).lean<ContactDoc[]>())
|
||||
.filter((c) => visible(c, userId))
|
||||
.sort((a, b) => compareTr(a.name, b.name));
|
||||
const totals = await contactTotals(
|
||||
ctx.m.householdId,
|
||||
docs.map((d) => d._id),
|
||||
);
|
||||
const today = todayIstanbul();
|
||||
const contacts = docs.map((c) => toView(c, totals, today));
|
||||
let owesMeMinor = 0;
|
||||
let iOweMinor = 0;
|
||||
for (const c of contacts) {
|
||||
if (c.archived) continue;
|
||||
if (c.balanceMinor > 0) owesMeMinor += c.balanceMinor;
|
||||
else iOweMinor += -c.balanceMinor;
|
||||
}
|
||||
return { contacts, totals: { owesMeMinor, iOweMinor } };
|
||||
}
|
||||
|
||||
async function loadContact(ctx: Ctx, contactId: string, userId: string) {
|
||||
const id = toObjectId(contactId);
|
||||
if (!id) return null;
|
||||
const c = await ContactModel.findOne({
|
||||
_id: id,
|
||||
householdId: ctx.m.householdId,
|
||||
}).lean<ContactDoc>();
|
||||
return c && visible(c, userId) ? c : null;
|
||||
}
|
||||
|
||||
async function nameTaken(householdId: Types.ObjectId, name: string, exceptId?: Types.ObjectId) {
|
||||
const key = foldTr(name);
|
||||
const others = await ContactModel.find(
|
||||
{ householdId, archivedAt: null, ...(exceptId ? { _id: { $ne: exceptId } } : {}) },
|
||||
{ name: 1 },
|
||||
).lean();
|
||||
return others.some((o) => foldTr(o.name) === key);
|
||||
}
|
||||
|
||||
// --- Cari yazma ---
|
||||
|
||||
export async function createContact(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: ContactForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
if (await nameTaken(ctx.m.householdId, input.name))
|
||||
return fail(t.errors.duplicate, { name: t.errors.duplicate });
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const doc = await ContactModel.create({
|
||||
householdId: ctx.m.householdId,
|
||||
name: input.name,
|
||||
relation: input.relation,
|
||||
phone: input.phone,
|
||||
note: input.note,
|
||||
openingBalanceMinor: input.openingBalance,
|
||||
openingDate: istanbulDayStart(input.openingDate)!,
|
||||
dueDate: input.dueDate,
|
||||
reminderDays: input.reminderDays,
|
||||
isPrivate: input.isPrivate,
|
||||
ownerUserId: input.isPrivate ? actor : null,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
export async function updateContact(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
contactId: string,
|
||||
input: ContactForm,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const c = await loadContact(ctx, contactId, actorId);
|
||||
if (!c) return fail(tr.common.notFound);
|
||||
if (await nameTaken(ctx.m.householdId, input.name, c._id))
|
||||
return fail(t.errors.duplicate, { name: t.errors.duplicate });
|
||||
// Gizlilik yalnızca sahibince (veya ortak cariyi gizlerken işlemi yapanca) belirlenir.
|
||||
const owner = c.ownerUserId ?? (input.isPrivate ? new Types.ObjectId(actorId) : null);
|
||||
await ContactModel.updateOne(
|
||||
{ _id: c._id, householdId: ctx.m.householdId },
|
||||
{
|
||||
$set: {
|
||||
name: input.name,
|
||||
relation: input.relation,
|
||||
phone: input.phone,
|
||||
note: input.note,
|
||||
openingBalanceMinor: input.openingBalance,
|
||||
openingDate: istanbulDayStart(input.openingDate)!,
|
||||
dueDate: input.dueDate,
|
||||
reminderDays: input.reminderDays,
|
||||
isPrivate: input.isPrivate,
|
||||
ownerUserId: input.isPrivate ? owner : null,
|
||||
updatedBy: new Types.ObjectId(actorId),
|
||||
},
|
||||
},
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
export async function setContactArchived(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
contactId: string,
|
||||
archived: boolean,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const c = await loadContact(ctx, contactId, actorId);
|
||||
if (!c) return fail(tr.common.notFound);
|
||||
if (!archived && (await nameTaken(ctx.m.householdId, c.name, c._id)))
|
||||
return fail(t.errors.duplicate);
|
||||
await ContactModel.updateOne(
|
||||
{ _id: c._id, householdId: ctx.m.householdId },
|
||||
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: new Types.ObjectId(actorId) } },
|
||||
);
|
||||
await writeAudit({
|
||||
action: archived ? "contact.archive" : "contact.unarchive",
|
||||
actorId: new Types.ObjectId(actorId),
|
||||
householdId: ctx.m.householdId,
|
||||
targetType: "contact",
|
||||
targetId: String(c._id),
|
||||
diff: { name: c.name },
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Cari hareketleri ---
|
||||
|
||||
function entryFields(ctx: Ctx, contact: ContactDoc, input: ContactEntry, keepAccount?: string) {
|
||||
const info = ctx.accountMap.get(input.accountId);
|
||||
if (!info || info.hidden || (info.archived && input.accountId !== keepAccount)) {
|
||||
return fail(tr.transactions.validation.accountInvalid, {
|
||||
accountId: tr.transactions.validation.accountInvalid,
|
||||
});
|
||||
}
|
||||
const date = istanbulDayStart(input.date);
|
||||
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
|
||||
const out = isOutgoing(input.kind);
|
||||
return ok({
|
||||
type: "transfer" as const,
|
||||
date,
|
||||
accountId: out ? info.id : null,
|
||||
toAccountId: out ? null : info.id,
|
||||
contactId: contact._id,
|
||||
contactKind: input.kind,
|
||||
amountMinor: input.amount,
|
||||
categoryId: null,
|
||||
splits: [],
|
||||
effectiveCategoryIds: [],
|
||||
payeeName: "",
|
||||
description: input.description,
|
||||
searchText: foldTr(`${contact.name} ${input.description}`),
|
||||
});
|
||||
}
|
||||
|
||||
async function applyDue(contact: ContactDoc, input: ContactEntry, actor: Types.ObjectId) {
|
||||
if (input.dueDate === undefined) return;
|
||||
await ContactModel.updateOne(
|
||||
{ _id: contact._id },
|
||||
{
|
||||
$set: {
|
||||
dueDate: input.dueDate,
|
||||
...(input.reminderDays !== undefined ? { reminderDays: input.reminderDays } : {}),
|
||||
updatedBy: actor,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function createContactEntry(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
contactId: string,
|
||||
input: ContactEntry,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const contact = await loadContact(ctx, contactId, actorId);
|
||||
if (!contact) return fail(tr.common.notFound);
|
||||
if (contact.archivedAt) return fail(t.errors.archived);
|
||||
const fields = entryFields(ctx, contact, input);
|
||||
if (!fields.ok) return fields;
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const doc = await TransactionModel.create({
|
||||
...fields.data,
|
||||
householdId: ctx.m.householdId,
|
||||
status: "posted",
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
await applyDue(contact, input, actor);
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
export async function updateContactEntry(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
transactionId: string,
|
||||
input: ContactEntry,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const tx = await loadOwnTransaction(ctx, transactionId);
|
||||
if (!tx?.contactId) return fail(tr.common.notFound);
|
||||
const contact = await loadContact(ctx, String(tx.contactId), actorId);
|
||||
if (!contact) return fail(tr.common.notFound);
|
||||
const keep = String(tx.accountId ?? tx.toAccountId ?? "");
|
||||
const fields = entryFields(ctx, contact, input, keep);
|
||||
if (!fields.ok) return fields;
|
||||
const d = fields.data;
|
||||
const balanceChanged =
|
||||
d.amountMinor !== tx.amountMinor ||
|
||||
d.date.getTime() !== new Date(tx.date).getTime() ||
|
||||
String(d.accountId ?? "") !== String(tx.accountId ?? "") ||
|
||||
String(d.toAccountId ?? "") !== String(tx.toAccountId ?? "");
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await TransactionModel.updateOne(
|
||||
{ _id: tx._id, householdId: ctx.m.householdId },
|
||||
{
|
||||
$set: {
|
||||
...d,
|
||||
updatedBy: actor,
|
||||
...(balanceChanged ? { reconciledAccountIds: [] } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
await applyDue(contact, input, actor);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Ekstre ---
|
||||
|
||||
export type ContactStatementRow = {
|
||||
item: TransactionListItem;
|
||||
deltaMinor: number;
|
||||
balanceMinor: number;
|
||||
};
|
||||
|
||||
export type ContactStatement = {
|
||||
contact: ContactView;
|
||||
/** Yeniden eskiye. */
|
||||
rows: ContactStatementRow[];
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
export async function getContactStatement(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
contactId: string,
|
||||
): Promise<ContactStatement | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const contact = await loadContact(ctx, contactId, userId);
|
||||
if (!contact) return null;
|
||||
const txs = (await TransactionModel.find({
|
||||
householdId: ctx.m.householdId,
|
||||
contactId: contact._id,
|
||||
contactKind: { $ne: null },
|
||||
status: "posted",
|
||||
deletedAt: null,
|
||||
})
|
||||
.sort({ date: 1, createdAt: 1 })
|
||||
.limit(5000)
|
||||
.lean()) as unknown as LeanTx[];
|
||||
const names = await userNames(txs.map((x) => x.createdBy as Types.ObjectId));
|
||||
let balance = contact.openingBalanceMinor;
|
||||
const rows = txs.map((x): ContactStatementRow => {
|
||||
const deltaMinor = contactDelta(x.contactKind!, x.amountMinor);
|
||||
balance += deltaMinor;
|
||||
const item = toItem(ctx, x, names);
|
||||
// Başkasının gizli hesabıyla yapılan hareket: hesap adı gizlenir.
|
||||
for (const a of [item.account, item.toAccount])
|
||||
if (a && ctx.accountMap.get(a.id)?.hidden) a.name = tr.transactions.list.hiddenAccount;
|
||||
return { item, deltaMinor, balanceMinor: balance };
|
||||
});
|
||||
const totals = new Map([
|
||||
[
|
||||
String(contact._id),
|
||||
{ delta: balance - contact.openingBalanceMinor, last: txs.at(-1)?.date ?? null },
|
||||
],
|
||||
]) as Map<string, { delta: number; last: Date }>;
|
||||
return {
|
||||
contact: toView(contact, totals, todayIstanbul()),
|
||||
rows: rows.reverse(),
|
||||
canWrite: hasHouseholdRole(ctx.m.role, "member"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Dashboard: vadesi geçmiş veya yaklaşan (hatırlatma penceresindeki) cari sayısı. */
|
||||
export async function countDueContacts(userId: string, householdId: string): Promise<number> {
|
||||
const list = await listContacts(userId, householdId);
|
||||
return list?.contacts.filter((c) => !c.archived && c.due !== "none").length ?? 0;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { getDb } from "@/lib/db/mongo";
|
||||
import { connectMongoose } from "@/lib/db/mongoose";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { installDefaultCategories } from "@/features/categories/service";
|
||||
import { installDefaultStockCategories } from "@/features/stock/service";
|
||||
import { HouseholdModel } from "@/models/household";
|
||||
import { HouseholdMemberModel } from "@/models/household-member";
|
||||
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).
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"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 {
|
||||
assetPriceFormSchema,
|
||||
contributionSchema,
|
||||
inKindFormSchema,
|
||||
paymentFormSchema,
|
||||
} from "@/shared/schemas/in-kind";
|
||||
import {
|
||||
addContribution,
|
||||
createInKind,
|
||||
deleteInKind,
|
||||
recordPayment,
|
||||
savePrice,
|
||||
updateInKind,
|
||||
} 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;
|
||||
}
|
||||
|
||||
const revalidate = () => revalidatePath("/", "layout");
|
||||
|
||||
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) revalidate();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function savePriceAction(input: unknown) {
|
||||
return run(assetPriceFormSchema, input, savePrice);
|
||||
}
|
||||
|
||||
/** Yeni aynî kayıt (debtId boş, contactId dolu) veya mevcut kaydı düzenle. */
|
||||
export async function saveInKindAction(contactId: unknown, debtId: unknown, input: unknown) {
|
||||
if (debtId) {
|
||||
const id = idSchema.safeParse(debtId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(inKindFormSchema, input, (u, h, d) => updateInKind(u, h, id.data, d));
|
||||
}
|
||||
const cid = idSchema.safeParse(contactId);
|
||||
if (!cid.success) return fail(tr.common.notFound);
|
||||
return run(inKindFormSchema, input, (u, h, d) => createInKind(u, h, cid.data, d));
|
||||
}
|
||||
|
||||
export async function deleteInKindAction(debtId: unknown): Promise<ActionResult<unknown>> {
|
||||
const id = idSchema.safeParse(debtId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.unknown(), null, (u, h) => deleteInKind(u, h, id.data));
|
||||
}
|
||||
|
||||
export async function addContributionAction(debtId: unknown, input: unknown) {
|
||||
const id = idSchema.safeParse(debtId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(contributionSchema, input, (u, h, d) => addContribution(u, h, id.data, d));
|
||||
}
|
||||
|
||||
export async function recordPaymentAction(debtId: unknown, input: unknown) {
|
||||
const id = idSchema.safeParse(debtId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(paymentFormSchema, input, (u, h, d) => recordPayment(u, h, id.data, d));
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
"use client";
|
||||
|
||||
import { Coins, EllipsisVertical, HandCoins, Pencil, PiggyBank, Plus, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getAssetOptionsAction, type AssetOption } from "@/features/assets/actions";
|
||||
import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
|
||||
import { AccountSelect } from "@/features/transactions/components/transaction-form";
|
||||
import type { TransactionFormOptions } from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASSET_KEYS, ASSETS, formatAssetQuantity, type AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { QUANTITY_SCALE } from "@/shared/quantity";
|
||||
import {
|
||||
addContributionAction,
|
||||
deleteInKindAction,
|
||||
recordPaymentAction,
|
||||
saveInKindAction,
|
||||
} from "../actions";
|
||||
import type { InKindView } from "../service";
|
||||
|
||||
const t = tr.inKind;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
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 qtyInput = (q: number) =>
|
||||
(q / QUANTITY_SCALE).toLocaleString("tr-TR", { maximumFractionDigits: 3, useGrouping: false });
|
||||
|
||||
/** Hesap seçenekleri (diyaloglar açıldığında). */
|
||||
export function useAccounts() {
|
||||
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getTransactionFormOptionsAction().then((r) => {
|
||||
if (!cancelled && r.ok) setOptions(r.data);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
return options?.accounts ?? null;
|
||||
}
|
||||
|
||||
/** Kaydın varlık türündeki birikim varlıkları (ödeme diyaloğu açıldığında). */
|
||||
function useAssetOptions(assetKey: AssetKey) {
|
||||
const [options, setOptions] = useState<AssetOption[] | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAssetOptionsAction().then((r) => {
|
||||
if (!cancelled && r.ok) setOptions(r.data.filter((a) => a.assetKey === assetKey));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [assetKey]);
|
||||
return options;
|
||||
}
|
||||
|
||||
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("font-semibold tabular-nums", tone)}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Pending =
|
||||
| { kind: "form"; item: InKindView | null }
|
||||
| { kind: "contribute"; item: InKindView }
|
||||
| { kind: "pay"; item: InKindView }
|
||||
| { kind: "delete"; item: InKindView }
|
||||
| null;
|
||||
|
||||
/** Cari sayfasında aynî borç/alacak bölümü. */
|
||||
export function InKindSection({
|
||||
contactId,
|
||||
items,
|
||||
canWrite,
|
||||
}: {
|
||||
contactId: string;
|
||||
items: InKindView[];
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const close = () => setPending(null);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3" aria-labelledby="in-kind-title">
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<h2 id="in-kind-title" className="text-base font-medium">
|
||||
{t.title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t.description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/fiyatlar">
|
||||
<Coins />
|
||||
{t.prices}
|
||||
</Link>
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPending({ kind: "form", item: null })}
|
||||
>
|
||||
<Plus />
|
||||
{t.new}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.map((item) => (
|
||||
<InKindCard key={item.id} item={item} canWrite={canWrite} onAction={setPending} />
|
||||
))}
|
||||
|
||||
{pending?.kind === "form" ? (
|
||||
<InKindForm contactId={contactId} item={pending.item} onClose={close} />
|
||||
) : null}
|
||||
{pending?.kind === "contribute" ? (
|
||||
<ContributionForm item={pending.item} onClose={close} />
|
||||
) : null}
|
||||
{pending?.kind === "pay" ? <PaymentForm item={pending.item} onClose={close} /> : null}
|
||||
<AlertDialog open={pending?.kind === "delete"} onOpenChange={(o) => !o && close()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t.deleteDescription}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (pending?.kind !== "delete") return;
|
||||
const r = await deleteInKindAction(pending.item.id);
|
||||
close();
|
||||
if (r.ok) toast.success(t.toast.deleted);
|
||||
else toast.error(r.error);
|
||||
}}
|
||||
>
|
||||
{t.delete}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InKindCard({
|
||||
item,
|
||||
canWrite,
|
||||
onAction,
|
||||
}: {
|
||||
item: InKindView;
|
||||
canWrite: boolean;
|
||||
onAction: (p: Pending) => void;
|
||||
}) {
|
||||
const p = item.progress;
|
||||
const debt = item.direction === "debt";
|
||||
const open = !item.closedAt;
|
||||
return (
|
||||
<Card size="sm" className={cn(!open && "opacity-70")}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
|
||||
{item.label}
|
||||
<Badge variant={debt ? "destructive" : "secondary"}>
|
||||
{t.directions[item.direction]}
|
||||
</Badge>
|
||||
{item.closedAt ? (
|
||||
<Badge variant="outline">{t.stats.closed(d(item.closedAt))}</Badge>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t.stats.dateValue(d(item.date))}:{" "}
|
||||
{item.valueMinor !== null ? formatMoney(item.valueMinor) : "—"}
|
||||
{item.unitPriceMinor !== null
|
||||
? ` · ${t.stats.priceAt(formatMoney(item.unitPriceMinor), d(item.date))}`
|
||||
: ""}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" aria-label={tr.recurring.actions.more}>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => onAction({ kind: "form", item })}>
|
||||
<Pencil />
|
||||
{tr.common.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onAction({ kind: "delete", item })}
|
||||
>
|
||||
<Trash2 />
|
||||
{t.delete}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : 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.stats.remaining}
|
||||
value={formatAssetQuantity(item.assetKey, p.remainingQuantity)}
|
||||
/>
|
||||
<Stat
|
||||
label={t.stats.currentValue}
|
||||
value={
|
||||
p.currentValueMinor !== null ? formatMoney(p.currentValueMinor) : t.stats.noPrice
|
||||
}
|
||||
tone={debt ? "text-expense" : "text-income"}
|
||||
/>
|
||||
{item.savingsAccount ? (
|
||||
<>
|
||||
<Stat
|
||||
label={`${t.stats.accumulated} · ${item.savingsAccount.name}`}
|
||||
value={formatMoney(p.accumulatedMinor)}
|
||||
tone="text-income"
|
||||
/>
|
||||
<Stat
|
||||
label={t.stats.remainingNeed}
|
||||
value={p.remainingNeedMinor !== null ? formatMoney(p.remainingNeedMinor) : "—"}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{p.estimatedCloseMonth && open ? (
|
||||
<p className="text-sm">
|
||||
{t.stats.estimatedClose}:{" "}
|
||||
<span className="font-medium capitalize">
|
||||
{monthFmt.format(new Date(`${p.estimatedCloseMonth}-15T12:00:00+03:00`))}
|
||||
</span>
|
||||
{item.monthlyPlanMinor ? (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· {formatMoney(item.monthlyPlanMinor)}/ay
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{item.currentPrice ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.stats.currentPrice(
|
||||
debt,
|
||||
formatMoney(debt ? item.currentPrice.sellMinor : item.currentPrice.buyMinor),
|
||||
d(item.currentPrice.date),
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{item.note ? <p className="text-sm text-muted-foreground">{item.note}</p> : null}
|
||||
{item.payments.length ? (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">{t.payments}</p>
|
||||
<ul className="divide-y rounded-lg border text-sm">
|
||||
{item.payments.map((x) => (
|
||||
<li key={x.id} className="flex items-center justify-between gap-2 p-2">
|
||||
<span className="min-w-0 truncate">
|
||||
{d(x.date)} ·{" "}
|
||||
{t.paymentLine(
|
||||
t.methods[x.method],
|
||||
formatAssetQuantity(item.assetKey, x.quantity),
|
||||
x.amountMinor ? formatMoney(x.amountMinor) : null,
|
||||
)}
|
||||
</span>
|
||||
{x.account ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{x.account}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{canWrite && open ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{debt && item.savingsAccount ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onAction({ kind: "contribute", item })}
|
||||
>
|
||||
<PiggyBank />
|
||||
{t.contribute}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" onClick={() => onAction({ kind: "pay", item })}>
|
||||
<HandCoins />
|
||||
{t.pay}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InKindForm({
|
||||
contactId,
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
contactId: string;
|
||||
item: InKindView | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const accounts = useAccounts();
|
||||
const [v, setV] = useState({
|
||||
direction: item?.direction ?? "debt",
|
||||
assetKey: (item?.assetKey ?? "gram_gold") as AssetKey,
|
||||
quantity: item ? qtyInput(item.quantity) : "",
|
||||
date: item?.date ?? todayIstanbul(),
|
||||
unitPrice: item?.unitPriceMinor ? formatMoneyInput(item.unitPriceMinor) : "",
|
||||
note: item?.note ?? "",
|
||||
savingsAccountId: item?.savingsAccount?.id ?? "",
|
||||
monthlyPlan: item?.monthlyPlanMinor ? formatMoneyInput(item.monthlyPlanMinor) : "",
|
||||
});
|
||||
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 NONE = "__none__";
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveInKindAction(contactId, item?.id ?? null, {
|
||||
...v,
|
||||
savingsAccountId: v.direction === "debt" ? v.savingsAccountId : "",
|
||||
monthlyPlan: v.direction === "debt" && v.savingsAccountId ? v.monthlyPlan : "",
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={item ? t.editTitle : t.createTitle}
|
||||
footer={<Footer busy={busy} formId="in-kind-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="in-kind-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Tabs
|
||||
value={v.direction}
|
||||
onValueChange={(x) => set("direction", x as "debt" | "receivable")}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{(["debt", "receivable"] as const).map((x) => (
|
||||
<TabsTrigger
|
||||
key={x}
|
||||
value={x}
|
||||
className="flex-1"
|
||||
disabled={Boolean(item?.payments.length)}
|
||||
>
|
||||
{t.directions[x]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="grid grid-cols-[1fr_8rem] gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ik-asset">{f.asset}</FieldLabel>
|
||||
<Select
|
||||
value={v.assetKey}
|
||||
onValueChange={(x) => set("assetKey", x as AssetKey)}
|
||||
disabled={Boolean(item?.payments.length)}
|
||||
>
|
||||
<SelectTrigger id="ik-asset" className="w-full">
|
||||
<SelectValue>{ASSETS[v.assetKey].label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASSET_KEYS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{ASSETS[k].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.quantity)}>
|
||||
<FieldLabel htmlFor="ik-qty">
|
||||
{f.quantity} ({ASSETS[v.assetKey].unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="ik-qty"
|
||||
inputMode="decimal"
|
||||
value={v.quantity}
|
||||
onChange={(e) => set("quantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.quantity)}
|
||||
/>
|
||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="ik-date">{f.date}</FieldLabel>
|
||||
<Input
|
||||
id="ik-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
onChange={(e) => set("date", e.target.value)}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.unitPrice)}>
|
||||
<FieldLabel htmlFor="ik-price">{f.unitPrice}</FieldLabel>
|
||||
<MoneyInput id="ik-price" value={v.unitPrice} onChange={(x) => set("unitPrice", x)} />
|
||||
{errors.unitPrice ? <FieldError>{errors.unitPrice}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">{f.unitPriceHint}</FieldDescription>
|
||||
{v.direction === "debt" ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.savingsAccountId)}>
|
||||
<FieldLabel htmlFor="ik-savings">{f.savingsAccount}</FieldLabel>
|
||||
<Select
|
||||
value={v.savingsAccountId || NONE}
|
||||
onValueChange={(x) => set("savingsAccountId", x === NONE ? "" : x)}
|
||||
>
|
||||
<SelectTrigger id="ik-savings" className="w-full">
|
||||
<SelectValue>
|
||||
{accounts?.find((a) => a.id === v.savingsAccountId)?.name ??
|
||||
(v.savingsAccountId ? item?.savingsAccount?.name : f.savingsNone)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{f.savingsNone}</SelectItem>
|
||||
{(accounts ?? [])
|
||||
.filter((a) => a.type !== "credit_card")
|
||||
.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.savingsAccountId ? (
|
||||
<FieldError>{errors.savingsAccountId}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ik-plan">{f.monthlyPlan}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="ik-plan"
|
||||
value={v.monthlyPlan}
|
||||
onChange={(x) => set("monthlyPlan", x)}
|
||||
disabled={!v.savingsAccountId}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
{v.direction === "debt" ? (
|
||||
<FieldDescription className="-mt-2">{f.planHint}</FieldDescription>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ik-note">{f.note}</FieldLabel>
|
||||
<Input id="ik-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ContributionForm({ item, onClose }: { item: InKindView; onClose: () => void }) {
|
||||
const accounts = useAccounts();
|
||||
const [v, setV] = useState({
|
||||
fromAccountId: "",
|
||||
amount: item.monthlyPlanMinor ? formatMoneyInput(item.monthlyPlanMinor) : "",
|
||||
date: todayIstanbul(),
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const sources = (accounts ?? []).filter((a) => a.id !== item.savingsAccount?.id);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await addContributionAction(item.id, {
|
||||
...v,
|
||||
fromAccountId: v.fromAccountId || sources[0]?.id || "",
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.contributed);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${t.contributionTitle} · ${item.label}`}
|
||||
description={`→ ${item.savingsAccount?.name ?? ""}`}
|
||||
footer={<Footer busy={busy} formId="contribution-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="contribution-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.fromAccountId)}>
|
||||
<FieldLabel htmlFor="ct-from">{t.fromAccount}</FieldLabel>
|
||||
<AccountSelect
|
||||
id="ct-from"
|
||||
value={v.fromAccountId || sources[0]?.id || ""}
|
||||
onChange={(x) => setV((s) => ({ ...s, fromAccountId: x }))}
|
||||
accounts={sources}
|
||||
/>
|
||||
{errors.fromAccountId ? <FieldError>{errors.fromAccountId}</FieldError> : null}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.amount)}>
|
||||
<FieldLabel htmlFor="ct-amount">{tr.transactions.fields.amount}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="ct-amount"
|
||||
value={v.amount}
|
||||
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
||||
/>
|
||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ct-date">{tr.transactions.fields.date}</FieldLabel>
|
||||
<Input
|
||||
id="ct-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void }) {
|
||||
const accounts = useAccounts();
|
||||
const debt = item.direction === "debt";
|
||||
const [method, setMethod] = useState<"asset" | "cash">("cash");
|
||||
const [v, setV] = useState({
|
||||
date: todayIstanbul(),
|
||||
quantity: qtyInput(item.progress.remainingQuantity),
|
||||
amount: "",
|
||||
accountId: item.savingsAccount?.id ?? "",
|
||||
note: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const assets = useAssetOptions(item.assetKey);
|
||||
const [link, setLink] = useState(false);
|
||||
const [assetId, setAssetId] = useState("");
|
||||
const linked = method === "asset" && link;
|
||||
const linkAssetId = assetId || assets?.[0]?.id || "";
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const amount = linked ? "" : v.amount;
|
||||
const r = await recordPaymentAction(item.id, {
|
||||
method,
|
||||
date: v.date,
|
||||
quantity: method === "cash" ? "" : v.quantity,
|
||||
amount,
|
||||
accountId: amount ? v.accountId || accounts?.[0]?.id || "" : "",
|
||||
assetId: linked ? linkAssetId : "",
|
||||
note: v.note,
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success((r.data as { closed: boolean }).closed ? t.toast.closed : t.toast.paid);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
const f = t.payFields;
|
||||
const hints = debt ? t.methodHints : t.receiveHints;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${t.payTitle} · ${item.label}`}
|
||||
description={`${t.stats.remaining}: ${formatAssetQuantity(item.assetKey, item.progress.remainingQuantity)}`}
|
||||
footer={<Footer busy={busy} formId="payment-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="payment-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Tabs value={method} onValueChange={(x) => setMethod(x as "asset" | "cash")}>
|
||||
<TabsList className="w-full">
|
||||
{(["cash", "asset"] as const).map((m) => (
|
||||
<TabsTrigger key={m} value={m} className="flex-1">
|
||||
{t.methods[m]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<p className="-mt-2 text-xs text-muted-foreground">{hints[method]}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{method === "asset" ? (
|
||||
<Field data-invalid={Boolean(errors.quantity)}>
|
||||
<FieldLabel htmlFor="pay-qty">
|
||||
{f.quantity} ({ASSETS[item.assetKey].unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="pay-qty"
|
||||
inputMode="decimal"
|
||||
value={v.quantity}
|
||||
onChange={(e) => setV((s) => ({ ...s, quantity: e.target.value }))}
|
||||
className="text-right tabular-nums"
|
||||
/>
|
||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
{linked || (method === "asset" && !debt) ? null : (
|
||||
<Field data-invalid={Boolean(errors.amount)}>
|
||||
<FieldLabel htmlFor="pay-amount">
|
||||
{method === "cash" ? f.amount : f.assetAmount}
|
||||
</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pay-amount"
|
||||
value={v.amount}
|
||||
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
||||
/>
|
||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||
</Field>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel>
|
||||
<Input
|
||||
id="pay-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{method === "cash" ? (
|
||||
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
|
||||
) : null}
|
||||
{method === "asset" ? (
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={link}
|
||||
onCheckedChange={(x) => setLink(x === true)}
|
||||
disabled={!assets?.length}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{debt ? t.link.debt : t.link.receivable}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{assets && !assets.length
|
||||
? t.link.none
|
||||
: debt
|
||||
? t.link.debtHint
|
||||
: t.link.receivableHint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{link && assets?.length ? (
|
||||
<Field data-invalid={Boolean(errors.assetId)}>
|
||||
<FieldLabel htmlFor="pay-asset" className="sr-only">
|
||||
{t.link.asset}
|
||||
</FieldLabel>
|
||||
<Select value={linkAssetId} onValueChange={setAssetId}>
|
||||
<SelectTrigger id="pay-asset" className="w-full">
|
||||
<SelectValue>{assets.find((a) => a.id === linkAssetId)?.name}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{assets.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name} · {formatAssetQuantity(item.assetKey, a.quantity)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.assetId ? <FieldError>{errors.assetId}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{(method === "cash" || v.amount) && !linked ? (
|
||||
<Field data-invalid={Boolean(errors.accountId)}>
|
||||
<FieldLabel htmlFor="pay-account">{f.account}</FieldLabel>
|
||||
<AccountSelect
|
||||
id="pay-account"
|
||||
value={v.accountId || accounts?.[0]?.id || ""}
|
||||
onChange={(x) => setV((s) => ({ ...s, accountId: x }))}
|
||||
accounts={accounts ?? []}
|
||||
invalid={Boolean(errors.accountId)}
|
||||
/>
|
||||
{errors.accountId ? <FieldError>{errors.accountId}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pay-note">{t.fields.note}</FieldLabel>
|
||||
<Input
|
||||
id="pay-note"
|
||||
value={v.note}
|
||||
onChange={(e) => setV((s) => ({ ...s, note: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { MoneyInput } from "@/components/money-input";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { 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 { cn } from "@/lib/utils";
|
||||
import { ASSET_KEYS, ASSETS, type AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { savePriceAction } from "../actions";
|
||||
import type { PriceRow } from "../service";
|
||||
|
||||
const t = tr.prices;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
|
||||
export function PricesView({ rows, canWrite }: { rows: PriceRow[]; canWrite: boolean }) {
|
||||
const [editing, setEditing] = useState<AssetKey | null>(null);
|
||||
const [openRow, setOpenRow] = useState<AssetKey | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{canWrite ? (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setEditing("gram_gold")}>
|
||||
<Plus />
|
||||
{t.add}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="p-3 text-left font-normal">{t.asset}</th>
|
||||
<th className="p-3 text-right font-normal">{t.buy}</th>
|
||||
<th className="p-3 text-right font-normal">{t.sell}</th>
|
||||
<th className="p-3 text-right font-normal">{t.date}</th>
|
||||
<th className="w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y tabular-nums">
|
||||
{rows.map((r) => (
|
||||
<Fragment key={r.assetKey}>
|
||||
<tr>
|
||||
<td className="p-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-left font-medium disabled:cursor-default"
|
||||
onClick={() => setOpenRow(openRow === r.assetKey ? null : r.assetKey)}
|
||||
disabled={r.history.length < 2}
|
||||
aria-expanded={openRow === r.assetKey}
|
||||
>
|
||||
{r.history.length > 1 ? (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
openRow === r.assetKey && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{ASSETS[r.assetKey].label}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{t.perUnit(ASSETS[r.assetKey].unit)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{r.latest ? formatMoney(r.latest.buyMinor) : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{r.latest ? formatMoney(r.latest.sellMinor) : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-right text-muted-foreground">
|
||||
{r.latest ? d(r.latest.date) : t.noPrice}
|
||||
</td>
|
||||
<td className="p-1 text-right">
|
||||
{canWrite ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditing(r.assetKey)}
|
||||
aria-label={`${t.add}: ${ASSETS[r.assetKey].label}`}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
{openRow === r.assetKey
|
||||
? r.history.slice(1).map((h) => (
|
||||
<tr key={h.date} className="bg-muted/30 text-muted-foreground">
|
||||
<td className="p-2 pl-9 text-xs">{t.history}</td>
|
||||
<td className="p-2 text-right">{formatMoney(h.buyMinor)}</td>
|
||||
<td className="p-2 text-right">{formatMoney(h.sellMinor)}</td>
|
||||
<td className="p-2 text-right">{d(h.date)}</td>
|
||||
<td />
|
||||
</tr>
|
||||
))
|
||||
: null}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{editing ? (
|
||||
<PriceForm
|
||||
key={editing}
|
||||
assetKey={editing}
|
||||
latest={rows.find((r) => r.assetKey === editing)?.latest ?? null}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceForm({
|
||||
assetKey: initial,
|
||||
latest,
|
||||
onClose,
|
||||
}: {
|
||||
assetKey: AssetKey;
|
||||
latest: PriceRow["latest"];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({
|
||||
assetKey: initial,
|
||||
date: todayIstanbul(),
|
||||
buy: latest ? formatMoneyInput(latest.buyMinor) : "",
|
||||
sell: latest ? formatMoneyInput(latest.sellMinor) : "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await savePriceAction(v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={t.add}
|
||||
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>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pr-asset">{t.asset}</FieldLabel>
|
||||
<Select
|
||||
value={v.assetKey}
|
||||
onValueChange={(x) => setV((s) => ({ ...s, assetKey: x as AssetKey }))}
|
||||
>
|
||||
<SelectTrigger id="pr-asset" className="w-full">
|
||||
<SelectValue>{ASSETS[v.assetKey].label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASSET_KEYS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{ASSETS[k].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="pr-date">{t.date}</FieldLabel>
|
||||
<Input
|
||||
id="pr-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.buy)}>
|
||||
<FieldLabel htmlFor="pr-buy">{t.buy}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pr-buy"
|
||||
value={v.buy}
|
||||
onChange={(x) => setV((s) => ({ ...s, buy: x }))}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.buy ? <FieldError>{errors.buy}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pr-sell">{t.sell}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pr-sell"
|
||||
value={v.sell}
|
||||
onChange={(x) => setV((s) => ({ ...s, sell: x }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">{t.sellHint}</FieldDescription>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import "server-only";
|
||||
import type { Types } from "mongoose";
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import type { AssetKey } from "@/shared/assets";
|
||||
|
||||
/** Fiyat tablosu okuma yardımcıları (aynî kayıtlar ve varlık takibi ortak kullanır). */
|
||||
|
||||
export type PriceQuote = { buyMinor: number; sellMinor: number; date: string };
|
||||
|
||||
/** Varlığın verilen tarihteki (veya öncesindeki en yakın) fiyatı. */
|
||||
export async function priceOn(
|
||||
householdId: Types.ObjectId,
|
||||
assetKey: AssetKey,
|
||||
date: string,
|
||||
): Promise<PriceQuote | null> {
|
||||
const p = await AssetPriceModel.findOne({ householdId, assetKey, date: { $lte: date } })
|
||||
.sort({ date: -1 })
|
||||
.lean();
|
||||
return p ? { buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Varlıkların `to` tarihine kadarki tüm fiyatları; dönen fonksiyon bir tarihteki (veya
|
||||
* öncesindeki en yakın) alış fiyatını verir (yoksa null).
|
||||
*/
|
||||
export async function buyPriceLookup(
|
||||
householdId: Types.ObjectId,
|
||||
assetKeys: AssetKey[],
|
||||
to: string,
|
||||
): Promise<(assetKey: AssetKey, date: string) => number | null> {
|
||||
const rows = await AssetPriceModel.find({
|
||||
householdId,
|
||||
assetKey: { $in: assetKeys },
|
||||
date: { $lte: to },
|
||||
})
|
||||
.sort({ date: 1 })
|
||||
.lean();
|
||||
const by = new Map<string, { date: string; buyMinor: number }[]>();
|
||||
for (const r of rows) {
|
||||
const list = by.get(r.assetKey) ?? [];
|
||||
list.push({ date: r.date, buyMinor: r.buyMinor });
|
||||
by.set(r.assetKey, list);
|
||||
}
|
||||
return (assetKey, date) => {
|
||||
const list = by.get(assetKey);
|
||||
if (!list) return null;
|
||||
// İkili arama: date'ten küçük-eşit son kayıt.
|
||||
let lo = 0;
|
||||
let hi = list.length - 1;
|
||||
let found = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (list[mid]!.date <= date) {
|
||||
found = mid;
|
||||
lo = mid + 1;
|
||||
} else hi = mid - 1;
|
||||
}
|
||||
return found === -1 ? null : list[found]!.buyMinor;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { listAccounts } from "@/features/accounts/service";
|
||||
import { createContact, listContacts } from "@/features/contacts/service";
|
||||
import { listTransactions, setTransactionDeleted } from "@/features/transactions/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays, addMonths } from "@/shared/recurrence";
|
||||
import { contactFormSchema } from "@/shared/schemas/contact";
|
||||
import {
|
||||
assetPriceFormSchema,
|
||||
contributionSchema,
|
||||
inKindFormSchema,
|
||||
paymentFormSchema,
|
||||
} from "@/shared/schemas/in-kind";
|
||||
import { transactionFilterSchema } from "@/shared/schemas/transaction";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
addContribution,
|
||||
createInKind,
|
||||
listInKind,
|
||||
listPrices,
|
||||
recordPayment,
|
||||
savePrice,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let emre = "";
|
||||
let zeynep = "";
|
||||
const today = todayIstanbul();
|
||||
const monthAgo = addDays(today, -30);
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
emre = ids.user("emre");
|
||||
zeynep = ids.user("zeynep");
|
||||
// Seed örnek fiyatları bu testin fiyat senaryolarını etkilemesin.
|
||||
await AssetPriceModel.deleteMany({});
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
const acc = (name: string) =>
|
||||
AccountModel.findOne({ householdId: cakir, name })
|
||||
.lean()
|
||||
.then((a) => String(a!._id));
|
||||
async function balance(accountId: string) {
|
||||
return (await listAccounts(ayse, cakir))!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
|
||||
}
|
||||
const price = (assetKey: string, date: string, buy: string, sell = "") =>
|
||||
assetPriceFormSchema.parse({ assetKey, date, buy, sell });
|
||||
async function contact(name: string, isPrivate = false) {
|
||||
const r = await createContact(
|
||||
ayse,
|
||||
cakir,
|
||||
contactFormSchema.parse({ name, openingDate: monthAgo, isPrivate }),
|
||||
);
|
||||
return r.ok ? r.data.id : "";
|
||||
}
|
||||
async function view(id: string) {
|
||||
return (await listInKind(ayse, cakir))!.find((v) => v.id === id)!;
|
||||
}
|
||||
|
||||
describe("fiyat tablosu", () => {
|
||||
it("elle fiyat; tek fiyat ikisine yazılır; gelecek tarih reddedilir; izleyici yazamaz", async () => {
|
||||
expect((await savePrice(ayse, cakir, price("gram_gold", today, "3.200", "3.300"))).ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect((await savePrice(ayse, cakir, price("usd", today, "41,50"))).ok).toBe(true);
|
||||
expect((await savePrice(ayse, cakir, price("usd", addDays(today, 1), "1"))).ok).toBe(false);
|
||||
expect((await savePrice(zeynep, cakir, price("usd", today, "1"))).ok).toBe(false);
|
||||
const rows = (await listPrices(ayse, cakir))!.rows;
|
||||
expect(rows.find((r) => r.assetKey === "usd")!.latest).toMatchObject({
|
||||
buyMinor: 41_50,
|
||||
sellMinor: 41_50,
|
||||
});
|
||||
// Aynı gün yeniden giriş günceller.
|
||||
await savePrice(ayse, cakir, price("usd", today, "42"));
|
||||
expect(
|
||||
(await listPrices(ayse, cakir))!.rows.find((r) => r.assetKey === "usd")!.history,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aynî borç", () => {
|
||||
let gold = "";
|
||||
let ahmet = "";
|
||||
|
||||
it("borç tarihindeki fiyat tablodan; yoksa birim fiyat istenir; değer satış fiyatıyla", async () => {
|
||||
ahmet = await contact("Altın Ahmet");
|
||||
const input = inKindFormSchema.parse({
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "5",
|
||||
date: monthAgo,
|
||||
savingsAccountId: await acc("Birikim (Vadeli)"),
|
||||
monthlyPlan: "2.000",
|
||||
});
|
||||
expect((await createInKind(ayse, cakir, ahmet, input)).ok).toBe(false);
|
||||
await savePrice(ayse, cakir, price("gram_gold", monthAgo, "2.900", "3.000"));
|
||||
const r = await createInKind(ayse, cakir, ahmet, input);
|
||||
expect(r.ok).toBe(true);
|
||||
gold = r.ok ? r.data.id : "";
|
||||
const v = await view(gold);
|
||||
expect(v).toMatchObject({
|
||||
unitPriceMinor: 3_000_00,
|
||||
valueMinor: 15_000_00,
|
||||
label: "5 gr Gram altın (24 ayar)",
|
||||
});
|
||||
// Güncel: 5 × 3.300 (satış) = 16.500; birikim 0; ayda 2.000 → 9 ay.
|
||||
expect(v.progress).toMatchObject({
|
||||
currentValueMinor: 16_500_00,
|
||||
accumulatedMinor: 0,
|
||||
remainingNeedMinor: 16_500_00,
|
||||
estimatedCloseMonth: addMonths(`${today.slice(0, 7)}-01`, 9).slice(0, 7),
|
||||
});
|
||||
});
|
||||
|
||||
it("birikim katkısı: hesaptan birikim hesabına transfer; borç kapanmaz; cari TL bakiyesi ve gelir/gider değişmez", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const birikim = await acc("Birikim (Vadeli)");
|
||||
const [z0, b0] = [await balance(ziraat), await balance(birikim)];
|
||||
const sums0 = (await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums;
|
||||
const r = await addContribution(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
contributionSchema.parse({ fromAccountId: ziraat, amount: "6.000", date: today }),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(await balance(ziraat)).toBe(z0 - 6_000_00);
|
||||
expect(await balance(birikim)).toBe(b0 + 6_000_00);
|
||||
const v = await view(gold);
|
||||
expect(v.progress.accumulatedMinor).toBe(6_000_00);
|
||||
expect(v.progress.remainingNeedMinor).toBe(10_500_00);
|
||||
expect(v.closedAt).toBeNull();
|
||||
expect(
|
||||
(await listContacts(ayse, cakir))!.contacts.find((c) => c.id === ahmet)!.balanceMinor,
|
||||
).toBe(0);
|
||||
expect((await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums).toEqual(
|
||||
sums0,
|
||||
);
|
||||
});
|
||||
|
||||
it("TL karşılığıyla kısmi ödeme: miktar güncel fiyattan; birikim hesabından çıkış", async () => {
|
||||
const birikim = await acc("Birikim (Vadeli)");
|
||||
const b0 = await balance(birikim);
|
||||
const r = await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
paymentFormSchema.parse({ method: "cash", date: today, amount: "6.600", accountId: birikim }),
|
||||
);
|
||||
expect(r).toMatchObject({ ok: true, data: { closed: false } });
|
||||
const v = await view(gold);
|
||||
expect(v.paidQuantity).toBe(2_000);
|
||||
expect(v.progress.remainingQuantity).toBe(3_000);
|
||||
expect(v.progress.accumulatedMinor).toBe(0);
|
||||
expect(await balance(birikim)).toBe(b0 - 6_600_00);
|
||||
// Ödeme işlemi tek başına silinemez.
|
||||
const doc = (await InKindDebtModel.findById(gold).lean())!;
|
||||
expect(
|
||||
(await setTransactionDeleted(ayse, cakir, String(doc.payments[0]!.transactionId), true)).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("aynı varlıkla ödeme kalanı aşarsa kalana sınırlanır ve kayıt kapanır", async () => {
|
||||
const r = await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
paymentFormSchema.parse({ method: "asset", date: today, quantity: "5" }),
|
||||
);
|
||||
expect(r).toMatchObject({ ok: true, data: { closed: true } });
|
||||
const v = await view(gold);
|
||||
expect(v.paidQuantity).toBe(5_000);
|
||||
expect(v.closedAt).toBe(today);
|
||||
expect(await AuditLogModel.exists({ action: "inkind.close", targetId: gold })).toBeTruthy();
|
||||
expect(
|
||||
(
|
||||
await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
paymentFormSchema.parse({ method: "asset", date: today, quantity: "1" }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("aynî alacak: alış fiyatıyla değerlenir, tahsilat hesaba girer; birikim planı alınmaz", async () => {
|
||||
const elif = await contact("Döviz Elif");
|
||||
const cuzdan = await acc("Cüzdan");
|
||||
expect(
|
||||
(
|
||||
await createInKind(
|
||||
ayse,
|
||||
cakir,
|
||||
elif,
|
||||
inKindFormSchema.parse({
|
||||
direction: "receivable",
|
||||
assetKey: "usd",
|
||||
quantity: "100",
|
||||
date: today,
|
||||
savingsAccountId: await acc("Birikim (Vadeli)"),
|
||||
}),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
const r = await createInKind(
|
||||
ayse,
|
||||
cakir,
|
||||
elif,
|
||||
inKindFormSchema.parse({
|
||||
direction: "receivable",
|
||||
assetKey: "usd",
|
||||
quantity: "100",
|
||||
date: today,
|
||||
}),
|
||||
);
|
||||
const id = r.ok ? r.data.id : "";
|
||||
expect((await view(id)).progress.currentValueMinor).toBe(4_200_00);
|
||||
const c0 = await balance(cuzdan);
|
||||
await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
paymentFormSchema.parse({ method: "cash", date: today, amount: "2.100", accountId: cuzdan }),
|
||||
);
|
||||
expect(await balance(cuzdan)).toBe(c0 + 2_100_00);
|
||||
expect((await view(id)).paidQuantity).toBe(50_000);
|
||||
});
|
||||
|
||||
it("gizli cari: aynî kayıt başkasına görünmez ve eklenemez", async () => {
|
||||
const secret = await contact("Gizli Altın", true);
|
||||
await createInKind(
|
||||
ayse,
|
||||
cakir,
|
||||
secret,
|
||||
inKindFormSchema.parse({
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "1",
|
||||
date: today,
|
||||
}),
|
||||
);
|
||||
expect((await listInKind(emre, cakir))!.some((v) => v.contactId === secret)).toBe(false);
|
||||
expect(await listInKind(emre, cakir, secret)).toBeNull();
|
||||
expect(
|
||||
(
|
||||
await createInKind(
|
||||
emre,
|
||||
cakir,
|
||||
secret,
|
||||
inKindFormSchema.parse({
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "1",
|
||||
date: today,
|
||||
}),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,617 @@
|
||||
import "server-only";
|
||||
import mongoose, { Types } 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 { writeAudit } from "@/lib/audit";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { addMovement } from "@/features/assets/service";
|
||||
import { AssetMovementModel } from "@/models/asset-movement";
|
||||
import { priceOn, type PriceQuote } from "./prices";
|
||||
import {
|
||||
ASSET_KEYS,
|
||||
formatAssetQuantity,
|
||||
inKindProgress,
|
||||
quantityForAmount,
|
||||
valuationPrice,
|
||||
valueOf,
|
||||
type AssetKey,
|
||||
type InKindDirection,
|
||||
type InKindProgress,
|
||||
} from "@/shared/assets";
|
||||
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||
import type {
|
||||
AssetPriceForm,
|
||||
ContributionForm,
|
||||
InKindForm,
|
||||
PaymentForm,
|
||||
} from "@/shared/schemas/in-kind";
|
||||
import { foldTr } from "@/shared/search";
|
||||
|
||||
/**
|
||||
* Aynî borç/alacak ve fiyat tablosu (Bölüm 6.5–6.6). Okuma: viewer; yazma: member.
|
||||
* Görünürlük cariye bağlıdır ("yalnızca ben" cari → kayıt da gizli).
|
||||
*/
|
||||
|
||||
const t = tr.inKind;
|
||||
|
||||
// --- Fiyat tablosu ---
|
||||
|
||||
export { priceOn, type PriceQuote } from "./prices";
|
||||
|
||||
export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] };
|
||||
|
||||
export async function listPrices(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<{ rows: PriceRow[]; canWrite: boolean } | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const all = await AssetPriceModel.find({ householdId: ctx.m.householdId })
|
||||
.sort({ date: -1 })
|
||||
.limit(1000)
|
||||
.lean();
|
||||
const rows = ASSET_KEYS.map((assetKey): PriceRow => {
|
||||
const history = all
|
||||
.filter((p) => p.assetKey === assetKey)
|
||||
.slice(0, 10)
|
||||
.map((p) => ({ buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date }));
|
||||
return { assetKey, latest: history[0] ?? null, history };
|
||||
});
|
||||
return { rows, canWrite: hasHouseholdRole(ctx.m.role, "member") };
|
||||
}
|
||||
|
||||
export async function savePrice(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: AssetPriceForm,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
if (input.date > todayIstanbul()) return fail(tr.validation.date, { date: tr.validation.date });
|
||||
await AssetPriceModel.updateOne(
|
||||
{ householdId: ctx.m.householdId, assetKey: input.assetKey, date: input.date },
|
||||
{
|
||||
$set: { buyMinor: input.buyMinor, sellMinor: input.sellMinor, source: "manual" },
|
||||
$setOnInsert: { createdBy: new Types.ObjectId(actorId) },
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Aynî kayıtlar ---
|
||||
|
||||
type PaymentDoc = {
|
||||
_id: Types.ObjectId;
|
||||
date: string;
|
||||
method: "asset" | "cash";
|
||||
quantity: number;
|
||||
amountMinor: number;
|
||||
accountId?: Types.ObjectId | null;
|
||||
transactionId?: Types.ObjectId | null;
|
||||
note: string;
|
||||
};
|
||||
|
||||
type DebtDoc = {
|
||||
_id: Types.ObjectId;
|
||||
householdId: Types.ObjectId;
|
||||
contactId: Types.ObjectId;
|
||||
direction: InKindDirection;
|
||||
assetKey: AssetKey;
|
||||
quantity: number;
|
||||
date: string;
|
||||
unitPriceMinor?: number | null;
|
||||
valueMinor?: number | null;
|
||||
note: string;
|
||||
savingsAccountId?: Types.ObjectId | null;
|
||||
monthlyPlanMinor?: number | null;
|
||||
payments: PaymentDoc[];
|
||||
closedAt?: string | null;
|
||||
};
|
||||
|
||||
export type InKindView = {
|
||||
id: string;
|
||||
contactId: string;
|
||||
contactName: string;
|
||||
direction: InKindDirection;
|
||||
assetKey: AssetKey;
|
||||
label: string;
|
||||
quantity: number;
|
||||
paidQuantity: number;
|
||||
date: string;
|
||||
unitPriceMinor: number | null;
|
||||
valueMinor: number | null;
|
||||
note: string;
|
||||
savingsAccount: { id: string; name: string } | null;
|
||||
monthlyPlanMinor: number | null;
|
||||
closedAt: string | null;
|
||||
currentPrice: PriceQuote | null;
|
||||
progress: InKindProgress;
|
||||
payments: {
|
||||
id: string;
|
||||
date: string;
|
||||
method: "asset" | "cash";
|
||||
quantity: number;
|
||||
amountMinor: number;
|
||||
account: string | null;
|
||||
note: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
function contactVisible(ctx: Ctx, contactId: Types.ObjectId | string) {
|
||||
const c = ctx.contactMap.get(String(contactId));
|
||||
return c && !c.hidden ? c : null;
|
||||
}
|
||||
|
||||
/** Birikim: borca bağlı katkılar (birikim hesabına giren) − birikim hesabından yapılan ödemeler. */
|
||||
async function savingsFlows(debtIds: Types.ObjectId[]) {
|
||||
const rows = await TransactionModel.aggregate<{
|
||||
_id: { debt: Types.ObjectId; role: string };
|
||||
sum: number;
|
||||
}>([
|
||||
{
|
||||
$match: {
|
||||
inKindDebtId: { $in: debtIds },
|
||||
status: "posted",
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: { debt: "$inKindDebtId", role: "$inKindRole" },
|
||||
sum: { $sum: "$amountMinor" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const map = new Map<string, { contributed: number }>();
|
||||
for (const r of rows) {
|
||||
if (r._id.role !== "contribution") continue;
|
||||
map.set(String(r._id.debt), { contributed: r.sum });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function toViews(ctx: Ctx, docs: DebtDoc[]): Promise<InKindView[]> {
|
||||
const today = todayIstanbul();
|
||||
const flows = await savingsFlows(docs.map((d) => d._id));
|
||||
const priceCache = new Map<string, PriceQuote | null>();
|
||||
const views: InKindView[] = [];
|
||||
for (const d of docs) {
|
||||
if (!priceCache.has(d.assetKey))
|
||||
priceCache.set(d.assetKey, await priceOn(ctx.m.householdId, d.assetKey, today));
|
||||
const price = priceCache.get(d.assetKey)!;
|
||||
const paidQuantity = d.payments.reduce((a, p) => a + p.quantity, 0);
|
||||
const savingsId = d.savingsAccountId ? String(d.savingsAccountId) : null;
|
||||
// Birikim hesabından yapılan ödemeler birikimden düşer.
|
||||
const spentMinor = d.payments
|
||||
.filter((p) => savingsId && String(p.accountId) === savingsId)
|
||||
.reduce((a, p) => a + p.amountMinor, 0);
|
||||
const savings = savingsId ? ctx.accountMap.get(savingsId) : undefined;
|
||||
views.push({
|
||||
id: String(d._id),
|
||||
contactId: String(d.contactId),
|
||||
contactName: ctx.contactMap.get(String(d.contactId))?.name ?? "—",
|
||||
direction: d.direction,
|
||||
assetKey: d.assetKey,
|
||||
label: formatAssetQuantity(d.assetKey, d.quantity),
|
||||
quantity: d.quantity,
|
||||
paidQuantity,
|
||||
date: d.date,
|
||||
unitPriceMinor: d.unitPriceMinor ?? null,
|
||||
valueMinor: d.valueMinor ?? null,
|
||||
note: d.note,
|
||||
savingsAccount: savings && !savings.hidden ? { id: savingsId!, name: savings.name } : null,
|
||||
monthlyPlanMinor: d.monthlyPlanMinor ?? null,
|
||||
closedAt: d.closedAt ?? null,
|
||||
currentPrice: price,
|
||||
progress: inKindProgress({
|
||||
quantity: d.quantity,
|
||||
paidQuantity,
|
||||
unitPriceMinor: valuationPrice(d.direction, price),
|
||||
contributedMinor: flows.get(String(d._id))?.contributed ?? 0,
|
||||
spentMinor,
|
||||
monthlyPlanMinor: d.direction === "debt" ? (d.monthlyPlanMinor ?? null) : null,
|
||||
today,
|
||||
}),
|
||||
payments: d.payments.map((p) => {
|
||||
const a = p.accountId ? ctx.accountMap.get(String(p.accountId)) : undefined;
|
||||
return {
|
||||
id: String(p._id),
|
||||
date: p.date,
|
||||
method: p.method,
|
||||
quantity: p.quantity,
|
||||
amountMinor: p.amountMinor,
|
||||
account: a ? (a.hidden ? tr.transactions.list.hiddenAccount : a.name) : null,
|
||||
note: p.note,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
/** Bir carinin (veya tüm görünür carilerin) aynî kayıtları; açıklar önce. */
|
||||
export async function listInKind(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
contactId?: string,
|
||||
): Promise<InKindView[] | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const filter: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null };
|
||||
if (contactId) {
|
||||
const id = toObjectId(contactId);
|
||||
if (!id || !contactVisible(ctx, id)) return null;
|
||||
filter.contactId = id;
|
||||
} else {
|
||||
filter.contactId = { $nin: ctx.hiddenContactIds };
|
||||
}
|
||||
const docs = await InKindDebtModel.find(filter).sort({ closedAt: 1, date: -1 }).lean<DebtDoc[]>();
|
||||
return toViews(
|
||||
ctx,
|
||||
docs.filter((d) => contactVisible(ctx, d.contactId)),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadDebt(ctx: Ctx, debtId: string) {
|
||||
const id = toObjectId(debtId);
|
||||
if (!id) return null;
|
||||
const d = await InKindDebtModel.findOne({
|
||||
_id: id,
|
||||
householdId: ctx.m.householdId,
|
||||
deletedAt: null,
|
||||
}).lean<DebtDoc>();
|
||||
return d && contactVisible(ctx, d.contactId) ? d : null;
|
||||
}
|
||||
|
||||
function checkAccount(ctx: Ctx, id: string | null) {
|
||||
if (!id) return null;
|
||||
const a = ctx.accountMap.get(id);
|
||||
return a && !a.hidden && !a.archived ? a : null;
|
||||
}
|
||||
|
||||
async function debtFields(ctx: Ctx, input: InKindForm) {
|
||||
let unitPriceMinor = input.unitPrice;
|
||||
if (unitPriceMinor === null) {
|
||||
const p = await priceOn(ctx.m.householdId, input.assetKey, input.date);
|
||||
unitPriceMinor = valuationPrice(input.direction, p);
|
||||
}
|
||||
if (unitPriceMinor === null) return fail(t.errors.noPrice, { unitPrice: t.errors.noPrice });
|
||||
if (input.savingsAccountId && input.direction !== "debt")
|
||||
return fail(t.errors.receivablePlan, { savingsAccountId: t.errors.receivablePlan });
|
||||
const savings = input.savingsAccountId ? checkAccount(ctx, input.savingsAccountId) : null;
|
||||
if (input.savingsAccountId && !savings)
|
||||
return fail(tr.transactions.validation.accountInvalid, {
|
||||
savingsAccountId: tr.transactions.validation.accountInvalid,
|
||||
});
|
||||
return ok({
|
||||
direction: input.direction,
|
||||
assetKey: input.assetKey,
|
||||
quantity: input.quantity,
|
||||
date: input.date,
|
||||
unitPriceMinor,
|
||||
valueMinor: valueOf(input.quantity, unitPriceMinor),
|
||||
note: input.note,
|
||||
savingsAccountId: savings?.id ?? null,
|
||||
monthlyPlanMinor: savings ? input.monthlyPlan : null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createInKind(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
contactId: string,
|
||||
input: InKindForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const cid = toObjectId(contactId);
|
||||
const contact = cid ? contactVisible(ctx, cid) : null;
|
||||
if (!contact) return fail(tr.common.notFound);
|
||||
if (contact.archived) return fail(tr.contacts.errors.archived);
|
||||
const fields = await debtFields(ctx, input);
|
||||
if (!fields.ok) return fields;
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const doc = await InKindDebtModel.create({
|
||||
...fields.data,
|
||||
householdId: ctx.m.householdId,
|
||||
contactId: contact.id,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
export async function updateInKind(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
debtId: string,
|
||||
input: InKindForm,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const d = await loadDebt(ctx, debtId);
|
||||
if (!d) return fail(tr.common.notFound);
|
||||
const paid = d.payments.reduce((a, p) => a + p.quantity, 0);
|
||||
if (input.quantity < paid)
|
||||
return fail(t.errors.quantityBelowPaid, { quantity: t.errors.quantityBelowPaid });
|
||||
// Ödeme varsa yön ve varlık değişemez (ödemeler o varlık cinsindendir).
|
||||
if (d.payments.length && (input.direction !== d.direction || input.assetKey !== d.assetKey))
|
||||
return fail(t.errors.closed);
|
||||
const fields = await debtFields(ctx, input);
|
||||
if (!fields.ok) return fields;
|
||||
await InKindDebtModel.updateOne(
|
||||
{ _id: d._id, householdId: ctx.m.householdId },
|
||||
{
|
||||
$set: {
|
||||
...fields.data,
|
||||
closedAt: paid >= input.quantity ? (d.closedAt ?? todayIstanbul()) : null,
|
||||
updatedBy: new Types.ObjectId(actorId),
|
||||
},
|
||||
},
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
export async function deleteInKind(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
debtId: string,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const d = await loadDebt(ctx, debtId);
|
||||
if (!d) return fail(tr.common.notFound);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await InKindDebtModel.updateOne(
|
||||
{ _id: d._id },
|
||||
{ $set: { deletedAt: new Date(), updatedBy: actor } },
|
||||
);
|
||||
// Bağlı varlık hareketleri serbest kalır (Birikim sayfasından yönetilebilir).
|
||||
await AssetMovementModel.updateMany(
|
||||
{ householdId: ctx.m.householdId, inKindDebtId: d._id },
|
||||
{ $set: { inKindDebtId: null } },
|
||||
);
|
||||
await writeAudit({
|
||||
action: "inkind.delete",
|
||||
actorId: actor,
|
||||
householdId: ctx.m.householdId,
|
||||
targetType: "inKindDebt",
|
||||
targetId: String(d._id),
|
||||
diff: { assetKey: d.assetKey, quantity: d.quantity, direction: d.direction },
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Birikim ve ödeme ---
|
||||
|
||||
/** Birikim katkısı: kaynak hesaptan borcun birikim hesabına transfer (borç kapanmaz). */
|
||||
export async function addContribution(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
debtId: string,
|
||||
input: ContributionForm,
|
||||
): Promise<ActionResult<{ transactionId: string }>> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const d = await loadDebt(ctx, debtId);
|
||||
if (!d) return fail(tr.common.notFound);
|
||||
if (d.closedAt) return fail(t.errors.closed);
|
||||
if (d.direction !== "debt" || !d.savingsAccountId) return fail(t.errors.noSavings);
|
||||
const from = checkAccount(ctx, input.fromAccountId);
|
||||
const to = checkAccount(ctx, String(d.savingsAccountId));
|
||||
if (!from || !to)
|
||||
return fail(tr.transactions.validation.accountInvalid, {
|
||||
fromAccountId: tr.transactions.validation.accountInvalid,
|
||||
});
|
||||
if (from.id.equals(to.id))
|
||||
return fail(t.errors.sameAccount, { fromAccountId: t.errors.sameAccount });
|
||||
const date = istanbulDayStart(input.date);
|
||||
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const contactName = ctx.contactMap.get(String(d.contactId))?.name ?? "";
|
||||
const description = t.contributionDescription(
|
||||
formatAssetQuantity(d.assetKey, d.quantity),
|
||||
contactName,
|
||||
);
|
||||
const tx = await TransactionModel.create({
|
||||
householdId: ctx.m.householdId,
|
||||
type: "transfer",
|
||||
status: "posted",
|
||||
date,
|
||||
accountId: from.id,
|
||||
toAccountId: to.id,
|
||||
amountMinor: input.amount,
|
||||
description,
|
||||
searchText: foldTr(description),
|
||||
contactId: d.contactId,
|
||||
inKindDebtId: d._id,
|
||||
inKindRole: "contribution",
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
return ok({ transactionId: String(tx._id) });
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ödeme gerçekleşti": kısmi ödeme miktarı düşürür, tam ödemede kayıt kapanır. TL tutar varsa
|
||||
* hesaptan çıkış (borç) veya hesaba giriş (alacak) işlemi aynı transaction içinde oluşturulur.
|
||||
*/
|
||||
export async function recordPayment(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
debtId: string,
|
||||
input: PaymentForm,
|
||||
): Promise<ActionResult<{ closed: boolean }>> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const d = await loadDebt(ctx, debtId);
|
||||
if (!d) return fail(tr.common.notFound);
|
||||
if (d.closedAt) return fail(t.errors.closed);
|
||||
if (input.date > todayIstanbul()) return fail(tr.validation.date, { date: tr.validation.date });
|
||||
const date = istanbulDayStart(input.date);
|
||||
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
|
||||
|
||||
const account = input.accountId ? checkAccount(ctx, input.accountId) : null;
|
||||
if (input.accountId && !account)
|
||||
return fail(tr.transactions.validation.accountInvalid, {
|
||||
accountId: tr.transactions.validation.accountInvalid,
|
||||
});
|
||||
|
||||
let quantity = input.quantity;
|
||||
if (quantity === null) {
|
||||
const price = valuationPrice(
|
||||
d.direction,
|
||||
await priceOn(ctx.m.householdId, d.assetKey, input.date),
|
||||
);
|
||||
if (!price) return fail(t.errors.noCurrentPrice, { quantity: t.errors.noCurrentPrice });
|
||||
quantity = quantityForAmount(input.amount ?? 0, price);
|
||||
}
|
||||
const paid = d.payments.reduce((a, p) => a + p.quantity, 0);
|
||||
const remaining = d.quantity - paid;
|
||||
quantity = Math.min(quantity, remaining);
|
||||
if (quantity <= 0) return fail(t.validation.quantity, { quantity: t.validation.quantity });
|
||||
const closed = paid + quantity >= d.quantity;
|
||||
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const amountMinor = input.amount ?? 0;
|
||||
const contactName = ctx.contactMap.get(String(d.contactId))?.name ?? "";
|
||||
const description = t.paymentDescription(
|
||||
d.direction === "debt",
|
||||
formatAssetQuantity(d.assetKey, quantity),
|
||||
contactName,
|
||||
);
|
||||
|
||||
if (input.assetId) {
|
||||
const asset = ctx.assetMap.get(input.assetId);
|
||||
if (!asset || asset.hidden || asset.deleted)
|
||||
return fail(tr.common.notFound, { assetId: tr.common.notFound });
|
||||
if (asset.assetKey !== d.assetKey)
|
||||
return fail(t.link.assetMismatch, { assetId: t.link.assetMismatch });
|
||||
}
|
||||
|
||||
const writePayment = async (session: mongoose.ClientSession) => {
|
||||
let transactionId: Types.ObjectId | null = null;
|
||||
if (amountMinor > 0 && account) {
|
||||
const out = d.direction === "debt";
|
||||
const [tx] = await TransactionModel.create(
|
||||
[
|
||||
{
|
||||
householdId: ctx.m.householdId,
|
||||
type: "transfer",
|
||||
status: "posted",
|
||||
date,
|
||||
// Borç ödemesi hesaptan çıkar; alacak tahsilatı hesaba girer.
|
||||
accountId: out ? account.id : null,
|
||||
toAccountId: out ? null : account.id,
|
||||
amountMinor,
|
||||
description,
|
||||
searchText: foldTr(description),
|
||||
contactId: d.contactId,
|
||||
inKindDebtId: d._id,
|
||||
inKindRole: "payment",
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
},
|
||||
],
|
||||
{ session },
|
||||
);
|
||||
transactionId = tx!._id;
|
||||
}
|
||||
await InKindDebtModel.updateOne(
|
||||
{ _id: d._id, householdId: ctx.m.householdId, closedAt: null },
|
||||
{
|
||||
$push: {
|
||||
payments: {
|
||||
date: input.date,
|
||||
method: input.method,
|
||||
quantity,
|
||||
amountMinor,
|
||||
accountId: account?.id ?? null,
|
||||
transactionId,
|
||||
note: input.note,
|
||||
createdBy: actor,
|
||||
},
|
||||
},
|
||||
$set: { closedAt: closed ? input.date : null, updatedBy: actor },
|
||||
},
|
||||
{ session },
|
||||
);
|
||||
await writeAudit(
|
||||
{
|
||||
action: closed ? "inkind.close" : "inkind.payment",
|
||||
actorId: actor,
|
||||
householdId: ctx.m.householdId,
|
||||
targetType: "inKindDebt",
|
||||
targetId: String(d._id),
|
||||
diff: { method: input.method, quantity, amountMinor, remaining: remaining - quantity },
|
||||
},
|
||||
session,
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
// Varlık bağlantısı: borç ödemesi varlıktan çıkış, alacak tahsilatı varlığa giriş.
|
||||
if (input.assetId) {
|
||||
const r = await addMovement(
|
||||
actorId,
|
||||
householdId,
|
||||
input.assetId,
|
||||
{
|
||||
kind: d.direction === "debt" ? "gift_out" : "gift_in",
|
||||
date: input.date,
|
||||
quantity,
|
||||
unitPrice: d.direction === "debt" ? null : (d.unitPriceMinor ?? null),
|
||||
amount: null,
|
||||
accountId: null,
|
||||
storage: "",
|
||||
note: t.link.movementNote(contactName),
|
||||
},
|
||||
{ inKindDebtId: d._id, session },
|
||||
);
|
||||
if (!r.ok) throw new LinkError(r.error, r.fieldErrors?.quantity);
|
||||
}
|
||||
await writePayment(session);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LinkError)
|
||||
return fail(error.message, error.quantity ? { quantity: error.quantity } : undefined);
|
||||
throw error;
|
||||
}
|
||||
return ok({ closed });
|
||||
}
|
||||
|
||||
/** Varlık hareketi eklenemedi: transaction geri alınır, hata kullanıcıya döner. */
|
||||
class LinkError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly quantity?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Cari listesi için açık aynî kayıt özetleri (cari kimliği → etiketler). */
|
||||
export async function openInKindByContact(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<Map<string, { direction: InKindDirection; label: string }[]>> {
|
||||
const views = (await listInKind(userId, householdId)) ?? [];
|
||||
const map = new Map<string, { direction: InKindDirection; label: string }[]>();
|
||||
for (const v of views) {
|
||||
if (v.closedAt) continue;
|
||||
const list = map.get(v.contactId) ?? [];
|
||||
list.push({
|
||||
direction: v.direction,
|
||||
label: formatAssetQuantity(v.assetKey, v.quantity - v.paidQuantity),
|
||||
});
|
||||
map.set(v.contactId, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import type { NetWorth } from "../service";
|
||||
|
||||
const t = tr.assets.netWorth;
|
||||
|
||||
/** Net varlık dökümü: artılar ve eksiler, altta toplam. */
|
||||
export function NetWorthCard({ data }: { data: NetWorth }) {
|
||||
const rows: { label: string; minor: number; sign: 1 | -1 }[] = [
|
||||
{ label: t.accounts, minor: data.accountsMinor, sign: 1 },
|
||||
{ label: t.assets, minor: data.assetsMinor, sign: 1 },
|
||||
{ label: t.contactReceivable, minor: data.contactReceivableMinor, sign: 1 },
|
||||
{ label: t.inKindReceivable, minor: data.inKindReceivableMinor, sign: 1 },
|
||||
{ label: t.cardDebt, minor: data.cardDebtMinor, sign: -1 },
|
||||
{ label: t.contactPayable, minor: data.contactPayableMinor, sign: -1 },
|
||||
{ label: t.inKindPayable, minor: data.inKindPayableMinor, sign: -1 },
|
||||
];
|
||||
const plus = rows.filter((r) => r.sign === 1);
|
||||
const minus = rows.filter((r) => r.sign === -1);
|
||||
const list = (items: typeof rows) => (
|
||||
<dl className="flex flex-col text-sm">
|
||||
{items.map((r) => (
|
||||
<div key={r.label} className="flex items-center justify-between gap-2 py-1">
|
||||
<dt className="text-muted-foreground">{r.label}</dt>
|
||||
<dd className="tabular-nums">
|
||||
{r.sign < 0 && r.minor ? "−" : ""}
|
||||
{formatMoney(r.minor)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-3xl font-semibold tracking-tight tabular-nums",
|
||||
data.totalMinor < 0 && "text-expense",
|
||||
)}
|
||||
>
|
||||
{formatMoney(data.totalMinor)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t.description}</p>
|
||||
</div>
|
||||
<div className="grid gap-x-8 gap-y-1 border-t pt-2 sm:grid-cols-2">
|
||||
{list(plus)}
|
||||
{list(minus)}
|
||||
</div>
|
||||
{data.missingPrice ? (
|
||||
<p className="text-xs text-muted-foreground">{t.missingPrice(data.missingPrice)}</p>
|
||||
) : null}
|
||||
{data.includesHidden ? (
|
||||
<p className="text-xs text-muted-foreground">{t.includesHidden}</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { addMovement, createAsset, getAsset, getPeriodReport } from "@/features/assets/service";
|
||||
import { createContactEntry, createContact } from "@/features/contacts/service";
|
||||
import {
|
||||
createInKind,
|
||||
deleteInKind,
|
||||
listInKind,
|
||||
recordPayment,
|
||||
savePrice,
|
||||
} from "@/features/in-kind/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AssetModel } from "@/models/asset";
|
||||
import { AssetMovementModel } from "@/models/asset-movement";
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays, addMonths } from "@/shared/recurrence";
|
||||
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||
import { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact";
|
||||
import {
|
||||
assetPriceFormSchema,
|
||||
inKindFormSchema,
|
||||
paymentFormSchema,
|
||||
} from "@/shared/schemas/in-kind";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import { getNetWorth } from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let emre = "";
|
||||
const today = todayIstanbul();
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
emre = ids.user("emre");
|
||||
// Seed varlık/fiyat/aynî örnekleri senaryoları etkilemesin.
|
||||
await Promise.all([
|
||||
AssetPriceModel.deleteMany({}),
|
||||
AssetModel.deleteMany({}),
|
||||
AssetMovementModel.deleteMany({}),
|
||||
InKindDebtModel.deleteMany({}),
|
||||
TransactionModel.deleteMany({
|
||||
$or: [{ assetId: { $ne: null } }, { inKindDebtId: { $ne: null } }],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
const acc = (name: string) =>
|
||||
AccountModel.findOne({ householdId: cakir, name })
|
||||
.lean()
|
||||
.then((a) => String(a!._id));
|
||||
const price = (assetKey: string, date: string, buy: string, sell = "") =>
|
||||
savePrice(ayse, cakir, assetPriceFormSchema.parse({ assetKey, date, buy, sell }));
|
||||
const mv = (input: Record<string, unknown>) => movementFormSchema.parse({ date: today, ...input });
|
||||
async function asset(name: string, assetKey = "gram_gold", isPrivate = false, by = ayse) {
|
||||
const r = await createAsset(by, cakir, assetFormSchema.parse({ assetKey, name, isPrivate }));
|
||||
return r.ok ? r.data.id : "";
|
||||
}
|
||||
async function contact(name: string) {
|
||||
const r = await createContact(
|
||||
ayse,
|
||||
cakir,
|
||||
contactFormSchema.parse({ name, openingDate: addDays(today, -200) }),
|
||||
);
|
||||
return r.ok ? r.data.id : "";
|
||||
}
|
||||
const netWorth = async (user = ayse) => (await getNetWorth(user, cakir))!;
|
||||
|
||||
describe("dönemsel rapor", () => {
|
||||
let gold = "";
|
||||
|
||||
it("son 6 ay: yatırılan, dönem sonu değer ve kâr; aylık noktalar", async () => {
|
||||
const m6 = addMonths(today, -6);
|
||||
await price("gram_gold", addDays(m6, -10), "2.500", "2.550");
|
||||
await price("gram_gold", addMonths(today, -3), "2.800", "2.850");
|
||||
await price("gram_gold", today, "3.000", "3.050");
|
||||
gold = await asset("Rapor altın");
|
||||
// Dönem öncesi 2 gr (başlangıç değeri), dönem içinde 10 gr + 1 gr.
|
||||
await addMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
mv({ kind: "buy", date: addDays(m6, -5), quantity: "2", amount: "5.000" }),
|
||||
);
|
||||
await addMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
mv({ kind: "buy", date: addDays(m6, 3), quantity: "10", amount: "25.000" }),
|
||||
);
|
||||
await addMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
mv({ kind: "buy", date: addMonths(today, -2), quantity: "1", amount: "2.850" }),
|
||||
);
|
||||
const r = (await getPeriodReport(ayse, cakir, "6", gold))!;
|
||||
expect(r.from).toBe(m6);
|
||||
expect(r.startValueMinor).toBe(5_000_00);
|
||||
expect(r.investedMinor).toBe(27_850_00);
|
||||
expect(r.endValueMinor).toBe(39_000_00);
|
||||
expect(r.profitMinor).toBe(39_000_00 - 5_000_00 - 27_850_00);
|
||||
expect(r.points[0]!.date).toBe(m6);
|
||||
expect(r.points.at(-1)).toMatchObject({
|
||||
date: today,
|
||||
valueMinor: 39_000_00,
|
||||
costMinor: 32_850_00,
|
||||
});
|
||||
expect(r.points.length).toBeGreaterThanOrEqual(7);
|
||||
// "Tümü" ilk hareketten başlar; başlangıç değeri 0, kâr = değer − maliyet.
|
||||
const all = (await getPeriodReport(ayse, cakir, "tum", gold))!;
|
||||
expect(all.startValueMinor).toBe(0);
|
||||
expect(all.profitMinor).toBe(39_000_00 - 32_850_00);
|
||||
});
|
||||
|
||||
it("toplam rapor gizli varlığı başkasına katmaz; fiyatsız varlık dışarıda sayılır", async () => {
|
||||
const secret = await asset("Gizli altın", "gram_gold", true, emre);
|
||||
await addMovement(emre, cakir, secret, mv({ kind: "buy", quantity: "5", amount: "15.000" }));
|
||||
const silver = await asset("Gümüş", "silver_gram");
|
||||
await addMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
silver,
|
||||
mv({ kind: "gift_in", quantity: "100", amount: "4.000" }),
|
||||
);
|
||||
const forAyse = (await getPeriodReport(ayse, cakir, "tum"))!;
|
||||
expect(forAyse.investedMinor).toBe(32_850_00);
|
||||
expect(forAyse.excluded).toBe(1);
|
||||
const forEmre = (await getPeriodReport(emre, cakir, "tum"))!;
|
||||
expect(forEmre.investedMinor).toBe(32_850_00 + 15_000_00);
|
||||
expect(await getPeriodReport(ayse, cakir, "6", secret)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("net varlık", () => {
|
||||
it("formül: hesaplar − kart borcu + varlıklar + alacaklar − borçlar", async () => {
|
||||
const n = await netWorth();
|
||||
expect(n.totalMinor).toBe(
|
||||
n.accountsMinor -
|
||||
n.cardDebtMinor +
|
||||
n.assetsMinor +
|
||||
n.contactReceivableMinor -
|
||||
n.contactPayableMinor +
|
||||
n.inKindReceivableMinor -
|
||||
n.inKindPayableMinor,
|
||||
);
|
||||
// Gümüşün fiyatı yok → katılmaz ve sayılır.
|
||||
expect(n.missingPrice).toBe(1);
|
||||
// Emre kendi gizli altınını da görür: 5 × 3.000.
|
||||
expect((await netWorth(emre)).assetsMinor - n.assetsMinor).toBe(15_000_00);
|
||||
});
|
||||
|
||||
it("satış fiyatından alım net varlığı yalnızca makas kadar düşürür; cariden borç almak değiştirmez", async () => {
|
||||
const gold = await asset("Makas altın");
|
||||
const n0 = (await netWorth()).totalMinor;
|
||||
await addMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
mv({ kind: "buy", quantity: "1", accountId: await acc("Ziraat Maaş") }),
|
||||
);
|
||||
expect((await netWorth()).totalMinor).toBe(n0 - 50_00);
|
||||
|
||||
const ali = await contact("Net Ali");
|
||||
const n1 = (await netWorth()).totalMinor;
|
||||
await createContactEntry(
|
||||
ayse,
|
||||
cakir,
|
||||
ali,
|
||||
contactEntrySchema.parse({
|
||||
kind: "borrow",
|
||||
accountId: await acc("Cüzdan"),
|
||||
amount: "1.000",
|
||||
date: today,
|
||||
}),
|
||||
);
|
||||
const n2 = await netWorth();
|
||||
expect(n2.totalMinor).toBe(n1);
|
||||
expect(n2.contactPayableMinor).toBeGreaterThanOrEqual(1_000_00);
|
||||
});
|
||||
|
||||
it("aynî borç satış, aynî alacak alış fiyatıyla düşülür/eklenir", async () => {
|
||||
const hasan = await contact("Net Hasan");
|
||||
const n0 = (await netWorth()).totalMinor;
|
||||
await createInKind(
|
||||
ayse,
|
||||
cakir,
|
||||
hasan,
|
||||
inKindFormSchema.parse({
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "2",
|
||||
date: today,
|
||||
}),
|
||||
);
|
||||
expect((await netWorth()).totalMinor).toBe(n0 - 6_100_00);
|
||||
await createInKind(
|
||||
ayse,
|
||||
cakir,
|
||||
hasan,
|
||||
inKindFormSchema.parse({
|
||||
direction: "receivable",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "1",
|
||||
date: today,
|
||||
}),
|
||||
);
|
||||
expect((await netWorth()).totalMinor).toBe(n0 - 6_100_00 + 3_000_00);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aynî kayıt ↔ varlık bağlantısı", () => {
|
||||
it("borç aynı varlıkla ödenirken varlıktan düşer; yetersizse hiçbir şey yazılmaz", async () => {
|
||||
const kasa = await asset("Kasa altın");
|
||||
await addMovement(ayse, cakir, kasa, mv({ kind: "buy", quantity: "3", amount: "9.000" }));
|
||||
const veli = await contact("Bağ Veli");
|
||||
const debt = await createInKind(
|
||||
ayse,
|
||||
cakir,
|
||||
veli,
|
||||
inKindFormSchema.parse({
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "5",
|
||||
date: today,
|
||||
}),
|
||||
);
|
||||
const debtId = debt.ok ? debt.data.id : "";
|
||||
|
||||
// 4 gr ödemek istiyor ama kasada 3 gr var → reddedilir, ödeme yazılmaz.
|
||||
const fail = await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
debtId,
|
||||
paymentFormSchema.parse({ method: "asset", date: today, quantity: "4", assetId: kasa }),
|
||||
);
|
||||
expect(fail.ok).toBe(false);
|
||||
expect((await InKindDebtModel.findById(debtId).lean())!.payments).toHaveLength(0);
|
||||
|
||||
const r = await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
debtId,
|
||||
paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: kasa }),
|
||||
);
|
||||
expect(r).toMatchObject({ ok: true, data: { closed: false } });
|
||||
const a = (await getAsset(ayse, cakir, kasa))!;
|
||||
expect(a.summary.quantity).toBe(1_000);
|
||||
expect(a.movements[0]).toMatchObject({ kind: "gift_out", quantity: 2_000, fromInKind: true });
|
||||
expect((await listInKind(ayse, cakir, veli))![0]!.paidQuantity).toBe(2_000);
|
||||
|
||||
// Kayıt silinince hareket serbest kalır.
|
||||
await deleteInKind(ayse, cakir, debtId);
|
||||
expect((await getAsset(ayse, cakir, kasa))!.movements[0]!.fromInKind).toBe(false);
|
||||
});
|
||||
|
||||
it("alacak aynı varlıkla tahsil edilince varlığa borç tarihindeki fiyatla eklenir", async () => {
|
||||
const ziynet = await asset("Ziynet kutusu", "quarter_gold");
|
||||
const ayten = await contact("Bağ Ayten");
|
||||
const rec = await createInKind(
|
||||
ayse,
|
||||
cakir,
|
||||
ayten,
|
||||
inKindFormSchema.parse({
|
||||
direction: "receivable",
|
||||
assetKey: "quarter_gold",
|
||||
quantity: "2",
|
||||
date: addDays(today, -30),
|
||||
unitPrice: "5.000",
|
||||
}),
|
||||
);
|
||||
const recId = rec.ok ? rec.data.id : "";
|
||||
const gold = await asset("Yanlış tür");
|
||||
expect(
|
||||
(
|
||||
await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
recId,
|
||||
paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: gold }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
expect(
|
||||
paymentFormSchema.safeParse({
|
||||
method: "cash",
|
||||
date: today,
|
||||
amount: "1",
|
||||
accountId: await acc("Cüzdan"),
|
||||
assetId: ziynet,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
|
||||
const r = await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
recId,
|
||||
paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: ziynet }),
|
||||
);
|
||||
expect(r).toMatchObject({ ok: true, data: { closed: true } });
|
||||
const a = (await getAsset(ayse, cakir, ziynet))!;
|
||||
expect(a.summary).toMatchObject({ quantity: 2_000, costMinor: 10_000_00, investedMinor: 0 });
|
||||
expect(a.movements[0]).toMatchObject({ kind: "gift_in", fromInKind: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import "server-only";
|
||||
import { listAccounts } from "@/features/accounts/service";
|
||||
import { listAssets } from "@/features/assets/service";
|
||||
import { listContacts } from "@/features/contacts/service";
|
||||
import { listInKind } from "@/features/in-kind/service";
|
||||
|
||||
/**
|
||||
* Net varlık (Bölüm 6.6): hesap bakiyeleri (kart borçları eksi) + varlıkların güncel değeri
|
||||
* (alış) + cari alacaklar − cari borçlar + aynî alacaklar (alış) − aynî borçlar (satış).
|
||||
* Görünürlük kaynak listelerin kurallarını izler (gizli varlık/cari sayılmaz; gizli hesap
|
||||
* bakiyeleri hane ayarına göre toplama girer).
|
||||
*/
|
||||
export type NetWorth = {
|
||||
accountsMinor: number;
|
||||
cardDebtMinor: number;
|
||||
assetsMinor: number;
|
||||
contactReceivableMinor: number;
|
||||
contactPayableMinor: number;
|
||||
inKindReceivableMinor: number;
|
||||
inKindPayableMinor: number;
|
||||
totalMinor: number;
|
||||
/** Fiyatı olmadığı için katılmayan varlık + aynî kayıt sayısı. */
|
||||
missingPrice: number;
|
||||
/** Başkasının gizli hesabının bakiyesi toplama dahil (hane ayarı). */
|
||||
includesHidden: boolean;
|
||||
};
|
||||
|
||||
export async function getNetWorth(userId: string, householdId: string): Promise<NetWorth | null> {
|
||||
const [accounts, assets, contacts, inKind] = await Promise.all([
|
||||
listAccounts(userId, householdId),
|
||||
listAssets(userId, householdId),
|
||||
listContacts(userId, householdId),
|
||||
listInKind(userId, householdId),
|
||||
]);
|
||||
if (!accounts || !assets || !contacts || !inKind) return null;
|
||||
let inKindReceivableMinor = 0;
|
||||
let inKindPayableMinor = 0;
|
||||
let missingPrice = assets.totals.missingPrice;
|
||||
for (const v of inKind) {
|
||||
if (v.closedAt || v.progress.remainingQuantity === 0) continue;
|
||||
const value = v.progress.currentValueMinor;
|
||||
if (value === null) {
|
||||
missingPrice++;
|
||||
continue;
|
||||
}
|
||||
if (v.direction === "debt") inKindPayableMinor += value;
|
||||
else inKindReceivableMinor += value;
|
||||
}
|
||||
const r = {
|
||||
accountsMinor: accounts.totals.assetsMinor,
|
||||
cardDebtMinor: -accounts.totals.cardDebtMinor,
|
||||
assetsMinor: assets.totals.valueMinor,
|
||||
contactReceivableMinor: contacts.totals.owesMeMinor,
|
||||
contactPayableMinor: contacts.totals.iOweMinor,
|
||||
inKindReceivableMinor,
|
||||
inKindPayableMinor,
|
||||
};
|
||||
return {
|
||||
...r,
|
||||
totalMinor:
|
||||
r.accountsMinor -
|
||||
r.cardDebtMinor +
|
||||
r.assetsMinor +
|
||||
r.contactReceivableMinor -
|
||||
r.contactPayableMinor +
|
||||
r.inKindReceivableMinor -
|
||||
r.inKindPayableMinor,
|
||||
missingPrice,
|
||||
includesHidden: accounts.totals.includesHidden,
|
||||
};
|
||||
}
|
||||
@@ -196,7 +196,7 @@ describe("kural ve planlı kayıtlar", () => {
|
||||
type: "expense",
|
||||
amount: 777_00,
|
||||
date: d.date,
|
||||
accountId: d.account.id,
|
||||
accountId: d.account!.id,
|
||||
toAccountId: null,
|
||||
categoryId: d.categoryId,
|
||||
splits: [],
|
||||
|
||||
@@ -376,7 +376,8 @@ export async function confirmOccurrence(
|
||||
ctx.m.householdId,
|
||||
tx.payeeName ?? "",
|
||||
tx.type,
|
||||
tx.accountId,
|
||||
// Planlı kayıtlar cari hareketi değildir; kaynak hesap her zaman dolu.
|
||||
tx.accountId!,
|
||||
tx.categoryId ?? null,
|
||||
);
|
||||
await TransactionModel.updateOne(
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import {
|
||||
productTypeFormSchema,
|
||||
stockCategoryFormSchema,
|
||||
stockCountFormSchema,
|
||||
stockCountSheetSchema,
|
||||
stockMovementFormSchema,
|
||||
} from "@/shared/schemas/stock";
|
||||
import {
|
||||
addStockMovement,
|
||||
countStock,
|
||||
createProductType,
|
||||
createStockCategory,
|
||||
deleteStockMovement,
|
||||
getStockOptions,
|
||||
quickConsume,
|
||||
setProductTypeArchived,
|
||||
setStockCategoryArchived,
|
||||
submitCount,
|
||||
updateProductType,
|
||||
updateStockCategory,
|
||||
type StockOption,
|
||||
} 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;
|
||||
}
|
||||
|
||||
function withId<R>(
|
||||
raw: unknown,
|
||||
fn: (id: string) => Promise<ActionResult<R>>,
|
||||
): Promise<ActionResult<R>> {
|
||||
const id = idSchema.safeParse(raw);
|
||||
if (!id.success) return Promise.resolve(fail(tr.common.notFound));
|
||||
return fn(id.data);
|
||||
}
|
||||
|
||||
/** Yeni (id boş) veya mevcut stok kategorisi. */
|
||||
export async function saveStockCategoryAction(categoryId: unknown, input: unknown) {
|
||||
if (categoryId)
|
||||
return withId(categoryId, (id) =>
|
||||
run(stockCategoryFormSchema, input, (u, h, d) => updateStockCategory(u, h, id, d)),
|
||||
);
|
||||
return run(stockCategoryFormSchema, input, createStockCategory);
|
||||
}
|
||||
|
||||
export async function setStockCategoryArchivedAction(categoryId: unknown, archived: unknown) {
|
||||
return withId(categoryId, (id) =>
|
||||
run(z.boolean(), archived, (u, h, a) => setStockCategoryArchived(u, h, id, a)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Yeni (id boş) veya mevcut ürün tipi. */
|
||||
export async function saveProductTypeAction(productTypeId: unknown, input: unknown) {
|
||||
if (productTypeId)
|
||||
return withId(productTypeId, (id) =>
|
||||
run(productTypeFormSchema, input, (u, h, d) => updateProductType(u, h, id, d)),
|
||||
);
|
||||
return run(productTypeFormSchema, input, createProductType);
|
||||
}
|
||||
|
||||
export async function setProductTypeArchivedAction(productTypeId: unknown, archived: unknown) {
|
||||
return withId(productTypeId, (id) =>
|
||||
run(z.boolean(), archived, (u, h, a) => setProductTypeArchived(u, h, id, a)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function addStockMovementAction(productTypeId: unknown, input: unknown) {
|
||||
return withId(productTypeId, (id) =>
|
||||
run(stockMovementFormSchema, input, (u, h, d) => addStockMovement(u, h, id, d)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function quickConsumeAction(productTypeId: unknown) {
|
||||
return withId(productTypeId, (id) => run(z.unknown(), null, (u, h) => quickConsume(u, h, id)));
|
||||
}
|
||||
|
||||
export async function countStockAction(productTypeId: unknown, input: unknown) {
|
||||
return withId(productTypeId, (id) =>
|
||||
run(stockCountFormSchema, input, (u, h, d) => countStock(u, h, id, d)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Toplu sayım (/stok/sayim). */
|
||||
export async function submitCountAction(input: unknown) {
|
||||
return run(stockCountSheetSchema, input, submitCount);
|
||||
}
|
||||
|
||||
export async function deleteStockMovementAction(movementId: unknown) {
|
||||
return withId(movementId, (id) =>
|
||||
run(z.unknown(), null, (u, h) => deleteStockMovement(u, h, id)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Ürün tipi ve stok kategorisi formları için seçenekler. */
|
||||
export async function getStockOptionsAction(): Promise<
|
||||
ActionResult<{ categories: StockOption[]; expenseCategories: StockOption[] }>
|
||||
> {
|
||||
const ctx = await getHouseholdActionContext("viewer");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const data = await getStockOptions(ctx.user.id, ctx.household.id);
|
||||
return data ? ok(data) : fail(tr.common.unauthorized);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, ClipboardCheck, Eraser, ListChecks } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseQuantity } from "@/shared/quantity";
|
||||
import { formatStock, quantityInput, WASTE_REASONS, type WasteReason } from "@/shared/stock";
|
||||
import { submitCountAction } from "../actions";
|
||||
import type { CountSheet as Data, CountSheetRow } from "../service";
|
||||
import { ymdLabel } from "./stock-view";
|
||||
|
||||
const t = tr.stock;
|
||||
const ALL = "__all__";
|
||||
|
||||
type Entry = { actual: string; waste: string; reason: WasteReason | "" };
|
||||
const EMPTY: Entry = { actual: "", waste: "", reason: "" };
|
||||
|
||||
function signed(q: number, unit: string) {
|
||||
if (q === 0) return t.count.same;
|
||||
return `${q > 0 ? "+" : "−"}${formatStock(Math.abs(q), unit)}`;
|
||||
}
|
||||
|
||||
/** /stok/sayim — liste hâlinde beklenen → sayılan; fark sayım düzeltmesi, bir kısmı fire. */
|
||||
export function CountSheet({ data }: { data: Data }) {
|
||||
const router = useRouter();
|
||||
const [entries, setEntries] = useState<Record<string, Entry>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [onlyDue, setOnlyDue] = useState(data.rows.some((r) => r.count?.due));
|
||||
const [note, setNote] = useState("");
|
||||
const [pending, start] = useTransition();
|
||||
|
||||
const rows = useMemo(
|
||||
() => (onlyDue ? data.rows.filter((r) => r.count?.due) : data.rows),
|
||||
[data.rows, onlyDue],
|
||||
);
|
||||
const groups = useMemo(() => {
|
||||
const by = new Map<string, { path: string; rows: CountSheetRow[] }>();
|
||||
for (const r of rows) {
|
||||
const g = by.get(r.categoryId) ?? { path: r.categoryPath, rows: [] };
|
||||
g.rows.push(r);
|
||||
by.set(r.categoryId, g);
|
||||
}
|
||||
return [...by.values()];
|
||||
}, [rows]);
|
||||
const dueTotal = data.rows.filter((r) => r.count?.due).length;
|
||||
const filled = data.rows.filter((r) => entries[r.id]?.actual.trim()).length;
|
||||
|
||||
const set = (id: string, patch: Partial<Entry>) =>
|
||||
setEntries((e) => ({ ...e, [id]: { ...(e[id] ?? EMPTY), ...patch } }));
|
||||
|
||||
const fillExpected = () =>
|
||||
setEntries((e) => {
|
||||
const next = { ...e };
|
||||
for (const r of rows)
|
||||
if (!next[r.id]?.actual.trim())
|
||||
next[r.id] = { ...(next[r.id] ?? EMPTY), actual: quantityInput(Math.max(0, r.expected)) };
|
||||
return next;
|
||||
});
|
||||
|
||||
const submit = () =>
|
||||
start(async () => {
|
||||
const sent = data.rows.filter((r) => entries[r.id]?.actual.trim());
|
||||
const r = await submitCountAction({
|
||||
date: data.today,
|
||||
categoryId: data.category?.id ?? "",
|
||||
note,
|
||||
rows: sent.map((row) => {
|
||||
const e = entries[row.id]!;
|
||||
// Fire alanı yalnızca sayımda eksik çıkınca geçerlidir.
|
||||
const actual = parseQuantity(e.actual);
|
||||
const short = actual !== null && actual < row.expected;
|
||||
return {
|
||||
productTypeId: row.id,
|
||||
actualQuantity: e.actual,
|
||||
wasteQuantity: short ? e.waste : "",
|
||||
wasteReason: short ? e.reason : "",
|
||||
};
|
||||
}),
|
||||
});
|
||||
if (r.ok) {
|
||||
const d = r.data as { counted: number; adjusted: number };
|
||||
toast.success(t.count.done(d.counted, d.adjusted));
|
||||
setEntries({});
|
||||
setErrors({});
|
||||
setNote("");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
const next: Record<string, string> = {};
|
||||
for (const [key, msg] of Object.entries(r.fieldErrors ?? {})) {
|
||||
const m = /^rows\.(\d+)\.(\w+)$/.exec(key);
|
||||
const row = m ? sent[Number(m[1])] : undefined;
|
||||
if (row && msg) next[`${row.id}.${m![2]}`] = msg;
|
||||
}
|
||||
setErrors(next);
|
||||
toast.error(r.fieldErrors?.rows ?? r.error);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/stok">
|
||||
<ArrowLeft />
|
||||
{t.count.back}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader
|
||||
title={data.category ? `${t.count.title}: ${data.category.path}` : t.count.title}
|
||||
description={t.count.description}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
value={data.category?.id ?? ALL}
|
||||
onValueChange={(v) =>
|
||||
router.push(v === ALL ? "/stok/sayim" : `/stok/sayim?kategori=${v}`)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-72" aria-label={t.count.category}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t.count.allCategories}</SelectItem>
|
||||
{data.categories.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<span style={{ paddingLeft: c.depth * 12 }}>{c.path.split(" › ").at(-1)}</span>
|
||||
{c.due ? (
|
||||
<span className="text-xs text-amber-700 dark:text-amber-400">
|
||||
{" "}
|
||||
· {t.count.dueCount(c.due)}
|
||||
</span>
|
||||
) : null}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{dueTotal > 0 ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={onlyDue} onCheckedChange={(x) => setOnlyDue(x === true)} />
|
||||
{t.count.onlyDue} ({dueTotal})
|
||||
</label>
|
||||
) : null}
|
||||
{data.canWrite && rows.length ? (
|
||||
<div className="flex gap-2 sm:ml-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={fillExpected}
|
||||
title={t.count.fillExpectedHint}
|
||||
>
|
||||
<ListChecks />
|
||||
{t.count.fillExpected}
|
||||
</Button>
|
||||
{filled ? (
|
||||
<Button size="sm" variant="ghost" onClick={() => setEntries({})}>
|
||||
<Eraser />
|
||||
{t.count.clear}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.count.empty}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{groups.map((g) => (
|
||||
<section key={g.path} className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">{g.path}</h2>
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{g.rows.map((r) => (
|
||||
<CountRow
|
||||
key={r.id}
|
||||
row={r}
|
||||
entry={entries[r.id] ?? EMPTY}
|
||||
errors={errors}
|
||||
disabled={!data.canWrite || pending}
|
||||
onChange={(patch) => set(r.id, patch)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{data.canWrite && data.rows.length ? (
|
||||
<div className="sticky bottom-[calc(4rem+env(safe-area-inset-bottom))] z-10 -mx-4 flex flex-wrap items-center gap-3 border-t bg-background/95 px-4 py-3 backdrop-blur md:bottom-0 md:mx-0 md:rounded-xl md:border">
|
||||
<Input
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t.count.note}
|
||||
aria-label={t.count.note}
|
||||
maxLength={300}
|
||||
className="min-w-40 flex-1"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{t.count.counted(filled)}
|
||||
</span>
|
||||
<Button onClick={submit} disabled={pending || filled === 0}>
|
||||
<ClipboardCheck />
|
||||
{t.count.submit}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountRow({
|
||||
row,
|
||||
entry,
|
||||
errors,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
row: CountSheetRow;
|
||||
entry: Entry;
|
||||
errors: Record<string, string>;
|
||||
disabled: boolean;
|
||||
onChange: (patch: Partial<Entry>) => void;
|
||||
}) {
|
||||
const actual = entry.actual.trim() ? parseQuantity(entry.actual) : null;
|
||||
const diff = actual === null ? null : actual - row.expected;
|
||||
const shortage = diff !== null && diff < 0 ? -diff : 0;
|
||||
const actualError = errors[`${row.id}.actualQuantity`];
|
||||
const wasteError = errors[`${row.id}.wasteQuantity`] ?? errors[`${row.id}.wasteReason`];
|
||||
const inputId = `count-${row.id}`;
|
||||
return (
|
||||
<li className="flex flex-col gap-2 p-3">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<div className="min-w-0 flex-1 basis-40">
|
||||
<label htmlFor={inputId} className="flex flex-wrap items-center gap-1.5 font-medium">
|
||||
<span className="truncate">{row.name}</span>
|
||||
{row.count?.due ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{t.count.dueBadge(row.count.overdueDays)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</label>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{[
|
||||
row.location,
|
||||
row.count
|
||||
? row.count.lastDate
|
||||
? t.count.lastCount(ymdLabel(row.count.lastDate))
|
||||
: t.count.neverCounted
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-20 text-right text-sm tabular-nums">
|
||||
<p className="text-xs text-muted-foreground">{t.count.expected}</p>
|
||||
{formatStock(Math.max(0, row.expected), row.unit)}
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.count.actual} ({row.unit})
|
||||
</span>
|
||||
<Input
|
||||
id={inputId}
|
||||
inputMode="decimal"
|
||||
value={entry.actual}
|
||||
onChange={(e) => onChange({ actual: e.target.value })}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(actualError) || (entry.actual.trim() !== "" && actual === null)}
|
||||
className="h-8 tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20 text-right text-sm tabular-nums">
|
||||
<p className="text-xs text-muted-foreground">{t.count.diff}</p>
|
||||
<span
|
||||
className={cn(
|
||||
diff === null
|
||||
? "text-muted-foreground"
|
||||
: diff > 0
|
||||
? "text-income"
|
||||
: diff < 0
|
||||
? "text-expense"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{diff === null ? "—" : signed(diff, row.unit)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{actualError ? <p className="text-xs text-destructive">{actualError}</p> : null}
|
||||
{shortage > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-muted/50 p-2 text-sm">
|
||||
<span className="text-xs text-muted-foreground">{t.count.markWaste}:</span>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={entry.waste}
|
||||
onChange={(e) => onChange({ waste: e.target.value })}
|
||||
disabled={disabled}
|
||||
placeholder={t.count.wasteMax(formatStock(shortage, row.unit))}
|
||||
aria-label={`${row.name}: ${t.count.wasteQuantity}`}
|
||||
aria-invalid={Boolean(errors[`${row.id}.wasteQuantity`])}
|
||||
className="h-8 w-36 tabular-nums"
|
||||
/>
|
||||
<Select
|
||||
value={entry.reason}
|
||||
onValueChange={(v) => onChange({ reason: v as WasteReason })}
|
||||
disabled={disabled || !entry.waste.trim()}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-48"
|
||||
aria-label={`${row.name}: ${t.fields.wasteReason}`}
|
||||
aria-invalid={Boolean(errors[`${row.id}.wasteReason`])}
|
||||
>
|
||||
<SelectValue placeholder={t.fields.wasteReason} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WASTE_REASONS.map((w) => (
|
||||
<SelectItem key={w} value={w}>
|
||||
{t.wasteReasons[w]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{wasteError ? <p className="w-full text-xs text-destructive">{wasteError}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ClipboardCheck, Clock } from "lucide-react";
|
||||
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 { formatStock } from "@/shared/stock";
|
||||
import { getStock } from "../service";
|
||||
|
||||
const t = tr.stock;
|
||||
const LIMIT = 5;
|
||||
|
||||
/** Dashboard: eşiğin altındaki ürünler, SKT'si yaklaşan partiler, sayımı gelen kategoriler. */
|
||||
export async function LowStockCard({
|
||||
userId,
|
||||
householdId,
|
||||
}: {
|
||||
userId: string;
|
||||
householdId: string;
|
||||
}) {
|
||||
const data = await getStock(userId, householdId);
|
||||
if (!data || (!data.suggestions.length && !data.expiring.length && !data.countDue.length))
|
||||
return null;
|
||||
const low = data.suggestions.slice(0, LIMIT);
|
||||
const expiring = data.expiring.slice(0, Math.max(0, LIMIT - low.length) || 2);
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.dashboard.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{t.dashboard.description(
|
||||
data.suggestions.length,
|
||||
data.expiring.length,
|
||||
data.countDue.length,
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<ul className="divide-y text-sm">
|
||||
{low.map((i) => (
|
||||
<li key={i.id} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<Link href={`/stok/${i.id}`} className="min-w-0 truncate hover:underline">
|
||||
{i.name}
|
||||
</Link>
|
||||
<span className="shrink-0 text-xs tabular-nums">
|
||||
<span className="text-expense">{formatStock(Math.max(0, i.current), i.unit)}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" · "}
|
||||
{t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{expiring.map(({ item, lot }) => (
|
||||
<li
|
||||
key={`${item.id}-${lot.index}`}
|
||||
className="flex items-center justify-between gap-3 py-1.5"
|
||||
>
|
||||
<Link href={`/stok/${item.id}`} className="min-w-0 truncate hover:underline">
|
||||
{item.name}
|
||||
</Link>
|
||||
<span
|
||||
className={
|
||||
lot.status === "expired"
|
||||
? "flex shrink-0 items-center gap-1 text-xs text-expense"
|
||||
: "flex shrink-0 items-center gap-1 text-xs text-amber-700 dark:text-amber-400"
|
||||
}
|
||||
>
|
||||
<Clock className="size-3" />
|
||||
{t.expiring.on(
|
||||
new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" }).format(
|
||||
new Date(`${lot.expiryDate}T12:00:00+03:00`),
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.countDue.slice(0, 3).map((g) => (
|
||||
<li
|
||||
key={`count-${g.categoryId}`}
|
||||
className="flex items-center justify-between gap-3 py-1.5"
|
||||
>
|
||||
<Link
|
||||
href={`/stok/sayim?kategori=${g.categoryId}`}
|
||||
className="min-w-0 truncate hover:underline"
|
||||
>
|
||||
{g.path}
|
||||
</Link>
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs text-amber-700 dark:text-amber-400">
|
||||
<ClipboardCheck className="size-3" />
|
||||
{t.count.dueBadge(g.overdueDays)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{data.suggestions.length > low.length ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.dashboard.more(data.suggestions.length - low.length)}
|
||||
</p>
|
||||
) : null}
|
||||
<Button size="sm" variant="outline" className="w-fit" asChild>
|
||||
<Link href="/stok">{t.dashboard.open}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ArrowLeft,
|
||||
ClipboardCheck,
|
||||
EllipsisVertical,
|
||||
Minus,
|
||||
Pencil,
|
||||
ShoppingBag,
|
||||
Trash,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } 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 { formatStock, roundForDisplay } from "@/shared/stock";
|
||||
import { deleteStockMovementAction, setProductTypeArchivedAction } from "../actions";
|
||||
import type { ProductTypeDetail, StockMovementRow, StockOption } from "../service";
|
||||
import {
|
||||
ProductTypeFormDialog,
|
||||
StockCountDialog,
|
||||
StockMovementDialog,
|
||||
type MovementKindInput,
|
||||
} from "./stock-dialogs";
|
||||
import {
|
||||
ExpiryBadge,
|
||||
LevelBadge,
|
||||
QuickConsumeButton,
|
||||
StockBar,
|
||||
suggestionReasons,
|
||||
ymdLabel,
|
||||
} from "./stock-view";
|
||||
|
||||
const t = tr.stock;
|
||||
|
||||
type Pending =
|
||||
| { kind: "edit" }
|
||||
| { kind: "movement"; movement: MovementKindInput }
|
||||
| { kind: "count" }
|
||||
| { kind: "archive" }
|
||||
| { kind: "deleteMovement"; row: StockMovementRow }
|
||||
| null;
|
||||
|
||||
function Stat({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<div className="font-semibold tabular-nums">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** /stok/:id — mevcut durum, partiler (SKT) ve hareketler. */
|
||||
export function ProductDetailView({
|
||||
data,
|
||||
categories,
|
||||
}: {
|
||||
data: ProductTypeDetail;
|
||||
categories: StockOption[];
|
||||
}) {
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const close = () => setPending(null);
|
||||
const q = (x: number) => formatStock(x, data.unit);
|
||||
const active = data.canWrite && !data.archived;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/stok">
|
||||
<ArrowLeft />
|
||||
{t.detail.back}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader
|
||||
title={data.name}
|
||||
description={data.categoryPath}
|
||||
actions={
|
||||
data.canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="outline" aria-label={tr.recurring.actions.more}>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={data.archived}
|
||||
onSelect={() => setPending({ kind: "edit" })}
|
||||
>
|
||||
<Pencil />
|
||||
{tr.common.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{data.archived ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={async () => {
|
||||
const r = await setProductTypeArchivedAction(data.id, false);
|
||||
if (r.ok) toast.success(t.toast.unarchived);
|
||||
else toast.error(r.error);
|
||||
}}
|
||||
>
|
||||
<ArchiveRestore />
|
||||
{t.detail.unarchive}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setPending({ kind: "archive" })}
|
||||
>
|
||||
<Archive />
|
||||
{t.detail.archive}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div className="-mt-2 flex flex-wrap gap-1.5">
|
||||
{data.archived ? <Badge variant="secondary">{t.detail.archived}</Badge> : null}
|
||||
<LevelBadge item={data} />
|
||||
</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.detail.current}>
|
||||
<span className={cn(data.level.low && "text-expense")}>
|
||||
{q(Math.max(0, data.current))}
|
||||
</span>
|
||||
</Stat>
|
||||
<Stat label={t.detail.target}>{q(data.targetQuantity)}</Stat>
|
||||
<Stat label={t.detail.threshold}>
|
||||
{q(data.level.thresholdQuantity)}{" "}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
(%{data.thresholdPct})
|
||||
</span>
|
||||
</Stat>
|
||||
<Stat label={t.detail.suggested}>{q(data.suggestedQuantity)}</Stat>
|
||||
</div>
|
||||
<StockBar item={data} />
|
||||
{suggestionReasons(data).length ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs",
|
||||
data.suggestion.waste
|
||||
? "text-amber-700 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{suggestionReasons(data).join(" ")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
|
||||
<Stat label={t.detail.usage}>
|
||||
{data.suggestion.usage ? (
|
||||
t.detail.perWeek(q(roundForDisplay(data.suggestion.usage.perDay * 7)))
|
||||
) : (
|
||||
<span className="font-normal text-muted-foreground">{t.detail.noUsage}</span>
|
||||
)}
|
||||
</Stat>
|
||||
<Stat label={t.detail.countSchedule}>
|
||||
{data.count ? (
|
||||
<span className="flex flex-wrap items-center gap-x-2">
|
||||
<span>{t.detail.countEvery(data.count.everyDays)}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
data.count.due
|
||||
? "text-amber-700 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{data.count.due
|
||||
? tr.stock.count.dueBadge(data.count.overdueDays)
|
||||
: t.detail.nextCount(ymdLabel(data.count.nextDate))}
|
||||
{data.count.lastDate
|
||||
? ` · ${tr.stock.count.lastCount(ymdLabel(data.count.lastDate))}`
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{t.detail.noCountSchedule}
|
||||
</span>
|
||||
)}
|
||||
</Stat>
|
||||
</div>
|
||||
{data.location ? (
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">{t.detail.location}:</span> {data.location}
|
||||
</p>
|
||||
) : null}
|
||||
{data.note ? <p className="text-sm text-muted-foreground">{data.note}</p> : null}
|
||||
{active ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<QuickConsumeButton item={data} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPending({ kind: "movement", movement: "consume" })}
|
||||
>
|
||||
<Minus />
|
||||
{t.actions.consume}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPending({ kind: "movement", movement: "waste" })}
|
||||
>
|
||||
<Trash />
|
||||
{t.actions.waste}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setPending({ kind: "count" })}>
|
||||
<ClipboardCheck />
|
||||
{t.actions.count}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPending({ kind: "movement", movement: "purchase" })}
|
||||
>
|
||||
<ShoppingBag />
|
||||
{t.actions.purchase}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.detail.lots}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t.detail.lotsHint}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.lots.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t.detail.noLots}</p>
|
||||
) : (
|
||||
<ul className="divide-y text-sm">
|
||||
{data.lots.map((lot, i) => (
|
||||
<li key={i} className="flex items-center justify-between gap-3 py-2">
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium tabular-nums">{q(lot.remaining)}</span>
|
||||
<span className="text-xs text-muted-foreground"> · {ymdLabel(lot.date)}</span>
|
||||
</span>
|
||||
{lot.expiryDate && lot.status && lot.status !== "ok" ? (
|
||||
<ExpiryBadge lot={{ expiryDate: lot.expiryDate, status: lot.status }} />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{lot.expiryDate ? t.expiring.on(ymdLabel(lot.expiryDate)) : t.detail.noExpiry}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="flex flex-col gap-2" aria-labelledby="stock-movements-title">
|
||||
<h2 id="stock-movements-title" className="text-base font-medium">
|
||||
{t.detail.movements}
|
||||
</h2>
|
||||
{data.movements.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.detail.noMovements}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{data.movements.map((row) => (
|
||||
<li key={row.id} className="flex items-start gap-3 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-x-2 text-sm font-medium">
|
||||
<span>{row.initial ? t.initial : t.kinds[row.kind]}</span>
|
||||
<span
|
||||
className={cn("tabular-nums", row.delta > 0 ? "text-income" : "text-expense")}
|
||||
>
|
||||
{row.delta > 0 ? "+" : "−"}
|
||||
{q(Math.abs(row.delta))}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ymdLabel(row.date)}
|
||||
{row.wasteReason ? ` · ${t.wasteReasons[row.wasteReason]}` : ""}
|
||||
{row.expiryDate ? ` · ${t.expiring.on(ymdLabel(row.expiryDate))}` : ""}
|
||||
{row.note ? ` · ${row.note}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{t.detail.after(q(row.quantityAfter))}
|
||||
</span>
|
||||
{data.canWrite ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t.detail.deleteMovement}
|
||||
onClick={() => setPending({ kind: "deleteMovement", row })}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{pending?.kind === "edit" ? (
|
||||
<ProductTypeFormDialog item={data} categories={categories} onClose={close} />
|
||||
) : null}
|
||||
{pending?.kind === "movement" ? (
|
||||
<StockMovementDialog item={data} initialKind={pending.movement} onClose={close} />
|
||||
) : null}
|
||||
{pending?.kind === "count" ? <StockCountDialog item={data} onClose={close} /> : null}
|
||||
<AlertDialog
|
||||
open={pending?.kind === "archive" || pending?.kind === "deleteMovement"}
|
||||
onOpenChange={(o) => !o && close()}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{pending?.kind === "archive" ? t.detail.archiveTitle : t.detail.deleteMovementTitle}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pending?.kind === "archive"
|
||||
? t.detail.archiveDescription(data.name)
|
||||
: t.detail.deleteMovementDescription}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const r =
|
||||
pending?.kind === "archive"
|
||||
? await setProductTypeArchivedAction(data.id, true)
|
||||
: pending?.kind === "deleteMovement"
|
||||
? await deleteStockMovementAction(pending.row.id)
|
||||
: null;
|
||||
const wasArchive = pending?.kind === "archive";
|
||||
close();
|
||||
if (!r) return;
|
||||
if (r.ok) toast.success(wasArchive ? t.toast.archived : t.toast.deleted);
|
||||
else toast.error(r.error);
|
||||
}}
|
||||
>
|
||||
{pending?.kind === "archive" ? t.detail.archive : t.detail.deleteMovement}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { ArrowLeft } 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 { cn } from "@/lib/utils";
|
||||
import { formatStock } from "@/shared/stock";
|
||||
import type { StockAnalysis, StockAnalysisRow } from "../service";
|
||||
|
||||
const t = tr.stock.analysis;
|
||||
/** Bu oranın (%) üstündeki fire vurgulanır. */
|
||||
const HIGH_WASTE_PCT = 15;
|
||||
|
||||
const monthShort = (month: string) =>
|
||||
new Intl.DateTimeFormat("tr-TR", { month: "short" }).format(new Date(`${month}-15T12:00:00Z`));
|
||||
const monthLong = (month: string) =>
|
||||
new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" }).format(
|
||||
new Date(`${month}-15T12:00:00Z`),
|
||||
);
|
||||
|
||||
/** Ürünün kendi ölçeğinde son aylar: tüketim (alt) + fire (üst), taban çizgisine oturur. */
|
||||
function Trend({ row }: { row: StockAnalysisRow }) {
|
||||
const max = Math.max(1, ...row.months.map((m) => m.used + m.wasted));
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t.chartLabel(row.name)}
|
||||
className="flex h-8 w-full items-end gap-1 border-b border-border"
|
||||
>
|
||||
{row.months.map((m) => {
|
||||
const total = m.used + m.wasted;
|
||||
const label = t.monthValue(
|
||||
monthLong(m.month),
|
||||
formatStock(m.used, row.unit),
|
||||
formatStock(m.wasted, row.unit),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={m.month}
|
||||
title={label}
|
||||
className="flex h-full min-w-0 flex-1 flex-col justify-end gap-0.5 hover:opacity-80"
|
||||
>
|
||||
{m.wasted > 0 ? (
|
||||
<div
|
||||
className="w-full rounded-t-[3px] bg-expense"
|
||||
style={{ height: `${(m.wasted / max) * 100}%`, minHeight: 2 }}
|
||||
/>
|
||||
) : null}
|
||||
{m.used > 0 ? (
|
||||
<div
|
||||
className={cn("w-full bg-budget", m.wasted > 0 ? "" : "rounded-t-[3px]")}
|
||||
style={{ height: `${(m.used / max) * 100}%`, minHeight: 2 }}
|
||||
/>
|
||||
) : null}
|
||||
{total === 0 ? <span className="sr-only">{label}</span> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Num({ value, unit, tone }: { value: number | null; unit: string; tone?: string }) {
|
||||
return (
|
||||
<span className={cn("tabular-nums", value === null && "text-muted-foreground", tone)}>
|
||||
{value === null ? t.noData : formatStock(value, unit)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** /stok/analiz: ürün tipi başına aylık ortalama tüketim, fire ve son ayların eğilimi. */
|
||||
export function StockAnalysisView({ data }: { data: StockAnalysis }) {
|
||||
const groups = new Map<string, StockAnalysisRow[]>();
|
||||
for (const r of data.rows) groups.set(r.categoryPath, [...(groups.get(r.categoryPath) ?? []), r]);
|
||||
const first = data.months[0]!;
|
||||
const last = data.months.at(-1)!;
|
||||
|
||||
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} />
|
||||
|
||||
{data.rows.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.empty}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t.trend(data.months.length)}: {monthShort(first)} – {monthShort(last)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span aria-hidden className="size-2.5 rounded-sm bg-budget" />
|
||||
{tr.stock.kinds.consume}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span aria-hidden className="size-2.5 rounded-sm bg-expense" />
|
||||
{tr.stock.kinds.waste}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border bg-card">
|
||||
<div className="hidden grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_repeat(4,minmax(0,0.7fr))] gap-3 border-b px-3 py-2 text-xs text-muted-foreground md:grid">
|
||||
<span>{t.product}</span>
|
||||
<span>{t.trend(data.months.length)}</span>
|
||||
<span className="text-right">{t.avgUsed}</span>
|
||||
<span className="text-right">{t.avgWasted}</span>
|
||||
<span className="text-right">{t.wastePct}</span>
|
||||
<span className="text-right">{t.thisMonth}</span>
|
||||
</div>
|
||||
{[...groups.entries()].map(([path, rows]) => (
|
||||
<section key={path}>
|
||||
<h2 className="border-b bg-muted/40 px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{path}
|
||||
</h2>
|
||||
<ul className="divide-y">
|
||||
{rows.map((r) => {
|
||||
const high = r.wastePct !== null && r.wastePct >= HIGH_WASTE_PCT;
|
||||
return (
|
||||
<li
|
||||
key={r.id}
|
||||
className="grid grid-cols-2 items-center gap-x-3 gap-y-2 px-3 py-2.5 text-sm md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_repeat(4,minmax(0,0.7fr))]"
|
||||
>
|
||||
<Link
|
||||
href={`/stok/${r.id}`}
|
||||
className="min-w-0 truncate font-medium hover:underline"
|
||||
>
|
||||
{r.name}
|
||||
</Link>
|
||||
<Trend row={r} />
|
||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
{t.avgUsed}
|
||||
</span>
|
||||
<Num value={r.avgMonthlyUsed} unit={r.unit} />
|
||||
</p>
|
||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
{t.avgWasted}
|
||||
</span>
|
||||
<Num value={r.avgMonthlyWasted} unit={r.unit} />
|
||||
</p>
|
||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
{t.wastePct}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
high ? "font-medium text-expense" : "",
|
||||
r.wastePct === null && "text-muted-foreground",
|
||||
)}
|
||||
title={high ? t.highWaste : undefined}
|
||||
>
|
||||
{r.wastePct === null ? t.noData : `%${r.wastePct}`}
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex justify-between gap-2 md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
{t.thisMonth}
|
||||
</span>
|
||||
<Num value={r.thisMonth.used} unit={r.unit} />
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ArrowLeft,
|
||||
EllipsisVertical,
|
||||
FolderPlus,
|
||||
Pencil,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import { saveStockCategoryAction, setStockCategoryArchivedAction } from "../actions";
|
||||
import type { StockCategoryView, StockOption } from "../service";
|
||||
|
||||
const t = tr.stock.categories;
|
||||
const NONE = "__none__";
|
||||
type Errors = Record<string, string | undefined>;
|
||||
|
||||
type Pending =
|
||||
| { kind: "create"; parentId: string | null }
|
||||
| { kind: "edit"; category: StockCategoryView }
|
||||
| { kind: "archive"; category: StockCategoryView }
|
||||
| null;
|
||||
|
||||
/** /stok/kategoriler: ağaç, gider eşlemesi, sayım sıklığı, arşiv. */
|
||||
export function StockCategoriesManager({
|
||||
categories,
|
||||
expenseCategories,
|
||||
canWrite,
|
||||
}: {
|
||||
categories: StockCategoryView[];
|
||||
expenseCategories: StockOption[];
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const close = () => setPending(null);
|
||||
const visible = categories.filter((c) => showArchived || !c.archived);
|
||||
|
||||
async function unarchive(c: StockCategoryView) {
|
||||
const r = await setStockCategoryArchivedAction(c.id, false);
|
||||
if (r.ok) toast.success(tr.stock.toast.unarchived);
|
||||
else toast.error(r.error);
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={showArchived} onCheckedChange={(x) => setShowArchived(x === true)} />
|
||||
{t.showArchived}
|
||||
</label>
|
||||
{canWrite ? (
|
||||
<Button size="sm" onClick={() => setPending({ kind: "create", parentId: null })}>
|
||||
<Plus />
|
||||
{t.new}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.empty}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{visible.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className={cn("flex items-center gap-2 p-3", c.archived && "opacity-60")}
|
||||
style={{ paddingLeft: `${12 + c.depth * 20}px` }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-sm font-medium">
|
||||
<span className="truncate">{c.name}</span>
|
||||
{c.isDefault ? <Badge variant="secondary">{t.defaultBadge}</Badge> : null}
|
||||
{c.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{[
|
||||
c.expense
|
||||
? c.expense.inherited
|
||||
? t.inherited(c.expense.path)
|
||||
: c.expense.path
|
||||
: t.noExpense,
|
||||
c.effectiveCountDays ? t.everyDays(c.effectiveCountDays) : null,
|
||||
c.productCount ? t.productCount(c.productCount) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" aria-label={tr.common.actions}>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{c.archived ? (
|
||||
<DropdownMenuItem onSelect={() => unarchive(c)}>
|
||||
<ArchiveRestore />
|
||||
{t.unarchive}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setPending({ kind: "create", parentId: c.id })}
|
||||
>
|
||||
<FolderPlus />
|
||||
{t.addChild}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setPending({ kind: "edit", category: c })}
|
||||
>
|
||||
<Pencil />
|
||||
{t.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setPending({ kind: "archive", category: c })}
|
||||
>
|
||||
<Archive />
|
||||
{t.archive}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{pending?.kind === "create" || pending?.kind === "edit" ? (
|
||||
<StockCategoryDialog
|
||||
category={pending.kind === "edit" ? pending.category : null}
|
||||
parentId={
|
||||
pending.kind === "create" ? pending.parentId : (pending.category.parentId ?? null)
|
||||
}
|
||||
categories={categories}
|
||||
expenseCategories={expenseCategories}
|
||||
onClose={close}
|
||||
/>
|
||||
) : null}
|
||||
<AlertDialog open={pending?.kind === "archive"} onOpenChange={(o) => !o && close()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pending?.kind === "archive" ? t.archiveDescription(pending.category.name) : null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (pending?.kind !== "archive") return;
|
||||
const r = await setStockCategoryArchivedAction(pending.category.id, true);
|
||||
close();
|
||||
if (r.ok) toast.success(tr.stock.toast.archived);
|
||||
else toast.error(r.error);
|
||||
}}
|
||||
>
|
||||
{t.archive}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StockCategoryDialog({
|
||||
category,
|
||||
parentId,
|
||||
categories,
|
||||
expenseCategories,
|
||||
onClose,
|
||||
}: {
|
||||
category: StockCategoryView | null;
|
||||
parentId: string | null;
|
||||
categories: StockCategoryView[];
|
||||
expenseCategories: StockOption[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({
|
||||
name: category?.name ?? "",
|
||||
parentId: parentId ?? "",
|
||||
expenseCategoryId: category?.expenseCategoryId ?? "",
|
||||
countEveryDays: category?.countEveryDays ? String(category.countEveryDays) : "",
|
||||
});
|
||||
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 }));
|
||||
|
||||
// Kendisi ve alt ağacı üst olarak seçilemez.
|
||||
const parents = useMemo(() => {
|
||||
if (!category) return categories.filter((c) => !c.archived);
|
||||
const blocked = new Set([category.id]);
|
||||
for (const c of categories) if (c.parentId && blocked.has(c.parentId)) blocked.add(c.id);
|
||||
return categories.filter((c) => !c.archived && !blocked.has(c.id));
|
||||
}, [categories, category]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveStockCategoryAction(category?.id ?? null, v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(tr.stock.toast.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={category ? t.editTitle : t.createTitle}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="stock-category-form" disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="stock-category-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.name)}>
|
||||
<FieldLabel htmlFor="sc-name">{t.name}</FieldLabel>
|
||||
<Input
|
||||
id="sc-name"
|
||||
value={v.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.parentId)}>
|
||||
<FieldLabel htmlFor="sc-parent">{t.parent}</FieldLabel>
|
||||
<Select
|
||||
value={v.parentId || NONE}
|
||||
onValueChange={(x) => set("parentId", x === NONE ? "" : x)}
|
||||
>
|
||||
<SelectTrigger id="sc-parent" className="w-full">
|
||||
<SelectValue>
|
||||
{categories.find((c) => c.id === v.parentId)?.path ?? t.noParent}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
<SelectItem value={NONE}>{t.noParent}</SelectItem>
|
||||
{parents.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<span style={{ paddingLeft: `${c.depth * 12}px` }}>{c.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.parentId ? <FieldError>{errors.parentId}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.expenseCategoryId)}>
|
||||
<FieldLabel htmlFor="sc-expense">{t.expenseCategory}</FieldLabel>
|
||||
<Select
|
||||
value={v.expenseCategoryId || NONE}
|
||||
onValueChange={(x) => set("expenseCategoryId", x === NONE ? "" : x)}
|
||||
>
|
||||
<SelectTrigger id="sc-expense" className="w-full">
|
||||
<SelectValue>
|
||||
{expenseCategories.find((c) => c.id === v.expenseCategoryId)?.path ??
|
||||
t.expenseInherit}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
<SelectItem value={NONE}>{t.expenseInherit}</SelectItem>
|
||||
{expenseCategories.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<span style={{ paddingLeft: `${c.depth * 12}px` }}>{c.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>{t.expenseHint}</FieldDescription>
|
||||
{errors.expenseCategoryId ? <FieldError>{errors.expenseCategoryId}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.countEveryDays)}>
|
||||
<FieldLabel htmlFor="sc-count">{t.countEveryDays}</FieldLabel>
|
||||
<Input
|
||||
id="sc-count"
|
||||
inputMode="numeric"
|
||||
value={v.countEveryDays}
|
||||
onChange={(e) => set("countEveryDays", e.target.value)}
|
||||
className="w-32 text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.countEveryDays)}
|
||||
/>
|
||||
<FieldDescription>{t.countEveryDaysHint}</FieldDescription>
|
||||
{errors.countEveryDays ? <FieldError>{errors.countEveryDays}</FieldError> : null}
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
"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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import {
|
||||
formatStock,
|
||||
quantityInput,
|
||||
STOCK_UNITS,
|
||||
WASTE_REASONS,
|
||||
type WasteReason,
|
||||
} from "@/shared/stock";
|
||||
import { addStockMovementAction, countStockAction, saveProductTypeAction } from "../actions";
|
||||
import type { ProductTypeView, StockOption } from "../service";
|
||||
|
||||
const t = tr.stock;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
const CUSTOM = "__custom__";
|
||||
|
||||
function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function errorsOf(r: { fieldErrors?: Partial<Record<string, string>>; error: string }): Errors {
|
||||
return { ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error };
|
||||
}
|
||||
|
||||
/** Yeni ürün tipi / düzenle. */
|
||||
export function ProductTypeFormDialog({
|
||||
item,
|
||||
categories,
|
||||
defaultCategoryId,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
item: ProductTypeView | null;
|
||||
categories: StockOption[];
|
||||
defaultCategoryId?: string;
|
||||
onClose: () => void;
|
||||
onCreated?: (id: string) => void;
|
||||
}) {
|
||||
const standard = (STOCK_UNITS as readonly string[]).includes(item?.unit ?? "adet");
|
||||
const [v, setV] = useState({
|
||||
name: item?.name ?? "",
|
||||
categoryId: item?.categoryId ?? defaultCategoryId ?? "",
|
||||
unitChoice: standard ? (item?.unit ?? "adet") : CUSTOM,
|
||||
customUnit: standard ? "" : (item?.unit ?? ""),
|
||||
targetQuantity: item ? quantityInput(item.targetQuantity) : "",
|
||||
thresholdPct: String(item?.thresholdPct ?? 20),
|
||||
location: item?.location ?? "",
|
||||
step: item ? quantityInput(item.step) : "1",
|
||||
countEveryDays: item?.countEveryDays ? String(item.countEveryDays) : "",
|
||||
note: item?.note ?? "",
|
||||
initialQuantity: "",
|
||||
});
|
||||
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 unit = v.unitChoice === CUSTOM ? v.customUnit.trim() : v.unitChoice;
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveProductTypeAction(item?.id ?? null, {
|
||||
name: v.name,
|
||||
categoryId: v.categoryId,
|
||||
unit,
|
||||
targetQuantity: v.targetQuantity,
|
||||
thresholdPct: v.thresholdPct,
|
||||
location: v.location,
|
||||
step: v.step,
|
||||
countEveryDays: v.countEveryDays,
|
||||
note: v.note,
|
||||
initialQuantity: item ? "" : v.initialQuantity,
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.saved);
|
||||
onClose();
|
||||
if (!item) onCreated?.((r.data as { id: string }).id);
|
||||
} else setErrors(errorsOf(r));
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
const unitSuffix = unit ? ` (${unit})` : "";
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={item ? t.editTitle : t.createTitle}
|
||||
footer={<Footer busy={busy} formId="product-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="product-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.name)}>
|
||||
<FieldLabel htmlFor="pt-name">{f.name}</FieldLabel>
|
||||
<Input
|
||||
id="pt-name"
|
||||
value={v.name}
|
||||
placeholder={f.namePlaceholder}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
autoFocus={!item}
|
||||
/>
|
||||
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.categoryId)}>
|
||||
<FieldLabel htmlFor="pt-cat">{f.category}</FieldLabel>
|
||||
<Select value={v.categoryId} onValueChange={(x) => set("categoryId", x)}>
|
||||
<SelectTrigger
|
||||
id="pt-cat"
|
||||
className="w-full"
|
||||
aria-invalid={Boolean(errors.categoryId)}
|
||||
>
|
||||
<SelectValue placeholder={t.validation.selectCategory}>
|
||||
{categories.find((c) => c.id === v.categoryId)?.path}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<span style={{ paddingLeft: `${c.depth * 12}px` }}>{c.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.categoryId ? <FieldError>{errors.categoryId}</FieldError> : null}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.unit)}>
|
||||
<FieldLabel htmlFor="pt-unit">{f.unit}</FieldLabel>
|
||||
<Select value={v.unitChoice} onValueChange={(x) => set("unitChoice", x)}>
|
||||
<SelectTrigger id="pt-unit" className="w-full">
|
||||
<SelectValue>{v.unitChoice === CUSTOM ? f.customUnit : v.unitChoice}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOCK_UNITS.map((u) => (
|
||||
<SelectItem key={u} value={u}>
|
||||
{u}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM}>{f.customUnitOption}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.unit && v.unitChoice !== CUSTOM ? (
|
||||
<FieldError>{errors.unit}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
{v.unitChoice === CUSTOM ? (
|
||||
<Field data-invalid={Boolean(errors.unit)}>
|
||||
<FieldLabel htmlFor="pt-custom">{f.customUnit}</FieldLabel>
|
||||
<Input
|
||||
id="pt-custom"
|
||||
value={v.customUnit}
|
||||
placeholder={f.customUnitPlaceholder}
|
||||
onChange={(e) => set("customUnit", e.target.value)}
|
||||
aria-invalid={Boolean(errors.unit)}
|
||||
/>
|
||||
{errors.unit ? <FieldError>{errors.unit}</FieldError> : null}
|
||||
</Field>
|
||||
) : (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pt-location">{f.location}</FieldLabel>
|
||||
<Input
|
||||
id="pt-location"
|
||||
value={v.location}
|
||||
placeholder={f.locationPlaceholder}
|
||||
onChange={(e) => set("location", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
{v.unitChoice === CUSTOM ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pt-location">{f.location}</FieldLabel>
|
||||
<Input
|
||||
id="pt-location"
|
||||
value={v.location}
|
||||
placeholder={f.locationPlaceholder}
|
||||
onChange={(e) => set("location", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.targetQuantity)}>
|
||||
<FieldLabel htmlFor="pt-target">
|
||||
{f.target}
|
||||
{unitSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="pt-target"
|
||||
inputMode="decimal"
|
||||
value={v.targetQuantity}
|
||||
onChange={(e) => set("targetQuantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.targetQuantity)}
|
||||
/>
|
||||
{errors.targetQuantity ? <FieldError>{errors.targetQuantity}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.thresholdPct)}>
|
||||
<FieldLabel htmlFor="pt-threshold">{f.thresholdPct}</FieldLabel>
|
||||
<Input
|
||||
id="pt-threshold"
|
||||
inputMode="numeric"
|
||||
value={v.thresholdPct}
|
||||
onChange={(e) => set("thresholdPct", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.thresholdPct)}
|
||||
/>
|
||||
{errors.thresholdPct ? <FieldError>{errors.thresholdPct}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">
|
||||
{f.targetHint} {f.thresholdHint}
|
||||
</FieldDescription>
|
||||
{!item ? (
|
||||
<Field data-invalid={Boolean(errors.initialQuantity)}>
|
||||
<FieldLabel htmlFor="pt-initial">
|
||||
{f.initialQuantity}
|
||||
{unitSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="pt-initial"
|
||||
inputMode="decimal"
|
||||
value={v.initialQuantity}
|
||||
onChange={(e) => set("initialQuantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.initialQuantity)}
|
||||
/>
|
||||
<FieldDescription>{f.initialHint}</FieldDescription>
|
||||
{errors.initialQuantity ? <FieldError>{errors.initialQuantity}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.step)}>
|
||||
<FieldLabel htmlFor="pt-step">
|
||||
{f.step}
|
||||
{unitSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="pt-step"
|
||||
inputMode="decimal"
|
||||
value={v.step}
|
||||
onChange={(e) => set("step", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.step)}
|
||||
/>
|
||||
{errors.step ? <FieldError>{errors.step}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.countEveryDays)}>
|
||||
<FieldLabel htmlFor="pt-count">{f.countEveryDays}</FieldLabel>
|
||||
<Input
|
||||
id="pt-count"
|
||||
inputMode="numeric"
|
||||
value={v.countEveryDays}
|
||||
onChange={(e) => set("countEveryDays", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.countEveryDays)}
|
||||
/>
|
||||
{errors.countEveryDays ? <FieldError>{errors.countEveryDays}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">
|
||||
{f.stepHint} {f.countEveryDaysHint}
|
||||
</FieldDescription>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pt-note">{f.note}</FieldLabel>
|
||||
<Input id="pt-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export type MovementKindInput = "purchase" | "consume" | "waste";
|
||||
|
||||
/** Alım, tüketim veya fire. */
|
||||
export function StockMovementDialog({
|
||||
item,
|
||||
initialKind,
|
||||
onClose,
|
||||
}: {
|
||||
item: ProductTypeView;
|
||||
initialKind: MovementKindInput;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [kind, setKind] = useState<MovementKindInput>(initialKind);
|
||||
const [v, setV] = useState({
|
||||
date: todayIstanbul(),
|
||||
quantity:
|
||||
initialKind === "purchase" && item.suggestedQuantity
|
||||
? quantityInput(item.suggestedQuantity)
|
||||
: "",
|
||||
wasteReason: "" as WasteReason | "",
|
||||
expiryDate: "",
|
||||
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 }));
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await addStockMovementAction(item.id, { kind, ...v });
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.movementSaved);
|
||||
onClose();
|
||||
} else setErrors(errorsOf(r));
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${t.movementTitles[kind]} · ${item.name}`}
|
||||
description={`${t.detail.current}: ${formatStock(item.current, item.unit)}`}
|
||||
footer={<Footer busy={busy} formId="stock-movement-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="stock-movement-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Tabs
|
||||
value={kind}
|
||||
onValueChange={(x) => {
|
||||
setKind(x as MovementKindInput);
|
||||
setErrors({});
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
{(["purchase", "consume", "waste"] as const).map((k) => (
|
||||
<TabsTrigger key={k} value={k}>
|
||||
{t.kinds[k]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.quantity)}>
|
||||
<FieldLabel htmlFor="sm-qty">
|
||||
{f.quantity} ({item.unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="sm-qty"
|
||||
inputMode="decimal"
|
||||
value={v.quantity}
|
||||
onChange={(e) => set("quantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.quantity)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="sm-date">{f.date}</FieldLabel>
|
||||
<Input
|
||||
id="sm-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => set("date", e.target.value)}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
{kind === "purchase" ? (
|
||||
<Field data-invalid={Boolean(errors.expiryDate)}>
|
||||
<FieldLabel htmlFor="sm-expiry">{f.expiryDate}</FieldLabel>
|
||||
<Input
|
||||
id="sm-expiry"
|
||||
type="date"
|
||||
value={v.expiryDate}
|
||||
min={v.date}
|
||||
onChange={(e) => set("expiryDate", e.target.value)}
|
||||
/>
|
||||
<FieldDescription>{f.expiryHint}</FieldDescription>
|
||||
{errors.expiryDate ? <FieldError>{errors.expiryDate}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
{kind === "waste" ? (
|
||||
<Field data-invalid={Boolean(errors.wasteReason)}>
|
||||
<FieldLabel htmlFor="sm-reason">{f.wasteReason}</FieldLabel>
|
||||
<Select
|
||||
value={v.wasteReason}
|
||||
onValueChange={(x) => set("wasteReason", x as WasteReason)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="sm-reason"
|
||||
className="w-full"
|
||||
aria-invalid={Boolean(errors.wasteReason)}
|
||||
>
|
||||
<SelectValue placeholder={t.validation.wasteReason}>
|
||||
{v.wasteReason ? t.wasteReasons[v.wasteReason] : null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WASTE_REASONS.map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{t.wasteReasons[r]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.wasteReason ? <FieldError>{errors.wasteReason}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="sm-note">{f.note}</FieldLabel>
|
||||
<Input id="sm-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tek ürün sayımı: gerçek miktar → fark sayım düzeltmesi. */
|
||||
export function StockCountDialog({
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
item: ProductTypeView;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({ date: todayIstanbul(), actualQuantity: "", 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 }));
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await countStockAction(item.id, v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
const diff = (r.data as { diff: number }).diff;
|
||||
if (diff === 0) toast.success(t.toast.noChange);
|
||||
else
|
||||
toast.success(
|
||||
t.toast.counted(`${diff > 0 ? "+" : "−"}${formatStock(Math.abs(diff), item.unit)}`),
|
||||
);
|
||||
onClose();
|
||||
} else setErrors(errorsOf(r));
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${t.countTitle} · ${item.name}`}
|
||||
description={t.countDescription}
|
||||
footer={<Footer busy={busy} formId="stock-count-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="stock-count-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.actualQuantity)}>
|
||||
<FieldLabel htmlFor="sc-qty">
|
||||
{f.actualQuantity} ({item.unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="sc-qty"
|
||||
inputMode="decimal"
|
||||
value={v.actualQuantity}
|
||||
onChange={(e) => set("actualQuantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.actualQuantity)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.actualQuantity ? <FieldError>{errors.actualQuantity}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="sc-date">{f.date}</FieldLabel>
|
||||
<Input
|
||||
id="sc-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => set("date", e.target.value)}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">
|
||||
{f.expected(formatStock(Math.max(0, item.current), item.unit))}
|
||||
</FieldDescription>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="sc-note">{f.note}</FieldLabel>
|
||||
<Input id="sc-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,397 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChartColumn,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
Clock,
|
||||
FolderTree,
|
||||
Plus,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate } from "@/shared/format";
|
||||
import { foldTr } from "@/shared/search";
|
||||
import { formatStock, stepLabel, type ExpiringLot } from "@/shared/stock";
|
||||
import { quickConsumeAction } from "../actions";
|
||||
import type { ProductTypeView, StockOption, StockView as Data } from "../service";
|
||||
import { ProductTypeFormDialog } from "./stock-dialogs";
|
||||
|
||||
const t = tr.stock;
|
||||
export const ymdLabel = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
|
||||
/** Mevcut/hedef çubuğu; eşik çizgiyle işaretli. */
|
||||
export function StockBar({ item }: { item: ProductTypeView }) {
|
||||
const pct = Math.min(100, item.level.pct ?? 0);
|
||||
return (
|
||||
<div
|
||||
className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-label={t.list.of(
|
||||
formatStock(Math.max(0, item.current), item.unit),
|
||||
formatStock(item.targetQuantity, item.unit),
|
||||
)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={pct}
|
||||
>
|
||||
<div
|
||||
className={cn("h-full rounded-full", item.level.low ? "bg-expense" : "bg-budget")}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
{item.thresholdPct > 0 && item.thresholdPct < 100 ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-0 h-full w-0.5 bg-foreground/50"
|
||||
style={{ left: `${item.thresholdPct}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LevelBadge({ item }: { item: ProductTypeView }) {
|
||||
if (item.current <= 0 && item.targetQuantity > 0)
|
||||
return <Badge variant="destructive">{t.list.outBadge}</Badge>;
|
||||
if (item.level.low)
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 border-expense/50 text-expense">
|
||||
<AlertTriangle className="size-3" />
|
||||
{t.list.lowBadge}
|
||||
</Badge>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ExpiryBadge({ lot }: { lot: Pick<ExpiringLot, "expiryDate" | "status"> }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"gap-1",
|
||||
lot.status === "expired"
|
||||
? "border-expense/50 text-expense"
|
||||
: "border-amber-500/50 text-amber-700 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
<Clock className="size-3" />
|
||||
{t.expiring.on(ymdLabel(lot.expiryDate))}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** "1 azalt" düğmesi. */
|
||||
export function QuickConsumeButton({ item }: { item: ProductTypeView }) {
|
||||
const [pending, start] = useTransition();
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0 tabular-nums"
|
||||
disabled={pending || item.current <= 0}
|
||||
aria-label={t.list.quickConsume(item.name)}
|
||||
onClick={() =>
|
||||
start(async () => {
|
||||
const r = await quickConsumeAction(item.id);
|
||||
if (r.ok)
|
||||
toast.success(
|
||||
t.list.quickDone(
|
||||
item.name,
|
||||
formatStock((r.data as { remaining: number }).remaining, item.unit),
|
||||
),
|
||||
);
|
||||
else toast.error(r.error);
|
||||
})
|
||||
}
|
||||
>
|
||||
{stepLabel(item.step, item.unit)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Önerilen miktarın gerekçesi: fire indirimi, tüketim hızı yuvarlaması, kaç gün yeter. */
|
||||
export function suggestionReasons(item: ProductTypeView): string[] {
|
||||
const s = item.suggestion;
|
||||
if (s.quantity === 0) return [];
|
||||
const out: string[] = [];
|
||||
if (s.waste) out.push(t.suggestions.wasteReduced(s.waste.purchases, s.waste.pct));
|
||||
if (s.roundedByUsage) out.push(t.suggestions.roundedByUsage(formatStock(s.roundTo, item.unit)));
|
||||
if (s.coverDays !== null) out.push(t.suggestions.covers(s.coverDays));
|
||||
return out;
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: number; 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", value > 0 && tone)}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** /stok: özet, öneriler, SKT uyarıları ve kategoriye göre ürün listesi. */
|
||||
export function StockView({ data, options }: { data: Data; options: StockOption[] }) {
|
||||
const router = useRouter();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [onlyLow, setOnlyLow] = useState(false);
|
||||
|
||||
const expired = data.expiring.filter((e) => e.lot.status === "expired").length;
|
||||
const groups = useMemo(() => {
|
||||
const q = foldTr(query.trim());
|
||||
const match = (i: ProductTypeView) =>
|
||||
(!onlyLow || i.level.low) &&
|
||||
(!q || foldTr(i.name).includes(q) || foldTr(i.location).includes(q));
|
||||
return data.categories
|
||||
.map((c) => ({
|
||||
category: c,
|
||||
items: data.items.filter((i) => i.categoryId === c.id && match(i)),
|
||||
}))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}, [data, query, onlyLow]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/stok/analiz">
|
||||
<ChartColumn />
|
||||
{t.analysisLink}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/stok/sayim">
|
||||
<ClipboardCheck />
|
||||
{t.countLink}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/stok/kategoriler">
|
||||
<FolderTree />
|
||||
{t.categoriesLink}
|
||||
</Link>
|
||||
</Button>
|
||||
{data.canWrite ? (
|
||||
<Button size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus />
|
||||
{t.newProduct}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Card size="sm">
|
||||
<CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label={t.summary.products} value={data.items.length} />
|
||||
<Stat label={t.summary.low} value={data.suggestions.length} tone="text-expense" />
|
||||
<Stat
|
||||
label={t.summary.expiring}
|
||||
value={data.expiring.length - expired}
|
||||
tone="text-amber-700 dark:text-amber-400"
|
||||
/>
|
||||
<Stat label={t.summary.expired} value={expired} tone="text-expense" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.suggestions.title}</CardTitle>
|
||||
<CardDescription>{t.suggestions.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.suggestions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t.suggestions.empty}</p>
|
||||
) : (
|
||||
<ul className="divide-y text-sm">
|
||||
{data.suggestions.map((i) => {
|
||||
const reasons = suggestionReasons(i);
|
||||
return (
|
||||
<li key={i.id} className="flex flex-col gap-0.5 py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link href={`/stok/${i.id}`} className="min-w-0 hover:underline">
|
||||
<span className="block truncate font-medium">{i.name}</span>
|
||||
<span className="block text-xs text-muted-foreground tabular-nums">
|
||||
{t.list.of(
|
||||
formatStock(Math.max(0, i.current), i.unit),
|
||||
formatStock(i.targetQuantity, i.unit),
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="shrink-0 text-right font-medium tabular-nums">
|
||||
{t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))}
|
||||
</span>
|
||||
</div>
|
||||
{reasons.length ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs",
|
||||
i.suggestion.waste
|
||||
? "text-amber-700 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{reasons.join(" ")}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{data.countDue.length ? (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.countDue.title}</CardTitle>
|
||||
<CardDescription>{t.countDue.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y text-sm">
|
||||
{data.countDue.map((g) => (
|
||||
<li key={g.categoryId} className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<span className="block truncate font-medium">{g.path}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t.countDue.products(g.products)} ·{" "}
|
||||
<span className="text-amber-700 dark:text-amber-400">
|
||||
{t.countDue.overdue(g.overdueDays)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{data.canWrite ? (
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href={`/stok/sayim?kategori=${g.categoryId}`}>
|
||||
<ClipboardCheck />
|
||||
{t.countDue.start}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{data.expiring.length ? (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.expiring.title}</CardTitle>
|
||||
<CardDescription>{t.expiring.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y text-sm">
|
||||
{data.expiring.map(({ item, lot }) => (
|
||||
<li
|
||||
key={`${item.id}-${lot.index}`}
|
||||
className="flex items-center justify-between gap-3 py-2"
|
||||
>
|
||||
<Link href={`/stok/${item.id}`} className="min-w-0 hover:underline">
|
||||
<span className="block truncate font-medium">{item.name}</span>
|
||||
<span className="block text-xs text-muted-foreground tabular-nums">
|
||||
{formatStock(lot.remaining, item.unit)}
|
||||
</span>
|
||||
</Link>
|
||||
<ExpiryBadge lot={lot} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{data.items.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.list.empty}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<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.list.search}
|
||||
aria-label={t.list.search}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={onlyLow} onCheckedChange={(x) => setOnlyLow(x === true)} />
|
||||
{t.list.onlyLow}
|
||||
</label>
|
||||
</div>
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t.list.noMatch}</p>
|
||||
) : null}
|
||||
{groups.map(({ category, items }) => (
|
||||
<section key={category.id} className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">{category.path}</h2>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<StockRow key={item.id} item={item} canWrite={data.canWrite} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{creating ? (
|
||||
<ProductTypeFormDialog
|
||||
item={null}
|
||||
categories={options}
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={(id) => router.push(`/stok/${id}`)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StockRow({ item, canWrite }: { item: ProductTypeView; canWrite: boolean }) {
|
||||
const lot = item.expiring[0];
|
||||
return (
|
||||
<li className="flex items-center gap-2 rounded-xl border bg-card p-3">
|
||||
<Link href={`/stok/${item.id}`} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="flex min-w-0 flex-wrap items-center gap-1.5 font-medium">
|
||||
<span className="truncate">{item.name}</span>
|
||||
<LevelBadge item={item} />
|
||||
{lot ? <ExpiryBadge lot={lot} /> : null}
|
||||
</p>
|
||||
<span className="shrink-0 text-sm tabular-nums">
|
||||
<span className="font-semibold">
|
||||
{formatStock(Math.max(0, item.current), item.unit)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
/ {formatStock(item.targetQuantity, item.unit)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<StockBar item={item} />
|
||||
{item.location ? (
|
||||
<p className="truncate text-xs text-muted-foreground">{item.location}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
{canWrite ? <QuickConsumeButton item={item} /> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Varsayılan stok kategori seti (Bölüm 6.10). `key` sabittir (tekrar yüklemede kopya olmaz).
|
||||
* `expenseKey`: eşlenen gider kategorisinin varsayılan anahtarı (Faz 13'te alışveriş giderleri).
|
||||
* Parantez içindeki örnekler (Kırmızı Mercimek, Toz Deterjan …) ürün tipidir; birim ve hedef
|
||||
* haneye göre değiştiği için otomatik eklenmez (seed örnek verisinde vardır).
|
||||
*/
|
||||
export type DefaultStockCategory = {
|
||||
key: string;
|
||||
name: string;
|
||||
expenseKey?: string;
|
||||
children?: Omit<DefaultStockCategory, "children" | "expenseKey">[];
|
||||
};
|
||||
|
||||
export const DEFAULT_STOCK_CATEGORIES: DefaultStockCategory[] = [
|
||||
{
|
||||
key: "mutfak",
|
||||
name: "Mutfak",
|
||||
expenseKey: "market.gida",
|
||||
children: [
|
||||
{ key: "mutfak.manav", name: "Manav" },
|
||||
{ key: "mutfak.sarkuteri", name: "Şarküteri" },
|
||||
{ key: "mutfak.bakliyat", name: "Bakliyat" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "genel-temizlik",
|
||||
name: "Genel Temizlik",
|
||||
expenseKey: "market.temizlik",
|
||||
children: [
|
||||
{ key: "genel-temizlik.bulasik", name: "Bulaşık" },
|
||||
{ key: "genel-temizlik.camasir", name: "Çamaşır" },
|
||||
{ key: "genel-temizlik.genel", name: "Genel" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "kisisel-temizlik",
|
||||
name: "Kişisel Temizlik",
|
||||
expenseKey: "market.kisisel-bakim",
|
||||
children: [
|
||||
{ key: "kisisel-temizlik.sampuan", name: "Şampuan" },
|
||||
{ key: "kisisel-temizlik.tiras", name: "Tıraş" },
|
||||
{ key: "kisisel-temizlik.kisisel-bakim", name: "Kişisel Bakım" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,446 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { StockCountModel } from "@/models/stock-count";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import {
|
||||
productTypeFormSchema,
|
||||
stockCategoryFormSchema,
|
||||
stockCountFormSchema,
|
||||
stockCountSheetSchema,
|
||||
stockMovementFormSchema,
|
||||
type ProductTypeFormInput,
|
||||
} from "@/shared/schemas/stock";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
addStockMovement,
|
||||
countStock,
|
||||
createProductType,
|
||||
createStockCategory,
|
||||
deleteStockMovement,
|
||||
getCountSheet,
|
||||
getProductType,
|
||||
getStock,
|
||||
getStockAnalysis,
|
||||
getStockOptions,
|
||||
listStockCategories,
|
||||
quickConsume,
|
||||
setProductTypeArchived,
|
||||
setStockCategoryArchived,
|
||||
submitCount,
|
||||
updateStockCategory,
|
||||
} 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);
|
||||
|
||||
async function categoryId(path: string) {
|
||||
const o = (await getStockOptions(ayse, cakir))!;
|
||||
return o.categories.find((c) => c.path === path)!.id;
|
||||
}
|
||||
|
||||
function product(input: Partial<ProductTypeFormInput> & { categoryId: string }) {
|
||||
return productTypeFormSchema.parse({
|
||||
name: "Test Ürün",
|
||||
unit: "kg",
|
||||
targetQuantity: "4",
|
||||
thresholdPct: "20",
|
||||
step: "0,5",
|
||||
...input,
|
||||
});
|
||||
}
|
||||
|
||||
describe("seed ve görünüm", () => {
|
||||
it("varsayılan ağaç, gider eşlemesi devralınır; azalanlar ve SKT", async () => {
|
||||
const s = (await getStock(ayse, cakir))!;
|
||||
expect(s.items).toHaveLength(19);
|
||||
const bakliyat = s.categories.find((c) => c.path === "Mutfak › Bakliyat")!;
|
||||
expect(bakliyat.depth).toBe(1);
|
||||
expect(bakliyat.expense).toEqual(
|
||||
expect.objectContaining({ path: "Market › Gıda", inherited: true }),
|
||||
);
|
||||
expect(s.categories.find((c) => c.path === "Mutfak")!.expense?.inherited).toBe(false);
|
||||
// Bir süredir alınmayanlar eşiğin altında.
|
||||
const low = s.suggestions.map((i) => i.name);
|
||||
expect(low).toEqual(expect.arrayContaining(["Kırmızı Mercimek", "Tablet Deterjan"]));
|
||||
for (const i of s.suggestions) {
|
||||
expect(i.level.low).toBe(true);
|
||||
expect(i.suggestedQuantity).toBe(i.targetQuantity - Math.max(0, i.current));
|
||||
}
|
||||
// Hiçbir stok eksiye düşmez; süt partilerinin SKT'si yakın.
|
||||
expect(s.items.every((i) => i.current >= 0)).toBe(true);
|
||||
expect(s.expiring.some((e) => e.item.name === "Süt")).toBe(true);
|
||||
// Kategori sırası: Mutfak ürünleri temizlikten önce.
|
||||
expect(s.items.findIndex((i) => i.name === "Domates")).toBeLessThan(
|
||||
s.items.findIndex((i) => i.name === "Toz Deterjan"),
|
||||
);
|
||||
|
||||
const annemler = (await getStock(fatma, ids.household("Annemler")))!;
|
||||
expect(annemler.items).toHaveLength(0);
|
||||
expect(annemler.categories.length).toBe(12);
|
||||
});
|
||||
|
||||
it("izolasyon: üye olmayan göremez, izleyici yazamaz", async () => {
|
||||
expect(await getStock(fatma, cakir)).toBeNull();
|
||||
const s = (await getStock(zeynep, cakir))!;
|
||||
expect(s.canWrite).toBe(false);
|
||||
const any = s.items[0]!;
|
||||
expect((await quickConsume(zeynep, cakir, any.id)).ok).toBe(false);
|
||||
expect(await getProductType(fatma, cakir, any.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ürün tipi ve hareketler", () => {
|
||||
it("başlangıç miktarı, tüketim sınırı, 1 azalt, fire, SKT FIFO, sayım, silme", async () => {
|
||||
const cat = await categoryId("Mutfak › Bakliyat");
|
||||
const created = await createProductType(
|
||||
ayse,
|
||||
cakir,
|
||||
product({ categoryId: cat, name: "Bulgur", initialQuantity: "1,5" }),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
const id = created.ok ? created.data.id : "";
|
||||
let p = (await getProductType(ayse, cakir, id))!;
|
||||
expect(p.current).toBe(1_500);
|
||||
expect(p.movements[0]).toMatchObject({ kind: "count_adjust", initial: true, delta: 1_500 });
|
||||
// 1,5 < %20 × 4 = 0,8? hayır → azalmadı.
|
||||
expect(p.level.low).toBe(false);
|
||||
|
||||
const dup = await createProductType(ayse, cakir, product({ categoryId: cat, name: "bulgur" }));
|
||||
expect(dup.ok).toBe(false);
|
||||
|
||||
const tooMuch = await addStockMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
stockMovementFormSchema.parse({ kind: "consume", date: today, quantity: "2" }),
|
||||
);
|
||||
expect(tooMuch).toMatchObject({ ok: false, fieldErrors: { quantity: expect.any(String) } });
|
||||
|
||||
const q = await quickConsume(ayse, cakir, id);
|
||||
expect(q).toEqual({ ok: true, data: { remaining: 1_000 } });
|
||||
|
||||
// Alım (SKT'li) sonra fire: fire en eski partiden (başlangıç) düşer.
|
||||
await addStockMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
stockMovementFormSchema.parse({
|
||||
kind: "purchase",
|
||||
date: today,
|
||||
quantity: "1",
|
||||
expiryDate: addDays(today, 3),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
stockMovementFormSchema.safeParse({ kind: "waste", date: today, quantity: "1" }).success,
|
||||
).toBe(false);
|
||||
await addStockMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
stockMovementFormSchema.parse({
|
||||
kind: "waste",
|
||||
date: today,
|
||||
quantity: "0,8",
|
||||
wasteReason: "spoiled",
|
||||
}),
|
||||
);
|
||||
p = (await getProductType(ayse, cakir, id))!;
|
||||
expect(p.current).toBe(1_200);
|
||||
expect(p.lots).toEqual([
|
||||
expect.objectContaining({ remaining: 200, expiryDate: null }),
|
||||
expect.objectContaining({ remaining: 1_000, expiryDate: addDays(today, 3), status: "soon" }),
|
||||
]);
|
||||
expect(p.expiring).toHaveLength(1);
|
||||
|
||||
// Sayım: 0,5 sayıldı → −0,7 düzeltme; aynı miktar tekrar → fark yok.
|
||||
const c = await countStock(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
stockCountFormSchema.parse({ date: today, actualQuantity: "0,5" }),
|
||||
);
|
||||
expect(c).toEqual({ ok: true, data: { diff: -700 } });
|
||||
const same = await countStock(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
stockCountFormSchema.parse({ date: today, actualQuantity: "0,5" }),
|
||||
);
|
||||
expect(same).toEqual({ ok: true, data: { diff: 0 } });
|
||||
p = (await getProductType(ayse, cakir, id))!;
|
||||
expect(p.current).toBe(500);
|
||||
expect(p.level.low).toBe(true);
|
||||
expect(p.suggestedQuantity).toBe(3_500);
|
||||
|
||||
// Hareket silme: yeniden hesap + denetim kaydı.
|
||||
const adj = p.movements[0]!;
|
||||
expect(adj.kind).toBe("count_adjust");
|
||||
expect((await deleteStockMovement(ayse, cakir, adj.id)).ok).toBe(true);
|
||||
expect((await getProductType(ayse, cakir, id))!.current).toBe(1_200);
|
||||
expect(
|
||||
await AuditLogModel.exists({ action: "stock.movement.delete", targetId: adj.id }),
|
||||
).toBeTruthy();
|
||||
expect(await StockMovementModel.exists({ _id: adj.id, deletedAt: null })).toBeNull();
|
||||
|
||||
// Arşivli üründe hareket yok; arşivden çıkınca devam.
|
||||
expect((await setProductTypeArchived(ayse, cakir, id, true)).ok).toBe(true);
|
||||
expect((await quickConsume(ayse, cakir, id)).ok).toBe(false);
|
||||
expect((await getStock(ayse, cakir))!.items.some((i) => i.id === id)).toBe(false);
|
||||
expect((await setProductTypeArchived(ayse, cakir, id, false)).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("boş stokta 1 azalt reddedilir; özel birim", async () => {
|
||||
const cat = await categoryId("Genel Temizlik › Çamaşır");
|
||||
const r = await createProductType(
|
||||
ayse,
|
||||
cakir,
|
||||
product({ categoryId: cat, name: "Leke Çıkarıcı", unit: "yıkama", step: "1" }),
|
||||
);
|
||||
const id = r.ok ? r.data.id : "";
|
||||
expect(await quickConsume(ayse, cakir, id)).toEqual({
|
||||
ok: false,
|
||||
error: tr.stock.validation.emptyStock,
|
||||
});
|
||||
expect((await getProductType(ayse, cakir, id))!.unit).toBe("yıkama");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stok kategorileri", () => {
|
||||
it("oluştur, gider eşle, taşıma kuralları, arşiv koşulu", async () => {
|
||||
const opts = (await getStockOptions(ayse, cakir))!;
|
||||
const market = opts.expenseCategories.find((c) => c.path === "Market")!;
|
||||
const parent = await createStockCategory(
|
||||
ayse,
|
||||
cakir,
|
||||
stockCategoryFormSchema.parse({
|
||||
name: "Bebek",
|
||||
parentId: "",
|
||||
expenseCategoryId: market.id,
|
||||
countEveryDays: "30",
|
||||
}),
|
||||
);
|
||||
const parentId = parent.ok ? parent.data.id : "";
|
||||
const child = await createStockCategory(
|
||||
ayse,
|
||||
cakir,
|
||||
stockCategoryFormSchema.parse({
|
||||
name: "Bez",
|
||||
parentId,
|
||||
expenseCategoryId: "",
|
||||
countEveryDays: "",
|
||||
}),
|
||||
);
|
||||
const childId = child.ok ? child.data.id : "";
|
||||
let list = (await listStockCategories(ayse, cakir))!.categories;
|
||||
const bez = list.find((c) => c.id === childId)!;
|
||||
expect(bez).toMatchObject({
|
||||
path: "Bebek › Bez",
|
||||
effectiveCountDays: 30,
|
||||
expense: expect.objectContaining({ path: "Market", inherited: true }),
|
||||
});
|
||||
|
||||
// Kendi altına taşınamaz; aynı adlı kardeş olamaz.
|
||||
const loop = await updateStockCategory(
|
||||
ayse,
|
||||
cakir,
|
||||
parentId,
|
||||
stockCategoryFormSchema.parse({
|
||||
name: "Bebek",
|
||||
parentId: childId,
|
||||
expenseCategoryId: "",
|
||||
countEveryDays: "",
|
||||
}),
|
||||
);
|
||||
expect(loop.ok).toBe(false);
|
||||
const dup = await createStockCategory(
|
||||
ayse,
|
||||
cakir,
|
||||
stockCategoryFormSchema.parse({
|
||||
name: "mutfak",
|
||||
parentId: "",
|
||||
expenseCategoryId: "",
|
||||
countEveryDays: "",
|
||||
}),
|
||||
);
|
||||
expect(dup.ok).toBe(false);
|
||||
|
||||
// Gelir kategorisi eşlenemez.
|
||||
const income = await createStockCategory(ayse, cakir, {
|
||||
name: "Yanlış",
|
||||
parentId: null,
|
||||
expenseCategoryId: "0".repeat(24),
|
||||
countEveryDays: null,
|
||||
});
|
||||
expect(income.ok).toBe(false);
|
||||
|
||||
// Ürün varken arşivlenemez; ürün yokken alt ağaçla arşivlenir.
|
||||
const r = await createProductType(
|
||||
ayse,
|
||||
cakir,
|
||||
product({ categoryId: childId, name: "Islak Mendil" }),
|
||||
);
|
||||
expect((await setStockCategoryArchived(ayse, cakir, parentId, true)).ok).toBe(false);
|
||||
await setProductTypeArchived(ayse, cakir, r.ok ? r.data.id : "", true);
|
||||
expect((await setStockCategoryArchived(ayse, cakir, parentId, true)).ok).toBe(true);
|
||||
list = (await listStockCategories(ayse, cakir))!.categories;
|
||||
expect(list.find((c) => c.id === childId)!.archived).toBe(true);
|
||||
expect((await getStockOptions(ayse, cakir))!.categories.some((c) => c.id === childId)).toBe(
|
||||
false,
|
||||
);
|
||||
// Arşivli kategoriye ürün eklenemez.
|
||||
expect(
|
||||
(await createProductType(ayse, cakir, product({ categoryId: childId, name: "Pudra" }))).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("periyodik sayım, öneri ve analiz (Faz 11b)", () => {
|
||||
it("seed: Mutfak sayımı gecikti, Genel Temizlik değil; fire öneriyi düşürür", async () => {
|
||||
const s = (await getStock(ayse, cakir))!;
|
||||
const paths = s.countDue.map((g) => g.path);
|
||||
expect(paths).toEqual(expect.arrayContaining(["Mutfak › Manav", "Mutfak › Bakliyat"]));
|
||||
expect(paths.some((p) => p.startsWith("Genel Temizlik"))).toBe(false);
|
||||
// Son sayım 21 gün önce, sıklık 15 → 6 gün gecikti.
|
||||
expect(s.countDue.find((g) => g.path === "Mutfak › Manav")!.overdueDays).toBe(6);
|
||||
const domates = s.items.find((i) => i.name === "Domates")!;
|
||||
expect(domates.count).toMatchObject({ everyDays: 15, lastDate: addDays(today, -21) });
|
||||
expect(domates.suggestion.waste?.pct).toBeGreaterThan(0);
|
||||
const deterjan = s.items.find((i) => i.name === "Toz Deterjan")!;
|
||||
expect(deterjan.count).toMatchObject({ everyDays: 30, due: false });
|
||||
expect(s.items.find((i) => i.name === "Şampuan")!.count).toBeNull();
|
||||
// Öneri hedefi aşmaz, tüketim hızı biliniyor.
|
||||
for (const i of s.suggestions) {
|
||||
expect(i.suggestedQuantity).toBeGreaterThan(0);
|
||||
expect(i.suggestedQuantity).toBeLessThanOrEqual(
|
||||
Math.ceil(i.suggestion.base / i.step) * i.step,
|
||||
);
|
||||
// Bugün eklenen test ürünlerinin geçmişi kısa: kullanım hızı yok.
|
||||
if (i.movementCount > 5) expect(i.suggestion.usage).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("sayım listesi kategori alt ağacıyla; toplu sayım fire + düzeltme, oturum kaydı", async () => {
|
||||
const opts = (await getStockOptions(ayse, cakir))!;
|
||||
const manav = opts.categories.find((c) => c.path === "Mutfak › Manav")!.id;
|
||||
const mutfak = opts.categories.find((c) => c.path === "Mutfak")!.id;
|
||||
const sheet = (await getCountSheet(ayse, cakir, mutfak))!;
|
||||
expect(sheet.rows.every((r) => r.categoryPath.startsWith("Mutfak"))).toBe(true);
|
||||
expect(sheet.categories.find((c) => c.id === manav)!.due).toBeGreaterThan(0);
|
||||
|
||||
const patates = sheet.rows.find((r) => r.name === "Patates")!;
|
||||
const sogan = sheet.rows.find((r) => r.name === "Kuru Soğan")!;
|
||||
const bad = await submitCount(
|
||||
ayse,
|
||||
cakir,
|
||||
stockCountSheetSchema.parse({
|
||||
date: today,
|
||||
categoryId: mutfak,
|
||||
rows: [
|
||||
{
|
||||
productTypeId: patates.id,
|
||||
actualQuantity: String((patates.expected - 500) / 1000).replace(".", ","),
|
||||
wasteQuantity: "0,6",
|
||||
wasteReason: "spoiled",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(bad).toMatchObject({
|
||||
ok: false,
|
||||
fieldErrors: { "rows.0.wasteQuantity": expect.any(String) },
|
||||
});
|
||||
|
||||
const r = await submitCount(
|
||||
ayse,
|
||||
cakir,
|
||||
stockCountSheetSchema.parse({
|
||||
date: today,
|
||||
categoryId: mutfak,
|
||||
note: "Ay sonu",
|
||||
rows: [
|
||||
{
|
||||
productTypeId: patates.id,
|
||||
actualQuantity: String((patates.expected - 500) / 1000).replace(".", ","),
|
||||
wasteQuantity: "0,3",
|
||||
wasteReason: "spoiled",
|
||||
},
|
||||
{
|
||||
productTypeId: sogan.id,
|
||||
actualQuantity: String(sogan.expected / 1000).replace(".", ","),
|
||||
},
|
||||
{ productTypeId: manav, actualQuantity: "" },
|
||||
].map((x) => ({ wasteQuantity: "", wasteReason: "", ...x })),
|
||||
}),
|
||||
);
|
||||
expect(r).toMatchObject({ ok: true, data: { counted: 2, adjusted: 1, diffs: [-500, 0] } });
|
||||
const countId = r.ok ? r.data.countId : "";
|
||||
const session = await StockCountModel.findById(countId).lean();
|
||||
expect(session!.items).toHaveLength(2);
|
||||
expect(session!.items[0]).toMatchObject({ expected: patates.expected, wasted: 300 });
|
||||
const moves = await StockMovementModel.find({ countId }).sort({ createdAt: 1 }).lean();
|
||||
expect(moves.map((m) => [m.kind, m.quantity, m.wasteReason])).toEqual([
|
||||
["waste", 300, "spoiled"],
|
||||
["count_adjust", -200, null],
|
||||
]);
|
||||
|
||||
// Fark sıfır olan soğan da sayıldı: takvim yenilendi.
|
||||
const p = (await getProductType(ayse, cakir, sogan.id))!;
|
||||
expect(p.count).toMatchObject({ lastDate: today, due: false, nextDate: addDays(today, 15) });
|
||||
expect((await getProductType(ayse, cakir, patates.id))!.current).toBe(patates.expected - 500);
|
||||
|
||||
// Hiçbir satır dolu değilse şema reddeder; izleyici sayamaz.
|
||||
expect(
|
||||
stockCountSheetSchema.safeParse({
|
||||
date: today,
|
||||
categoryId: "",
|
||||
rows: [{ productTypeId: sogan.id, actualQuantity: "", wasteQuantity: "", wasteReason: "" }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
(
|
||||
await submitCount(
|
||||
zeynep,
|
||||
cakir,
|
||||
stockCountSheetSchema.parse({
|
||||
date: today,
|
||||
categoryId: "",
|
||||
rows: [
|
||||
{ productTypeId: sogan.id, actualQuantity: "1", wasteQuantity: "", wasteReason: "" },
|
||||
],
|
||||
}),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
expect(await getCountSheet(fatma, cakir, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("tüketim analizi: aylık ortalama ve fire oranı", async () => {
|
||||
const a = (await getStockAnalysis(ayse, cakir))!;
|
||||
expect(a.months).toHaveLength(6);
|
||||
expect(a.months.at(-1)! < a.thisMonth).toBe(true);
|
||||
const domates = a.rows.find((r) => r.name === "Domates")!;
|
||||
expect(domates.avgMonthlyUsed).toBeGreaterThan(5_000);
|
||||
expect(domates.wastePct).toBeGreaterThan(0);
|
||||
const sampuan = a.rows.find((r) => r.name === "Şampuan")!;
|
||||
expect(sampuan.avgMonthlyWasted).toBe(0);
|
||||
expect(sampuan.wastePct).toBe(0);
|
||||
expect(await getStockAnalysis(fatma, cakir)).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -102,7 +102,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
||||
type: x.type,
|
||||
amount: formatMoneyInput(x.amountMinor),
|
||||
date: mode.kind === "copy" ? todayIstanbul() : x.date,
|
||||
accountId: x.account.id,
|
||||
accountId: x.account?.id ?? "",
|
||||
toAccountId: x.toAccount?.id ?? "",
|
||||
categoryId: x.categoryId ?? "",
|
||||
splits: x.splits.map((s) => ({
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftRight, CheckCheck, Copy, Pencil, Scale, Split, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Coins,
|
||||
Users,
|
||||
CheckCheck,
|
||||
Copy,
|
||||
Pencil,
|
||||
Scale,
|
||||
Split,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { IconBadge } from "@/components/appearance/swatch";
|
||||
@@ -83,6 +94,13 @@ export function RowIcon({ item }: { item: TransactionListItem }) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (item.contact) {
|
||||
return (
|
||||
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Users className="size-4.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (item.isAdjustment) {
|
||||
return (
|
||||
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
@@ -112,6 +130,8 @@ export function rowTitle(item: TransactionListItem) {
|
||||
|
||||
function baseTitle(item: TransactionListItem) {
|
||||
if (item.isAdjustment) return t.adjustment;
|
||||
if (item.contact) return item.contact.name;
|
||||
if (item.asset) return item.asset.name;
|
||||
if (item.payeeName) return item.payeeName;
|
||||
if (item.description) return item.description;
|
||||
if (item.type === "transfer") return t.types.transfer;
|
||||
@@ -119,14 +139,22 @@ function baseTitle(item: TransactionListItem) {
|
||||
}
|
||||
|
||||
export function rowSubtitle(item: TransactionListItem) {
|
||||
if (item.contact) {
|
||||
const account = item.account?.name ?? item.toAccount?.name ?? "—";
|
||||
return `${tr.contacts.kinds[item.contact.kind]} · ${account}`;
|
||||
}
|
||||
if (item.asset)
|
||||
return item.account
|
||||
? t.list.transfer(item.account.name, item.asset.name)
|
||||
: t.list.transfer(item.asset.name, item.toAccount?.name ?? "—");
|
||||
if (item.type === "transfer")
|
||||
return t.list.transfer(item.account.name, item.toAccount?.name ?? "—");
|
||||
return t.list.transfer(item.account?.name ?? "—", item.toAccount?.name ?? "—");
|
||||
const what = item.isAdjustment
|
||||
? item.description
|
||||
: item.splitCount
|
||||
? t.list.split(item.splitCount)
|
||||
: (item.category?.path ?? "—");
|
||||
return `${what} · ${item.account.name}`;
|
||||
return `${what} · ${item.account?.name ?? "—"}`;
|
||||
}
|
||||
|
||||
function DetailDialog({
|
||||
@@ -161,14 +189,28 @@ function DetailDialog({
|
||||
onOpenChange={onOpenChange}
|
||||
title={d.title}
|
||||
footer={
|
||||
detail && canWrite ? (
|
||||
detail && canWrite && detail.asset ? (
|
||||
<Button asChild>
|
||||
<Link href={`/birikim/${detail.asset.id}`}>
|
||||
<Coins />
|
||||
{tr.assets.open}
|
||||
</Link>
|
||||
</Button>
|
||||
) : detail && canWrite ? (
|
||||
<>
|
||||
<Button variant="destructive" onClick={() => onDelete(detail)}>
|
||||
<Trash2 />
|
||||
{t.actions.delete}
|
||||
</Button>
|
||||
{/* Mutabakat düzeltmesi düzenlenemez/kopyalanamaz; yalnızca silinebilir. */}
|
||||
{detail.isAdjustment ? null : (
|
||||
{/* Mutabakat düzeltmesi düzenlenemez/kopyalanamaz; cari hareketi cari sayfasında. */}
|
||||
{detail.contact ? (
|
||||
<Button asChild>
|
||||
<Link href={`/cari/${detail.contact.id}`}>
|
||||
<Pencil />
|
||||
{tr.contacts.openContact}
|
||||
</Link>
|
||||
</Button>
|
||||
) : detail.isAdjustment ? null : (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onCopy(detail)}>
|
||||
<Copy />
|
||||
@@ -202,10 +244,28 @@ function DetailDialog({
|
||||
<dl className="divide-y border-y">
|
||||
{row(t.filters.type, <Badge variant="secondary">{t.types[detail.type]}</Badge>)}
|
||||
{detail.status !== "posted" ? row(t.filters.status, t.statuses[detail.status]) : null}
|
||||
{row(
|
||||
detail.type === "transfer" ? t.fields.fromAccount : t.fields.account,
|
||||
detail.account.name,
|
||||
)}
|
||||
{detail.contact
|
||||
? row(
|
||||
tr.contacts.contact,
|
||||
<Link href={`/cari/${detail.contact.id}`} className="hover:underline">
|
||||
{detail.contact.name} · {tr.contacts.kinds[detail.contact.kind]}
|
||||
</Link>,
|
||||
)
|
||||
: null}
|
||||
{detail.asset
|
||||
? row(
|
||||
tr.assets.asset,
|
||||
<Link href={`/birikim/${detail.asset.id}`} className="hover:underline">
|
||||
{detail.asset.name}
|
||||
</Link>,
|
||||
)
|
||||
: null}
|
||||
{detail.account
|
||||
? row(
|
||||
detail.type === "transfer" ? t.fields.fromAccount : t.fields.account,
|
||||
detail.account.name,
|
||||
)
|
||||
: null}
|
||||
{detail.toAccount ? row(t.fields.toAccount, detail.toAccount.name) : null}
|
||||
{detail.category ? row(t.fields.category, detail.category.path) : null}
|
||||
{detail.isAdjustment ? row(t.fields.category, t.adjustment) : null}
|
||||
|
||||
@@ -383,7 +383,7 @@ describe("filtreler", () => {
|
||||
);
|
||||
expect(byAccount!.total).toBeGreaterThan(0);
|
||||
expect(
|
||||
byAccount!.items.every((i) => i.toAccount?.id === cuzdan || i.account.id === cuzdan),
|
||||
byAccount!.items.every((i) => i.toAccount?.id === cuzdan || i.account!.id === cuzdan),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,10 +13,13 @@ import { writeAudit } from "@/lib/audit";
|
||||
import { getDb } from "@/lib/db/mongo";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AssetModel } from "@/models/asset";
|
||||
import { ContactModel } from "@/models/contact";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { PayeeModel } from "@/models/payee";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { AccountType } from "@/shared/accounts";
|
||||
import type { ContactEntryKind } from "@/shared/contacts";
|
||||
import { allowedAttachmentKinds } from "@/shared/attachments";
|
||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||
import { installmentSchedule } from "@/shared/card-periods";
|
||||
@@ -68,7 +71,7 @@ export async function loadContext(
|
||||
const m = await resolveMembership(userId, householdId, minRole);
|
||||
if (!m) return null;
|
||||
await postDueInstallments(m.householdId);
|
||||
const [accounts, categories] = await Promise.all([
|
||||
const [accounts, categories, contacts, assets] = await Promise.all([
|
||||
AccountModel.find({ householdId: m.householdId })
|
||||
.select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 })
|
||||
.lean(),
|
||||
@@ -84,6 +87,12 @@ export async function loadContext(
|
||||
ancestors: 1,
|
||||
})
|
||||
.lean(),
|
||||
ContactModel.find({ householdId: m.householdId })
|
||||
.select({ name: 1, isPrivate: 1, ownerUserId: 1, archivedAt: 1 })
|
||||
.lean(),
|
||||
AssetModel.find({ householdId: m.householdId })
|
||||
.select({ name: 1, assetKey: 1, isPrivate: 1, ownerUserId: 1, deletedAt: 1 })
|
||||
.lean(),
|
||||
]);
|
||||
const accountMap = new Map<string, AccountInfo>(
|
||||
accounts.map((a) => [
|
||||
@@ -126,18 +135,55 @@ export async function loadContext(
|
||||
]),
|
||||
);
|
||||
const hiddenAccountIds = [...accountMap.values()].filter((a) => a.hidden).map((a) => a.id);
|
||||
return { m, accountMap, categoryMap, hiddenAccountIds, flatCategories: flat };
|
||||
const contactMap = new Map(
|
||||
contacts.map((c) => [
|
||||
String(c._id),
|
||||
{
|
||||
id: c._id,
|
||||
name: c.name,
|
||||
archived: Boolean(c.archivedAt),
|
||||
hidden: Boolean(c.isPrivate) && String(c.ownerUserId) !== userId,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const hiddenContactIds = [...contactMap.values()].filter((c) => c.hidden).map((c) => c.id);
|
||||
const assetMap = new Map(
|
||||
assets.map((a) => [
|
||||
String(a._id),
|
||||
{
|
||||
id: a._id,
|
||||
name: a.name,
|
||||
assetKey: a.assetKey,
|
||||
deleted: Boolean(a.deletedAt),
|
||||
hidden: Boolean(a.isPrivate) && String(a.ownerUserId) !== userId,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const hiddenAssetIds = [...assetMap.values()].filter((a) => a.hidden).map((a) => a.id);
|
||||
return {
|
||||
m,
|
||||
accountMap,
|
||||
categoryMap,
|
||||
contactMap,
|
||||
hiddenAccountIds,
|
||||
hiddenContactIds,
|
||||
assetMap,
|
||||
hiddenAssetIds,
|
||||
flatCategories: flat,
|
||||
};
|
||||
}
|
||||
|
||||
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
|
||||
|
||||
/** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */
|
||||
/** Gizli hesaplara, gizli carilere veya gizli varlıklara dokunan işlemleri dışlayan temel filtre. */
|
||||
export function baseFilter(ctx: Ctx): Record<string, unknown> {
|
||||
const f: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null };
|
||||
if (ctx.hiddenAccountIds.length) {
|
||||
f.accountId = { $nin: ctx.hiddenAccountIds };
|
||||
f.toAccountId = { $nin: ctx.hiddenAccountIds };
|
||||
}
|
||||
if (ctx.hiddenContactIds.length) f.contactId = { $nin: ctx.hiddenContactIds };
|
||||
if (ctx.hiddenAssetIds.length) f.assetId = { $nin: ctx.hiddenAssetIds };
|
||||
return f;
|
||||
}
|
||||
|
||||
@@ -221,7 +267,7 @@ function checkCategory(
|
||||
}
|
||||
|
||||
type Existing = {
|
||||
accountId: Types.ObjectId;
|
||||
accountId?: Types.ObjectId | null;
|
||||
toAccountId?: Types.ObjectId | null;
|
||||
categoryId?: Types.ObjectId | null;
|
||||
splits?: { categoryId: Types.ObjectId }[];
|
||||
@@ -238,7 +284,7 @@ export function buildDoc(
|
||||
const accountId = checkAccount(
|
||||
ctx,
|
||||
input.accountId,
|
||||
existing ? String(existing.accountId) : null,
|
||||
existing?.accountId ? String(existing.accountId) : null,
|
||||
);
|
||||
if (!accountId)
|
||||
return fail(t.validation.accountInvalid, { accountId: t.validation.accountInvalid });
|
||||
@@ -445,6 +491,9 @@ export async function updateTransaction(
|
||||
const current = await loadOwnTransaction(ctx, transactionId);
|
||||
if (!current) return fail(tr.common.notFound);
|
||||
if (current.isAdjustment) return fail(t.adjustmentReadOnly);
|
||||
// Cari hareketi cari sayfasındaki formla düzenlenir (tür ve yön cariye özgü).
|
||||
if (current.contactId) return fail(t.contactEditHint);
|
||||
if (current.assetId) return fail(t.assetLocked);
|
||||
const built = buildDoc(ctx, input, current);
|
||||
if (!built.ok) return built;
|
||||
const d = built.data as {
|
||||
@@ -467,7 +516,7 @@ export async function updateTransaction(
|
||||
d.type !== current.type ||
|
||||
d.amountMinor !== current.amountMinor ||
|
||||
d.date.getTime() !== new Date(current.date).getTime() ||
|
||||
!d.accountId.equals(current.accountId) ||
|
||||
String(d.accountId) !== String(current.accountId ?? "") ||
|
||||
String(d.toAccountId ?? "") !== String(current.toAccountId ?? "");
|
||||
try {
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
@@ -504,6 +553,10 @@ export async function setTransactionDeleted(
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const current = await loadOwnTransaction(ctx, transactionId, { includeDeleted: true });
|
||||
if (!current || Boolean(current.deletedAt) === deleted) return fail(tr.common.notFound);
|
||||
// Aynî borç ödemesi kaydın ödeme geçmişinin parçasıdır; tek başına silinmez.
|
||||
if (current.inKindRole === "payment") return fail(t.inKindPaymentLocked);
|
||||
// Varlık alım/satışı hareketin parçasıdır; hareket Birikim sayfasından silinir.
|
||||
if (current.assetId) return fail(t.assetLocked);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await TransactionModel.updateOne(
|
||||
{ _id: current._id, householdId: ctx.m.householdId },
|
||||
@@ -567,8 +620,13 @@ export type TransactionListItem = {
|
||||
status: TransactionStatus;
|
||||
date: string;
|
||||
amountMinor: number;
|
||||
account: { id: string; name: string };
|
||||
/** Kaynak hesap; cariden girişte (borç alma, tahsilat) null. */
|
||||
account: { id: string; name: string } | null;
|
||||
toAccount: { id: string; name: string } | null;
|
||||
/** Cari transferi: kişi ve hareket türü. */
|
||||
contact: { id: string; name: string; kind: ContactEntryKind } | null;
|
||||
/** Varlık alımı/satışı: karşı taraf varlık. */
|
||||
asset: { id: string; name: string } | null;
|
||||
category: { id: string; name: string; path: string; icon: IconKey; color: Swatch } | null;
|
||||
splitCount: number;
|
||||
payeeName: string;
|
||||
@@ -629,7 +687,7 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
|
||||
status: tx.status,
|
||||
date: toIstanbulYmd(tx.date),
|
||||
amountMinor: tx.amountMinor,
|
||||
account: acc(tx.accountId)!,
|
||||
account: acc(tx.accountId),
|
||||
toAccount: acc(tx.toAccountId),
|
||||
category: cat
|
||||
? { id: String(cat.id), name: cat.name, path: cat.path, icon: cat.icon, color: cat.color }
|
||||
@@ -642,6 +700,17 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
|
||||
attachmentCount: tx.attachmentCount ?? 0,
|
||||
isAdjustment: Boolean(tx.isAdjustment),
|
||||
reconciledAccountIds: (tx.reconciledAccountIds ?? []).map(String),
|
||||
contact:
|
||||
tx.contactId && tx.contactKind
|
||||
? {
|
||||
id: String(tx.contactId),
|
||||
name: ctx.contactMap.get(String(tx.contactId))?.name ?? "—",
|
||||
kind: tx.contactKind,
|
||||
}
|
||||
: null,
|
||||
asset: tx.assetId
|
||||
? { id: String(tx.assetId), name: ctx.assetMap.get(String(tx.assetId))?.name ?? "—" }
|
||||
: null,
|
||||
installment: tx.installment?.planId
|
||||
? {
|
||||
planId: String(tx.installment.planId),
|
||||
@@ -836,7 +905,11 @@ export async function exportTransactions(
|
||||
} else {
|
||||
out.push({
|
||||
...common,
|
||||
category: r.isAdjustment ? t.adjustment : categoryPath(r.categoryId),
|
||||
category: r.isAdjustment
|
||||
? t.adjustment
|
||||
: r.contactId && r.contactKind
|
||||
? `${tr.contacts.contact}: ${ctx.contactMap.get(String(r.contactId))?.name ?? ""} (${tr.contacts.kinds[r.contactKind]})`
|
||||
: categoryPath(r.categoryId),
|
||||
note: "",
|
||||
amountMinor: sign(r.type) * r.amountMinor,
|
||||
});
|
||||
|
||||
@@ -201,7 +201,15 @@ export async function getAccountStatement(
|
||||
const effects = rowsAsc.map((tx) => {
|
||||
const item = toItem(ctx, tx, names);
|
||||
const counterpart = tx.type === "transfer" ? [item.account, item.toAccount] : [];
|
||||
const restricted = counterpart.some((a) => a && ctx.accountMap.get(a.id)?.hidden);
|
||||
// Gizli cariye bağlı hareket (cari hareketi veya aynî borç ödemesi): kişi adı ve açıklama gizlenir.
|
||||
const hiddenContact = Boolean(tx.contactId && ctx.contactMap.get(String(tx.contactId))?.hidden);
|
||||
if (hiddenContact) {
|
||||
if (item.contact) item.contact.name = tr.transactions.list.hiddenContact;
|
||||
item.description = "";
|
||||
item.payeeName = tr.transactions.list.hiddenContact;
|
||||
}
|
||||
const restricted =
|
||||
hiddenContact || counterpart.some((a) => a && ctx.accountMap.get(a.id)?.hidden);
|
||||
if (restricted) {
|
||||
// Gizli karşı hesabın adı sızmasın.
|
||||
for (const a of counterpart) {
|
||||
|
||||
+740
-1
@@ -298,6 +298,7 @@ export const tr = {
|
||||
total: (n: number) => `${n} işlem`,
|
||||
sums: { income: "Gelir", expense: "Gider", net: "Net" },
|
||||
hiddenAccount: "Gizli hesap",
|
||||
hiddenContact: "Gizli cari",
|
||||
},
|
||||
filters: {
|
||||
title: "Filtreler",
|
||||
@@ -361,6 +362,9 @@ export const tr = {
|
||||
"Mutabakat düzeltmesi düzenlenemez; gerekirse silip mutabakatı yeniden yapın.",
|
||||
reconciled: "Mutabakatı yapıldı",
|
||||
cancelInstallments: "Kalan taksitleri iptal et",
|
||||
contactEditHint: "Cari hareketi cari sayfasından düzenlenir.",
|
||||
inKindPaymentLocked: "Aynî borç ödemesi silinemez; ödeme cari sayfasındaki kayda bağlıdır.",
|
||||
assetLocked: "Varlık alım/satış işlemi Birikim sayfasındaki hareketten yönetilir.",
|
||||
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
|
||||
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
|
||||
noAccountsLink: "Hesaplara git",
|
||||
@@ -473,6 +477,740 @@ export const tr = {
|
||||
upload: "Dosya yüklenemedi.",
|
||||
},
|
||||
},
|
||||
contacts: {
|
||||
title: "Cari hesaplar",
|
||||
description:
|
||||
"Kişilerle borç-alacak takibi. Cari hareketleri transferdir; gelir/gider raporlarına girmez.",
|
||||
contact: "Cari",
|
||||
openContact: "Cariyi aç",
|
||||
new: "Yeni cari",
|
||||
createTitle: "Yeni cari",
|
||||
editTitle: "Cariyi düzenle",
|
||||
relations: {
|
||||
family: "Aile / akraba",
|
||||
friend: "Arkadaş",
|
||||
neighbor: "Komşu",
|
||||
work: "İş",
|
||||
other: "Diğer",
|
||||
},
|
||||
kinds: { lend: "Borç verdim", repay: "Borç ödedim", borrow: "Borç aldım", collect: "Tahsilat" },
|
||||
kindHints: {
|
||||
lend: "Hesabınızdan kişiye para verdiniz; size borçlanır.",
|
||||
repay: "Kişiye olan borcunuzu hesabınızdan ödediniz.",
|
||||
borrow: "Kişiden hesabınıza para aldınız; ona borçlanırsınız.",
|
||||
collect: "Kişinin size olan borcunu hesabınıza tahsil ettiniz.",
|
||||
},
|
||||
fields: {
|
||||
name: "Ad soyad",
|
||||
relation: "Yakınlık",
|
||||
phone: "Telefon",
|
||||
note: "Not",
|
||||
openingBalance: "Açılış bakiyesi (TL)",
|
||||
openingHint:
|
||||
"Uygulamadan önceki durum: size borçluysa pozitif, siz borçluysanız başına - koyarak girin.",
|
||||
isPrivate: "Yalnızca ben (bu cariyi ve hareketlerini yalnızca ben görürüm)",
|
||||
dueDate: "Vade tarihi",
|
||||
dueHint: "Kalan bakiyenin ödenmesi beklenen tarih (isteğe bağlı).",
|
||||
reminderDays: "Kaç gün önce uyarılsın",
|
||||
account: "Hesap",
|
||||
fromAccount: "Ödemenin çıktığı hesap",
|
||||
toAccount: "Paranın girdiği hesap",
|
||||
},
|
||||
balance: { owesMe: "Bana borçlu", iOwe: "Ben borçluyum", settled: "Hesap kapalı" },
|
||||
summary: { owesMe: "Bana borçlu", iOwe: "Ben borçluyum", net: "Net" },
|
||||
newEntry: "Hareket ekle",
|
||||
entryTitle: "Cari hareketi",
|
||||
editEntryTitle: "Cari hareketini düzenle",
|
||||
due: {
|
||||
overdue: (d: string) => `Vade geçti · ${d}`,
|
||||
upcoming: (d: string) => `Vade yaklaşıyor · ${d}`,
|
||||
on: (d: string) => `Vade · ${d}`,
|
||||
},
|
||||
dueBanner: (n: number) => `${n} carinin vadesi geçti veya yaklaşıyor.`,
|
||||
dueLink: "Carilere git",
|
||||
statement: "Cari ekstresi",
|
||||
opening: "Açılış bakiyesi",
|
||||
runningBalance: "Bakiye",
|
||||
lastActivity: (d: string) => `Son hareket ${d}`,
|
||||
empty: "Henüz cari yok. Borç verdiğiniz veya aldığınız kişileri ekleyin.",
|
||||
emptyEntries: "Henüz hareket yok.",
|
||||
archive: "Arşivle",
|
||||
unarchive: "Arşivden çıkar",
|
||||
archivedSection: (n: number) => `Arşivlenmiş cariler (${n})`,
|
||||
privateBadge: "Yalnızca ben",
|
||||
readOnly: "İzleyici rolündesiniz; cari hareketi ekleyemezsiniz.",
|
||||
toast: {
|
||||
created: "Cari oluşturuldu.",
|
||||
updated: "Cari güncellendi.",
|
||||
entrySaved: "Cari hareketi kaydedildi.",
|
||||
archived: "Cari arşivlendi.",
|
||||
unarchived: "Cari arşivden çıkarıldı.",
|
||||
},
|
||||
errors: {
|
||||
duplicate: "Bu adla bir cari zaten var.",
|
||||
archived: "Arşivlenmiş cariye hareket eklenemez.",
|
||||
},
|
||||
},
|
||||
inKind: {
|
||||
title: "Aynî borç ve alacaklar",
|
||||
description:
|
||||
"Altın, ziynet veya döviz cinsinden borç/alacak. Kapanış yalnızca “Ödeme gerçekleşti” ile.",
|
||||
new: "Aynî kayıt ekle",
|
||||
createTitle: "Yeni aynî borç/alacak",
|
||||
editTitle: "Aynî kaydı düzenle",
|
||||
directions: { debt: "Ben borçluyum", receivable: "Bana borçlu" },
|
||||
fields: {
|
||||
direction: "Yön",
|
||||
asset: "Varlık",
|
||||
quantity: "Miktar",
|
||||
date: "Borç tarihi",
|
||||
unitPrice: "O tarihteki birim fiyat (TL)",
|
||||
unitPriceHint: "Boş bırakılırsa fiyat tablosundaki o tarihe ait son fiyat kullanılır.",
|
||||
note: "Not",
|
||||
savingsAccount: "Birikim hesabı",
|
||||
savingsNone: "— Birikim planı yok —",
|
||||
monthlyPlan: "Aylık ayrılacak tutar (TL)",
|
||||
planHint:
|
||||
"Borcu kapatmak için her ay bu hesaba para ayırın; borç yalnızca ödeme yapılınca kapanır.",
|
||||
},
|
||||
stats: {
|
||||
remaining: "Kalan",
|
||||
dateValue: (d: string) => `${d} değeri`,
|
||||
currentValue: "Güncel değer",
|
||||
accumulated: "Biriken",
|
||||
remainingNeed: "Kalan ihtiyaç",
|
||||
estimatedClose: "Tahmini kapanış",
|
||||
noPrice: "Güncel fiyat yok",
|
||||
closed: (d: string) => `Kapandı · ${d}`,
|
||||
priceAt: (price: string, date: string) => `Fiyat ${price} (${date})`,
|
||||
currentPrice: (debt: boolean, price: string, date: string) =>
|
||||
`Güncel ${debt ? "satış" : "alış"} fiyatı ${price} · ${date}`,
|
||||
},
|
||||
contribute: "Birikim ekle",
|
||||
contributionTitle: "Birikim ekle",
|
||||
fromAccount: "Kaynak hesap",
|
||||
contributionDescription: (label: string, name: string) => `Birikim: ${label} borcu (${name})`,
|
||||
pay: "Ödeme gerçekleşti",
|
||||
payTitle: "Ödeme gerçekleşti",
|
||||
methods: { asset: "Aynı varlıkla", cash: "TL karşılığıyla" },
|
||||
methodHints: {
|
||||
asset: "Varlığın kendisiyle ödendi (ör. kuyumcudan alınan altın verildi).",
|
||||
cash: "Varlığın TL karşılığı ödendi; hesaptan çıkış işlemi oluşturulur.",
|
||||
},
|
||||
receiveHints: {
|
||||
asset: "Varlığın kendisi teslim alındı.",
|
||||
cash: "Varlığın TL karşılığı hesaba tahsil edildi.",
|
||||
},
|
||||
payFields: {
|
||||
quantity: "Ödenen miktar",
|
||||
quantityHint: "Boş bırakılırsa tutar güncel fiyata bölünerek hesaplanır.",
|
||||
amount: "Tutar (TL)",
|
||||
assetAmount: "Varlığı almak için harcanan (TL, isteğe bağlı)",
|
||||
account: "Hesap",
|
||||
},
|
||||
paymentDescription: (debt: boolean, label: string, name: string) =>
|
||||
debt ? `Aynî borç ödemesi: ${label} (${name})` : `Aynî alacak tahsilatı: ${label} (${name})`,
|
||||
payments: "Ödemeler",
|
||||
paymentLine: (method: string, qty: string, amount: string | null) =>
|
||||
amount ? `${method} · ${qty} · ${amount}` : `${method} · ${qty}`,
|
||||
delete: "Kaydı sil",
|
||||
deleteTitle: "Aynî kayıt silinsin mi?",
|
||||
deleteDescription:
|
||||
"Kayıt listeden kaldırılır. Oluşturulmuş para hareketleri (birikim, ödeme) işlemlerde kalır.",
|
||||
prices: "Fiyat tablosu",
|
||||
toast: {
|
||||
saved: "Aynî kayıt kaydedildi.",
|
||||
contributed: "Birikim eklendi.",
|
||||
paid: "Ödeme kaydedildi.",
|
||||
closed: "Ödeme kaydedildi; kayıt kapandı.",
|
||||
deleted: "Aynî kayıt silindi.",
|
||||
},
|
||||
errors: {
|
||||
noPrice: "Bu tarih için fiyat yok; birim fiyatı girin veya fiyat tablosunu güncelleyin.",
|
||||
noSavings: "Önce kayda bir birikim hesabı seçin.",
|
||||
closed: "Kayıt kapanmış.",
|
||||
quantityBelowPaid: "Miktar ödenmiş miktardan az olamaz.",
|
||||
receivablePlan: "Birikim planı yalnızca borçlar içindir.",
|
||||
noCurrentPrice: "Güncel fiyat yok; ödenen miktarı girin.",
|
||||
sameAccount: "Kaynak hesap birikim hesabıyla aynı olamaz.",
|
||||
},
|
||||
validation: {
|
||||
quantity: "Geçerli bir miktar girin (en fazla 3 ondalık)",
|
||||
price: "Alış veya satış fiyatı girin",
|
||||
amount: "Tutar girin",
|
||||
account: "Hesap seçin",
|
||||
assetOnlyWithAsset: "Varlık bağlantısı yalnızca aynı varlıkla ödemede kullanılır.",
|
||||
noAmountWithAsset: "Varlıktan ödemede TL tutar girilmez.",
|
||||
},
|
||||
link: {
|
||||
debt: "Birikimdeki varlıktan düş",
|
||||
receivable: "Birikimdeki varlığa ekle",
|
||||
debtHint: "Ödenen miktar seçilen varlıktan ortalama maliyetle çıkar.",
|
||||
receivableHint:
|
||||
"Tahsil edilen miktar seçilen varlığa eklenir; maliyet borç tarihindeki birim fiyattır.",
|
||||
asset: "Varlık",
|
||||
none: "Bu türde varlık yok; önce Birikim sayfasından ekleyin.",
|
||||
movementNote: (name: string) => `Aynî kayıt: ${name}`,
|
||||
assetMismatch: "Seçilen varlık bu kaydın varlık türünde değil.",
|
||||
},
|
||||
},
|
||||
assets: {
|
||||
title: "Birikim",
|
||||
description:
|
||||
"Altın, döviz ve ziynet birikimleri: alım lotları, satış ve çıkışlar, ortalama maliyet ve kâr/zarar. Değerleme alış fiyatıyla.",
|
||||
new: "Varlık ekle",
|
||||
asset: "Varlık",
|
||||
open: "Varlığa git",
|
||||
createTitle: "Yeni varlık",
|
||||
editTitle: "Varlığı düzenle",
|
||||
empty: "Henüz varlık yok. Bir varlık ekleyip ilk alımı girin.",
|
||||
closedGroup: "Elde kalmayanlar",
|
||||
privateBadge: "Yalnızca ben",
|
||||
fields: {
|
||||
asset: "Varlık türü",
|
||||
name: "Ad",
|
||||
namePlaceholder: "Ör. Gram altın birikimi",
|
||||
note: "Not",
|
||||
isPrivate: "Yalnızca ben görebilirim",
|
||||
isPrivateHint: "İşaretlenirse bu varlık ve hareketleri hanedeki diğer üyelere görünmez.",
|
||||
},
|
||||
stats: {
|
||||
quantity: "Eldeki miktar",
|
||||
avgCost: "Ortalama maliyet",
|
||||
cost: "Maliyet",
|
||||
currentValue: "Güncel değer",
|
||||
unrealized: "Gerçekleşmemiş K/Z",
|
||||
realized: "Gerçekleşmiş K/Z",
|
||||
invested: "Yatırılan",
|
||||
proceeds: "Satış geliri",
|
||||
noPrice: "Fiyat yok",
|
||||
price: (price: string, date: string) => `Güncel alış fiyatı ${price} · ${date}`,
|
||||
perUnit: (unit: string) => `/ ${unit}`,
|
||||
},
|
||||
totals: {
|
||||
value: "Toplam güncel değer",
|
||||
cost: "Toplam maliyet",
|
||||
unrealized: "Gerçekleşmemiş K/Z",
|
||||
realized: "Gerçekleşmiş K/Z",
|
||||
missingPrice: (n: number) => `${n} varlığın fiyatı yok; toplam değere katılmadı.`,
|
||||
},
|
||||
kinds: {
|
||||
buy: "Alım",
|
||||
gift_in: "Hediye / miras geldi",
|
||||
sell: "Satış",
|
||||
gift_out: "Hediye / çıkış",
|
||||
},
|
||||
kindHints: {
|
||||
buy: "Para seçilen hesaptan çıkar (transfer; gider sayılmaz).",
|
||||
gift_in: "Hesap hareketi oluşmaz; maliyet o günkü değerdir.",
|
||||
sell: "Satış tutarı seçilen hesaba girer; ortalama maliyete göre kâr/zarar yazılır.",
|
||||
gift_out: "Hesap hareketi oluşmaz; ortalama maliyetle elden çıkar.",
|
||||
},
|
||||
movement: {
|
||||
add: "Hareket ekle",
|
||||
title: (kind: string, name: string) => `${kind} · ${name}`,
|
||||
date: "Tarih",
|
||||
quantity: "Miktar",
|
||||
unitPrice: "Birim fiyat (TL)",
|
||||
amount: "Toplam tutar (TL)",
|
||||
priceHint:
|
||||
"Tutar veya birim fiyattan birini girin; ikisi de boşsa fiyat tablosu kullanılır (alımda satış, satışta alış fiyatı).",
|
||||
giftValueHint: "Boş bırakılırsa fiyat tablosundaki o günkü alış fiyatı kullanılır.",
|
||||
account: "Hesap",
|
||||
accountNone: "— Hesap yok —",
|
||||
accountHint: "Hesap seçilmezse hesap hareketi oluşmaz.",
|
||||
storage: "Saklama yeri",
|
||||
storagePlaceholder: "Ör. ev kasası, banka kiralık kasa",
|
||||
note: "Not",
|
||||
held: (q: string) => `Eldeki: ${q}`,
|
||||
},
|
||||
history: "Hareketler",
|
||||
noMovements: "Henüz hareket yok.",
|
||||
report: {
|
||||
title: "Dönemsel rapor",
|
||||
ranges: { "3": "3 ay", "6": "6 ay", "12": "12 ay", tum: "Tümü" },
|
||||
rangeLabel: "Dönem",
|
||||
period: (from: string, to: string) => `${from} – ${to}`,
|
||||
startValue: "Dönem başı değer",
|
||||
invested: "Yatırılan",
|
||||
endValue: "Dönem sonu değer",
|
||||
profit: "Dönem kâr/zararı",
|
||||
proceeds: "Satış geliri",
|
||||
giftIn: "Gelen hediye",
|
||||
giftOut: "Çıkan hediye",
|
||||
formula:
|
||||
"Kâr/zarar = dönem sonu değer + satış + çıkan hediye − dönem başı değer − yatırılan − gelen hediye.",
|
||||
value: "Güncel değer",
|
||||
cost: "Maliyet",
|
||||
chartLabel: "Aylık değer ve maliyet",
|
||||
table: "Tablo olarak göster",
|
||||
date: "Tarih",
|
||||
noPrice: "Fiyat yok",
|
||||
excluded: (n: number) => `${n} varlık fiyatı olmadığı için rapora katılmadı.`,
|
||||
empty: "Bu dönemde gösterilecek varlık yok.",
|
||||
},
|
||||
netWorth: {
|
||||
title: "Net varlık",
|
||||
description: "Hesaplar + birikim + alacaklar − borçlar (TL ve aynî).",
|
||||
accounts: "Hesap bakiyeleri",
|
||||
cardDebt: "Kart ve KMH borçları",
|
||||
assets: "Birikim (altın, döviz)",
|
||||
contactReceivable: "Cari alacaklar",
|
||||
contactPayable: "Cari borçlar",
|
||||
inKindReceivable: "Aynî alacaklar",
|
||||
inKindPayable: "Aynî borçlar",
|
||||
total: "Net varlık",
|
||||
missingPrice: (n: number) => `${n} kalemin fiyatı yok; toplama katılmadı.`,
|
||||
includesHidden: "Hane ayarı gereği diğer üyelerin gizli hesap bakiyeleri dahil.",
|
||||
},
|
||||
table: {
|
||||
date: "Tarih",
|
||||
kind: "Hareket",
|
||||
quantity: "Miktar",
|
||||
unitPrice: "Birim fiyat",
|
||||
amount: "Tutar",
|
||||
result: "K/Z",
|
||||
balance: "Kalan",
|
||||
},
|
||||
deleteMovement: "Hareketi sil",
|
||||
deleteMovementTitle: "Hareket silinsin mi?",
|
||||
deleteMovementDescription:
|
||||
"Hareket ve bağlı hesap işlemi silinir; ortalama maliyet ve kâr/zarar yeniden hesaplanır.",
|
||||
delete: "Varlığı sil",
|
||||
deleteTitle: "Varlık silinsin mi?",
|
||||
deleteDescription: "Yalnızca hareketi olmayan varlık silinebilir.",
|
||||
prices: "Fiyat tablosu",
|
||||
back: "Birikim",
|
||||
txDescription: {
|
||||
buy: (label: string, name: string) => `Varlık alımı: ${label} (${name})`,
|
||||
sell: (label: string, name: string) => `Varlık satışı: ${label} (${name})`,
|
||||
},
|
||||
toast: {
|
||||
saved: "Varlık kaydedildi.",
|
||||
movement: "Hareket kaydedildi.",
|
||||
movementDeleted: "Hareket silindi.",
|
||||
deleted: "Varlık silindi.",
|
||||
},
|
||||
errors: {
|
||||
noPrice: "Bu tarih için fiyat yok; tutar veya birim fiyat girin.",
|
||||
insufficient: "Bu tarihte eldeki miktar yetersiz.",
|
||||
deleteBreaks: "Bu hareket silinirse sonraki bir satış/çıkış eldeki miktarı aşar.",
|
||||
hasMovements: "Hareketi olan varlık silinemez; önce hareketleri silin.",
|
||||
assetKeyLocked: "Hareketi olan varlığın türü değiştirilemez.",
|
||||
futureDate: "Gelecek tarihli hareket girilemez.",
|
||||
privateOwner: "Yalnızca sahibi gizliliği değiştirebilir.",
|
||||
},
|
||||
validation: {
|
||||
noAccountForGift: "Hediye hareketinde hesap seçilmez.",
|
||||
},
|
||||
},
|
||||
budget: {
|
||||
title: "Bütçe",
|
||||
description:
|
||||
"Aylık kategori limitleri, gerçekleşen harcama ve ay sonu tahmini. Üst kategori limiti alt kategorileri kapsar.",
|
||||
prevMonth: "Önceki ay",
|
||||
nextMonth: "Sonraki ay",
|
||||
thisMonth: "Bu ay",
|
||||
period: (from: string, to: string) => `${from} – ${to}`,
|
||||
totals: {
|
||||
limit: "Bütçe",
|
||||
actual: "Harcanan",
|
||||
remaining: "Kalan",
|
||||
over: "Aşım",
|
||||
forecast: "Ay sonu tahmini",
|
||||
planned: (amount: string) => `Planlı: ${amount}`,
|
||||
unbudgeted: "Bütçesiz harcama",
|
||||
monthProgress: (day: number, total: number, pct: string) =>
|
||||
`Ayın ${day}/${total}. günü (%${pct}).`,
|
||||
},
|
||||
status: {
|
||||
ok: "Yolunda",
|
||||
warning: "%80 geçildi",
|
||||
over: "Limit aşıldı",
|
||||
full: "Limit doldu",
|
||||
none: "Limitsiz",
|
||||
covered: "Üst kategori limitinde",
|
||||
forecastOver: "Tahmin limiti aşıyor",
|
||||
},
|
||||
line: {
|
||||
ofLimit: (actual: string, limit: string) => `${actual} / ${limit}`,
|
||||
remaining: (amount: string) => `${amount} kaldı`,
|
||||
over: (amount: string) => `${amount} aşıldı`,
|
||||
forecast: (amount: string) => `Tahmin ${amount}`,
|
||||
planned: (amount: string) => `Planlı ${amount}`,
|
||||
},
|
||||
uncategorized: "Kategorisiz",
|
||||
empty: "Bu ay için bütçe yok.",
|
||||
emptyHint:
|
||||
"Limitleri elle girebilir, önceki ayın bütçesini kopyalayabilir veya önceki ayın harcamalarından oluşturabilirsiniz.",
|
||||
noSpending: "Bu ay henüz gider yok.",
|
||||
edit: "Limitleri düzenle",
|
||||
editTitle: (month: string) => `Bütçe limitleri · ${month}`,
|
||||
editHint:
|
||||
"Boş bırakılan kategori limitsizdir. Alt kategori limiti üst kategorinin içinde bir alt sınırdır.",
|
||||
editTotal: (amount: string) => `Toplam bütçe: ${amount}`,
|
||||
copyPrevious: "Önceki aydan kopyala",
|
||||
fromActuals: "Önceki ayın harcamalarından oluştur",
|
||||
fromActualsHint:
|
||||
"Ana kategorilerin önceki ay harcaması 100 TL'ye yukarı yuvarlanarak limit olur.",
|
||||
replaceTitle: "Mevcut limitler değiştirilsin mi?",
|
||||
replaceDescription: "Bu ayın tüm limitleri seçilen kaynakla değiştirilir.",
|
||||
replace: "Değiştir",
|
||||
includesHidden:
|
||||
"Hane ayarı gereği diğer üyelerin gizli hesaplarından yapılan harcamalar dahil.",
|
||||
warnings: (n: number) => `${n} kategoride bütçenin %80'i geçildi veya limit aşıldı.`,
|
||||
goToBudget: "Bütçeye git",
|
||||
income: {
|
||||
title: "Gelir planı",
|
||||
description:
|
||||
"Gerçekleşen gelirler, planlı (düzenli) gelirler ve elle eklenen beklenen gelirler.",
|
||||
actual: "Gerçekleşen",
|
||||
pending: "Bekleyen planlı",
|
||||
manual: "Elle eklenen",
|
||||
expected: "Beklenen toplam",
|
||||
plannedLeft: "Plana göre elde kalacak",
|
||||
projectedLeft: "Bu tempoda elde kalacak",
|
||||
plannedLeftHint: "Beklenen gelir − bütçe",
|
||||
projectedLeftHint: "Beklenen gelir − ay sonu tahmini gider",
|
||||
received: "Alındı",
|
||||
planned: "Planlı",
|
||||
empty: "Bu ay için planlı gelir yok.",
|
||||
edit: "Beklenen gelir ekle",
|
||||
editTitle: (month: string) => `Elle eklenen gelirler · ${month}`,
|
||||
editHint:
|
||||
"Planlı işlemi olmayan beklenen gelirler (ör. prim). Gelir gerçekleşip işlem olarak girildiğinde kalemi buradan kaldırın; aksi halde iki kez sayılır.",
|
||||
itemName: "Açıklama",
|
||||
itemAmount: "Tutar",
|
||||
addItem: "Kalem ekle",
|
||||
removeItem: "Kalemi kaldır",
|
||||
saved: "Gelir planı kaydedildi.",
|
||||
},
|
||||
daily: {
|
||||
title: "Günlük plan",
|
||||
headline: (monthPct: number, spentPct: number) =>
|
||||
`Bugün ayın %${monthPct}'si geçti, bütçenin %${spentPct}'i harcandı.`,
|
||||
today: "Bugün için",
|
||||
todayHint: "Bugünün planı + önceki günlerden kalan (veya aşan) tutar",
|
||||
plannedToDate: "Bugüne kadar planlanan",
|
||||
actualToDate: "Bugüne kadar harcanan",
|
||||
projectedLeft: "Bu tempoda ay sonunda elde kalacak",
|
||||
chartLabel: "Kümülatif planlanan ve gerçekleşen gider",
|
||||
cumPlanned: "Planlanan (kümülatif)",
|
||||
cumActual: "Gerçekleşen (kümülatif)",
|
||||
income: "Beklenen gelir",
|
||||
modes: {
|
||||
even: "Değişken bütçe günlere eşit dağıtıldı.",
|
||||
previous_month_pattern: "Değişken bütçe önceki ayın günlük örüntüsüne göre dağıtıldı.",
|
||||
},
|
||||
fixedNote: (amount: string) => `Tarihe bağlı giderler kendi günlerinde: ${amount}.`,
|
||||
table: "Günleri tablo olarak göster",
|
||||
day: "Gün",
|
||||
planned: "Plan",
|
||||
actual: "Gerçekleşen",
|
||||
noBudget: "Günlük plan için bu ayın bütçesini oluşturun.",
|
||||
next: (month: string) => `Sonraki ay (${month})`,
|
||||
nextIncome: "Beklenen gelir",
|
||||
nextBudget: "Bütçe",
|
||||
nextDiff: "Fark",
|
||||
nextNoBudget: "Bütçe yok",
|
||||
},
|
||||
toast: {
|
||||
saved: "Bütçe kaydedildi.",
|
||||
copied: "Önceki ayın limitleri kopyalandı.",
|
||||
created: "Limitler önceki ayın harcamalarından oluşturuldu.",
|
||||
},
|
||||
errors: {
|
||||
noPrevious: "Önceki ay için bütçe yok.",
|
||||
noPreviousSpending: "Önceki ayda gider yok.",
|
||||
category: "Geçersiz veya arşivlenmiş kategori.",
|
||||
},
|
||||
validation: {
|
||||
month: "Geçersiz ay",
|
||||
category: "Kategori seçin",
|
||||
},
|
||||
},
|
||||
stock: {
|
||||
title: "Ev stoğu",
|
||||
description:
|
||||
"Evdeki ürünlerin miktarı, kritik eşik ve son kullanma tarihleri. Stok ürün tipi düzeyinde tutulur.",
|
||||
newProduct: "Yeni ürün tipi",
|
||||
categoriesLink: "Stok kategorileri",
|
||||
countLink: "Sayım",
|
||||
analysisLink: "Tüketim analizi",
|
||||
countNote: "Sayım",
|
||||
countDue: {
|
||||
title: "Sayımı gelenler",
|
||||
description:
|
||||
"Sayım sıklığı dolan kategoriler. Sıklık ürün tipinde veya stok kategorisinde ayarlanır.",
|
||||
products: (n: number) => `${n} ürün`,
|
||||
overdue: (days: number) => (days > 0 ? `${days} gün gecikti` : "Bugün"),
|
||||
start: "Sayıma başla",
|
||||
},
|
||||
count: {
|
||||
title: "Sayım",
|
||||
description:
|
||||
"Evdeki gerçek miktarları girin. Kayıtlarla arasındaki fark sayım düzeltmesi olur; eksiğin bir kısmını fire olarak işaretleyebilirsiniz. Boş bıraktığınız ürünler sayılmaz.",
|
||||
back: "Ev stoğuna dön",
|
||||
allCategories: "Tüm stok",
|
||||
category: "Kategori",
|
||||
dueCount: (n: number) => `${n} sayımı gelen`,
|
||||
onlyDue: "Yalnızca sayımı gelenler",
|
||||
expected: "Beklenen",
|
||||
actual: "Sayılan",
|
||||
diff: "Fark",
|
||||
same: "Aynı",
|
||||
markWaste: "Eksiğin bir kısmı fire",
|
||||
wasteQuantity: "Fire miktarı",
|
||||
wasteMax: (q: string) => `En çok ${q}`,
|
||||
dueBadge: (days: number) => (days > 0 ? `Sayım ${days} gün gecikti` : "Sayım bugün"),
|
||||
lastCount: (date: string) => `Son sayım ${date}`,
|
||||
neverCounted: "Hiç sayılmadı",
|
||||
fillExpected: "Beklenenle doldur",
|
||||
fillExpectedHint: "Sayılmayan satırlara beklenen miktarı yazar; değişenleri düzeltin.",
|
||||
clear: "Temizle",
|
||||
note: "Not",
|
||||
counted: (n: number) => `${n} ürün sayılacak`,
|
||||
submit: "Sayımı kaydet",
|
||||
empty: "Bu kategoride sayılacak ürün yok.",
|
||||
done: (counted: number, adjusted: number) =>
|
||||
adjusted
|
||||
? `${counted} ürün sayıldı; ${adjusted} üründe fark düzeltildi.`
|
||||
: `${counted} ürün sayıldı; kayıtlarla aynı.`,
|
||||
},
|
||||
analysis: {
|
||||
title: "Tüketim analizi",
|
||||
description:
|
||||
"Ürün tipi başına aylık tüketim ve fire. Tüketim: girilen tüketimler ve sayımda çıkan eksikler. Maliyet, ürün fiyatları (Faz 12) gelince eklenecek.",
|
||||
back: "Ev stoğuna dön",
|
||||
product: "Ürün tipi",
|
||||
avgUsed: "Aylık ort. tüketim",
|
||||
avgWasted: "Aylık ort. fire",
|
||||
wastePct: "Fire oranı",
|
||||
thisMonth: "Bu ay",
|
||||
trend: (n: number) => `Son ${n} ay`,
|
||||
noData: "—",
|
||||
empty: "Analiz için ürün tipi yok.",
|
||||
chartLabel: (name: string) => `${name}: aylık tüketim`,
|
||||
monthValue: (month: string, used: string, wasted: string) =>
|
||||
`${month}: tüketim ${used}, fire ${wasted}`,
|
||||
highWaste: "Fire oranı yüksek",
|
||||
table: "Tablo",
|
||||
},
|
||||
readOnly: "İzleyici rolündesiniz; stoğu değiştiremezsiniz.",
|
||||
summary: {
|
||||
products: "Ürün tipi",
|
||||
low: "Azalan",
|
||||
expiring: "SKT yaklaşan",
|
||||
expired: "SKT geçmiş",
|
||||
},
|
||||
suggestions: {
|
||||
title: "Alınması önerilenler",
|
||||
description: "Kritik eşiğin altına düşen ürünler ve hedefe tamamlamak için gereken miktar.",
|
||||
empty: "Eşiğin altına düşen ürün yok.",
|
||||
suggested: (q: string) => `Öneri: ${q}`,
|
||||
wasteReduced: (purchases: number, pct: number) =>
|
||||
`Son ${purchases} alımda %${pct} fire, miktar azaltıldı.`,
|
||||
roundedByUsage: (week: string) => `Haftalık tüketime (${week}) göre yuvarlandı.`,
|
||||
covers: (days: number) => `Mevcutla birlikte yaklaşık ${days} gün yeter.`,
|
||||
},
|
||||
expiring: {
|
||||
title: "Son kullanma tarihi",
|
||||
description: "Evde kalan miktarı olan ve SKT'si 7 gün içinde dolan veya geçmiş partiler.",
|
||||
expired: "Geçti",
|
||||
soon: "Yaklaşıyor",
|
||||
on: (date: string) => `SKT ${date}`,
|
||||
},
|
||||
list: {
|
||||
empty: "Henüz ürün tipi yok. “Yeni ürün tipi” ile evdeki ürünleri ekleyin.",
|
||||
emptyCategory: "Bu kategoride ürün yok.",
|
||||
search: "Ürün ara",
|
||||
onlyLow: "Yalnızca azalanlar",
|
||||
noMatch: "Aramaya uyan ürün yok.",
|
||||
lowBadge: "Azaldı",
|
||||
outBadge: "Bitti",
|
||||
of: (current: string, target: string) => `${current} / ${target}`,
|
||||
quickConsume: (name: string) => `${name}: 1 azalt`,
|
||||
quickDone: (name: string, rest: string) => `${name} azaltıldı, kalan ${rest}.`,
|
||||
},
|
||||
fields: {
|
||||
name: "Ad",
|
||||
namePlaceholder: "ör. Toz Deterjan, Kırmızı Mercimek",
|
||||
category: "Stok kategorisi",
|
||||
unit: "Temel ölçü birimi",
|
||||
customUnit: "Özel birim",
|
||||
customUnitOption: "Özel birim…",
|
||||
customUnitPlaceholder: "ör. yıkama, tablet",
|
||||
target: "Hedef (tam) stok",
|
||||
targetHint: "Evde bulunmasını istediğiniz tam miktar.",
|
||||
thresholdPct: "Kritik eşik (%)",
|
||||
thresholdHint: "Mevcut miktar hedefin bu yüzdesinin altına düşerse ürün önerilir.",
|
||||
location: "Saklama yeri",
|
||||
locationPlaceholder: "Kiler, banyo dolabı…",
|
||||
step: "“1 azalt” miktarı",
|
||||
stepHint: "Hızlı azaltma düğmesinin düşeceği miktar.",
|
||||
countEveryDays: "Sayım sıklığı (gün)",
|
||||
countEveryDaysHint: "Boş bırakılırsa kategoriden devralınır.",
|
||||
note: "Not",
|
||||
initialQuantity: "Evdeki mevcut miktar",
|
||||
initialHint: "Tahmini veya ambalaj üzerinden. Boş bırakılırsa 0 kabul edilir.",
|
||||
date: "Tarih",
|
||||
quantity: "Miktar",
|
||||
wasteReason: "Fire nedeni",
|
||||
expiryDate: "Son kullanma tarihi",
|
||||
expiryHint: "İsteğe bağlı. Yaklaşınca uyarı verilir.",
|
||||
actualQuantity: "Sayılan (gerçek) miktar",
|
||||
expected: (q: string) => `Kayıtlara göre beklenen: ${q}`,
|
||||
},
|
||||
createTitle: "Yeni ürün tipi",
|
||||
editTitle: "Ürün tipini düzenle",
|
||||
kinds: {
|
||||
purchase: "Alım",
|
||||
consume: "Tüketim",
|
||||
waste: "Fire / zarar",
|
||||
count_adjust: "Sayım düzeltmesi",
|
||||
},
|
||||
actions: {
|
||||
purchase: "Alım ekle",
|
||||
consume: "Tüketim gir",
|
||||
waste: "Fire gir",
|
||||
count: "Sayım yap",
|
||||
},
|
||||
movementTitles: {
|
||||
purchase: "Alım ekle",
|
||||
consume: "Tüketim gir",
|
||||
waste: "Fire / zarar gir",
|
||||
},
|
||||
countTitle: "Sayım yap",
|
||||
countDescription:
|
||||
"Evdeki gerçek miktarı girin; kayıtlarla arasındaki fark sayım düzeltmesi olarak işlenir.",
|
||||
wasteReasons: {
|
||||
spoiled: "Bozuldu",
|
||||
expired: "Son kullanma tarihi geçti",
|
||||
damaged: "Hasar / döküldü",
|
||||
other: "Diğer",
|
||||
},
|
||||
initial: "Başlangıç miktarı",
|
||||
detail: {
|
||||
back: "Ev stoğuna dön",
|
||||
current: "Mevcut",
|
||||
target: "Hedef",
|
||||
threshold: "Eşik",
|
||||
suggested: "Önerilen alım",
|
||||
usage: "Tüketim hızı",
|
||||
perWeek: (q: string) => `Haftada ~${q}`,
|
||||
noUsage: "Henüz yeterli geçmiş yok",
|
||||
countSchedule: "Periyodik sayım",
|
||||
countEvery: (n: number) => `${n} günde bir`,
|
||||
nextCount: (date: string) => `Sonraki sayım ${date}`,
|
||||
noCountSchedule: "Sayım sıklığı ayarlanmadı",
|
||||
location: "Saklama yeri",
|
||||
lots: "Evdeki partiler",
|
||||
lotsHint: "Çıkışlar en eski alımdan düşülür (FIFO).",
|
||||
noLots: "Evde bu üründen yok.",
|
||||
noExpiry: "SKT yok",
|
||||
movements: "Hareketler",
|
||||
noMovements: "Henüz hareket yok.",
|
||||
after: (q: string) => `Sonra: ${q}`,
|
||||
archive: "Arşivle",
|
||||
unarchive: "Arşivden çıkar",
|
||||
archived: "Arşivli",
|
||||
archiveTitle: "Ürün tipi arşivlensin mi?",
|
||||
archiveDescription: (name: string) =>
|
||||
`“${name}” stok listesinde ve önerilerde görünmez. Hareketleri silinmez; istediğinizde arşivden çıkarabilirsiniz.`,
|
||||
deleteMovement: "Hareketi sil",
|
||||
deleteMovementTitle: "Hareket silinsin mi?",
|
||||
deleteMovementDescription: "Mevcut miktar bu hareket olmadan yeniden hesaplanır.",
|
||||
},
|
||||
categories: {
|
||||
title: "Stok kategorileri",
|
||||
description:
|
||||
"Stok kategorileri gider kategorilerinden ayrıdır. Her kategori bir gider kategorisine eşlenebilir; alışveriş tamamlanınca ürünler o gidere yazılır.",
|
||||
back: "Ev stoğuna dön",
|
||||
new: "Yeni kategori",
|
||||
addChild: "Alt kategori ekle",
|
||||
edit: "Düzenle",
|
||||
archive: "Arşivle",
|
||||
unarchive: "Arşivden çıkar",
|
||||
showArchived: "Arşivi göster",
|
||||
archived: "Arşivli",
|
||||
defaultBadge: "Varsayılan",
|
||||
empty: "Stok kategorisi yok.",
|
||||
createTitle: "Yeni stok kategorisi",
|
||||
editTitle: "Stok kategorisini düzenle",
|
||||
name: "Ad",
|
||||
parent: "Üst kategori",
|
||||
noParent: "— Ana kategori —",
|
||||
expenseCategory: "Gider kategorisi",
|
||||
expenseInherit: "— Üstten devral —",
|
||||
expenseHint: "Alışveriş tamamlanınca bu kategorideki ürünlerin yazılacağı gider kategorisi.",
|
||||
inherited: (name: string) => `${name} (üstten)`,
|
||||
noExpense: "Eşlenmedi",
|
||||
countEveryDays: "Sayım sıklığı (gün)",
|
||||
countEveryDaysHint: "Boş bırakılırsa üst kategoriden devralınır.",
|
||||
everyDays: (n: number) => `${n} günde bir sayım`,
|
||||
productCount: (n: number) => `${n} ürün`,
|
||||
archiveTitle: "Stok kategorisi arşivlensin mi?",
|
||||
archiveDescription: (name: string) =>
|
||||
`“${name}” ve alt kategorileri yeni ürünlerde seçilemez.`,
|
||||
hasProducts:
|
||||
"Bu kategoride veya altında aktif ürün tipi var; önce onları başka kategoriye taşıyın veya arşivleyin.",
|
||||
duplicate: "Bu düzeyde aynı adlı bir kategori zaten var.",
|
||||
expenseInvalid: "Geçerli bir gider kategorisi seçin.",
|
||||
},
|
||||
toast: {
|
||||
saved: "Kaydedildi.",
|
||||
movementSaved: "Hareket kaydedildi.",
|
||||
deleted: "Silindi.",
|
||||
archived: "Arşivlendi.",
|
||||
unarchived: "Arşivden çıkarıldı.",
|
||||
counted: (diff: string) => `Sayım kaydedildi; fark ${diff}.`,
|
||||
noChange: "Sayım kayıtlarla aynı; düzeltme gerekmedi.",
|
||||
},
|
||||
validation: {
|
||||
selectCategory: "Bir stok kategorisi seçin",
|
||||
quantity: "Geçerli bir miktar girin (ör. 1,5)",
|
||||
countDays: "1 ile 365 arasında gün sayısı girin",
|
||||
unit: "Birim 1–20 karakter olmalı",
|
||||
thresholdPct: "0 ile 100 arasında tam sayı girin",
|
||||
wasteReason: "Fire nedenini seçin",
|
||||
expiryBeforeDate: "Son kullanma tarihi alım tarihinden önce olamaz",
|
||||
notEnough: (q: string) => `Stokta yalnızca ${q} var.`,
|
||||
nothingCounted: "En az bir ürünün sayılan miktarını girin.",
|
||||
wasteOverShortage: (q: string) => `Fire, sayımda çıkan eksikten (${q}) fazla olamaz.`,
|
||||
emptyStock: "Stok zaten boş.",
|
||||
duplicate: "Bu adla bir ürün tipi zaten var.",
|
||||
categoryArchived: "Seçilen stok kategorisi arşivli veya bulunamadı.",
|
||||
archived: "Ürün tipi arşivli.",
|
||||
},
|
||||
dashboard: {
|
||||
title: "Stok uyarıları",
|
||||
description: (low: number, expiring: number, countDue = 0) =>
|
||||
[
|
||||
low ? `${low} ürün eşiğin altında` : null,
|
||||
expiring ? `${expiring} partinin SKT'si yaklaşıyor` : null,
|
||||
countDue ? `${countDue} kategoride sayım zamanı` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
more: (n: number) => `+${n} ürün daha`,
|
||||
open: "Ev stoğuna git",
|
||||
},
|
||||
},
|
||||
prices: {
|
||||
title: "Fiyat tablosu",
|
||||
description:
|
||||
"Altın ve döviz fiyatları (elle). Varlıklar ve aynî alacaklar alış, aynî borçlar satış fiyatıyla değerlenir.",
|
||||
add: "Fiyat gir",
|
||||
asset: "Varlık",
|
||||
date: "Tarih",
|
||||
buy: "Alış (TL)",
|
||||
sell: "Satış (TL)",
|
||||
sellHint: "Boş bırakılırsa alış fiyatı kullanılır (tek fiyat).",
|
||||
noPrice: "Fiyat yok",
|
||||
history: "Son fiyatlar",
|
||||
perUnit: (unit: string) => `/ ${unit}`,
|
||||
saved: "Fiyat kaydedildi.",
|
||||
},
|
||||
card: {
|
||||
title: "Kart özeti",
|
||||
debt: "Toplam borç",
|
||||
@@ -676,7 +1414,7 @@ export const tr = {
|
||||
dashboard: {
|
||||
title: "Ana sayfa",
|
||||
welcome: (name: string) => `Hoş geldin, ${name}`,
|
||||
welcomeDescription: "Bu ayın özetini burada göreceksin. Veriler sonraki fazlarda bağlanacak.",
|
||||
welcomeDescription: "Bu ayın özeti, günlük plan ve uyarılar.",
|
||||
cards: {
|
||||
monthIncome: "Bu ay gelir",
|
||||
monthExpense: "Bu ay gider",
|
||||
@@ -766,6 +1504,7 @@ export const tr = {
|
||||
validation: {
|
||||
nameMin: "Ad en az 2 karakter olmalı",
|
||||
nameMax: "Ad en fazla 80 karakter olabilir",
|
||||
phone: "Geçerli bir telefon numarası girin",
|
||||
usernameFormat: "Yalnızca küçük harf, rakam, nokta ve alt çizgi; 3–30 karakter",
|
||||
email: "Geçerli bir e-posta adresi girin",
|
||||
passwordMin: "Şifre en az 8 karakter olmalı",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { MOVEMENT_KINDS } from "@/shared/portfolio";
|
||||
|
||||
/**
|
||||
* Varlık hareketi (lot): alım, gelen hediye, satış, hediye/çıkış. Alım ve satış bir hesaba
|
||||
* bağlıysa transfer işlemi oluşturulur (`transactionId`; gelir/gider raporlarına girmez).
|
||||
* Sıra: tarih, sonra oluşturulma anı.
|
||||
*/
|
||||
const assetMovementSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
assetId: { type: Schema.Types.ObjectId, required: true },
|
||||
kind: { type: String, enum: MOVEMENT_KINDS, required: true },
|
||||
/** "YYYY-MM-DD" (İstanbul). */
|
||||
date: { type: String, required: true },
|
||||
/** Binde bir. */
|
||||
quantity: { type: Number, required: true, min: 1 },
|
||||
unitPriceMinor: { type: Number, default: null },
|
||||
/** Alımda ödenen, satışta alınan, gelen hediyede değer; çıkışta 0. */
|
||||
amountMinor: { type: Number, default: 0, min: 0 },
|
||||
accountId: { type: Schema.Types.ObjectId, default: null },
|
||||
transactionId: { type: Schema.Types.ObjectId, default: null },
|
||||
/** Saklama yeri (kasa, banka kiralık kasası …). */
|
||||
storage: { type: String, default: "", maxlength: 120 },
|
||||
note: { type: String, default: "", maxlength: 300 },
|
||||
/** Aynî borç/alacak ödemesinden gelen hareket (Faz 9b). */
|
||||
inKindDebtId: { type: Schema.Types.ObjectId, default: null },
|
||||
deletedAt: { type: Date, default: null },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "assetMovements" },
|
||||
);
|
||||
|
||||
assetMovementSchema.index({ householdId: 1, assetId: 1, deletedAt: 1, date: 1, createdAt: 1 });
|
||||
assetMovementSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type AssetMovement = InferSchemaType<typeof assetMovementSchema>;
|
||||
|
||||
export const AssetMovementModel: Model<AssetMovement> =
|
||||
(models.AssetMovement as Model<AssetMovement> | undefined) ??
|
||||
model<AssetMovement>("AssetMovement", assetMovementSchema);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { ASSET_KEYS } from "@/shared/assets";
|
||||
|
||||
/**
|
||||
* Fiyat tablosu (Bölüm 6.6): varlık × tarih × alış/satış (birim başına kuruş). İlk aşamada elle
|
||||
* giriş; otomatik kaynaklar Faz 17'de `source` ile eklenir. Gün başına tek kayıt (yeniden girişte
|
||||
* güncellenir).
|
||||
*/
|
||||
const assetPriceSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
assetKey: { type: String, enum: ASSET_KEYS, required: true },
|
||||
/** "YYYY-MM-DD" (İstanbul). */
|
||||
date: { type: String, required: true },
|
||||
buyMinor: { type: Number, required: true, min: 0 },
|
||||
sellMinor: { type: Number, required: true, min: 0 },
|
||||
source: { type: String, enum: ["manual"], default: "manual" },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "assetPrices" },
|
||||
);
|
||||
|
||||
assetPriceSchema.index({ householdId: 1, assetKey: 1, date: -1 }, { unique: true });
|
||||
|
||||
export type AssetPrice = InferSchemaType<typeof assetPriceSchema>;
|
||||
|
||||
export const AssetPriceModel: Model<AssetPrice> =
|
||||
(models.AssetPrice as Model<AssetPrice> | undefined) ??
|
||||
model<AssetPrice>("AssetPrice", assetPriceSchema);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { ASSET_KEYS } from "@/shared/assets";
|
||||
|
||||
/**
|
||||
* Varlık (Bölüm 6.6): bir varlık türünde tutulan birikim, ör. "Gram altın birikimi" veya
|
||||
* "Düğün ziynetleri". Miktar ve maliyet `assetMovements`ten hesaplanır (ağırlıklı ortalama).
|
||||
* "Yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür.
|
||||
*/
|
||||
const assetSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
assetKey: { type: String, enum: ASSET_KEYS, required: true },
|
||||
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||
note: { type: String, default: "", maxlength: 500 },
|
||||
isPrivate: { type: Boolean, default: false },
|
||||
ownerUserId: { type: Schema.Types.ObjectId, default: null },
|
||||
deletedAt: { type: Date, default: null },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "assets" },
|
||||
);
|
||||
|
||||
assetSchema.index({ householdId: 1, deletedAt: 1, name: 1 });
|
||||
assetSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type Asset = InferSchemaType<typeof assetSchema>;
|
||||
|
||||
export const AssetModel: Model<Asset> =
|
||||
(models.Asset as Model<Asset> | undefined) ?? model<Asset>("Asset", assetSchema);
|
||||
@@ -28,6 +28,18 @@ export const AUDIT_ACTIONS = [
|
||||
"transaction.delete",
|
||||
"transaction.restore",
|
||||
"recurring.delete",
|
||||
"contact.archive",
|
||||
"contact.unarchive",
|
||||
"inkind.payment",
|
||||
"inkind.close",
|
||||
"inkind.delete",
|
||||
"asset.delete",
|
||||
"asset.movement.delete",
|
||||
"stock.category.archive",
|
||||
"stock.category.unarchive",
|
||||
"stock.product.archive",
|
||||
"stock.product.unarchive",
|
||||
"stock.movement.delete",
|
||||
] as const;
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
|
||||
/**
|
||||
* Aylık bütçe (Bölüm 6.9): hane × bütçe ayı ("YYYY-MM") başına kategori limitleri.
|
||||
* Ayın sınırları hane ayarı `budgetMonthStartDay`'e göre hesaplanır (belgede saklanmaz).
|
||||
*/
|
||||
const limitSchema = new Schema(
|
||||
{
|
||||
categoryId: { type: Schema.Types.ObjectId, required: true },
|
||||
amountMinor: { type: Number, required: true, min: 0 },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
/** Gelir planına elle eklenen beklenen gelir (planlı işlemi olmayan, ör. prim). */
|
||||
const incomeItemSchema = new Schema(
|
||||
{
|
||||
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||
amountMinor: { type: Number, required: true, min: 1 },
|
||||
},
|
||||
{ _id: true },
|
||||
);
|
||||
|
||||
const budgetSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
month: { type: String, required: true, match: /^\d{4}-(0[1-9]|1[0-2])$/ },
|
||||
limits: { type: [limitSchema], default: [] },
|
||||
incomeItems: { type: [incomeItemSchema], default: [] },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "budgets" },
|
||||
);
|
||||
|
||||
budgetSchema.index({ householdId: 1, month: 1 }, { unique: true });
|
||||
|
||||
export type Budget = InferSchemaType<typeof budgetSchema>;
|
||||
|
||||
export const BudgetModel: Model<Budget> =
|
||||
(models.Budget as Model<Budget> | undefined) ?? model<Budget>("Budget", budgetSchema);
|
||||
@@ -0,0 +1,41 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { CONTACT_RELATIONS } from "@/shared/contacts";
|
||||
|
||||
/**
|
||||
* Cari kişi (Bölüm 6.4). Hareketler `transactions` içinde transfer olarak tutulur (`contactId`);
|
||||
* bakiye: açılış + çıkışlar − girişler (+ bana borçlu, − ben borçluyum).
|
||||
* "Yalnızca ben" işaretli cari ve hareketleri yalnızca sahibine görünür.
|
||||
*/
|
||||
const contactSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||
relation: { type: String, enum: CONTACT_RELATIONS, default: "other" },
|
||||
phone: { type: String, default: "", maxlength: 30 },
|
||||
note: { type: String, default: "", maxlength: 500 },
|
||||
/** Uygulamaya başlamadan önceki bakiye (+ bana borçlu, − ben borçluyum). */
|
||||
openingBalanceMinor: { type: Number, default: 0 },
|
||||
openingDate: { type: Date, required: true },
|
||||
/** Kalan bakiyenin vadesi ("YYYY-MM-DD") ve kaç gün önce uyarılacağı. */
|
||||
dueDate: { type: String, default: null },
|
||||
reminderDays: { type: Number, default: 3, min: 0, max: 60 },
|
||||
isPrivate: { type: Boolean, default: false },
|
||||
ownerUserId: { type: Schema.Types.ObjectId, default: null },
|
||||
archivedAt: { type: Date, default: null },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "contacts" },
|
||||
);
|
||||
|
||||
contactSchema.index({ householdId: 1, archivedAt: 1, name: 1 });
|
||||
contactSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type Contact = InferSchemaType<typeof contactSchema>;
|
||||
|
||||
export const ContactModel: Model<Contact> =
|
||||
(models.Contact as Model<Contact> | undefined) ?? model<Contact>("Contact", contactSchema);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { ASSET_KEYS } from "@/shared/assets";
|
||||
|
||||
/**
|
||||
* Aynî borç/alacak (Bölüm 6.5): bir cariyle altın, ziynet veya döviz cinsinden borç.
|
||||
* Borç tarihi fiyatı ve TL karşılığı not olarak saklanır; güncel değer fiyat tablosundan.
|
||||
* Kapanış yalnızca "Ödeme gerçekleşti" kayıtlarıyla (kısmi ödeme miktarı düşürür).
|
||||
* Birikim planı: katkılar seçilen birikim hesabına transferdir (`transactions.inKindDebtId`).
|
||||
*/
|
||||
const paymentSchema = new Schema(
|
||||
{
|
||||
date: { type: String, required: true },
|
||||
/** asset: aynı varlıkla · cash: TL karşılığıyla. */
|
||||
method: { type: String, enum: ["asset", "cash"], required: true },
|
||||
quantity: { type: Number, required: true, min: 1 },
|
||||
/** Ödemede harcanan / tahsil edilen TL (aynı varlıkla ödemede varlığı almak için harcanan). */
|
||||
amountMinor: { type: Number, default: 0, min: 0 },
|
||||
accountId: { type: Schema.Types.ObjectId, default: null },
|
||||
transactionId: { type: Schema.Types.ObjectId, default: null },
|
||||
note: { type: String, default: "", maxlength: 300 },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ _id: true, timestamps: { createdAt: true, updatedAt: false } },
|
||||
);
|
||||
|
||||
const inKindDebtSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
contactId: { type: Schema.Types.ObjectId, required: true },
|
||||
/** debt: ben borçluyum · receivable: bana borçlu. */
|
||||
direction: { type: String, enum: ["debt", "receivable"], required: true },
|
||||
assetKey: { type: String, enum: ASSET_KEYS, required: true },
|
||||
/** Binde bir (5 gr → 5000). */
|
||||
quantity: { type: Number, required: true, min: 1 },
|
||||
date: { type: String, required: true },
|
||||
unitPriceMinor: { type: Number, default: null },
|
||||
valueMinor: { type: Number, default: null },
|
||||
note: { type: String, default: "", maxlength: 500 },
|
||||
savingsAccountId: { type: Schema.Types.ObjectId, default: null },
|
||||
monthlyPlanMinor: { type: Number, default: null },
|
||||
payments: { type: [paymentSchema], default: [] },
|
||||
closedAt: { type: String, default: null },
|
||||
deletedAt: { type: Date, default: null },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "inKindDebts" },
|
||||
);
|
||||
|
||||
inKindDebtSchema.index({ householdId: 1, contactId: 1, deletedAt: 1 });
|
||||
inKindDebtSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type InKindDebt = InferSchemaType<typeof inKindDebtSchema>;
|
||||
|
||||
export const InKindDebtModel: Model<InKindDebt> =
|
||||
(models.InKindDebt as Model<InKindDebt> | undefined) ??
|
||||
model<InKindDebt>("InKindDebt", inKindDebtSchema);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
|
||||
/**
|
||||
* Ürün tipi (genel ürün, markadan bağımsız ihtiyaç: "Toz Deterjan"). Stok bu seviyede tutulur.
|
||||
* Miktarlar binde bir (QUANTITY_SCALE). Birim hazır listeden ya da özel ("yıkama").
|
||||
*/
|
||||
const productTypeSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
categoryId: { type: Schema.Types.ObjectId, required: true },
|
||||
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||
nameKey: { type: String, required: true },
|
||||
unit: { type: String, required: true, trim: true, maxlength: 20 },
|
||||
/** Hedef (tam) stok miktarı. */
|
||||
targetQuantity: { type: Number, required: true, min: 0 },
|
||||
/** Kritik eşik, hedefin yüzdesi (varsayılan 20). */
|
||||
thresholdPct: { type: Number, default: 20, min: 0, max: 100 },
|
||||
/** Saklama yeri (kiler, banyo dolabı …). */
|
||||
location: { type: String, default: "", maxlength: 80 },
|
||||
/** "1 azalt" düğmesinin düşeceği miktar. */
|
||||
step: { type: Number, default: 1000, min: 1 },
|
||||
/** Sayım sıklığı (gün); null ise kategoriden devralınır (Faz 11b). */
|
||||
countEveryDays: { type: Number, default: null, min: 1, max: 365 },
|
||||
note: { type: String, default: "", maxlength: 300 },
|
||||
archivedAt: { type: Date, default: null },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "productTypes" },
|
||||
);
|
||||
|
||||
productTypeSchema.index({ householdId: 1, archivedAt: 1, categoryId: 1 });
|
||||
productTypeSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type ProductType = InferSchemaType<typeof productTypeSchema>;
|
||||
|
||||
export const ProductTypeModel: Model<ProductType> =
|
||||
(models.ProductType as Model<ProductType> | undefined) ??
|
||||
model<ProductType>("ProductType", productTypeSchema);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
|
||||
/**
|
||||
* Stok kategori ağacı (Bölüm 6.10), gelir/gider kategorilerinden ayrı. Silme yok; arşivleme.
|
||||
* `expenseCategoryId`: alışveriş tamamlanınca (Faz 13) kalemlerin yazılacağı gider kategorisi;
|
||||
* null ise üst kategoriden devralınır. `countEveryDays`: periyodik sayım sıklığı (Faz 11b);
|
||||
* null ise üstten devralınır.
|
||||
*/
|
||||
const stockCategorySchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||
nameKey: { type: String, required: true },
|
||||
parentId: { type: Schema.Types.ObjectId, default: null },
|
||||
ancestors: { type: [Schema.Types.ObjectId], default: [] },
|
||||
order: { type: Number, default: 0 },
|
||||
expenseCategoryId: { type: Schema.Types.ObjectId, default: null },
|
||||
countEveryDays: { type: Number, default: null, min: 1, max: 365 },
|
||||
archivedAt: { type: Date, default: null },
|
||||
defaultKey: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "stockCategories" },
|
||||
);
|
||||
|
||||
stockCategorySchema.index({ householdId: 1, parentId: 1, order: 1 });
|
||||
stockCategorySchema.index(
|
||||
{ householdId: 1, defaultKey: 1 },
|
||||
{ unique: true, partialFilterExpression: { defaultKey: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type StockCategory = InferSchemaType<typeof stockCategorySchema>;
|
||||
|
||||
export const StockCategoryModel: Model<StockCategory> =
|
||||
(models.StockCategory as Model<StockCategory> | undefined) ??
|
||||
model<StockCategory>("StockCategory", stockCategorySchema);
|
||||
@@ -0,0 +1,45 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
|
||||
/**
|
||||
* Sayım oturumu (Bölüm 6.10, periyodik sayım). Her sayılan ürün için beklenen ve sayılan miktar,
|
||||
* fireye ayrılan kısım. Fark sıfır olsa da kayıt tutulur: "son sayım" ve sayım takvimi buradan
|
||||
* türetilir. Oluşan hareketler `countId` ile bağlanır.
|
||||
*/
|
||||
const stockCountItemSchema = new Schema(
|
||||
{
|
||||
productTypeId: { type: Schema.Types.ObjectId, required: true },
|
||||
expected: { type: Number, required: true },
|
||||
actual: { type: Number, required: true },
|
||||
/** Eksiğin fire olarak işaretlenen kısmı (≥ 0). */
|
||||
wasted: { type: Number, default: 0 },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const stockCountSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
/** "YYYY-MM-DD" (İstanbul). */
|
||||
date: { type: String, required: true },
|
||||
/** Sayılan stok kategorisi (alt ağacıyla); null: tüm stok veya tek ürün. */
|
||||
categoryId: { type: Schema.Types.ObjectId, default: null },
|
||||
items: { type: [stockCountItemSchema], default: [] },
|
||||
note: { type: String, default: "", maxlength: 300 },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "stockCounts" },
|
||||
);
|
||||
|
||||
stockCountSchema.index({ householdId: 1, "items.productTypeId": 1, date: -1 });
|
||||
stockCountSchema.index({ householdId: 1, date: -1 });
|
||||
stockCountSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type StockCount = InferSchemaType<typeof stockCountSchema>;
|
||||
|
||||
export const StockCountModel: Model<StockCount> =
|
||||
(models.StockCount as Model<StockCount> | undefined) ??
|
||||
model<StockCount>("StockCount", stockCountSchema);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { STOCK_MOVEMENT_KINDS, WASTE_REASONS } from "@/shared/stock";
|
||||
|
||||
/**
|
||||
* Stok hareketi: alım, tüketim, fire (neden ile), sayım düzeltmesi. Mevcut miktar hareketlerden
|
||||
* türetilir. Miktar binde bir; yalnızca sayım düzeltmesi işaretlidir. Sıra: tarih, oluşturulma.
|
||||
*/
|
||||
const stockMovementSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
productTypeId: { type: Schema.Types.ObjectId, required: true },
|
||||
kind: { type: String, enum: STOCK_MOVEMENT_KINDS, required: true },
|
||||
/** "YYYY-MM-DD" (İstanbul). */
|
||||
date: { type: String, required: true },
|
||||
quantity: { type: Number, required: true },
|
||||
wasteReason: { type: String, enum: [...WASTE_REASONS, null], default: null },
|
||||
/** Son kullanma tarihi (yalnızca alımda, isteğe bağlı). */
|
||||
expiryDate: { type: String, default: null },
|
||||
note: { type: String, default: "", maxlength: 300 },
|
||||
/** İlk kurulumda girilen başlangıç miktarı (sayım düzeltmesi olarak). */
|
||||
initial: { type: Boolean, default: false },
|
||||
/** Sayım oturumu (Faz 11b). */
|
||||
countId: { type: Schema.Types.ObjectId, default: null },
|
||||
deletedAt: { type: Date, default: null },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "stockMovements" },
|
||||
);
|
||||
|
||||
stockMovementSchema.index({
|
||||
householdId: 1,
|
||||
productTypeId: 1,
|
||||
deletedAt: 1,
|
||||
date: 1,
|
||||
createdAt: 1,
|
||||
});
|
||||
stockMovementSchema.index({ householdId: 1, kind: 1, date: -1 });
|
||||
stockMovementSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type StockMovement = InferSchemaType<typeof stockMovementSchema>;
|
||||
|
||||
export const StockMovementModel: Model<StockMovement> =
|
||||
(models.StockMovement as Model<StockMovement> | undefined) ??
|
||||
model<StockMovement>("StockMovement", stockMovementSchema);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { CONTACT_ENTRY_KINDS } from "@/shared/contacts";
|
||||
import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions";
|
||||
|
||||
/**
|
||||
@@ -21,7 +22,8 @@ const transactionSchema = new Schema(
|
||||
status: { type: String, enum: TRANSACTION_STATUSES, default: "posted" },
|
||||
/** İstanbul gününün başlangıç anı (UTC). */
|
||||
date: { type: Date, required: true },
|
||||
accountId: { type: Schema.Types.ObjectId, required: true },
|
||||
/** Kaynak hesap. Cariden girişte (borç alma, tahsilat) null; hedef `toAccountId`. */
|
||||
accountId: { type: Schema.Types.ObjectId, default: null },
|
||||
toAccountId: { type: Schema.Types.ObjectId, default: null },
|
||||
amountMinor: { type: Number, required: true, min: 1 },
|
||||
/** Bölünmemiş gelir/giderde kategori; bölünmüşte null ve kalemler `splits`te. */
|
||||
@@ -53,7 +55,20 @@ const transactionSchema = new Schema(
|
||||
default: null,
|
||||
},
|
||||
statementId: { type: Schema.Types.ObjectId, default: null },
|
||||
/** Cari transferi: karşı taraf kişi (hesabın biri boş). */
|
||||
contactId: { type: Schema.Types.ObjectId, default: null },
|
||||
contactKind: { type: String, enum: [...CONTACT_ENTRY_KINDS, null], default: null },
|
||||
/**
|
||||
* Aynî borca bağlı para hareketi: birikim katkısı (hesap → birikim hesabı) veya ödeme.
|
||||
* `contactId` görünürlük için doludur ama `contactKind` boştur: cari TL bakiyesine girmez.
|
||||
*/
|
||||
inKindDebtId: { type: Schema.Types.ObjectId, default: null },
|
||||
inKindRole: { type: String, enum: ["contribution", "payment", null], default: null },
|
||||
/**
|
||||
* Varlık alımı (hesap → varlık) veya satışı (varlık → hesap): karşı hesap boş transfer.
|
||||
* Hareketi varlık sayfasından yönetilir; görünürlük varlığa bağlıdır.
|
||||
*/
|
||||
assetId: { type: Schema.Types.ObjectId, default: null },
|
||||
/**
|
||||
* Mutabakatı yapılmış hesaplar (Bölüm 6.1). Transfer iki hesaba dokunduğu için hesap bazında
|
||||
* tutulur. Tutar, tarih, tür veya hesap değişirse işaret kalkar.
|
||||
@@ -76,7 +91,9 @@ transactionSchema.index({ householdId: 1, accountId: 1, date: -1 });
|
||||
transactionSchema.index({ householdId: 1, toAccountId: 1, date: -1 });
|
||||
transactionSchema.index({ householdId: 1, effectiveCategoryIds: 1, date: -1 });
|
||||
transactionSchema.index({ householdId: 1, tags: 1 });
|
||||
transactionSchema.index({ householdId: 1, contactId: 1, date: 1 }, { sparse: true });
|
||||
transactionSchema.index({ "installment.planId": 1 }, { sparse: true });
|
||||
transactionSchema.index({ householdId: 1, assetId: 1 }, { sparse: true });
|
||||
transactionSchema.index(
|
||||
{ recurringRuleId: 1, occurrenceIndex: 1 },
|
||||
{ unique: true, partialFilterExpression: { recurringRuleId: { $type: "objectId" } } },
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatAssetQuantity,
|
||||
inKindProgress,
|
||||
quantityForAmount,
|
||||
valuationPrice,
|
||||
valueOf,
|
||||
} from "./assets";
|
||||
|
||||
describe("varlık hesapları", () => {
|
||||
it("değer ve miktar karşılığı (binde bir miktar, kuruş fiyat)", () => {
|
||||
// 5 gr × 3.250,50 TL = 16.252,50 TL
|
||||
expect(valueOf(5_000, 3_250_50)).toBe(16_252_50);
|
||||
expect(valueOf(1_500, 100_00)).toBe(150_00);
|
||||
expect(quantityForAmount(16_252_50, 3_250_50)).toBe(5_000);
|
||||
expect(quantityForAmount(100, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("biçim", () => {
|
||||
expect(formatAssetQuantity("gram_gold", 5_000)).toBe("5 gr Gram altın (24 ayar)");
|
||||
expect(formatAssetQuantity("quarter_gold", 2_000)).toBe("2 adet Çeyrek altın");
|
||||
expect(formatAssetQuantity("usd", 1_500_000)).toBe("1.500 USD");
|
||||
expect(formatAssetQuantity("bracelet_22k", 12_345)).toBe("12,345 gr 22 ayar bilezik");
|
||||
});
|
||||
|
||||
it("değerleme fiyatı: borç satış, alacak alış", () => {
|
||||
const p = { buyMinor: 3_200_00, sellMinor: 3_300_00 };
|
||||
expect(valuationPrice("debt", p)).toBe(3_300_00);
|
||||
expect(valuationPrice("receivable", p)).toBe(3_200_00);
|
||||
expect(valuationPrice("debt", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("aynî borç ilerlemesi", () => {
|
||||
const base = {
|
||||
quantity: 5_000,
|
||||
paidQuantity: 0,
|
||||
unitPriceMinor: 3_000_00,
|
||||
contributedMinor: 6_000_00,
|
||||
spentMinor: 0,
|
||||
monthlyPlanMinor: 2_000_00,
|
||||
today: "2026-11-05",
|
||||
};
|
||||
|
||||
it("kalan ihtiyaç ve tahmini kapanış ayı", () => {
|
||||
// 5 gr × 3.000 = 15.000; biriken 6.000 → 9.000 kaldı; ayda 2.000 → 5 ay → 2027-04
|
||||
expect(inKindProgress(base)).toEqual({
|
||||
remainingQuantity: 5_000,
|
||||
currentValueMinor: 15_000_00,
|
||||
accumulatedMinor: 6_000_00,
|
||||
remainingNeedMinor: 9_000_00,
|
||||
estimatedCloseMonth: "2027-04",
|
||||
});
|
||||
});
|
||||
|
||||
it("kısmi ödeme, birikim yeterli, fiyat yok, plan yok", () => {
|
||||
expect(inKindProgress({ ...base, paidQuantity: 3_000 }).remainingNeedMinor).toBe(0);
|
||||
expect(inKindProgress({ ...base, paidQuantity: 3_000 }).estimatedCloseMonth).toBe("2026-11");
|
||||
expect(inKindProgress({ ...base, unitPriceMinor: null }).remainingNeedMinor).toBeNull();
|
||||
expect(inKindProgress({ ...base, monthlyPlanMinor: null }).estimatedCloseMonth).toBeNull();
|
||||
expect(inKindProgress({ ...base, paidQuantity: 5_000 }).remainingQuantity).toBe(0);
|
||||
expect(inKindProgress({ ...base, spentMinor: 9_000_00 }).accumulatedMinor).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { QUANTITY_SCALE } from "./quantity";
|
||||
import { addMonths } from "./recurrence";
|
||||
|
||||
/**
|
||||
* Varlık türleri (Bölüm 6.5–6.6): altın, ziynet, gümüş ve döviz. Miktarlar binde bir tamsayı
|
||||
* (5 gr → 5000, 2 adet çeyrek → 2000). Fiyatlar birim başına kuruş.
|
||||
*
|
||||
* Fiyat terimleri kuyumcu/döviz bürosu tarafından: "alış" = kurumun sizden aldığı (düşük),
|
||||
* "satış" = kurumun size sattığı (yüksek). Faz 0 kararı: varlıklar ve aynî alacaklar ALIŞ,
|
||||
* aynî borçlar SATIŞ fiyatıyla değerlenir.
|
||||
*/
|
||||
|
||||
export const ASSET_KEYS = [
|
||||
"gram_gold",
|
||||
"quarter_gold",
|
||||
"half_gold",
|
||||
"full_gold",
|
||||
"republic_gold",
|
||||
"bracelet_22k",
|
||||
"silver_gram",
|
||||
"usd",
|
||||
"eur",
|
||||
"gbp",
|
||||
] as const;
|
||||
export type AssetKey = (typeof ASSET_KEYS)[number];
|
||||
|
||||
export type AssetUnit = "gr" | "adet" | "birim";
|
||||
|
||||
export const ASSETS: Record<
|
||||
AssetKey,
|
||||
{ label: string; unit: AssetUnit; group: "gold" | "silver" | "fx" }
|
||||
> = {
|
||||
gram_gold: { label: "Gram altın (24 ayar)", unit: "gr", group: "gold" },
|
||||
quarter_gold: { label: "Çeyrek altın", unit: "adet", group: "gold" },
|
||||
half_gold: { label: "Yarım altın", unit: "adet", group: "gold" },
|
||||
full_gold: { label: "Tam altın", unit: "adet", group: "gold" },
|
||||
republic_gold: { label: "Cumhuriyet (Ata) altını", unit: "adet", group: "gold" },
|
||||
bracelet_22k: { label: "22 ayar bilezik", unit: "gr", group: "gold" },
|
||||
silver_gram: { label: "Gümüş (gram)", unit: "gr", group: "silver" },
|
||||
usd: { label: "ABD doları", unit: "birim", group: "fx" },
|
||||
eur: { label: "Euro", unit: "birim", group: "fx" },
|
||||
gbp: { label: "İngiliz sterlini", unit: "birim", group: "fx" },
|
||||
};
|
||||
|
||||
const FX_SYMBOL: Partial<Record<AssetKey, string>> = { usd: "USD", eur: "EUR", gbp: "GBP" };
|
||||
|
||||
/** "5 gr Gram altın (24 ayar)", "2 adet Çeyrek altın", "1.500 USD". */
|
||||
export function formatAssetQuantity(key: AssetKey, quantity: number): string {
|
||||
const n = (quantity / QUANTITY_SCALE).toLocaleString("tr-TR", { maximumFractionDigits: 3 });
|
||||
const fx = FX_SYMBOL[key];
|
||||
if (fx) return `${n} ${fx}`;
|
||||
const a = ASSETS[key];
|
||||
return `${n} ${a.unit} ${a.label}`;
|
||||
}
|
||||
|
||||
/** Miktar × birim fiyat (kuruş, yuvarlanır). */
|
||||
export function valueOf(quantity: number, unitPriceMinor: number): number {
|
||||
return Math.round((quantity * unitPriceMinor) / QUANTITY_SCALE);
|
||||
}
|
||||
|
||||
/** TL tutarın karşılığı olan miktar (binde bir, yuvarlanır). */
|
||||
export function quantityForAmount(amountMinor: number, unitPriceMinor: number): number {
|
||||
if (unitPriceMinor <= 0) return 0;
|
||||
return Math.round((amountMinor * QUANTITY_SCALE) / unitPriceMinor);
|
||||
}
|
||||
|
||||
export type InKindDirection = "debt" | "receivable";
|
||||
|
||||
/** Değerlemede kullanılacak fiyat: borç → satış, alacak → alış. */
|
||||
export function valuationPrice(
|
||||
direction: InKindDirection,
|
||||
price: { buyMinor: number; sellMinor: number } | null,
|
||||
): number | null {
|
||||
if (!price) return null;
|
||||
return direction === "debt" ? price.sellMinor : price.buyMinor;
|
||||
}
|
||||
|
||||
export type InKindProgress = {
|
||||
remainingQuantity: number;
|
||||
/** Kalan miktarın güncel TL karşılığı (fiyat yoksa null). */
|
||||
currentValueMinor: number | null;
|
||||
/** Birikim planı: biriken − ödemelerde kullanılan. */
|
||||
accumulatedMinor: number;
|
||||
remainingNeedMinor: number | null;
|
||||
/** Aylık birikimle kalan ihtiyaç kaç ayda kapanır; "YYYY-MM" (plan yoksa null). */
|
||||
estimatedCloseMonth: string | null;
|
||||
};
|
||||
|
||||
export function inKindProgress(input: {
|
||||
quantity: number;
|
||||
paidQuantity: number;
|
||||
unitPriceMinor: number | null;
|
||||
contributedMinor: number;
|
||||
spentMinor: number;
|
||||
monthlyPlanMinor: number | null;
|
||||
today: string;
|
||||
}): InKindProgress {
|
||||
const remainingQuantity = Math.max(0, input.quantity - input.paidQuantity);
|
||||
const currentValueMinor =
|
||||
input.unitPriceMinor === null ? null : valueOf(remainingQuantity, input.unitPriceMinor);
|
||||
const accumulatedMinor = Math.max(0, input.contributedMinor - input.spentMinor);
|
||||
const remainingNeedMinor =
|
||||
currentValueMinor === null ? null : Math.max(0, currentValueMinor - accumulatedMinor);
|
||||
let estimatedCloseMonth: string | null = null;
|
||||
if (remainingQuantity === 0) estimatedCloseMonth = null;
|
||||
else if (remainingNeedMinor === 0) estimatedCloseMonth = input.today.slice(0, 7);
|
||||
else if (remainingNeedMinor !== null && input.monthlyPlanMinor && input.monthlyPlanMinor > 0) {
|
||||
const months = Math.ceil(remainingNeedMinor / input.monthlyPlanMinor);
|
||||
estimatedCloseMonth = addMonths(`${input.today.slice(0, 7)}-01`, months).slice(0, 7);
|
||||
}
|
||||
return {
|
||||
remainingQuantity,
|
||||
currentValueMinor,
|
||||
accumulatedMinor,
|
||||
remainingNeedMinor,
|
||||
estimatedCloseMonth,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user