Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8ad362f8b | |||
| 5793490cb4 | |||
| ebcdf3f741 | |||
| c52e8f9976 | |||
| b11972fda3 | |||
| 19536f45de | |||
| c0b114efeb | |||
| afc6da1606 | |||
| 18c892bdcd | |||
| add845684c | |||
| 092db5a8c2 | |||
| 6ce8ffa4a5 | |||
| 1bdcedefdc | |||
| 7d7dd61c60 | |||
| 42561b570a | |||
| 4b5def9452 | |||
| c3c3c43bd1 | |||
| 8623c5a02e | |||
| d9a22551e1 | |||
| 0c421a2d34 | |||
| 29d7b29cfa | |||
| bc2a8a3bde | |||
| d77a2efcb8 | |||
| 47396cba52 | |||
| 6ec29aa030 | |||
| c8678da7b5 | |||
| bd5a20ce64 | |||
| 3dd9834a3d | |||
| 55f2269f82 | |||
| fa70168622 | |||
| 8ab2244755 | |||
| 0b7de17b5c | |||
| d46869c644 | |||
| 9e32f2988d | |||
| e67a1d5411 | |||
| 78503b1787 | |||
| d52f41ab0c | |||
| f0b570284e |
@@ -2,6 +2,7 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -10,6 +11,7 @@ next-env.d.ts
|
||||
.env*
|
||||
!.env.example
|
||||
uploads
|
||||
import
|
||||
|
||||
# Geliştirme araçları
|
||||
.git
|
||||
|
||||
@@ -46,3 +46,7 @@ next-env.d.ts
|
||||
|
||||
# local tooling
|
||||
/scratch/
|
||||
/dist
|
||||
|
||||
# Kişisel ekstre dosyaları (içe aktarma için) — asla repoya girmez
|
||||
/import/
|
||||
|
||||
@@ -5,3 +5,4 @@ coverage
|
||||
uploads
|
||||
next-env.d.ts
|
||||
docs/PROMPT.md
|
||||
dist/
|
||||
|
||||
+142
-1
@@ -6,6 +6,146 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
||||
|
||||
## [Yayınlanmamış]
|
||||
|
||||
Faz 17a: Kakeibo K2 — ekstre içe aktarma altyapısı.
|
||||
|
||||
### Eklendi
|
||||
|
||||
- **Ekstre içe aktarma** (`/islemler/ice-aktar`, İşlemler sayfasında "İçe aktar"): Kakeibo çalışma kitabı (.xlsx; "Kredi Kartı" ve "Maaş Hesabı" sayfaları) yüklenir, önce taslak olarak önizlenir, sonra tek seferde işlenir.
|
||||
- Kurulum adımı: dosyanın ihtiyaç duyduğu hesaplar (kredi kartı, maaş hesabı, nakit, ek hesap) mevcut hesaplardan seçilir ya da önerilen açılış bakiyesiyle tek tıkla oluşturulur (açılış bakiyesi dosyadaki devreden bakiyeden, kart borcu ilk kart ödemesinden).
|
||||
- Kontroller: her ekstre için **ekstre toplamı = satır toplamı**, maaş hesabı bakiye zinciri ve son bakiye, daha önce aktarılmış satırlar, bekletilen altın satırları ve oluşturulacak kategoriler.
|
||||
- Önizleme: satır başına tür (gider / gelir / atla), kategori ve Kakeibo grubu düzenlenebilir; "aynı açıklamalı tüm satırlara uygula"; İnceleme gereken / Grupsuz / Transfer / Atlanacak süzgeçleri.
|
||||
- Muhasebe kuralları: kart ödemesi, ATM çekimi ve hesaplar arası havale transfer (çift sayma yok); kampanya iadesi ve iadeler gelir ("İade"); KMH faizi, KKDF/BSMV ve banka ücretleri banka maliyeti; taksitler ekstreye yansıdığı kesim tarihinde, ortak taksit planıyla; aynı gün çıkan promosyon çifti atlanır.
|
||||
- Tekrar engeli: her satırın parmak izi saklanır; aynı dosya yeniden yüklendiğinde aktarılmış satırlar atlanır. **Toplu geri alma** aktarmanın tüm işlemlerini siler ve dosya yeniden aktarılabilir hale gelir.
|
||||
- Eksik Kakeibo kategorileri (Sigorta, Online alışveriş, Elektronik ve hobi, Kitap ve kültür; Prim, Yol parası, Avans, Eş katkısı, Aile desteği) işlenirken oluşturulur.
|
||||
- Migration `017-imports`: aktarma partileri ve işlem parmak izi dizinleri (geri alınabilir).
|
||||
- Testler: çalışma kitabı ayrıştırma, taksit metni, ekstre ve bakiye kontrolleri (birim); taslak, hesap oluşturma, toplu düzenleme, işleme, bakiyeler, tekrar yükleme ve geri alma (entegrasyon).
|
||||
|
||||
### Bilinen sınırlamalar
|
||||
|
||||
- Altın alım/satım satırları Faz 17b'de altın hesabı dosyasıyla eşleştirilene dek bekletilir; o zamana dek maaş hesabı bakiyesi bu satırların neti kadar farklı görünür.
|
||||
- Yapı Kredi "Güncel Dönem" (.xls) ve Ek Hesap / Altın Hesabı (PDF) dosyaları Faz 17b'de.
|
||||
|
||||
## [1.1.0] - 2026-11-07
|
||||
|
||||
Faz 16: Kakeibo K1 — dört harcama grubu.
|
||||
|
||||
### Eklendi
|
||||
|
||||
- Kakeibo grupları: gider kategorilerine **Zorunlu / İstek / Kültür / Beklenmeyen** grubu; alt kategori grubu üst kategoriden devralır. Kategori formunda grup seçimi ve açıklaması; kategori listesinde grup rozeti (devralınan soluk) ve grupsuz kategori uyarısı.
|
||||
- **Banka maliyeti:** KMH faizi, KKDF/BSMV, kart ve hesap ücretleri Kakeibo harcamasına ve dört gruba girmez, ayrı gösterilir (kategori düzeyinde işaret, alt ağaç devralır).
|
||||
- İşlem formunda **Kakeibo grubu**: varsayılan kategoriden ("Kategoriden: Zorunlu"), bu kayıt için değiştirilebilir; bölünmüş işlemde kalem başına.
|
||||
- Kakeibo raporu (`/raporlar/kakeibo`): Kakeibo harcaması ve grup payları (yığılmış çubuk), grup başına tutar, pay, önceki aya göre değişim ve kategori kırılımı; banka maliyeti ve grupsuz harcamalar ayrı; CSV. Bütçe sayfasında seçili ayın Kakeibo özeti.
|
||||
- Migration `016-kakeibo-groups`: varsayılan kategoriler beceri eşlemesine göre doldurulur (kullanıcı kategorileri grupsuz kalır, mevcut seçimlere dokunulmaz, kategori silinmez). Migration'lar geri alınabilir: `npm run migrate -- --down <ad>`.
|
||||
- Testler: grup devralma, banka maliyeti, kayıt bazında grup, grup özeti ve sıfıra bölme (birim); varsayılan gruplar, rapor toplamı = bütçenin gerçekleşeni, grup değiştirme, kategori güncelleme, migration geri alma/yeniden uygulama (entegrasyon).
|
||||
|
||||
### Güvenlik
|
||||
|
||||
- `import/` klasörü (kişisel ekstre dosyaları) git'e ve Docker imajına girmez.
|
||||
|
||||
## [1.0.0] - 2026-11-07
|
||||
|
||||
Faz 15: canlıya alma (Dokploy + Gitea, `butcem.ta1sph.website`).
|
||||
|
||||
### Eklendi
|
||||
|
||||
- Konteyner başlatma adımı (`docker-entrypoint.sh` → `scripts/bootstrap.mjs`): MongoDB replica set ve PRIMARY kontrolü (yoksa açık hata mesajıyla durur), bekleyen migration'lar, `SEED_ADMIN_*` tanımlıysa ilk admin hesabı (varsa dokunulmaz). esbuild ile tek dosyaya derlenir; imajda `tsx` gerekmez. Acil durumda `SKIP_BOOTSTRAP=1`.
|
||||
- `/api/health` yanıtında `replicaSet` alanı (transaction desteğinin canlıda doğrulanması için).
|
||||
- `docs/DEPLOY.md`: Dokploy uygulama kurulumu, ortam değişkenleri, volume, domain/HTTPS, Gitea webhook ile otomatik deploy, güncelleme/geri alma, yedekleme, geri yükleme ve sorun giderme; her komut açıklamalı.
|
||||
- `docs/MONGODB-KURULUM.md`: mongo:7 servisinde replica set kontrolü, keyfile ile tek düğümlü replica set'e geçiş, uygulama kullanıcısı yetkileri, bağlantı dizesi, transaction denemesi.
|
||||
- `scripts/smoke-test.sh`: sağlık (veritabanı, replica set, sürüm), güvenlik başlıkları, HTTP→HTTPS, oturumsuz erişim engeli ve isteğe bağlı giriş kontrolü; veri değiştirmez.
|
||||
- `scripts/backup/`: günlük yedek betiği (mongodump + uploads arşivi, SHA-256 özetleri, 7 gün saklama, parola komut satırında görünmez), onaylı geri yükleme betiği ve ayar örneği.
|
||||
|
||||
### Değişti
|
||||
|
||||
- Docker imajı: `ENTRYPOINT` başlatma adımıyla; sağlık kontrolü başlangıç payı 60 sn.
|
||||
- Uygulama mongo:7.0 ile uyumlu (tüm entegrasyon testleri mongo:7 replica set'e karşı geçti).
|
||||
|
||||
## [0.14.0] - 2026-11-07
|
||||
|
||||
Faz 14: dashboard ana grafiği ve raporlar (14a ve 14b alt fazları).
|
||||
|
||||
### Eklendi — Faz 14b: cari, birikim, alışveriş, birim fiyat ve fire raporları
|
||||
|
||||
- Cari ve aynî borçlar (`/raporlar/cari`): bakiyesi olan cariler (bana borçlu / ben borçluyum, vade durumu), açık aynî borç ve alacaklar kalan miktar ve güncel TL değeriyle; toplamlar.
|
||||
- Birikim kâr/zarar (`/raporlar/birikim`): varlık başına miktar, maliyet, güncel değer, gerçekleşmemiş ve gerçekleşen K/Z; son 3/6/12 ay veya tümü için dönem özeti (başlangıç değeri, yatırılan, satıştan alınan, bitiş değeri, dönem kârı).
|
||||
- Alışveriş (`/raporlar/alisveris`): tamamlanan listelerde planlanan / gerçekleşen / fark, eşleşme sayıları ve farkın nedenleri; mağaza bazlı harcama (alıcısı mağaza adıyla başlayan giderler, şubeler birlikte, "Alışverişi tamamla"dan gelen kısım ayrıca).
|
||||
- Birim fiyat geçmişi (`/raporlar/birim-fiyat`): ürün tipi seçimi, son 12 bütçe ayında aylık en düşük ve ortalama birim fiyat (grafik ve tablo), mağazalarda dönemin en düşük birim fiyatı.
|
||||
- Fire / zarar (`/raporlar/fire`): ürün tipi başına fire, alım, fire/alım oranı, nedenlere göre dağılım ve tahmini maliyet (fire × son bilinen birim fiyat).
|
||||
- Hepsinde CSV dışa aktarma; rapor ana sayfasında yeni rapor kartları.
|
||||
- Testler: mağaza adı eşleşmesi ve aylık birim fiyat özeti (birim); cari/aynî, birikim, alışveriş (tamamlanan liste dahil), birim fiyat ve fire raporları (entegrasyon).
|
||||
|
||||
### Eklendi — Faz 14a: dashboard ana grafiği ve finans raporları
|
||||
|
||||
- Dashboard ana çizgi grafiği: Planlanan gelir (yeşil), Planlanan gider / bütçe (mavi, kesikli), Gerçekleşen gider (kırmızı). Periyotlar: Haftalık (bu hafta, günlük), Aylık (bütçe ayı, günlük), Altı aylık ve Yıllık (bütçe ayları). Kümülatif / dönemsel görünüm; seçim URL'de (`?periyot=…&gorunum=…`). Bütçesi olmayan dönemde bütçe çizgisi boş, gelecek günlerde gerçekleşen çizilmez; grafiğin altında tablo görünümü.
|
||||
- Raporlar (`/raporlar`): bu bütçe ayının özeti (gelir, gider, net, tasarruf oranı, en çok harcanan kategoriler) ve rapor sayfaları; her raporda ay seçici ve CSV dışa aktarma (Türkçe Excel uyumlu).
|
||||
- Kategori dağılımı: halka grafik ve tablo; üst kategori toplamları ve alt kategori kırılımı, toplam gidere oran (%14,3 gibi), önceki aya göre tutar ve yüzde değişim. Dilim renkleri kategorinin kendi rengi; 7'den fazla kategori "Diğer"de toplanır.
|
||||
- Aylık trend: son 12 bütçe ayı gelir ve gider sütunları, ayrı grafikte tasarruf oranı (devam eden ay grafikte gösterilmez), net ve 12 ay toplamı.
|
||||
- Kişi ve hesap bazlı: gideri kaydeden üyeye göre harcama ve pay; hesap başına gelir, gider, gelen/giden transfer ve net değişim. Başka üyelerin "yalnızca ben" hesapları hesap tablosunda hiç görünmez.
|
||||
- Bütçe ve gerçekleşen: kategori bazında limit, gerçekleşen, kalan ve kullanım; son 6 ayın limit/gerçekleşen karşılaştırması.
|
||||
- Kurallar bütçeyle aynı: bütçe ayı, gerçekleşmiş işlemler, bölünmüş işlemler kalem bazında, taksit modu ve gizli hesap giderleri hane ayarına göre.
|
||||
- Testler: periyot aralıkları ve kümülatif dönüşüm, kategori payı/değişim/tasarruf oranı ve alt kategori kırılımı (birim); rapor toplamlarının bütçeyle tutarlılığı, gizli hesap görünürlüğü, dashboard periyotları (entegrasyon).
|
||||
|
||||
### Değişti — Faz 14a
|
||||
|
||||
- Dashboard'daki günlük plan kartı istatistik ve tabloya indi; kümülatif ay grafiği ana grafiğin "Aylık" görünümüne taşındı.
|
||||
- Bütçe, gelir planı ve günlük plan hesapları bağlam alan yardımcılara ayrıldı (raporlarda birden çok ay için yeniden kullanılır).
|
||||
|
||||
## [0.13.0] - 2026-11-06
|
||||
|
||||
Faz 13: alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu ve alışveriş sonrası giriş (13a ve 13b alt fazları).
|
||||
|
||||
### Eklendi — Faz 13b: alışveriş sonrası giriş, karşılaştırma ve "Alışverişi tamamla"
|
||||
|
||||
- Alışveriş sonrası giriş (`/alisveris/:id/tamamla`): liste kalemleri tek tek işaretlenir; her biri için alınan ürün (aynı ya da aynı ürün tipinden muadil; ürün tipi kaleminde marka belirtmeden de), mağaza, paket adedi ve ödenen tutar. Tutar, bilinen paket fiyatıyla ön doldurulur. Listede olmayan ekstra ürünler eklenebilir. Mağaza başına ödeme hesabı (yemek kartı geçen mağazada uygun yemek kartı önseçili) ve fiş görselleri.
|
||||
- "Alışverişi tamamla" tek MongoDB transaction'ı: stok alım hareketleri, fiyat kayıtları (kaynak `receipt`; ürün × mağaza başına, paket fiyatı = tutar / paket), mağaza başına bölünmüş gider işlemi (kalemler stok kategorisinin eşlendiği gider kategorisine; eşlenmemiş ve serbest metin kalemleri seçilen yedek kategoriye), fiş görselleri işlemin eki, alınmayanların taşınması ve listenin kapanması. Herhangi bir adım başarısızsa hiçbir şey yazılmaz.
|
||||
- Alınmayanlar: başka aktif listeye taşı, yeni liste aç ya da bırak.
|
||||
- Karşılaştırma raporu (tamamlanan listede): planlanan (tamamlama anındaki akıllı öneri) vs. gerçekleşen tutar ve fark; farkın nedenleri (aynı üründe fiyat farkı, miktar farkı, muadil, alınmayanlar, plansız ekstralar, planda fiyatı olmayanlar; toplamları farka eşit); kalem bazında Aynı / Muadil / Alınmadı / Plansız ekstra; mağaza giderleri ve kullanılan hesaplar. Etiketler tamamlama anındaki hâliyle saklanır.
|
||||
- Liste sayfasında ve alışveriş modunda "Alışverişi tamamla" düğmesi.
|
||||
- Stok hareketleri ve işlemlerde `shoppingListId` (kaynağı izlemek için).
|
||||
- Testler: eşleşme ve fark ayrıştırması (birim); tamamlama ekranı verisi, doğrulamalar (muadil tipi, gelecek tarih, izleyici), ek dosya hatasında geri alma, stok/fiyat/mağaza başına bölünmüş gider, markasız kalem, kalanların taşınması ve rapor (entegrasyon).
|
||||
|
||||
### Eklendi — Faz 13a: alışveriş listeleri, stok önerileri, akıllı mağaza önerisi, alışveriş modu
|
||||
|
||||
- Alışveriş listeleri (`shoppingLists`, `/alisveris`): birden çok aktif liste; kalem olarak ürün tipi (genel), belirli ürün (marka/paket) ya da serbest metin. Miktar ürün tipinde tipin biriminde, üründe paket adedi; boşsa en az bir paket. Aynı ürün listede iki kez olamaz; kalem düzenleme, çıkarma ve başka aktif listeye taşıma. Liste silme soft delete ve denetim kaydı. Aile üyeleri aynı listeyi kullanır: sayfa odaklanınca ve 30 saniyede bir yenilenir.
|
||||
- Stok eşiği önerileri: eşiğin altındaki ürün tipleri hiçbir aktif listede yoksa liste sayfasında onayla/reddet önerisi olarak gelir (fire ayarlı önerilen miktar ve nedeni). Onaylanan önerilen miktarla eklenir ("Stok önerisi" rozeti), "Tümünü ekle" ile toplu. Reddedilen öneri, ürün için yeni alım girilene (stok yenilenene) kadar gösterilmez.
|
||||
- Akıllı mağaza önerisi (saf fonksiyon, birim testli): son bilinen fiyatlarla her kalem için mağaza ve marka; ihtiyacı karşılayan paket sayısı × paket fiyatı. Fiziksel mağaza ziyareti ve hacimli kalemler için zahmet skoruna bağlı maliyet cezası, online teslimat ücreti ve ücretsiz teslimat alt limiti, yemek kartı önceliği (kartın geçtiği mağazada kartla ödenebilecek tutar avantajlı sayılır; bakiye mağazalar arasında bir kez harcanır). En az mağazalı eksiksiz plandan başlanır; ek mağaza ancak tasarruf hane ayarındaki eşiği aşarsa eklenir. Çıktı: "Tek mağaza en iyi seçenek: X" ya da "Önerilen dağılım: A (7 ürün), B (3 ürün): Y (Z tasarruf)"; önerilen ve tek mağaza dağılımları, fiyatı bilinmeyen kalemler.
|
||||
- Alışveriş modu (`/alisveris/:id/mod`): plana göre mağazaya gruplanmış, büyük dokunma alanlı kontrol listesi; "sepete alındı" işareti anında görünür ve kaydedilir, ilerleme çubuğu; önerilen / tek mağaza planı arasında geçiş.
|
||||
- Ürün tipine "Ağır / hacimli" işareti (akıllı öneride taşıma cezası).
|
||||
- Dashboard'da "Alışveriş" kartı (aktif listeler ve bekleyen öneri sayısı); stok sayfasındaki "Alınması önerilenler" alışveriş listelerine bağlanır. Kenar menüde "Alışveriş".
|
||||
- Migration `015-shopping-lists`. Seed: "Haftalık market" (9 kalem: tip, marka, serbest metin, fiyatı olmayan domates) ve "Ay sonu toptan" listeleri; deterjan, yumuşatıcı, çamaşır suyu ve tuvalet kağıdı hacimli.
|
||||
- Testler: planlayıcı (paket yuvarlama, marka kısıtı, eşik, eksik kapsama, zahmet ve hacimli cezası, teslimat, yemek kartı), miktar ve öneri reddi kuralları (birim); liste/kalem işlemleri, yetki, öneri onay/red, plan çıktısı, silme ve denetim (entegrasyon).
|
||||
|
||||
### Değişti — Faz 13a
|
||||
|
||||
- Fiyat servisinde ürün tipi teklifleri ortak yükleyicide (`loadTypeOffers`); tüketim analizindeki son birim fiyat da bunu kullanır.
|
||||
|
||||
## [0.12.0] - 2026-11-06
|
||||
|
||||
Faz 12: mağazalar, ürünler, fiyatlar ve birim fiyat (12a ve 12b alt fazları).
|
||||
|
||||
### Eklendi — Faz 12b: fiyat kayıtları ve birim fiyat
|
||||
|
||||
- Fiyat kayıtları (`productPrices`): ürün × mağaza × tarih × paket fiyatı; kampanya/indirim işareti ve geçerlilik sonu; kaynak `manual` | `receipt` | `provider:<ad>` (şimdilik elle giriş). Gelecek tarihli fiyat ve arşivli ürün/mağazaya fiyat girilemez; kayıt silme denetim kaydına yazılır.
|
||||
- Birim fiyat: her fiyat ürün tipinin birimine çevrilir (TL/kg, TL/lt, TL/adet, TL/yıkama; g ve ml gösterimde kg ve lt). Yuvarlama yalnızca gösterimde. Geçerli fiyat mağaza başına son kayıt; süresi biten kampanyada önceki normal fiyata dönülür ("Kampanya bitti, normal fiyat"), 90 günden eski fiyat işaretlenir.
|
||||
- Ürün tipi detayında "Birim fiyat karşılaştırması": tüm marka/paket ve mağazalar birim fiyata göre sıralı, en uygun vurgulu, diğerleri "%X pahalı"; buradan fiyat girişi (birim fiyat anlık önizlemesiyle).
|
||||
- Ürün sayfası (`/stok/urunler/:id`): görseller, mağazalardaki geçerli fiyatlar, mağaza başına fiyat geçmişi grafiği (basamaklı çizgi, bugüne uzanır) ve fiyat kayıtları listesi. Mağaza renkleri sabit kategorik paletten (mağazanın ekleniş sırası; açık/koyu tema için doğrulanmış `--series-1…8`).
|
||||
- Tüketim analizinde aylık ortalama maliyet: aylık ortalama tüketim × ürün tipinin son bilinen birim fiyatı; fiyatı bilinen ürünlerin toplamı.
|
||||
- Migration `014-product-prices`. Seed: 22 ürün için son 6 ayın 265 fiyat kaydı (mağaza farkları, aylık artış, süren ve biten kampanya, eski fiyat örneği).
|
||||
- Testler: birim fiyat ve gösterim birimi, geçerli fiyat (biten kampanya, eski fiyat), sıralama ve eşitlik, renk yuvası (birim); karşılaştırma sırası, kampanya dönüşü, fiyat girişi kuralları, silme ve denetim, analiz maliyeti (entegrasyon).
|
||||
|
||||
### Değişti — Faz 12b
|
||||
|
||||
- Sayfa başlığı açıklaması bağlantı içerebilir; ek dosya seçicisine özel düğme metni ve ipucu.
|
||||
|
||||
### Eklendi — Faz 12a: mağazalar ve ürünler (marka/paket)
|
||||
|
||||
- Mağazalar (`stores`, `/magazalar`): ad, tür (fiziksel market / online platform / pazar), şube, eve uzaklık (km), taşıma güçlüğü (1–5), online ise teslimat ücreti ve ücretsiz teslimat alt limiti, yemek kartı geçerliliği ve geçerli sağlayıcılar, not. Zahmet skoru (0–100; uzaklık 20 km'ye kadar ve taşıma güçlüğü eşit ağırlıkla, online mağazada uzaklık sayılmaz) ve düzeyi; "ada göre / en yakın / en uzak / en zahmetli" sıralama. Aynı ad + şube tekil; arşivleme denetim kaydına yazılır. Kenar menüde "Mağazalar".
|
||||
- Ürünler (`products`): ürün tipine bağlı marka, tanım, isteğe bağlı barkod (8–14 rakam, hanede tekil), paket miktarı ve birimi. Paket birimi ürün tipinin birimine kendiliğinden çevrilebiliyorsa (aynı birim, kg↔g, lt↔ml) dönüşüm otomatik; çevrilemiyorsa dönüşüm zorunlu ("5 kg = 33 yıkama", "1 paket = 50 tablet", "75 ml = 1 adet"). Ürün tipinin birimi sonradan değişirse "Dönüşüm gerekli" uyarısı.
|
||||
- Ürün görselleri: birden çok görsel, mevcut ek dosya altyapısıyla (WebP'ye dönüştürme, küçük resim, yetkili okuma; hane üyeleri görür). Liste küçük resmi ve görüntüleyici.
|
||||
- Ürün tipi detayında "Ürünler (marka / paket)" bölümü; `/stok/urunler` ürün kataloğu (ürün tipine göre gruplu, marka/tanım/barkodla arama, arşiv).
|
||||
- Migration `013-stores-products` (index'ler). Seed: Çakır Ailesi için 8 mağaza ve 22 ürün.
|
||||
- Testler: zahmet skoru, sıralama, uzaklık gösterimi, paket dönüşümü ve açıklaması (birim); mağaza şeması ve kuralları, ürün dönüşüm zorunluluğu, barkod tekilliği, görsel bağlama/sıralama/çıkarma ve okuma yetkisi, arşiv (entegrasyon).
|
||||
|
||||
## [0.11.0] - 2026-11-06
|
||||
|
||||
Faz 11: ev stoğu (11a ve 11b alt fazları).
|
||||
@@ -350,7 +490,8 @@ 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.11.0...HEAD
|
||||
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.12.0...HEAD
|
||||
[0.12.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.11.0...v0.12.0
|
||||
[0.11.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.10.0...v0.11.0
|
||||
[0.10.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.9.0...v0.10.0
|
||||
[0.9.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.8.0...v0.9.0
|
||||
|
||||
+10
-3
@@ -2,6 +2,7 @@
|
||||
# BÜTÇEM — çok aşamalı üretim imajı (Next.js standalone çıktı).
|
||||
# Build: docker build --build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
|
||||
# Çalıştır: docker run -p 3000:3000 --env-file .env.production -v butcem-uploads:/app/uploads butcem
|
||||
# Başlarken: replica set kontrolü, bekleyen migration'lar, ilk admin (docker-entrypoint.sh).
|
||||
|
||||
ARG NODE_VERSION=26
|
||||
|
||||
@@ -10,6 +11,8 @@ FROM node:${NODE_VERSION}-alpine AS deps
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat
|
||||
COPY package.json package-lock.json ./
|
||||
# SheetJS resmi dağıtımdan vendor/ altında (npm uzak tarball indirmez).
|
||||
COPY vendor ./vendor
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
# 2) Build
|
||||
@@ -22,7 +25,8 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
APP_COMMIT=${APP_COMMIT}
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
# Uygulama + başlatma betiği (migration ve ilk admin; tek dosya, tsx gerekmez).
|
||||
RUN npm run build && npm run build:scripts
|
||||
|
||||
# 3) Çalışma zamanı — yalnızca standalone çıktı, root olmayan kullanıcı.
|
||||
FROM node:${NODE_VERSION}-alpine AS runner
|
||||
@@ -43,12 +47,15 @@ RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs \
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/dist/bootstrap.mjs ./scripts/bootstrap.mjs
|
||||
COPY --chown=nextjs:nodejs --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
VOLUME ["/app/uploads"]
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
# start-period: ilk deploy'da migration'lar için pay.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
|
||||
@@ -52,19 +52,20 @@ Giriş kullanıcı adı **veya** e-posta ile yapılır. Kayıt kapalıdır; kull
|
||||
|
||||
## Scriptler
|
||||
|
||||
| Komut | Açıklama |
|
||||
| ----------------------------- | --------------------------------- |
|
||||
| `npm run dev` | Geliştirme sunucusu |
|
||||
| `npm run build` / `start` | Üretim build'i / sunucusu |
|
||||
| `npm run lint` | ESLint |
|
||||
| `npm run typecheck` | `tsc --noEmit` |
|
||||
| `npm run test` | Vitest birim testleri |
|
||||
| `npm run format` | Prettier |
|
||||
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
|
||||
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
|
||||
| `npm run test:integration` | Entegrasyon testleri (MongoDB) |
|
||||
| `npm run migrate` | Bekleyen migration'lar |
|
||||
| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil |
|
||||
| Komut | Açıklama |
|
||||
| ----------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `npm run dev` | Geliştirme sunucusu |
|
||||
| `npm run build` / `start` | Üretim build'i / sunucusu |
|
||||
| `npm run lint` | ESLint |
|
||||
| `npm run typecheck` | `tsc --noEmit` |
|
||||
| `npm run test` | Vitest birim testleri |
|
||||
| `npm run format` | Prettier |
|
||||
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
|
||||
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
|
||||
| `npm run test:integration` | Entegrasyon testleri (MongoDB) |
|
||||
| `npm run migrate` | Bekleyen migration'lar |
|
||||
| `npm run bootstrap` | Canlı başlatma adımı (replica set kontrolü + migration; production'da ilk admin) |
|
||||
| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil |
|
||||
|
||||
## Haneler ve yetki
|
||||
|
||||
@@ -83,4 +84,13 @@ docker build --build-arg APP_VERSION=$(node -p "require('./package.json').versio
|
||||
--build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
|
||||
```
|
||||
|
||||
Canlı ortam (Dokploy, `butcem.ta1sph.website`) kurulumu Faz 15'te `docs/DEPLOY.md` içinde anlatılacak.
|
||||
Konteyner başlarken `docker-entrypoint.sh` önce başlatma adımını (`scripts/bootstrap.mjs`: replica
|
||||
set kontrolü, bekleyen migration'lar, `SEED_ADMIN_*` tanımlıysa ilk admin) sonra uygulamayı çalıştırır.
|
||||
|
||||
## Canlı ortam
|
||||
|
||||
- Kurulum, ortam değişkenleri, domain, volume, otomatik deploy, yedekleme ve geri yükleme:
|
||||
[`docs/DEPLOY.md`](docs/DEPLOY.md)
|
||||
- MongoDB replica set ve uygulama kullanıcısı: [`docs/MONGODB-KURULUM.md`](docs/MONGODB-KURULUM.md)
|
||||
- Duman testi: `scripts/smoke-test.sh https://butcem.ta1sph.website`
|
||||
- Yedek / geri yükleme betikleri: `scripts/backup/`
|
||||
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
# BÜTÇEM konteyner girişi: önce başlatma (replica set kontrolü, migration'lar, ilk admin),
|
||||
# başarılıysa uygulama. Başlatma başarısız olursa konteyner durur; Dokploy günlüğünde sebep görünür.
|
||||
# Acil durumda atlamak için: SKIP_BOOTSTRAP=1
|
||||
set -e
|
||||
if [ "${SKIP_BOOTSTRAP:-0}" != "1" ]; then
|
||||
node /app/scripts/bootstrap.mjs
|
||||
fi
|
||||
exec node /app/server.js
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
# Canlıya alma (Dokploy + Gitea)
|
||||
|
||||
BÜTÇEM'i Atlas sunucusunda Dokploy ile `https://butcem.ta1sph.website` adresinde yayınlamak,
|
||||
güncellemek, yedeklemek ve geri yüklemek için adım adım kılavuz.
|
||||
|
||||
**Mimari:** Gitea (`main` dalı) → webhook → Dokploy, Dockerfile ile imaj build eder → konteyner
|
||||
başlarken veritabanını hazırlar (replica set kontrolü, migration'lar, ilk admin) → Next.js
|
||||
uygulaması 3000 portunda → Traefik HTTPS ile dışarı açar. MongoDB aynı Dokploy ağında, dışa portsuz.
|
||||
Yüklenen dosyalar `/app/uploads` volume'unda.
|
||||
|
||||
> Sunucu komutları Atlas'ta çalıştırılır. Her komutun altında ne yaptığı yazıyor. `<…>` ile
|
||||
> gösterilen yerleri kendi değerlerinizle değiştirin.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ön koşullar
|
||||
|
||||
- [ ] MongoDB servisi **replica set** olarak çalışıyor ve `butcem_app` kullanıcısı hazır →
|
||||
[`MONGODB-KURULUM.md`](MONGODB-KURULUM.md) (1–5. adımlar).
|
||||
- [ ] Gitea'da BÜTÇEM reposu (`main` dalı güncel; `git push origin main`).
|
||||
- [ ] DNS: `butcem.ta1sph.website` için Atlas'ın genel IP'sine **A kaydı** (ve gerekiyorsa
|
||||
modemde 80/443 yönlendirmesi Traefik'e).
|
||||
|
||||
```bash
|
||||
dig +short butcem.ta1sph.website
|
||||
```
|
||||
|
||||
Alan adının çözüldüğü IP'yi gösterir; Atlas'ın genel IP'si olmalı.
|
||||
|
||||
## 2. Dokploy'da uygulama
|
||||
|
||||
Dokploy › proje › **Create Service › Application**, ad: `butcem`.
|
||||
|
||||
### 2.1 Kaynak (General)
|
||||
|
||||
- **Provider:** Gitea (Dokploy › Settings › Git'te Gitea bağlantısı tanımlıysa) ya da **Git**
|
||||
(repo URL'si + Dokploy'un SSH anahtarı Gitea'da deploy key olarak ekli).
|
||||
- **Repository / Branch:** BÜTÇEM reposu, `main`.
|
||||
- **Build Type:** `Dockerfile` (yol: `Dockerfile`, context: `.`).
|
||||
- **Build Args** (isteğe bağlı): `APP_COMMIT=<kısa-commit>` — sağlık kontrolünde görünür; boşsa
|
||||
`unknown` yazar. Sürüm `package.json`'dan otomatik gelir.
|
||||
|
||||
### 2.2 Ortam değişkenleri (Environment)
|
||||
|
||||
| Değişken | Değer | Not |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `MONGODB_URI` | `mongodb://butcem_app:<parola>@<iç-host>:27017/butcem?authSource=<admin\|butcem>&replicaSet=rs0&directConnection=true` | Ayrıntı: MONGODB-KURULUM.md › 5 |
|
||||
| `BETTER_AUTH_SECRET` | 32+ karakter rastgele | Aşağıdaki komutla üretin; **değiştirirseniz tüm oturumlar kapanır** |
|
||||
| `BETTER_AUTH_URL` | `https://butcem.ta1sph.website` | Sonunda `/` yok |
|
||||
| `SEED_ADMIN_EMAIL` | yönetici e-postası | İlk açılışta admin oluşturulur |
|
||||
| `SEED_ADMIN_PASSWORD` | güçlü parola (8+) | İlk girişten sonra parolayı değiştirip bu değişkeni silebilirsiniz |
|
||||
| `MAX_UPLOAD_MB` | `10` | İsteğe bağlı |
|
||||
|
||||
`NODE_ENV=production`, `PORT=3000` ve `UPLOAD_DIR=/app/uploads` imajda tanımlıdır; eklemeyin.
|
||||
`SEED_USER_PASSWORD` canlıda kullanılmaz (örnek kullanıcı oluşturulmaz).
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
`BETTER_AUTH_SECRET` için 32 baytlık rastgele bir değer üretir.
|
||||
|
||||
### 2.3 Volume (Advanced › Volumes / Mounts)
|
||||
|
||||
- **Volume Mount** · ad: `butcem-uploads` · bağlama yolu: **`/app/uploads`**.
|
||||
Fiş, ürün görseli ve ek dosyalar burada tutulur; imaj yeniden oluşturulduğunda kaybolmaz.
|
||||
(Bind mount yerine adlandırılmış volume kullanın; imajdaki dizin izinleri — uid 1001 — volume'a
|
||||
ilk oluşturulurken aktarılır.)
|
||||
|
||||
### 2.4 Domain (Domains)
|
||||
|
||||
- **Host:** `butcem.ta1sph.website` · **Path:** `/` · **Container Port:** `3000`
|
||||
- **HTTPS:** açık · **Certificate:** Let's Encrypt
|
||||
|
||||
### 2.5 İlk deploy
|
||||
|
||||
**Deploy**'a basın ve **Logs**'ta şu satırları bekleyin:
|
||||
|
||||
```
|
||||
• MongoDB rs0 PRIMARY
|
||||
✓ Migration: 001-initial-indexes — … (ilk kurulumda 15 migration)
|
||||
✓ Admin oluşturuldu: <e-posta> (kullanıcı adı: admin)
|
||||
✓ Ready in …
|
||||
```
|
||||
|
||||
"MongoDB replica set olarak çalışmıyor" hatası görürseniz MONGODB-KURULUM.md › 3'ü uygulayın.
|
||||
Konteynerin Docker sağlık durumu birkaç saniye içinde **healthy** olur (`/api/health`).
|
||||
|
||||
### 2.6 İlk giriş
|
||||
|
||||
`https://butcem.ta1sph.website` › kullanıcı adı **`admin`** ve `SEED_ADMIN_PASSWORD`.
|
||||
|
||||
1. **Yönetim › Kullanıcılar**'dan aile üyelerini oluşturun.
|
||||
2. **Yönetim › Haneler**'den haneyi açıp üyeleri ve rollerini ekleyin.
|
||||
3. (Önerilir) Admin parolasını **Yönetim › Kullanıcılar › kendi satırınız › Şifre sıfırla** ile
|
||||
değiştirip Dokploy'da `SEED_ADMIN_PASSWORD`'u silin ve yeniden deploy edin; başlatma artık
|
||||
yalnızca migration'ları çalıştırır. (Admin hesabı varken bu değişken parolayı değiştirmez.)
|
||||
|
||||
## 3. Duman testi (smoke test)
|
||||
|
||||
Kendi bilgisayarınızda, repo klasöründe:
|
||||
|
||||
```bash
|
||||
scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||
```
|
||||
|
||||
Sağlık (veritabanı, replica set, sürüm), güvenlik başlıkları, HTTP→HTTPS yönlendirmesi ve
|
||||
oturumsuz erişimin engellendiğini kontrol eder; **veri değiştirmez**. Tümü ✓ olmalı.
|
||||
|
||||
```bash
|
||||
SMOKE_USER=admin SMOKE_PASSWORD='<parola>' scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||
```
|
||||
|
||||
Ayrıca giriş yapıp ana sayfanın açıldığını dener (parola komut geçmişine düşer; sonra
|
||||
`history -d` ile silebilir ya da satırı boşlukla başlatabilirsiniz).
|
||||
|
||||
Elle kontrol listesi (bir kez, tarayıcıdan):
|
||||
|
||||
- [ ] Giriş / çıkış, telefondan PWA olarak ana ekrana ekleme.
|
||||
- [ ] Bir hesap ve bir gider işlemi oluşturma (transaction'lı yazma).
|
||||
- [ ] İşleme fiş fotoğrafı ekleyip görüntüleme (uploads volume).
|
||||
- [ ] Bir transfer (iki hesap birden; transaction).
|
||||
- [ ] Dokploy'da uygulamayı **Restart** edip fişin hâlâ açıldığını görme (volume kalıcılığı).
|
||||
|
||||
## 4. Otomatik deploy (Gitea webhook)
|
||||
|
||||
Dokploy › `butcem` › **Deployments** › **Webhook URL**'yi kopyalayın (Auto Deploy açık olmalı).
|
||||
Gitea › repo › **Settings › Webhooks › Add Webhook › Gitea**:
|
||||
|
||||
- **Target URL:** kopyaladığınız Dokploy URL'si · **HTTP Method:** POST · **Content type:** JSON
|
||||
- **Trigger On:** Push Events · **Branch filter:** `main`
|
||||
- Kaydedip **Test Delivery** ile deneyin; Dokploy'da yeni bir deployment başlamalı.
|
||||
|
||||
Bundan sonra `git push origin main` → otomatik build ve yayın. Her deploy'da bekleyen
|
||||
migration'lar uygulama açılmadan önce çalışır.
|
||||
|
||||
## 5. Güncelleme ve geri alma
|
||||
|
||||
- **Normal güncelleme:** `main`'e push (webhook) ya da Dokploy'da **Deploy**.
|
||||
- **Büyük güncellemeden önce** elle yedek alın: `sudo /opt/butcem/butcem-backup.sh` (bkz. 6).
|
||||
- **Geri alma:** Dokploy › Deployments › önceki başarılı deployment › **Rollback** (ya da
|
||||
Gitea'da önceki commit'e dönüp push). Migration'lar yalnızca ileri yönlüdür; yeni sürümün
|
||||
eklediği index/alanlar eski sürümü bozmaz. Veri bozulduysa 7'deki geri yüklemeyi kullanın.
|
||||
- **Acil durum:** başlatma adımı (migration) hata verip uygulama açılmıyorsa geçici olarak
|
||||
`SKIP_BOOTSTRAP=1` ortam değişkeniyle açabilirsiniz; sorunu giderince kaldırın.
|
||||
|
||||
## 6. Yedekleme
|
||||
|
||||
Günlük: `butcem` veritabanı (`mongodump`) + uploads volume arşivi + SHA-256 özetleri,
|
||||
**7 gün saklama**, `/var/backups/butcem` altında. Betik: `scripts/backup/butcem-backup.sh`.
|
||||
|
||||
> ⚠️ Şimdilik yedekler **yalnızca sunucuda**. Disk arızasında yedekler de gider; bir sonraki adım
|
||||
> olarak sunucu dışına kopya (NAS'a `rsync` ya da S3 uyumlu depolama) eklenmeli.
|
||||
|
||||
### 6.1 Kurulum (bir kez)
|
||||
|
||||
```bash
|
||||
sudo install -d -m 755 /opt/butcem
|
||||
```
|
||||
|
||||
Betikler için `/opt/butcem` klasörünü oluşturur.
|
||||
|
||||
```bash
|
||||
sudo install -m 750 scripts/backup/butcem-backup.sh scripts/backup/butcem-restore.sh /opt/butcem/
|
||||
```
|
||||
|
||||
Yedek ve geri yükleme betiklerini (repo klasöründen; sunucuya `scp` ile de kopyalayabilirsiniz)
|
||||
`/opt/butcem`'e yalnızca root'un çalıştırabileceği izinle kopyalar.
|
||||
|
||||
```bash
|
||||
sudo install -m 600 scripts/backup/butcem-backup.env.example /etc/butcem-backup.env
|
||||
sudo nano /etc/butcem-backup.env
|
||||
```
|
||||
|
||||
Ayar dosyasını yalnızca root'un okuyabileceği izinle oluşturur ve düzenlemek için açar. Doldurun:
|
||||
|
||||
- `MONGO_CONTAINER`: MongoDB konteyner adının ayırt edici kısmı (`docker ps --format '{{.Names}}'`).
|
||||
- `MONGO_ROOT_USER` / `MONGO_ROOT_PASSWORD`: `butcem_root` bilgileri.
|
||||
- `UPLOADS_VOLUME`: uygulamanın volume adı.
|
||||
```bash
|
||||
docker volume ls | grep -i uploads
|
||||
```
|
||||
Volume adlarını listeler (Dokploy'da verdiğiniz `butcem-uploads` adını doğrulayın).
|
||||
|
||||
### 6.2 İlk çalıştırma
|
||||
|
||||
```bash
|
||||
sudo /opt/butcem/butcem-backup.sh
|
||||
```
|
||||
|
||||
Yedeği hemen alır; sonunda `Tamam: … veritabanı, … dosya` yazmalı.
|
||||
|
||||
```bash
|
||||
sudo ls -lh /var/backups/butcem && sudo sh -c 'cd /var/backups/butcem && sha256sum -c SHA256SUMS-*'
|
||||
```
|
||||
|
||||
Yedek dosyalarını listeler ve bütünlüklerini doğrular (`OK`).
|
||||
|
||||
### 6.3 Günlük zamanlama
|
||||
|
||||
```bash
|
||||
sudo crontab -e
|
||||
```
|
||||
|
||||
root'un zamanlanmış görevlerini açar. Şu satırı ekleyin (her gece 03:30):
|
||||
|
||||
```
|
||||
30 3 * * * /opt/butcem/butcem-backup.sh >> /var/log/butcem-backup.log 2>&1
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo tail -n 20 /var/log/butcem-backup.log
|
||||
```
|
||||
|
||||
Ertesi gün son yedeğin günlüğünü gösterir.
|
||||
|
||||
## 7. Geri yükleme
|
||||
|
||||
```bash
|
||||
sudo ls -1 /var/backups/butcem
|
||||
```
|
||||
|
||||
Mevcut yedekleri listeler; dönülecek tarih damgasını seçin.
|
||||
|
||||
1. Dokploy › `butcem` › **Stop** (yazma olmasın).
|
||||
2. Geri yükleyin:
|
||||
```bash
|
||||
sudo /opt/butcem/butcem-restore.sh /var/backups/butcem/mongo-<damga>.archive.gz /var/backups/butcem/uploads-<damga>.tar.gz
|
||||
```
|
||||
Bütünlüğü kontrol eder, onay ister (`evet`), `butcem` veritabanını yedektekiyle **değiştirir**
|
||||
ve uploads volume'unu arşivden yeniden yazar. Yalnızca veritabanı için ikinci dosyayı vermeyin.
|
||||
3. Dokploy › **Start**, ardından duman testi (3).
|
||||
|
||||
**Tatbikat:** Ayda bir, en son yedeği bu adımlarla deneme amaçlı bir Dokploy kopyasına ya da
|
||||
lokale (`docker compose` mongo'su) yükleyip açılıp açılmadığını kontrol edin. Yedeği hiç denenmemiş
|
||||
bir yedek, yedek değildir.
|
||||
|
||||
## 8. Sorun giderme
|
||||
|
||||
| Belirti | Neden / çözüm |
|
||||
| --------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Logs: `MongoDB replica set olarak çalışmıyor` | MONGODB-KURULUM.md › 3 |
|
||||
| Logs: `Authentication failed` | `MONGODB_URI` kullanıcı/parola/`authSource`; parola URL kodlu mu? |
|
||||
| Logs: `Geçersiz ortam değişkenleri` | Listelenen değişkeni düzeltin (ör. `BETTER_AUTH_SECRET` < 32 karakter) |
|
||||
| Konteyner `unhealthy` | `curl -s https://butcem.ta1sph.website/api/health` → `db` alanı; MongoDB servisi çalışıyor mu? |
|
||||
| Giriş sonrası tekrar giriş sayfası | `BETTER_AUTH_URL` alan adıyla birebir aynı mı (https, sonda `/` yok)? |
|
||||
| Yüklenen dosya açılmıyor / yükleme hatası | Volume `/app/uploads`'a bağlı mı? `docker exec <app> ls -ln /app/uploads` sahibi 1001 olmalı |
|
||||
| Sertifika alınamadı | DNS A kaydı ve 80 portu Traefik'e ulaşıyor mu? |
|
||||
|
||||
```bash
|
||||
docker logs --tail 100 $(docker ps -q --filter name=<butcem-uygulama-adı> | head -n1)
|
||||
```
|
||||
|
||||
Uygulama konteynerinin son 100 log satırını gösterir (Dokploy › Logs ile aynı).
|
||||
@@ -0,0 +1,144 @@
|
||||
# MongoDB kurulumu (canlı, Dokploy)
|
||||
|
||||
BÜTÇEM çok adımlı yazmaları (transfer, "Alışverişi tamamla", kart ödemesi…) **MongoDB transaction**
|
||||
ile yapar. Transaction yalnızca **replica set** üzerinde çalışır; tek sunucu için **tek düğümlü
|
||||
replica set** (`rs0`) yeterlidir. Uygulama başlarken bunu kontrol eder; replica set yoksa açık bir
|
||||
hata mesajıyla durur.
|
||||
|
||||
Bu belge mevcut kurulumu esas alır: Dokploy'da **`mongo:7`** veritabanı servisi (mongo:8 sunucu
|
||||
çekirdeğiyle uyumsuz), kimlik doğrulama açık, `butcem_root` (yönetici) ve `butcem_app` (uygulama)
|
||||
kullanıcıları oluşturulmuş. Uygulama mongo:7.0 ile test edilmiştir (tüm entegrasyon testleri).
|
||||
|
||||
> Komutlar Atlas sunucusunda çalıştırılır. `<…>` ile gösterilen yerleri kendi değerlerinizle
|
||||
> değiştirin. Parolaları komut geçmişine yazmamak için `mongosh` parola sorduğunda girin.
|
||||
|
||||
---
|
||||
|
||||
## 1. MongoDB konteynerini bul
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Names}}' | grep -i mongo
|
||||
```
|
||||
|
||||
Çalışan konteynerlerin adlarından MongoDB'ninkini listeler. Dokploy ada rastgele bir son ek ekler
|
||||
(ör. `butcem-mongo-a1b2c3.1.xyz`). Aşağıda bu adı `<MONGO>` olarak kullanın; adın ayırt edici
|
||||
başlangıcı (ör. `butcem-mongo-a1b2c3`) da yeterlidir.
|
||||
|
||||
```bash
|
||||
MONGO=$(docker ps -q --filter name=<mongo-servis-adı> | head -n1); echo "$MONGO"
|
||||
```
|
||||
|
||||
Konteyner kimliğini bir değişkene alır; sonraki komutlar `$MONGO` ile kısalır.
|
||||
|
||||
## 2. Replica set açık mı?
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||
--eval 'try { const s = rs.status(); print(s.set, s.members[0].stateStr) } catch (e) { print("REPLICA SET YOK: " + e.codeName) }'
|
||||
```
|
||||
|
||||
Yönetici olarak bağlanıp replica set durumunu yazdırır (parolayı sorar).
|
||||
|
||||
- Çıktı **`rs0 PRIMARY`** ise replica set hazır; **4. adıma** geçin.
|
||||
- Çıktı **`REPLICA SET YOK: NoReplicationEnabled`** ise **3. adımı** uygulayın.
|
||||
|
||||
## 3. Tek düğümlü replica set'i aç
|
||||
|
||||
Dokploy'un MongoDB servisinde bir **"Replica Set"** seçeneği varsa (sürüme göre değişir) onu açıp
|
||||
servisi yeniden deploy etmeniz ve 3.4'e geçmeniz yeterlidir. Yoksa aşağıdaki elle yöntemi uygulayın.
|
||||
Kimlik doğrulama açıkken replica set bir **anahtar dosyası (keyfile)** ister.
|
||||
|
||||
### 3.1 Yedek alın
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongodump -u butcem_root --authenticationDatabase admin --gzip --archive=/data/db/oncesi.archive.gz
|
||||
docker cp "$MONGO":/data/db/oncesi.archive.gz ~/mongo-oncesi.archive.gz && docker exec "$MONGO" rm /data/db/oncesi.archive.gz
|
||||
```
|
||||
|
||||
Değişiklikten önce tüm veritabanlarının yedeğini konteyner içinde bir dosyaya alır (parolayı sorar),
|
||||
sonra ev dizinine kopyalayıp konteynerdeki kopyayı siler. Yeni kurulumda veri yoksa atlanabilir.
|
||||
|
||||
### 3.2 Anahtar dosyasını oluştur
|
||||
|
||||
```bash
|
||||
docker exec "$MONGO" sh -c 'head -c 756 /dev/urandom | base64 > /data/db/rs-keyfile && chmod 400 /data/db/rs-keyfile && chown 999:999 /data/db/rs-keyfile && ls -l /data/db/rs-keyfile'
|
||||
```
|
||||
|
||||
Kalıcı veri volume'u (`/data/db`) içinde rastgele bir anahtar dosyası üretir; yalnızca `mongodb`
|
||||
kullanıcısı (uid 999) okuyabilir. Çıktıda `-r-------- … mongodb mongodb` görmelisiniz. Dosya
|
||||
volume'da olduğu için yeniden başlatmalarda korunur.
|
||||
|
||||
### 3.3 Başlatma komutunu değiştir ve yeniden deploy et
|
||||
|
||||
Dokploy › MongoDB servisi › **Advanced** sekmesi › **Command** alanına:
|
||||
|
||||
```
|
||||
mongod --replSet rs0 --bind_ip_all --keyFile /data/db/rs-keyfile
|
||||
```
|
||||
|
||||
yazıp kaydedin ve servisi **Redeploy** edin. (`MONGO_INITDB_ROOT_*` ortam değişkenleri yerinde
|
||||
kaldığı için kimlik doğrulama açık kalır.) Konteyner yeniden oluşacağından 1. adımdaki `MONGO`
|
||||
değişkenini yeniden alın.
|
||||
|
||||
### 3.4 Replica set'i başlat (tek seferlik)
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||
--eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })'
|
||||
```
|
||||
|
||||
`rs0` adlı tek üyeli replica set'i başlatır; çıktıda `{ ok: 1 }` görmelisiniz. Uygulama
|
||||
`directConnection=true` ile bağlandığı için üye adresinin `localhost` olması sorun değildir.
|
||||
|
||||
Birkaç saniye sonra 2. adımdaki kontrolü tekrarlayın: **`rs0 PRIMARY`**.
|
||||
|
||||
## 4. Uygulama kullanıcısını doğrula
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin --eval '
|
||||
for (const d of ["admin", "butcem"]) {
|
||||
const u = db.getSiblingDB(d).getUser("butcem_app");
|
||||
if (u) print("tanımlı veritabanı:", d, "| roller:", JSON.stringify(u.roles));
|
||||
}'
|
||||
```
|
||||
|
||||
`butcem_app` kullanıcısının hangi veritabanında tanımlı olduğunu (bağlantı dizesindeki
|
||||
`authSource`) ve rollerini yazdırır. Beklenen rol: `[{"role":"readWrite","db":"butcem"}]`.
|
||||
|
||||
Rol farklıysa düzeltin:
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||
--eval 'db.getSiblingDB("<authSource>").updateUser("butcem_app", { roles: [{ role: "readWrite", db: "butcem" }] })'
|
||||
```
|
||||
|
||||
Uygulama kullanıcısına yalnızca `butcem` veritabanında okuma-yazma yetkisi verir (migration'lar
|
||||
index oluşturmak için bu yetkiyle yetinir).
|
||||
|
||||
## 5. Bağlantı dizesi (Dokploy uygulama ortam değişkeni)
|
||||
|
||||
```
|
||||
MONGODB_URI=mongodb://butcem_app:<parola-url-kodlu>@<iç-host>:27017/butcem?authSource=<authSource>&replicaSet=rs0&directConnection=true
|
||||
```
|
||||
|
||||
- `<iç-host>`: Dokploy › MongoDB servisi › **Internal Host** (ör. `butcem-mongo-a1b2c3`). Uygulama
|
||||
ve veritabanı aynı Dokploy ağında olduğundan **dışa port açmaya gerek yoktur**; açmayın.
|
||||
- `<authSource>`: 4. adımın çıktısındaki veritabanı (`admin` veya `butcem`).
|
||||
- Parolada `@ : / ? # %` gibi karakterler varsa URL kodlayın:
|
||||
```bash
|
||||
python3 -c 'import urllib.parse,getpass; print(urllib.parse.quote(getpass.getpass(), safe=""))'
|
||||
```
|
||||
Parolayı sorar ve bağlantı dizesine yazılacak kodlu hâlini basar (geçmişe düşmez).
|
||||
|
||||
## 6. Transaction denemesi (isteğe bağlı)
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh "mongodb://butcem_app@localhost:27017/butcem?authSource=<authSource>&replicaSet=rs0&directConnection=true" --eval '
|
||||
const s = db.getMongo().startSession(); const c = s.getDatabase("butcem").getCollection("tx_probe");
|
||||
s.startTransaction(); c.insertOne({ a: 1 }); s.commitTransaction();
|
||||
print("transaction tamam:", db.getSiblingDB("butcem").getCollection("tx_probe").countDocuments({}));
|
||||
db.getSiblingDB("butcem").getCollection("tx_probe").drop();'
|
||||
```
|
||||
|
||||
Uygulama kullanıcısıyla bağlanıp küçük bir transaction yazar, sonucu basar ve deneme
|
||||
koleksiyonunu siler. `transaction tamam: 1` görmelisiniz.
|
||||
+13
-2
@@ -295,8 +295,13 @@ Skill fazları (0–4) uygulanır, ardından alan fazları gelir. Her faz kendi
|
||||
| 13 | Alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu, **alışveriş sonrası giriş ve karşılaştırma**, "alışverişi tamamla" transaction'ı | 0.13.0 |
|
||||
| 14 | Dashboard (çizgi grafik + periyotlar) ve raporlar, CSV dışa aktarma | 0.14.0 |
|
||||
| 15 | Canlıya alma: `docs/DEPLOY.md`, Dokploy + Gitea, domain, volume, yedekleme, smoke test | **1.0.0** |
|
||||
| 16 | `/api/v1` mobil API ve mobil kimlik doğrulama | 1.1.0 |
|
||||
| 17 | Otomatik fiyat sağlayıcıları (market ve altın/döviz) araştırması ve izinliyse entegrasyonu | 1.2.0 |
|
||||
| 16 | **Kakeibo K1:** dört grup (Zorunlu/İstek/Kültür/Beklenmeyen), banka maliyeti ayrımı, kayıt bazında grup, Kakeibo raporu | 1.1.0 |
|
||||
| 17 | **Kakeibo K2:** ekstre içe aktarma (Yapı Kredi kart/hesap, Kakeibo çalışma kitabı; önizleme, tekrar engeli, ekstre toplamı kontrolü, geri alma) | 1.2.0 |
|
||||
| 18 | **Kakeibo K3:** ay planı ve dört soru (ay başı sihirbazı, harcanabilir, grup bütçeleri, haftalık nakit limiti), birikim hedefleri | 1.3.0 |
|
||||
| 19 | **Kakeibo K4:** haftalık kontrol, ay sonu değerlendirmesi (pişmanlık, iyileştirme hedefleri), Kakeibo panosu (KMH faizi/gün, taksit yükü, kayıtsız nakit) | 1.4.0 |
|
||||
| 20 | **Kakeibo K5:** satın almadan önce kontrol ve 24 saat bekleme listesi | 1.5.0 |
|
||||
| 21 | `/api/v1` mobil API ve mobil kimlik doğrulama | 1.6.0 |
|
||||
| 22 | Otomatik fiyat sağlayıcıları (market ve altın/döviz) araştırması ve izinliyse entegrasyonu | 1.7.0 |
|
||||
|
||||
---
|
||||
|
||||
@@ -323,6 +328,12 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
||||
- **Faz 9 kararları (2026-11-05):** 9a varlık lotları ve kâr/zarar, 9b dönemsel rapor ve net varlık; `v0.9.0` 9b sonunda. Varlıklar hane ortaktır; "yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür. Varlık alım/satışı hesaba bağlıysa transfer olarak kaydedilir (gelir/gider raporlarına girmez). Aynî kayıt "aynı varlıkla" kapanırken isteğe bağlı olarak varlıktan düşülür / varlığa eklenir.
|
||||
- **Faz 10 kararları (2026-11-06):** 10a aylık bütçe, 10b gelir planı ve günlük plan; `v0.10.0` 10b sonunda. Limit her gider kategorisine verilebilir; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır (toplamda iki kez sayılmaz). Başkasının "yalnızca ben" hesabından yapılan harcama hane ayarına (gizli hesapları toplamlara kat) göre yalnızca tutar olarak bütçeye girer.
|
||||
- **Faz 11 kararları (2026-11-06):** 11a stok kategorileri, ürün tipleri, hareketler, SKT ve %20 eşik; 11b periyodik sayım, fire ayarlı öneri ve tüketim analizi; `v0.11.0` 11b sonunda. Eşiğin altındaki ürünler Faz 11'de "Alınması önerilenler" listesinde gösterilir (koleksiyon yok); Faz 13'te aktif alışveriş listesine onayla/reddet önerisi olarak bağlanır. SKT alım hareketinde (isteğe bağlı) girilir; tüketim ve fire FIFO ile en eski alımdan düşülür.
|
||||
- **Faz 12 kararları (2026-11-06):** 12a mağazalar ve ürünler (marka/paket, görseller), 12b fiyat kayıtları, birim fiyat ve fiyat geçmişi; `v0.12.0` 12b sonunda. Mağazalar, ürünler ve fiyatlar hane bazlıdır (hane içinde ortak, başka hane görmez). Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa ürün kaydında dönüşüm zorunludur ("5 kg = 33 yıkama"); her fiyat ürün tipinin biriminde karşılaştırılır.
|
||||
- **Faz 13 kararları (2026-11-06):** 13a alışveriş listeleri, stok eşiği önerileri (onayla/reddet), akıllı mağaza önerisi ve alışveriş modu; 13b alışveriş sonrası giriş, karşılaştırma raporu ve "Alışverişi tamamla"; `v0.13.0` 13b sonunda. Birden çok mağazadan alışverişte mağaza başına ayrı bölünmüş gider (her mağaza için ayrı hesap ve fiş). Tamamlanınca liste kapanır ve rapor sabitlenir; alınmayanlar başka aktif listeye taşınır, yeni listeye alınır ya da bırakılır. Reddedilen öneri yeni alım girilene kadar gösterilmez. Markası belirtilmeyen ürün tipi kalemi (ör. pazardan domates) tipin biriminde stoğa işlenir, fiyat kaydı oluşmaz.
|
||||
- **Faz 14 kararları (2026-11-07):** 14a dashboard ana grafiği (Haftalık/Aylık/Altı aylık/Yıllık, kümülatif/dönemsel) ve finans raporları (kategori dağılımı, aylık trend, kişi ve hesap bazlı, bütçe vs. gerçekleşen); 14b cari ve aynî borçlar, birikim kâr/zarar, alışveriş (liste vs. gerçekleşen, mağaza bazlı harcama), birim fiyat geçmişi, fire/zarar; `v0.14.0` 14b sonunda. Raporlar `/raporlar` altında ayrı sayfalar; dönem ve filtreler URL'de, her raporda CSV. "Ay" bütçe ayıdır; gider kuralları bütçeyle aynı (taksit modu, gizli hesap ayarı). Kişi bazlı harcama gideri kaydeden üyeye göre; mağaza bazlı harcama gider alıcısının mağaza adıyla başlamasına göre (şubeler birlikte).
|
||||
- **Faz 15 kararları (2026-11-07):** Canlı MongoDB Dokploy servisi **mongo:7** (mongo:8 sunucu çekirdeğiyle uyumsuz); uygulama mongo:7.0'a karşı tüm entegrasyon testleriyle doğrulandı. Replica set kimlik doğrulamalı tek düğüm (`rs0`, keyfile), uygulama `directConnection=true` ile bağlanır (`docs/MONGODB-KURULUM.md`). Migration'lar ve ilk admin konteyner başlarken otomatik (`scripts/bootstrap.mjs`; replica set yoksa uygulama açılmaz). Gitea `main` push → Dokploy webhook ile otomatik deploy. Yedek: günlük `mongodump` + uploads arşivi, 7 gün, şimdilik yalnızca sunucuda (sunucu dışı kopya sonraki adım).
|
||||
- **Kakeibo kararları (2026-11-07):** Uygulama `.claude/skills/kakeibo` becerisine göre Kakeibo yöntemine uyarlanır (Faz 16–20); mobil API ve fiyat sağlayıcıları öncelik değil, 21–22'ye kaydı. KMH faizi, KKDF/BSMV ve banka ücretleri "banka maliyeti"dir: dört gruba ve Kakeibo harcama toplamına girmez, ayrı gösterilir. Günlük plan korunur, Kakeibo haftalık kontrolü yanına eklenir. Kişilere giden havale/EFT gider sayılır; kampanya iadeleri "İade" geliri. Ekstre dosyaları `import/` (git ve Docker dışı); gerçek veri önce dev veritabanında denenir, canlıya aktarmadan önce sorulur.
|
||||
- **Faz 16 kararları (2026-11-07):** Kakeibo grubu gider kategorisinde tutulur ve alt kategoriye devredilir; işlem ya da bölünmüş kalem bazında değiştirilebilir ("kategoriyi ürün değil amaç belirler"). Banka maliyeti işareti kategori düzeyinde (varsayılan "Banka ve kart giderleri" ağacı); grup değiştirilse bile banka maliyeti kalır. Varsayılan kategoriler beceri eşlemesine göre doldurulur, kullanıcı kategorileri grupsuz kalır ve uyarı gösterilir. Migration'lar artık geri alınabilir (`npm run migrate -- --down <ad>`).
|
||||
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
||||
|
||||
---
|
||||
|
||||
@@ -20,6 +20,8 @@ const eslintConfig = defineConfig([
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
// Derlenmiş başlatma betiği (esbuild paketi).
|
||||
"dist/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
Generated
+15
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.11.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "butcem",
|
||||
"version": "0.11.0",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^7.0.2",
|
||||
"@tanstack/react-table": "^9.3.1",
|
||||
@@ -31,6 +31,7 @@
|
||||
"sonner": "^2.0.8",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -13158,6 +13159,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.20.3",
|
||||
"resolved": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.11.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||
"engines": {
|
||||
@@ -23,6 +23,8 @@
|
||||
"icons": "tsx scripts/generate-icons.ts",
|
||||
"test:integration": "vitest run --config vitest.integration.config.mts",
|
||||
"migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts",
|
||||
"bootstrap": "tsx --conditions=react-server --env-file=.env.local scripts/bootstrap.ts",
|
||||
"build:scripts": "esbuild scripts/bootstrap.ts --bundle --platform=node --format=esm --target=node22 --conditions=react-server --outfile=dist/bootstrap.mjs --external:sharp --external:kerberos --external:snappy --external:socks --external:aws4 --external:@mongodb-js/zstd --external:mongodb-client-encryption --external:gcp-metadata --external:@aws-sdk/credential-providers --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --log-level=warning",
|
||||
"cleanup": "tsx --conditions=react-server --env-file=.env.local scripts/cleanup.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -49,6 +51,7 @@
|
||||
"sonner": "^2.0.8",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# /etc/butcem-backup.env — butcem-backup.sh ayarları. İzin: sudo chmod 600 /etc/butcem-backup.env
|
||||
# Değerler için docs/DEPLOY.md › Yedekleme.
|
||||
|
||||
# MongoDB konteyner adı veya adının ayırt edici bir parçası (docker ps --format '{{.Names}}')
|
||||
MONGO_CONTAINER="butcem-mongo"
|
||||
MONGO_ROOT_USER="butcem_root"
|
||||
MONGO_ROOT_PASSWORD=""
|
||||
|
||||
# Uygulamanın /app/uploads volume adı (docker volume ls)
|
||||
UPLOADS_VOLUME="butcem-uploads"
|
||||
|
||||
BACKUP_DIR="/var/backups/butcem"
|
||||
RETENTION_DAYS="7"
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# BÜTÇEM günlük yedek — sunucuda (Atlas) root olarak, cron ile çalışır. Ayrıntı: docs/DEPLOY.md › Yedekleme
|
||||
#
|
||||
# Üretir (BACKUP_DIR altında, tarih damgalı):
|
||||
# mongo-YYYYmmdd-HHMMSS.archive.gz → butcem veritabanı (mongodump --archive --gzip)
|
||||
# uploads-YYYYmmdd-HHMMSS.tar.gz → yüklenen dosyalar (uploads volume)
|
||||
# SHA256SUMS-YYYYmmdd-HHMMSS → bütünlük özetleri
|
||||
# RETENTION_DAYS günden eski yedekleri siler.
|
||||
#
|
||||
# Ayarlar /etc/butcem-backup.env dosyasından okunur (yalnızca root okuyabilmeli: chmod 600).
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG="${BUTCEM_BACKUP_CONFIG:-/etc/butcem-backup.env}"
|
||||
# shellcheck source=/dev/null
|
||||
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||
|
||||
: "${MONGO_CONTAINER:?MONGO_CONTAINER tanımlı değil (MongoDB konteyner adı veya adının bir parçası)}"
|
||||
: "${MONGO_ROOT_USER:?MONGO_ROOT_USER tanımlı değil}"
|
||||
: "${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD tanımlı değil}"
|
||||
: "${UPLOADS_VOLUME:?UPLOADS_VOLUME tanımlı değil (uygulamanın /app/uploads volume adı)}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/var/backups/butcem}"
|
||||
RETENTION_DAYS="${RETENTION_DAYS:-7}"
|
||||
DB_NAME="${DB_NAME:-butcem}"
|
||||
HELPER_IMAGE="${HELPER_IMAGE:-alpine:3.20}"
|
||||
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
log() { echo "[$(date '+%F %T')] $*"; }
|
||||
|
||||
# Dokploy konteyner adlarına rastgele son ek ekler; adın bir parçasıyla eşleşen çalışan konteyner.
|
||||
CID="$(docker ps -q --filter "name=${MONGO_CONTAINER}" | head -n1)"
|
||||
[ -n "$CID" ] || { log "HATA: '${MONGO_CONTAINER}' adlı çalışan MongoDB konteyneri yok."; exit 1; }
|
||||
docker volume inspect "$UPLOADS_VOLUME" >/dev/null 2>&1 || { log "HATA: '${UPLOADS_VOLUME}' volume'u yok."; exit 1; }
|
||||
|
||||
install -d -m 700 "$BACKUP_DIR"
|
||||
MONGO_FILE="$BACKUP_DIR/mongo-$TS.archive.gz"
|
||||
UPLOADS_FILE="$BACKUP_DIR/uploads-$TS.tar.gz"
|
||||
|
||||
# 1) MongoDB: parola komut satırında görünmesin diye geçici yapılandırma dosyasıyla.
|
||||
log "MongoDB yedeği alınıyor ($DB_NAME)…"
|
||||
CONF="/tmp/butcem-dump-$TS.yaml"
|
||||
printf 'password: "%s"\n' "$MONGO_ROOT_PASSWORD" | docker exec -i "$CID" sh -c "umask 077 && cat > $CONF"
|
||||
if ! docker exec "$CID" mongodump --quiet --config "$CONF" --username "$MONGO_ROOT_USER" \
|
||||
--authenticationDatabase admin --db "$DB_NAME" --archive --gzip >"$MONGO_FILE"; then
|
||||
docker exec "$CID" rm -f "$CONF"
|
||||
rm -f "$MONGO_FILE"
|
||||
log "HATA: mongodump başarısız."
|
||||
exit 1
|
||||
fi
|
||||
docker exec "$CID" rm -f "$CONF"
|
||||
[ -s "$MONGO_FILE" ] || { log "HATA: MongoDB yedeği boş."; exit 1; }
|
||||
|
||||
# 2) Yüklenen dosyalar: volume salt okunur bağlanır.
|
||||
log "Uploads yedeği alınıyor ($UPLOADS_VOLUME)…"
|
||||
docker run --rm -v "$UPLOADS_VOLUME":/data:ro -v "$BACKUP_DIR":/backup "$HELPER_IMAGE" \
|
||||
tar czf "/backup/$(basename "$UPLOADS_FILE")" -C /data .
|
||||
[ -s "$UPLOADS_FILE" ] || { log "HATA: uploads yedeği boş."; exit 1; }
|
||||
|
||||
# 3) Bütünlük özeti ve izinler
|
||||
( cd "$BACKUP_DIR" && sha256sum "$(basename "$MONGO_FILE")" "$(basename "$UPLOADS_FILE")" >"SHA256SUMS-$TS" )
|
||||
chmod 600 "$MONGO_FILE" "$UPLOADS_FILE" "$BACKUP_DIR/SHA256SUMS-$TS"
|
||||
|
||||
# 4) Saklama süresi
|
||||
find "$BACKUP_DIR" -maxdepth 1 -type f \( -name 'mongo-*.archive.gz' -o -name 'uploads-*.tar.gz' -o -name 'SHA256SUMS-*' \) \
|
||||
-mtime +"$RETENTION_DAYS" -print -delete | sed 's/^/ silindi: /'
|
||||
|
||||
log "Tamam: $(du -h "$MONGO_FILE" | cut -f1) veritabanı, $(du -h "$UPLOADS_FILE" | cut -f1) dosya → $BACKUP_DIR"
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# BÜTÇEM geri yükleme — sunucuda root olarak, ELLE çalıştırılır. Ayrıntı: docs/DEPLOY.md › Geri yükleme
|
||||
#
|
||||
# sudo scripts/backup/butcem-restore.sh /var/backups/butcem/mongo-….archive.gz [/var/backups/butcem/uploads-….tar.gz]
|
||||
#
|
||||
# DİKKAT: butcem veritabanındaki koleksiyonlar yedektekilerle DEĞİŞTİRİLİR (--drop); uploads arşivi
|
||||
# verilirse volume içeriği silinip arşivden yazılır. Önce uygulamayı durdurun (Dokploy › Stop).
|
||||
# Ayarlar butcem-backup.sh ile aynı dosyadan: /etc/butcem-backup.env
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG="${BUTCEM_BACKUP_CONFIG:-/etc/butcem-backup.env}"
|
||||
# shellcheck source=/dev/null
|
||||
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||
: "${MONGO_CONTAINER:?}" "${MONGO_ROOT_USER:?}" "${MONGO_ROOT_PASSWORD:?}" "${UPLOADS_VOLUME:?}"
|
||||
DB_NAME="${DB_NAME:-butcem}"
|
||||
HELPER_IMAGE="${HELPER_IMAGE:-alpine:3.20}"
|
||||
|
||||
MONGO_FILE="${1:?Kullanım: butcem-restore.sh <mongo-arşivi> [uploads-arşivi]}"
|
||||
UPLOADS_FILE="${2:-}"
|
||||
[ -s "$MONGO_FILE" ] || { echo "Arşiv yok veya boş: $MONGO_FILE"; exit 1; }
|
||||
[ -z "$UPLOADS_FILE" ] || [ -s "$UPLOADS_FILE" ] || { echo "Arşiv yok veya boş: $UPLOADS_FILE"; exit 1; }
|
||||
|
||||
# Bütünlük kontrolü (aynı klasörde SHA256SUMS varsa)
|
||||
DIR="$(cd "$(dirname "$MONGO_FILE")" && pwd)"
|
||||
STAMP="$(basename "$MONGO_FILE" | sed -E 's/^mongo-(.*)\.archive\.gz$/\1/')"
|
||||
if [ -f "$DIR/SHA256SUMS-$STAMP" ]; then
|
||||
(cd "$DIR" && sha256sum -c "SHA256SUMS-$STAMP") || { echo "Bütünlük kontrolü başarısız."; exit 1; }
|
||||
fi
|
||||
|
||||
CID="$(docker ps -q --filter "name=${MONGO_CONTAINER}" | head -n1)"
|
||||
[ -n "$CID" ] || { echo "Çalışan MongoDB konteyneri yok: $MONGO_CONTAINER"; exit 1; }
|
||||
|
||||
echo "Geri yüklenecek: $MONGO_FILE${UPLOADS_FILE:+ ve $UPLOADS_FILE}"
|
||||
echo "'$DB_NAME' veritabanındaki mevcut veri yedektekiyle DEĞİŞTİRİLECEK."
|
||||
if [ "${ASSUME_YES:-0}" != "1" ]; then
|
||||
read -r -p "Devam etmek için 'evet' yazın: " ANSWER
|
||||
[ "$ANSWER" = "evet" ] || { echo "Vazgeçildi."; exit 1; }
|
||||
fi
|
||||
|
||||
CONF="/tmp/butcem-restore-$$.yaml"
|
||||
printf 'password: "%s"\n' "$MONGO_ROOT_PASSWORD" | docker exec -i "$CID" sh -c "umask 077 && cat > $CONF"
|
||||
trap 'docker exec "$CID" rm -f "$CONF" >/dev/null 2>&1 || true' EXIT
|
||||
docker exec -i "$CID" mongorestore --quiet --config "$CONF" --username "$MONGO_ROOT_USER" \
|
||||
--authenticationDatabase admin --archive --gzip --drop --nsInclude "${DB_NAME}.*" <"$MONGO_FILE"
|
||||
echo "✓ Veritabanı geri yüklendi."
|
||||
|
||||
if [ -n "$UPLOADS_FILE" ]; then
|
||||
docker run --rm -v "$UPLOADS_VOLUME":/data -v "$(cd "$(dirname "$UPLOADS_FILE")" && pwd)":/backup:ro "$HELPER_IMAGE" \
|
||||
sh -c "find /data -mindepth 1 -delete && tar xzf /backup/$(basename "$UPLOADS_FILE") -C /data && chown -R 1001:1001 /data"
|
||||
echo "✓ Yüklenen dosyalar geri yüklendi."
|
||||
fi
|
||||
echo "Uygulamayı yeniden başlatın (Dokploy › Start) ve duman testini çalıştırın."
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Canlı başlatma (Docker entrypoint, uygulamadan önce): replica set kontrolü, bekleyen migration'lar
|
||||
* ve SEED_ADMIN_* tanımlıysa ilk admin hesabı (varsa dokunulmaz). İdempotent; her deploy'da çalışır.
|
||||
* İmajda esbuild ile tek dosyaya derlenir (scripts/bootstrap.mjs). Lokal: npm run bootstrap
|
||||
*/
|
||||
import mongoose from "mongoose";
|
||||
import { getDb, getMongoClient } from "@/lib/db/mongo";
|
||||
import { env } from "@/lib/env";
|
||||
import { runMigrations } from "./migrations";
|
||||
import { closeSeedConnections, runSeed } from "./seed/run";
|
||||
|
||||
async function main() {
|
||||
const e = env();
|
||||
const hello = await getDb().admin().command({ hello: 1 });
|
||||
if (!hello.setName) {
|
||||
throw new Error(
|
||||
"MongoDB replica set olarak çalışmıyor; transaction'lı yazmalar başarısız olur. " +
|
||||
"docs/DEPLOY.md › 'MongoDB replica set' adımlarını uygulayın.",
|
||||
);
|
||||
}
|
||||
if (!hello.isWritablePrimary) throw new Error("MongoDB düğümü PRIMARY değil (yazılamaz).");
|
||||
console.log(`• MongoDB ${hello.setName} PRIMARY`);
|
||||
|
||||
if (e.NODE_ENV === "production" && e.SEED_ADMIN_EMAIL && e.SEED_ADMIN_PASSWORD) {
|
||||
// Production'da seed yalnızca migration + admin yapar (örnek veri oluşturulmaz).
|
||||
await runSeed();
|
||||
} else {
|
||||
await runMigrations();
|
||||
if (e.NODE_ENV === "production")
|
||||
console.log("• SEED_ADMIN_* tanımlı değil; admin kontrolü atlandı.");
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err: unknown) => {
|
||||
console.error(`✗ Başlatma hatası: ${err instanceof Error ? err.message : err}`);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await closeSeedConnections();
|
||||
await mongoose.disconnect();
|
||||
await getMongoClient().close();
|
||||
});
|
||||
+10
-2
@@ -4,9 +4,17 @@
|
||||
*/
|
||||
import mongoose from "mongoose";
|
||||
import { getMongoClient } from "@/lib/db/mongo";
|
||||
import { runMigrations } from "./migrations";
|
||||
import { revertMigration, runMigrations } from "./migrations";
|
||||
|
||||
runMigrations()
|
||||
// npm run migrate → bekleyen migration'lar
|
||||
// npm run migrate -- --down <ad> → bir migration'ı geri al
|
||||
const downIndex = process.argv.indexOf("--down");
|
||||
const task =
|
||||
downIndex >= 0
|
||||
? revertMigration(process.argv[downIndex + 1] ?? "")
|
||||
: runMigrations().then(() => {});
|
||||
|
||||
task
|
||||
.catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { StoreModel } from "@/models/store";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "013-stores-products",
|
||||
description: "mağaza ve ürün (marka/paket) koleksiyonlarının index'leri",
|
||||
async up() {
|
||||
await StoreModel.createIndexes();
|
||||
await ProductModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ProductPriceModel } from "@/models/product-price";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "014-product-prices",
|
||||
description: "ürün fiyat kayıtları (productPrices) index'leri",
|
||||
async up() {
|
||||
await ProductPriceModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ShoppingListModel } from "@/models/shopping-list";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "015-shopping-lists",
|
||||
description: "alışveriş listeleri (shoppingLists) index'leri",
|
||||
async up() {
|
||||
await ShoppingListModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defaultKakeibo } from "@/features/categories/service";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/**
|
||||
* Kakeibo dört grup (Faz 16): gider kategorilerine `kakeiboGroup` ve `bankCost`. Varsayılan
|
||||
* kategoriler beceri eşlemesine göre doldurulur; kullanıcının kategorileri grupsuz kalır (arayüz
|
||||
* "grup seç" uyarısı gösterir). Alanı zaten olan kategoriye dokunulmaz. Hiçbir kategori silinmez.
|
||||
* Geri alma: alanlar kaldırılır (npm run migrate -- --down 016-kakeibo-groups).
|
||||
*/
|
||||
export const migration: Migration = {
|
||||
name: "016-kakeibo-groups",
|
||||
description: "gider kategorilerine Kakeibo grubu ve banka maliyeti; varsayılanlar dolduruldu",
|
||||
async up() {
|
||||
const defaults = await CategoryModel.find({
|
||||
kind: "expense",
|
||||
defaultKey: { $type: "string" },
|
||||
kakeiboGroup: { $exists: false },
|
||||
})
|
||||
.select({ defaultKey: 1 })
|
||||
.lean<{ _id: unknown; defaultKey: string }[]>();
|
||||
const ops = defaults.map((c) => ({
|
||||
updateOne: { filter: { _id: c._id }, update: { $set: defaultKakeibo(c.defaultKey) } },
|
||||
}));
|
||||
if (ops.length) await CategoryModel.bulkWrite(ops);
|
||||
await CategoryModel.updateMany(
|
||||
{ kakeiboGroup: { $exists: false } },
|
||||
{ $set: { kakeiboGroup: null, bankCost: false } },
|
||||
);
|
||||
},
|
||||
async down() {
|
||||
await CategoryModel.updateMany({}, { $unset: { kakeiboGroup: "", bankCost: "" } });
|
||||
await TransactionModel.updateMany(
|
||||
{ kakeiboGroup: { $exists: true } },
|
||||
{ $unset: { kakeiboGroup: "" } },
|
||||
);
|
||||
await TransactionModel.updateMany(
|
||||
{ "splits.kakeiboGroup": { $exists: true } },
|
||||
{ $unset: { "splits.$[].kakeiboGroup": "" } },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ImportBatchModel } from "@/models/import-batch";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Ekstre içe aktarma (Faz 17): importBatches index'i ve işlemlerde tekrar engeli index'i. */
|
||||
export const migration: Migration = {
|
||||
name: "017-imports",
|
||||
description: "içe aktarma partileri ve işlemlerde tekrar engeli (importKey) index'leri",
|
||||
async up() {
|
||||
await ImportBatchModel.createIndexes();
|
||||
await TransactionModel.createIndexes();
|
||||
},
|
||||
async down(db) {
|
||||
await db
|
||||
.collection("transactions")
|
||||
.dropIndex("householdId_1_importKey_1")
|
||||
.catch(() => {});
|
||||
},
|
||||
};
|
||||
@@ -12,6 +12,11 @@ import { migration as m009 } from "./009-assets";
|
||||
import { migration as m010 } from "./010-budgets";
|
||||
import { migration as m011 } from "./011-stock";
|
||||
import { migration as m012 } from "./012-stock-counts";
|
||||
import { migration as m013 } from "./013-stores-products";
|
||||
import { migration as m014 } from "./014-product-prices";
|
||||
import { migration as m015 } from "./015-shopping-lists";
|
||||
import { migration as m016 } from "./016-kakeibo-groups";
|
||||
import { migration as m017 } from "./017-imports";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||
@@ -28,6 +33,11 @@ export const MIGRATIONS: Migration[] = [
|
||||
m010,
|
||||
m011,
|
||||
m012,
|
||||
m013,
|
||||
m014,
|
||||
m015,
|
||||
m016,
|
||||
m017,
|
||||
];
|
||||
|
||||
const COLLECTION = "migrations";
|
||||
@@ -57,3 +67,23 @@ export async function runMigrations(log: (line: string) => void = console.log):
|
||||
if (!ran.length) log("• Bekleyen migration yok.");
|
||||
return ran;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bir migration'ı geri alır (yalnızca `down` tanımlıysa) ve kaydını siler; bir sonraki
|
||||
* `runMigrations` onu yeniden uygular. Kullanım: npm run migrate -- --down 016-kakeibo-groups
|
||||
*/
|
||||
export async function revertMigration(
|
||||
name: string,
|
||||
log: (line: string) => void = console.log,
|
||||
): Promise<void> {
|
||||
const m = MIGRATIONS.find((x) => x.name === name);
|
||||
if (!m) throw new Error(`Migration bulunamadı: ${name}`);
|
||||
if (!m.down) throw new Error(`${name} geri alınamaz (down tanımlı değil).`);
|
||||
const db = getDb();
|
||||
await connectMongoose();
|
||||
const col = db.collection<{ name: string }>(COLLECTION);
|
||||
if (!(await col.findOne({ name }))) throw new Error(`${name} uygulanmamış.`);
|
||||
await m.down(db);
|
||||
await col.deleteOne({ name });
|
||||
log(`↩ Geri alındı: ${name}`);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Db } from "mongodb";
|
||||
|
||||
/** Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). Geri alma yok; ileri yönlü. */
|
||||
/**
|
||||
* Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). İleri yönlü; Faz 16'dan
|
||||
* itibaren veri değiştirenler `down` ile geri alınabilir (npm run migrate -- --down <ad>).
|
||||
*/
|
||||
export type Migration = {
|
||||
name: string;
|
||||
description: string;
|
||||
up: (db: Db) => Promise<void>;
|
||||
down?: (db: Db) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -35,6 +35,9 @@ describe("seed", () => {
|
||||
expect(summary.created.assets).toBe(4);
|
||||
expect(summary.created.budgets).toBe(2);
|
||||
expect(summary.created.stock).toBe(19);
|
||||
expect(summary.created.stores).toBe(8);
|
||||
expect(summary.created.products).toBe(22);
|
||||
expect(summary.created.prices).toBeGreaterThan(200);
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
||||
});
|
||||
@@ -55,6 +58,10 @@ describe("seed", () => {
|
||||
assets: 0,
|
||||
budgets: 0,
|
||||
stock: 0,
|
||||
stores: 0,
|
||||
products: 0,
|
||||
prices: 0,
|
||||
shoppingLists: 0,
|
||||
});
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { runMigrations } from "../migrations";
|
||||
import { seedContacts } from "./contacts";
|
||||
import { seedAssets } from "./assets";
|
||||
import { seedBudgets } from "./budgets";
|
||||
import { seedProductPrices, seedShopping, seedShoppingLists } from "./shopping";
|
||||
import { seedStock } from "./stock";
|
||||
import { seedInKind } from "./in-kind";
|
||||
import { seedInstallments } from "./installments";
|
||||
@@ -57,6 +58,10 @@ export type SeedSummary = {
|
||||
assets: number;
|
||||
budgets: number;
|
||||
stock: number;
|
||||
stores: number;
|
||||
products: number;
|
||||
prices: number;
|
||||
shoppingLists: number;
|
||||
};
|
||||
accounts: SeedAccountRow[];
|
||||
};
|
||||
@@ -84,6 +89,10 @@ const DOMAIN_COLLECTIONS = [
|
||||
"productTypes",
|
||||
"stockMovements",
|
||||
"stockCounts",
|
||||
"stores",
|
||||
"products",
|
||||
"productPrices",
|
||||
"shoppingLists",
|
||||
];
|
||||
|
||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||
@@ -140,6 +149,10 @@ export async function runSeed({
|
||||
assets: 0,
|
||||
budgets: 0,
|
||||
stock: 0,
|
||||
stores: 0,
|
||||
products: 0,
|
||||
prices: 0,
|
||||
shoppingLists: 0,
|
||||
},
|
||||
accounts: [],
|
||||
};
|
||||
@@ -324,6 +337,16 @@ export async function runSeed({
|
||||
if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`);
|
||||
summary.created.stock = await seedStock(householdIds, userIdMap);
|
||||
if (summary.created.stock) log(`✓ Örnek stok ürün tipleri: ${summary.created.stock}`);
|
||||
const shopping = await seedShopping(householdIds, userIdMap);
|
||||
summary.created.stores = shopping.stores;
|
||||
summary.created.products = shopping.products;
|
||||
if (shopping.stores || shopping.products)
|
||||
log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`);
|
||||
summary.created.prices = await seedProductPrices(householdIds, userIdMap);
|
||||
if (summary.created.prices) log(`✓ Örnek ürün fiyatları: ${summary.created.prices}`);
|
||||
summary.created.shoppingLists = await seedShoppingLists(householdIds, userIdMap);
|
||||
if (summary.created.shoppingLists)
|
||||
log(`✓ Örnek alışveriş listeleri: ${summary.created.shoppingLists}`);
|
||||
|
||||
// 7) Geliştirme hesapları tablosu
|
||||
const all = [
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
import { Types } from "mongoose";
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { ProductPriceModel } from "@/models/product-price";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { ShoppingListModel } from "@/models/shopping-list";
|
||||
import { StoreModel } from "@/models/store";
|
||||
import type { MealCardProvider } from "@/shared/accounts";
|
||||
import { autoConvertible } from "@/shared/products";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import { foldTr } from "@/shared/search";
|
||||
import type { StoreKind } from "@/shared/stores";
|
||||
|
||||
/**
|
||||
* Örnek mağazalar ve ürünler (Faz 12). Çakır Ailesi (Kadıköy) için marketler, pazar ve online
|
||||
* platform; stok ürün tiplerine bağlı marka/paketler. Elle dönüşüm gereken örnekler: deterjan
|
||||
* (kg → yıkama), tablet paketi (paket → tablet), diş macunu/şampuan (ml → adet). Görsel yok.
|
||||
* Idempotent (`seedRef`). Miktarlar binde bir, tutarlar kuruş.
|
||||
*/
|
||||
|
||||
type SeedStore = {
|
||||
ref: string;
|
||||
name: string;
|
||||
kind: StoreKind;
|
||||
branch?: string;
|
||||
distanceM?: number;
|
||||
carry: number;
|
||||
deliveryFee?: number;
|
||||
freeDeliveryMin?: number;
|
||||
mealCards?: MealCardProvider[];
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export const STORES: SeedStore[] = [
|
||||
{
|
||||
ref: "migros",
|
||||
name: "Migros",
|
||||
kind: "market",
|
||||
branch: "Bahariye",
|
||||
distanceM: 1_200,
|
||||
carry: 2,
|
||||
mealCards: ["pluxee", "multinet", "edenred", "setcard"],
|
||||
},
|
||||
{ ref: "a101", name: "A101", kind: "market", branch: "Moda", distanceM: 400, carry: 1 },
|
||||
{ ref: "bim", name: "BİM", kind: "market", branch: "Yeldeğirmeni", distanceM: 650, carry: 2 },
|
||||
{
|
||||
ref: "sok",
|
||||
name: "Şok",
|
||||
kind: "market",
|
||||
branch: "Rasimpaşa",
|
||||
distanceM: 300,
|
||||
carry: 1,
|
||||
mealCards: ["multinet", "pluxee"],
|
||||
},
|
||||
{
|
||||
ref: "carrefoursa",
|
||||
name: "CarrefourSA",
|
||||
kind: "market",
|
||||
branch: "Acıbadem",
|
||||
distanceM: 3_800,
|
||||
carry: 4,
|
||||
mealCards: ["pluxee", "multinet", "edenred", "setcard", "metropol"],
|
||||
note: "Hafta sonu arabayla; büyük alışveriş.",
|
||||
},
|
||||
{
|
||||
ref: "sali-pazari",
|
||||
name: "Salı Pazarı",
|
||||
kind: "bazaar",
|
||||
branch: "Kadıköy",
|
||||
distanceM: 1_900,
|
||||
carry: 4,
|
||||
note: "Salı ve cuma; sebze-meyve.",
|
||||
},
|
||||
{
|
||||
ref: "getir-buyuk",
|
||||
name: "Getir Büyük",
|
||||
kind: "online",
|
||||
carry: 1,
|
||||
deliveryFee: 39_90,
|
||||
freeDeliveryMin: 750_00,
|
||||
mealCards: ["multinet"],
|
||||
},
|
||||
{
|
||||
ref: "metro",
|
||||
name: "Metro Toptancı",
|
||||
kind: "market",
|
||||
branch: "Kozyatağı",
|
||||
distanceM: 9_500,
|
||||
carry: 5,
|
||||
},
|
||||
];
|
||||
|
||||
type SeedProduct = {
|
||||
ref: string;
|
||||
/** Stok seed'indeki ürün tipi `seedRef`'i. */
|
||||
type: string;
|
||||
brand: string;
|
||||
name?: string;
|
||||
barcode?: string;
|
||||
qty: number;
|
||||
unit: string;
|
||||
/** Elle dönüşüm (bir paket = bu kadar ürün tipi birimi). */
|
||||
typeQty?: number;
|
||||
};
|
||||
|
||||
const K = 1000;
|
||||
|
||||
export const PRODUCTS: SeedProduct[] = [
|
||||
{
|
||||
ref: "ariel-5kg",
|
||||
type: "toz-deterjan",
|
||||
brand: "Ariel",
|
||||
name: "Matik Dağ Esintisi",
|
||||
qty: 5 * K,
|
||||
unit: "kg",
|
||||
typeQty: 33 * K,
|
||||
barcode: "8690000100015",
|
||||
},
|
||||
{
|
||||
ref: "omo-6kg",
|
||||
type: "toz-deterjan",
|
||||
brand: "Omo",
|
||||
name: "Matik Active Fresh",
|
||||
qty: 6 * K,
|
||||
unit: "kg",
|
||||
typeQty: 40 * K,
|
||||
},
|
||||
{
|
||||
ref: "bingo-4kg",
|
||||
type: "toz-deterjan",
|
||||
brand: "Bingo",
|
||||
name: "Matik",
|
||||
qty: 4 * K,
|
||||
unit: "kg",
|
||||
typeQty: 26 * K,
|
||||
},
|
||||
{
|
||||
ref: "fairy-50",
|
||||
type: "tablet-deterjan",
|
||||
brand: "Fairy",
|
||||
name: "Platinum Plus",
|
||||
qty: 1 * K,
|
||||
unit: "paket",
|
||||
typeQty: 50 * K,
|
||||
barcode: "8690000100022",
|
||||
},
|
||||
{
|
||||
ref: "finish-40",
|
||||
type: "tablet-deterjan",
|
||||
brand: "Finish",
|
||||
name: "Quantum",
|
||||
qty: 40 * K,
|
||||
unit: "tablet",
|
||||
},
|
||||
{
|
||||
ref: "yayla-mercimek",
|
||||
type: "kirmizi-mercimek",
|
||||
brand: "Yayla",
|
||||
name: "Kırmızı Mercimek",
|
||||
qty: 1 * K,
|
||||
unit: "kg",
|
||||
},
|
||||
{
|
||||
ref: "reis-mercimek",
|
||||
type: "kirmizi-mercimek",
|
||||
brand: "Reis",
|
||||
name: "Kırmızı Mercimek",
|
||||
qty: 1 * K,
|
||||
unit: "kg",
|
||||
},
|
||||
{
|
||||
ref: "duru-mercimek",
|
||||
type: "kirmizi-mercimek",
|
||||
brand: "Duru",
|
||||
name: "Kırmızı Mercimek",
|
||||
qty: 2_500,
|
||||
unit: "kg",
|
||||
},
|
||||
{ ref: "yayla-baldo", type: "pirinc", brand: "Yayla", name: "Baldo", qty: 2_500, unit: "kg" },
|
||||
{ ref: "reis-baldo", type: "pirinc", brand: "Reis", name: "Baldo", qty: 1 * K, unit: "kg" },
|
||||
{
|
||||
ref: "pinar-sut",
|
||||
type: "sut",
|
||||
brand: "Pınar",
|
||||
name: "Tam Yağlı Süt",
|
||||
qty: 1 * K,
|
||||
unit: "lt",
|
||||
barcode: "8690000100039",
|
||||
},
|
||||
{ ref: "sutas-sut", type: "sut", brand: "Sütaş", name: "Tam Yağlı Süt", qty: 1 * K, unit: "lt" },
|
||||
{
|
||||
ref: "icim-sut-500",
|
||||
type: "sut",
|
||||
brand: "İçim",
|
||||
name: "Yarım Yağlı Süt",
|
||||
qty: 500 * K,
|
||||
unit: "ml",
|
||||
},
|
||||
{
|
||||
ref: "keskinoglu-30",
|
||||
type: "yumurta",
|
||||
brand: "Keskinoğlu",
|
||||
name: "L Boy 30'lu",
|
||||
qty: 30 * K,
|
||||
unit: "adet",
|
||||
},
|
||||
{
|
||||
ref: "koy-yumurta-15",
|
||||
type: "yumurta",
|
||||
brand: "Köy Yumurtası",
|
||||
name: "15'li",
|
||||
qty: 15 * K,
|
||||
unit: "adet",
|
||||
},
|
||||
{
|
||||
ref: "pinar-peynir",
|
||||
type: "beyaz-peynir",
|
||||
brand: "Pınar",
|
||||
name: "Beyaz Peynir",
|
||||
qty: 500 * K,
|
||||
unit: "g",
|
||||
},
|
||||
{
|
||||
ref: "yumos-1440",
|
||||
type: "yumusatici",
|
||||
brand: "Yumoş",
|
||||
name: "Extra Lilyum",
|
||||
qty: 1_440 * K,
|
||||
unit: "ml",
|
||||
},
|
||||
{
|
||||
ref: "selpak-32",
|
||||
type: "tuvalet-kagidi",
|
||||
brand: "Selpak",
|
||||
name: "32'li",
|
||||
qty: 32 * K,
|
||||
unit: "rulo",
|
||||
},
|
||||
{
|
||||
ref: "solo-24",
|
||||
type: "tuvalet-kagidi",
|
||||
brand: "Solo",
|
||||
name: "24'lü",
|
||||
qty: 24 * K,
|
||||
unit: "rulo",
|
||||
},
|
||||
{
|
||||
ref: "colgate-75",
|
||||
type: "dis-macunu",
|
||||
brand: "Colgate",
|
||||
name: "Total 75 ml",
|
||||
qty: 75 * K,
|
||||
unit: "ml",
|
||||
typeQty: 1 * K,
|
||||
},
|
||||
{
|
||||
ref: "sensodyne-75",
|
||||
type: "dis-macunu",
|
||||
brand: "Sensodyne",
|
||||
name: "Repair 75 ml",
|
||||
qty: 75 * K,
|
||||
unit: "ml",
|
||||
typeQty: 1 * K,
|
||||
},
|
||||
{
|
||||
ref: "elidor-500",
|
||||
type: "sampuan",
|
||||
brand: "Elidor",
|
||||
name: "500 ml",
|
||||
qty: 500 * K,
|
||||
unit: "ml",
|
||||
typeQty: 1 * K,
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedShopping(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
users: Map<string, string>,
|
||||
): Promise<{ stores: number; products: number }> {
|
||||
const actor = new Types.ObjectId(users.get("ayse")!);
|
||||
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||
let stores = 0;
|
||||
for (const s of STORES) {
|
||||
if (await StoreModel.exists({ householdId, seedRef: s.ref })) continue;
|
||||
await StoreModel.create({
|
||||
householdId,
|
||||
name: s.name,
|
||||
nameKey: foldTr(s.name),
|
||||
kind: s.kind,
|
||||
branch: s.branch ?? "",
|
||||
distanceM: s.kind === "online" ? null : (s.distanceM ?? null),
|
||||
carryDifficulty: s.carry,
|
||||
deliveryFeeMinor: s.kind === "online" ? (s.deliveryFee ?? null) : null,
|
||||
freeDeliveryMinMinor: s.kind === "online" ? (s.freeDeliveryMin ?? null) : null,
|
||||
mealCardAccepted: Boolean(s.mealCards?.length),
|
||||
mealCardProviders: s.mealCards ?? [],
|
||||
note: s.note ?? "",
|
||||
seedRef: s.ref,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
stores++;
|
||||
}
|
||||
|
||||
const types = await ProductTypeModel.find({ householdId, seedRef: { $type: "string" } })
|
||||
.select({ seedRef: 1, unit: 1 })
|
||||
.lean<{ _id: Types.ObjectId; seedRef: string; unit: string }[]>();
|
||||
const typeByRef = new Map(types.map((x) => [x.seedRef, x]));
|
||||
let products = 0;
|
||||
for (const p of PRODUCTS) {
|
||||
const type = typeByRef.get(p.type);
|
||||
if (!type || (await ProductModel.exists({ householdId, seedRef: p.ref }))) continue;
|
||||
if (!autoConvertible(p.unit, type.unit) && !p.typeQty)
|
||||
throw new Error(`Seed ürünü ${p.ref}: ${p.unit} → ${type.unit} dönüşümü eksik`);
|
||||
await ProductModel.create({
|
||||
householdId,
|
||||
productTypeId: type._id,
|
||||
brand: p.brand,
|
||||
name: p.name ?? "",
|
||||
searchKey: foldTr(`${p.brand} ${p.name ?? ""}`),
|
||||
barcode: p.barcode ?? null,
|
||||
packageQuantity: p.qty,
|
||||
packageUnit: p.unit,
|
||||
typeQuantity: autoConvertible(p.unit, type.unit) ? null : p.typeQty,
|
||||
seedRef: p.ref,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
products++;
|
||||
}
|
||||
return { stores, products };
|
||||
}
|
||||
|
||||
// --- Fiyatlar (Faz 12b) ---
|
||||
|
||||
/** Bugünkü paket fiyatı (kuruş, Kasım 2026 tahmini). */
|
||||
const CURRENT_PRICE: Record<string, number> = {
|
||||
"ariel-5kg": 519_90,
|
||||
"omo-6kg": 559_90,
|
||||
"bingo-4kg": 329_90,
|
||||
"fairy-50": 479_90,
|
||||
"finish-40": 419_90,
|
||||
"yayla-mercimek": 84_90,
|
||||
"reis-mercimek": 79_90,
|
||||
"duru-mercimek": 189_90,
|
||||
"yayla-baldo": 259_90,
|
||||
"reis-baldo": 109_90,
|
||||
"pinar-sut": 41_90,
|
||||
"sutas-sut": 39_90,
|
||||
"icim-sut-500": 23_90,
|
||||
"keskinoglu-30": 174_90,
|
||||
"koy-yumurta-15": 119_90,
|
||||
"pinar-peynir": 209_90,
|
||||
"yumos-1440": 139_90,
|
||||
"selpak-32": 329_90,
|
||||
"solo-24": 229_90,
|
||||
"colgate-75": 84_90,
|
||||
"sensodyne-75": 159_90,
|
||||
"elidor-500": 119_90,
|
||||
};
|
||||
|
||||
/** Mağaza fiyat çarpanı (indirim marketi ucuz, online pahalı). */
|
||||
const STORE_FACTOR: Record<string, number> = {
|
||||
migros: 1.06,
|
||||
a101: 0.94,
|
||||
bim: 0.91,
|
||||
sok: 0.96,
|
||||
carrefoursa: 1.0,
|
||||
"getir-buyuk": 1.12,
|
||||
metro: 0.88,
|
||||
};
|
||||
const PRICE_STORES = Object.keys(STORE_FACTOR);
|
||||
/** Aylık fiyat artışı. */
|
||||
const MONTHLY_INFLATION = 0.025;
|
||||
|
||||
/** x,90 biçimine yuvarla (ör. 87,34 → 86,90 / 87,90). */
|
||||
const ninety = (minor: number) => Math.max(90, Math.round((minor - 90) / 100) * 100 + 90);
|
||||
|
||||
type SeedPrice = {
|
||||
ref: string;
|
||||
product: string;
|
||||
store: string;
|
||||
daysAgo: number;
|
||||
priceMinor: number;
|
||||
isPromo?: boolean;
|
||||
promoUntilDaysAgo?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Her ürün 3 mağazada (ürün sırasına göre dönüşümlü), son 6 ayda ~45 günde bir fiyat.
|
||||
* Kampanyalar: Ariel @ Migros hâlâ süren (%20), Pınar süt @ A101 süresi bitmiş (önceki normal
|
||||
* fiyata dönülür). Reis mercimek @ Metro yalnızca eski kayıtlarla ("eski fiyat").
|
||||
*/
|
||||
export function seedPrices(): SeedPrice[] {
|
||||
const out: SeedPrice[] = [];
|
||||
PRODUCTS.forEach((p, i) => {
|
||||
const current = CURRENT_PRICE[p.ref]!;
|
||||
const stores = [0, 2, 4].map((k) => PRICE_STORES[(i + k) % PRICE_STORES.length]!);
|
||||
stores.forEach((store, si) => {
|
||||
for (let step = 0; step < 4; step++) {
|
||||
const daysAgo = 170 - step * 45 - si * 7 - (i % 5);
|
||||
if (daysAgo < 0) continue;
|
||||
if (p.ref === "reis-mercimek" && store === "metro" && daysAgo < 100) continue;
|
||||
const months = daysAgo / 30;
|
||||
const price = (current * STORE_FACTOR[store]!) / (1 + MONTHLY_INFLATION) ** months;
|
||||
out.push({
|
||||
ref: `${p.ref}:${store}:${step}`,
|
||||
product: p.ref,
|
||||
store,
|
||||
daysAgo,
|
||||
priceMinor: ninety(price),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
out.push(
|
||||
{
|
||||
ref: "ariel-5kg:migros:promo",
|
||||
product: "ariel-5kg",
|
||||
store: "migros",
|
||||
daysAgo: 4,
|
||||
priceMinor: ninety(CURRENT_PRICE["ariel-5kg"]! * 1.06 * 0.8),
|
||||
isPromo: true,
|
||||
promoUntilDaysAgo: -6,
|
||||
},
|
||||
{
|
||||
ref: "pinar-sut:a101:normal",
|
||||
product: "pinar-sut",
|
||||
store: "a101",
|
||||
daysAgo: 30,
|
||||
priceMinor: ninety(CURRENT_PRICE["pinar-sut"]! * 0.94),
|
||||
},
|
||||
{
|
||||
ref: "pinar-sut:a101:promo",
|
||||
product: "pinar-sut",
|
||||
store: "a101",
|
||||
daysAgo: 12,
|
||||
priceMinor: ninety(CURRENT_PRICE["pinar-sut"]! * 0.75),
|
||||
isPromo: true,
|
||||
promoUntilDaysAgo: 3,
|
||||
},
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function seedProductPrices(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
users: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const actor = new Types.ObjectId(users.get("ayse")!);
|
||||
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||
const [products, stores] = await Promise.all([
|
||||
ProductModel.find({ householdId, seedRef: { $type: "string" } })
|
||||
.select({ seedRef: 1 })
|
||||
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
|
||||
StoreModel.find({ householdId, seedRef: { $type: "string" } })
|
||||
.select({ seedRef: 1 })
|
||||
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
|
||||
]);
|
||||
const productByRef = new Map(products.map((p) => [p.seedRef, p._id]));
|
||||
const storeByRef = new Map(stores.map((x) => [x.seedRef, x._id]));
|
||||
const existing = new Set(
|
||||
(
|
||||
await ProductPriceModel.find({ householdId, seedRef: { $type: "string" } })
|
||||
.select({ seedRef: 1 })
|
||||
.lean<{ seedRef: string }[]>()
|
||||
).map((x) => x.seedRef),
|
||||
);
|
||||
const today = todayIstanbul();
|
||||
const base = Date.now() - 200 * 86_400_000;
|
||||
const docs = seedPrices()
|
||||
.filter((x) => !existing.has(x.ref) && productByRef.has(x.product) && storeByRef.has(x.store))
|
||||
.sort((a, b) => b.daysAgo - a.daysAgo)
|
||||
.map((x, i) => {
|
||||
const at = new Date(base + i * 1000);
|
||||
return {
|
||||
householdId,
|
||||
productId: productByRef.get(x.product)!,
|
||||
storeId: storeByRef.get(x.store)!,
|
||||
date: addDays(today, -x.daysAgo),
|
||||
priceMinor: x.priceMinor,
|
||||
isPromo: x.isPromo ?? false,
|
||||
promoUntil: x.promoUntilDaysAgo === undefined ? null : addDays(today, -x.promoUntilDaysAgo),
|
||||
source: "manual",
|
||||
note: "",
|
||||
deletedAt: null,
|
||||
seedRef: x.ref,
|
||||
createdBy: actor,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
});
|
||||
if (docs.length) await ProductPriceModel.collection.insertMany(docs);
|
||||
return docs.length;
|
||||
}
|
||||
|
||||
type SeedListItem =
|
||||
| { type: string; qty?: number }
|
||||
| { product: string; qty?: number }
|
||||
| { text: string; qty?: number; unit?: string; note?: string };
|
||||
|
||||
/**
|
||||
* Örnek alışveriş listeleri (Faz 13). Eşiğin altındaki ürünler (mercimek, tablet deterjan, diş
|
||||
* macunu) listelere eklenmez; liste sayfasında öneri olarak görünür. Domatesin fiyatı yok:
|
||||
* akıllı öneride "fiyatı bilinmeyen" örneği.
|
||||
*/
|
||||
const SHOPPING_LISTS: { ref: string; name: string; note?: string; items: SeedListItem[] }[] = [
|
||||
{
|
||||
ref: "haftalik",
|
||||
name: "Haftalık market",
|
||||
items: [
|
||||
{ type: "sut", qty: 4 * K },
|
||||
{ type: "yumurta", qty: 30 * K },
|
||||
{ type: "pirinc", qty: 2_500 },
|
||||
{ type: "beyaz-peynir", qty: 500 },
|
||||
{ type: "domates", qty: 2 * K },
|
||||
{ type: "toz-deterjan", qty: 40 * K },
|
||||
{ type: "tuvalet-kagidi", qty: 32 * K },
|
||||
{ product: "yumos-1440", qty: 1 * K },
|
||||
{ text: "Doğum günü mumu", qty: 1 * K, unit: "paket", note: "Rakam mum: 7" },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "ay-sonu",
|
||||
name: "Ay sonu toptan",
|
||||
note: "Metro'ya arabayla.",
|
||||
items: [{ type: "camasir-suyu", qty: 4 * K }],
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedShoppingLists(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
users: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const actor = new Types.ObjectId(users.get("ayse")!);
|
||||
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||
const [types, products] = await Promise.all([
|
||||
ProductTypeModel.find({ householdId, seedRef: { $type: "string" } })
|
||||
.select({ seedRef: 1 })
|
||||
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
|
||||
ProductModel.find({ householdId, seedRef: { $type: "string" } })
|
||||
.select({ seedRef: 1 })
|
||||
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
|
||||
]);
|
||||
const typeByRef = new Map(types.map((x) => [x.seedRef, x._id]));
|
||||
const productByRef = new Map(products.map((x) => [x.seedRef, x._id]));
|
||||
let created = 0;
|
||||
for (const l of SHOPPING_LISTS) {
|
||||
if (await ShoppingListModel.exists({ householdId, seedRef: l.ref })) continue;
|
||||
const items = l.items.flatMap((i): Record<string, unknown>[] => {
|
||||
const common = {
|
||||
_id: new Types.ObjectId(),
|
||||
quantity: i.qty ?? null,
|
||||
source: "manual",
|
||||
addedBy: actor,
|
||||
addedAt: new Date(),
|
||||
};
|
||||
if ("type" in i) {
|
||||
const id = typeByRef.get(i.type);
|
||||
return id ? [{ ...common, kind: "type", productTypeId: id }] : [];
|
||||
}
|
||||
if ("product" in i) {
|
||||
const id = productByRef.get(i.product);
|
||||
return id ? [{ ...common, kind: "product", productId: id }] : [];
|
||||
}
|
||||
return [{ ...common, kind: "text", text: i.text, unit: i.unit ?? "", note: i.note ?? "" }];
|
||||
});
|
||||
await ShoppingListModel.create({
|
||||
householdId,
|
||||
name: l.name,
|
||||
note: l.note ?? "",
|
||||
items,
|
||||
seedRef: l.ref,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
+16
-1
@@ -33,6 +33,8 @@ type SeedProduct = {
|
||||
waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" };
|
||||
/** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */
|
||||
stopRefillDaysAgo?: number;
|
||||
/** Ağır/hacimli (akıllı öneride taşıma cezası). */
|
||||
bulky?: boolean;
|
||||
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
|
||||
countDrift?: { everyCounts: number; pct: number };
|
||||
};
|
||||
@@ -184,6 +186,7 @@ export const PRODUCTS: SeedProduct[] = [
|
||||
},
|
||||
{
|
||||
ref: "toz-deterjan",
|
||||
bulky: true,
|
||||
category: "genel-temizlik.camasir",
|
||||
name: "Toz Deterjan",
|
||||
unit: "yıkama",
|
||||
@@ -195,6 +198,7 @@ export const PRODUCTS: SeedProduct[] = [
|
||||
},
|
||||
{
|
||||
ref: "yumusatici",
|
||||
bulky: true,
|
||||
category: "genel-temizlik.camasir",
|
||||
name: "Yumuşatıcı",
|
||||
unit: "lt",
|
||||
@@ -206,6 +210,7 @@ export const PRODUCTS: SeedProduct[] = [
|
||||
},
|
||||
{
|
||||
ref: "camasir-suyu",
|
||||
bulky: true,
|
||||
category: "genel-temizlik.genel",
|
||||
name: "Çamaşır Suyu",
|
||||
unit: "lt",
|
||||
@@ -217,6 +222,7 @@ export const PRODUCTS: SeedProduct[] = [
|
||||
},
|
||||
{
|
||||
ref: "tuvalet-kagidi",
|
||||
bulky: true,
|
||||
category: "genel-temizlik.genel",
|
||||
name: "Tuvalet Kağıdı",
|
||||
unit: "rulo",
|
||||
@@ -359,7 +365,15 @@ export async function seedStock(
|
||||
const today = todayIstanbul();
|
||||
let created = 0;
|
||||
for (const p of PRODUCTS) {
|
||||
if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) continue;
|
||||
if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) {
|
||||
// Faz 13 öncesi seed'lenmiş veritabanında hacimli işaretini tamamla.
|
||||
if (p.bulky)
|
||||
await ProductTypeModel.updateOne(
|
||||
{ householdId, seedRef: p.ref, bulky: { $exists: false } },
|
||||
{ $set: { bulky: true } },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const doc = await ProductTypeModel.create({
|
||||
householdId,
|
||||
categoryId: catByKey.get(p.category)!,
|
||||
@@ -370,6 +384,7 @@ export async function seedStock(
|
||||
thresholdPct: p.threshold ?? 20,
|
||||
location: p.location,
|
||||
step: p.step ?? K,
|
||||
bulky: p.bulky ?? false,
|
||||
seedRef: p.ref,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# BÜTÇEM duman testi (smoke test) — canlıya alma sonrası, yalnızca OKUMA yapar; veri değiştirmez.
|
||||
#
|
||||
# Kullanım:
|
||||
# scripts/smoke-test.sh https://butcem.ta1sph.website [beklenen-sürüm]
|
||||
# SMOKE_USER=admin SMOKE_PASSWORD='…' scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||
#
|
||||
# Gerekenler: bash, curl. Çıkış kodu: 0 = hepsi geçti, 1 = en az bir kontrol başarısız.
|
||||
set -uo pipefail
|
||||
|
||||
BASE="${1:?Kullanım: scripts/smoke-test.sh <adres> [beklenen-sürüm]}"
|
||||
BASE="${BASE%/}"
|
||||
EXPECT_VERSION="${2:-}"
|
||||
FAIL=0
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
|
||||
bad() { printf ' \033[31m✗\033[0m %s\n' "$1"; FAIL=1; }
|
||||
check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; }
|
||||
|
||||
echo "BÜTÇEM duman testi: $BASE"
|
||||
|
||||
echo "1) Sağlık kontrolü"
|
||||
HEALTH_CODE=$(curl -s -o "$TMP/health" -w '%{http_code}' "$BASE/api/health")
|
||||
HEALTH=$(cat "$TMP/health")
|
||||
echo " $HEALTH"
|
||||
check "GET /api/health 200" '[ "$HEALTH_CODE" = 200 ]'
|
||||
check "veritabanı erişilebilir" 'grep -q "\"db\":\"ok\"" <<<"$HEALTH"'
|
||||
check "MongoDB replica set (transaction için)" '! grep -q "\"replicaSet\":null" <<<"$HEALTH" && grep -q "\"replicaSet\":\"" <<<"$HEALTH"'
|
||||
if [ -n "$EXPECT_VERSION" ]; then
|
||||
check "sürüm $EXPECT_VERSION" 'grep -q "\"version\":\"$EXPECT_VERSION\"" <<<"$HEALTH"'
|
||||
fi
|
||||
|
||||
echo "2) Sayfalar ve güvenlik başlıkları"
|
||||
LOGIN_CODE=$(curl -s -D "$TMP/login-headers" -o /dev/null -w '%{http_code}' "$BASE/login")
|
||||
H=$(tr -d '\r' <"$TMP/login-headers" | tr 'A-Z' 'a-z')
|
||||
check "GET /login 200" '[ "$LOGIN_CODE" = 200 ]'
|
||||
check "Content-Security-Policy var" 'grep -q "^content-security-policy:" <<<"$H"'
|
||||
check "X-Frame-Options: DENY" 'grep -q "^x-frame-options: deny" <<<"$H"'
|
||||
check "X-Content-Type-Options: nosniff" 'grep -q "^x-content-type-options: nosniff" <<<"$H"'
|
||||
if [[ "$BASE" == https://* ]]; then
|
||||
check "Strict-Transport-Security var" 'grep -q "^strict-transport-security:" <<<"$H"'
|
||||
HTTP_URL="http://${BASE#https://}/login"
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$HTTP_URL")
|
||||
check "HTTP → HTTPS yönlendirmesi ($HTTP_CODE)" '[[ "$HTTP_CODE" =~ ^30[1278]$ ]]'
|
||||
fi
|
||||
|
||||
echo "3) Oturumsuz erişim engelleniyor"
|
||||
DASH=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$BASE/dashboard")
|
||||
check "/dashboard → giriş sayfasına yönlendirme ($DASH)" '[[ "$DASH" =~ ^30[27]\ .*/login ]]'
|
||||
FILE_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/files/000000000000000000000000")
|
||||
check "/api/files oturumsuz 401 ($FILE_CODE)" '[ "$FILE_CODE" = 401 ]'
|
||||
CSV=$(curl -s -o "$TMP/csv" -w '%{http_code}' "$BASE/raporlar/kategoriler/csv")
|
||||
check "rapor CSV'si oturumsuz veri vermiyor ($CSV)" '! grep -q "Toplam gidere oran" "$TMP/csv"'
|
||||
|
||||
if [ -n "${SMOKE_USER:-}" ] && [ -n "${SMOKE_PASSWORD:-}" ]; then
|
||||
echo "4) Giriş ($SMOKE_USER)"
|
||||
SIGN=$(curl -s -c "$TMP/jar" -o "$TMP/signin" -w '%{http_code}' -X POST "$BASE/api/auth/sign-in/username" \
|
||||
-H 'Content-Type: application/json' -H "Origin: $BASE" \
|
||||
--data "$(printf '{"username":"%s","password":"%s"}' "$SMOKE_USER" "$SMOKE_PASSWORD")")
|
||||
check "giriş 200 ($SIGN)" '[ "$SIGN" = 200 ]'
|
||||
PAGE=$(curl -s -b "$TMP/jar" -L -o /dev/null -w '%{http_code} %{url_effective}' "$BASE/dashboard")
|
||||
check "oturumla ana sayfa açılıyor ($PAGE)" '[[ "$PAGE" =~ ^200\ ]] && [[ ! "$PAGE" =~ /login ]]'
|
||||
curl -s -b "$TMP/jar" -X POST "$BASE/api/auth/sign-out" -H "Origin: $BASE" -o /dev/null
|
||||
else
|
||||
echo "4) Giriş testi atlandı (SMOKE_USER ve SMOKE_PASSWORD tanımlı değil)"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAIL" = 0 ]; then echo "Sonuç: tüm kontroller geçti."; else echo "Sonuç: başarısız kontrol var."; fi
|
||||
exit "$FAIL"
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ShoppingModeView } from "@/features/shopping/components/shopping-mode";
|
||||
import { getShoppingList } from "@/features/shopping/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.shopping.mode.title };
|
||||
|
||||
/** /alisveris/:id/mod — alışverişe çıkış: mağazaya göre gruplu kontrol listesi. */
|
||||
export default async function ShoppingModePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageProps<"/alisveris/[id]/mod">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [{ id }, query] = await Promise.all([params, searchParams]);
|
||||
const data = await getShoppingList(user.id, household.id, id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ShoppingModeView data={data} choice={query.plan === "single" ? "single" : "recommended"} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ShoppingListDetailView } from "@/features/shopping/components/list-detail";
|
||||
import { getShoppingList } from "@/features/shopping/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.shopping.title };
|
||||
|
||||
/** /alisveris/:id — liste kalemleri, stok önerileri ve akıllı mağaza önerisi. */
|
||||
export default async function ShoppingListPage({ params }: PageProps<"/alisveris/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const { id } = await params;
|
||||
const data = await getShoppingList(user.id, household.id, id);
|
||||
if (!data) notFound();
|
||||
return <ShoppingListDetailView data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CompletionForm } from "@/features/shopping/components/completion-form";
|
||||
import { getCompletionSheet } from "@/features/shopping/completion";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.shopping.complete.title };
|
||||
|
||||
/** /alisveris/:id/tamamla — alışveriş sonrası giriş: alınanlar, ödeme, fiş, alınmayanlar. */
|
||||
export default async function CompleteShoppingPage({
|
||||
params,
|
||||
}: PageProps<"/alisveris/[id]/tamamla">) {
|
||||
const { user, household } = await requireActiveHousehold("member");
|
||||
const { id } = await params;
|
||||
const sheet = await getCompletionSheet(user.id, household.id, id);
|
||||
if (!sheet) notFound();
|
||||
return <CompletionForm sheet={sheet} />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { ShoppingListsView } from "@/features/shopping/components/lists-view";
|
||||
import { listShoppingLists } from "@/features/shopping/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.shopping.title };
|
||||
|
||||
/** /alisveris — aktif ve tamamlanan alışveriş listeleri. */
|
||||
export default async function ShoppingPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await listShoppingLists(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.shopping.title} description={tr.shopping.description} />
|
||||
{!data.canWrite ? (
|
||||
<p className="text-sm text-muted-foreground">{tr.shopping.readOnly}</p>
|
||||
) : null}
|
||||
<ShoppingListsView
|
||||
active={data.active}
|
||||
completed={data.completed}
|
||||
suggestions={data.suggestions}
|
||||
canWrite={data.canWrite}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { BudgetView } from "@/features/budgets/components/budget-view";
|
||||
import { IncomePlanSection } from "@/features/budgets/components/income-plan";
|
||||
import { getIncomePlan } from "@/features/budgets/plan";
|
||||
import { getBudget } from "@/features/budgets/service";
|
||||
import { KakeiboBudgetCard } from "@/features/reports/components/kakeibo-card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
@@ -27,7 +28,13 @@ export default async function BudgetPage({ searchParams }: PageProps<"/butce">)
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.budget.title} description={tr.budget.description} />
|
||||
<BudgetView key={data.month} data={data} />
|
||||
<BudgetView
|
||||
key={data.month}
|
||||
data={data}
|
||||
summary={
|
||||
<KakeiboBudgetCard userId={user.id} householdId={household.id} month={data.month} />
|
||||
}
|
||||
/>
|
||||
<IncomePlanSection
|
||||
key={`income-${data.month}`}
|
||||
month={data.month}
|
||||
|
||||
@@ -3,6 +3,8 @@ 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 { Skeleton } from "@/components/ui/skeleton";
|
||||
import { OverviewCard } from "@/features/dashboard/components/overview-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";
|
||||
@@ -11,6 +13,7 @@ import { getBudget } from "@/features/budgets/service";
|
||||
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
||||
import { getNetWorth } from "@/features/net-worth/service";
|
||||
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
||||
import { ShoppingCard } from "@/features/shopping/components/shopping-card";
|
||||
import { LowStockCard } from "@/features/stock/components/low-stock-card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
@@ -83,7 +86,7 @@ async function SummaryCards({ userId, householdId }: { userId: string; household
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
export default async function DashboardPage({ searchParams }: PageProps<"/dashboard">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
return (
|
||||
<>
|
||||
@@ -103,12 +106,20 @@ export default async function DashboardPage() {
|
||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||
<SummaryCards userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<Skeleton className="h-96 rounded-xl" />}>
|
||||
<OverviewCard userId={user.id} householdId={household.id} searchParams={searchParams} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<DailyPlanCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<LowStockCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
|
||||
<Suspense fallback={null}>
|
||||
<LowStockCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<ShoppingCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ImportWizard } from "@/features/imports/components/import-wizard";
|
||||
import { getImportDraft } from "@/features/imports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.imports.title };
|
||||
|
||||
/** /islemler/ice-aktar/:id — aktarma taslağı ya da işlenmiş aktarma. */
|
||||
export default async function ImportPage({ params }: PageProps<"/islemler/ice-aktar/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("member");
|
||||
const { id } = await params;
|
||||
const draft = await getImportDraft(user.id, household.id, id);
|
||||
if (!draft) notFound();
|
||||
return <ImportWizard key={`${draft.id}-${draft.status}`} draft={draft} />;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ArrowLeft, ChevronRight } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UploadCard } from "@/features/imports/components/upload-card";
|
||||
import { listImports } from "@/features/imports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { formatDateTime } from "@/shared/format";
|
||||
|
||||
const t = tr.imports;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /islemler/ice-aktar — Kakeibo çalışma kitabı yükleme ve aktarma geçmişi. */
|
||||
export default async function ImportsPage() {
|
||||
const { user, household } = await requireActiveHousehold("member");
|
||||
const list = await listImports(user.id, household.id);
|
||||
if (!list) notFound();
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/islemler">
|
||||
<ArrowLeft />
|
||||
{tr.nav.transactions}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader title={t.title} description={t.description} />
|
||||
<UploadCard />
|
||||
<section aria-labelledby="imp-h" className="flex flex-col gap-2">
|
||||
<h2 id="imp-h" className="text-sm font-medium text-muted-foreground">
|
||||
{t.history}
|
||||
</h2>
|
||||
{list.length ? (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{list.map((b) => (
|
||||
<li key={b.id}>
|
||||
<Link
|
||||
href={`/islemler/ice-aktar/${b.id}`}
|
||||
className="flex items-center gap-3 p-3 text-sm hover:bg-accent/40"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{b.fileName}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{formatDateTime(b.createdAt)} · {t.rowsCount(b.rowCount)}
|
||||
{b.committed ? ` · ${t.committed(b.committed.transactions)}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant={b.status === "committed" ? "default" : "secondary"}>
|
||||
{t.statuses[b.status]}
|
||||
</Badge>
|
||||
<ChevronRight className="size-4 text-muted-foreground" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.empty}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { StoresView } from "@/features/stores/components/stores-view";
|
||||
import { listStores } from "@/features/stores/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.stores.title };
|
||||
|
||||
/** /magazalar — marketler, online platformlar ve pazarlar; uzaklık, taşıma ve zahmet skoru. */
|
||||
export default async function StoresPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await listStores(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.stores.title} description={tr.stores.description} />
|
||||
{!data.canWrite ? (
|
||||
<p className="text-sm text-muted-foreground">{tr.stores.readOnly}</p>
|
||||
) : null}
|
||||
<StoresView stores={data.stores} canWrite={data.canWrite} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getShoppingReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { parsePeriod } from "@/shared/reports";
|
||||
|
||||
/** GET /raporlar/alisveris/csv?donem=3|6|12&tablo=listeler|magazalar */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getShoppingReport(c.userId, c.householdId, parsePeriod(c.params.get("donem")));
|
||||
if (!r) return forbidden();
|
||||
const h = tr.reports.csvHeaders;
|
||||
const today = todayIstanbul();
|
||||
if (c.params.get("tablo") === "magazalar")
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.stores,
|
||||
r.stores.map((s) => [
|
||||
s.name,
|
||||
csvMoney(s.amountMinor),
|
||||
s.count,
|
||||
csvPct(s.sharePct),
|
||||
csvMoney(s.fromListsMinor),
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.stores(today),
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.lists,
|
||||
r.lists.map((l) => [
|
||||
l.date,
|
||||
l.name,
|
||||
csvMoney(l.summary.plannedMinor),
|
||||
csvMoney(l.summary.actualMinor),
|
||||
csvMoney(l.summary.diffMinor),
|
||||
l.summary.counts.same,
|
||||
l.summary.counts.substitute,
|
||||
l.summary.counts.skipped,
|
||||
l.summary.counts.extra,
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.lists(today),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
CsvButton,
|
||||
RangeNav,
|
||||
ReportFrame,
|
||||
ToolsEnd,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { ShoppingReportView } from "@/features/reports/components/shopping-waste-view";
|
||||
import { getShoppingReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { parsePeriod, REPORT_PERIODS } from "@/shared/reports";
|
||||
|
||||
const t = tr.reports.shopping;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/alisveris?donem=3|6|12 — liste vs. gerçekleşen, mağaza bazlı harcama. */
|
||||
export default async function ShoppingReportPage({
|
||||
searchParams,
|
||||
}: PageProps<"/raporlar/alisveris">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const period = parsePeriod((await searchParams).donem);
|
||||
const data = await getShoppingReport(user.id, household.id, period);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<>
|
||||
<RangeNav
|
||||
path="/raporlar/alisveris"
|
||||
label={tr.reports.periodLabel}
|
||||
value={String(period)}
|
||||
options={REPORT_PERIODS.map((p) => ({
|
||||
value: String(p),
|
||||
label: tr.reports.periods[p]!,
|
||||
}))}
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton
|
||||
href={`/raporlar/alisveris/csv?donem=${period}&tablo=listeler`}
|
||||
label={t.csvLists}
|
||||
/>
|
||||
<CsvButton
|
||||
href={`/raporlar/alisveris/csv?donem=${period}&tablo=magazalar`}
|
||||
label={t.csvStores}
|
||||
/>
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ShoppingReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { REPORT_RANGES, type ReportRange } from "@/features/assets/service";
|
||||
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getAssetsReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { formatAssetQuantity } from "@/shared/assets";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
|
||||
/** GET /raporlar/birikim/csv — varlık başına maliyet, değer ve kâr/zarar. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const q = c.params.get("donem");
|
||||
const range: ReportRange = REPORT_RANGES.includes(q as ReportRange) ? (q as ReportRange) : "6";
|
||||
const r = await getAssetsReport(c.userId, c.householdId, range);
|
||||
if (!r) return forbidden();
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
tr.reports.csvHeaders.assets,
|
||||
r.items.map((a) => [
|
||||
a.name || a.label,
|
||||
formatAssetQuantity(a.assetKey, a.summary.quantity),
|
||||
csvMoney(a.summary.costMinor),
|
||||
a.summary.currentValueMinor === null ? "" : csvMoney(a.summary.currentValueMinor),
|
||||
a.summary.unrealizedMinor === null ? "" : csvMoney(a.summary.unrealizedMinor),
|
||||
csvMoney(a.summary.realizedMinor),
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.assets(todayIstanbul()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AssetsReportView } from "@/features/reports/components/contacts-assets-view";
|
||||
import {
|
||||
CsvButton,
|
||||
RangeNav,
|
||||
ReportFrame,
|
||||
ToolsEnd,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getAssetsReport } from "@/features/reports/extra";
|
||||
import { REPORT_RANGES, type ReportRange } from "@/features/assets/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
const t = tr.reports.assets;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/birikim?donem=3|6|12|tum — varlık kâr/zarar ve dönem özeti. */
|
||||
export default async function AssetsReportPage({ searchParams }: PageProps<"/raporlar/birikim">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const q = (await searchParams).donem;
|
||||
const range: ReportRange = REPORT_RANGES.includes(q as ReportRange) ? (q as ReportRange) : "6";
|
||||
const data = await getAssetsReport(user.id, household.id, range);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<>
|
||||
<RangeNav
|
||||
path="/raporlar/birikim"
|
||||
label={tr.reports.periodLabel}
|
||||
value={range}
|
||||
options={REPORT_RANGES.map((r) => ({ value: r, label: t.ranges[r]! }))}
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton href="/raporlar/birikim/csv" />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AssetsReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getUnitPriceReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { unitPriceBase } from "@/shared/prices";
|
||||
|
||||
/** GET /raporlar/birim-fiyat/csv?tip=<ürün tipi> — ay başına en düşük ve ortalama (gösterim biriminde). */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getUnitPriceReport(c.userId, c.householdId, c.params.get("tip") ?? undefined);
|
||||
if (!r?.selected) return forbidden();
|
||||
const sel = r.selected;
|
||||
const base = unitPriceBase(sel.unit);
|
||||
const money = (x: number | null) => (x === null ? "" : csvMoney(Math.round(x * base.factor)));
|
||||
const header = tr.reports.csvHeaders.unitPrice.map((h, i) =>
|
||||
i === 1 || i === 2 ? `${h} (TL/${base.unit})` : h,
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
header,
|
||||
sel.points.map((p) => [p.month, money(p.minMinor), money(p.avgMinor), p.count]),
|
||||
),
|
||||
tr.reports.csvFiles.unitPrice(
|
||||
sel.name.toLocaleLowerCase("tr-TR").replace(/\s+/g, "-"),
|
||||
todayIstanbul(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CsvButton, ReportFrame, ToolsEnd } from "@/features/reports/components/report-frame";
|
||||
import { UnitPriceView } from "@/features/reports/components/unit-price-view";
|
||||
import { getUnitPriceReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
const t = tr.reports.unitPrice;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/birim-fiyat?tip=<ürün tipi> — aylık en düşük ve ortalama birim fiyat. */
|
||||
export default async function UnitPriceReportPage({
|
||||
searchParams,
|
||||
}: PageProps<"/raporlar/birim-fiyat">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const tip = (await searchParams).tip;
|
||||
const data = await getUnitPriceReport(
|
||||
user.id,
|
||||
household.id,
|
||||
typeof tip === "string" ? tip : undefined,
|
||||
);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
data.selected ? (
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/birim-fiyat/csv?tip=${data.selected.id}`} />
|
||||
</ToolsEnd>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<UnitPriceView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getBudgetReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
|
||||
/** GET /raporlar/butce/csv?ay=YYYY-MM — kategori bazında limit ve gerçekleşen. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getBudgetReport(c.userId, c.householdId, c.month);
|
||||
if (!r) return forbidden();
|
||||
const csv = toCsv(
|
||||
tr.reports.csvHeaders.budget,
|
||||
r.lines.map((l) => [
|
||||
l.path,
|
||||
l.limitMinor === null ? "" : csvMoney(l.limitMinor),
|
||||
csvMoney(l.actualMinor),
|
||||
l.remainingMinor === null ? "" : csvMoney(l.remainingMinor),
|
||||
csvPct(l.ratioBp === null ? null : Math.round(l.ratioBp / 10) / 10),
|
||||
]),
|
||||
);
|
||||
return csvResponse(csv, tr.reports.csvFiles.budget(r.month));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BudgetReportView } from "@/features/reports/components/budget-report-view";
|
||||
import {
|
||||
CsvButton,
|
||||
ToolsEnd,
|
||||
MonthNav,
|
||||
ReportFrame,
|
||||
monthLabel,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getBudgetReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
const t = tr.reports.budget;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/butce?ay=YYYY-MM — bütçe ve gerçekleşen. */
|
||||
export default async function BudgetReportPage({ searchParams }: PageProps<"/raporlar/butce">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||
const data = await getBudgetReport(user.id, household.id, ay.success ? ay.data : undefined);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description(monthLabel(data.month))}
|
||||
tools={
|
||||
<>
|
||||
<MonthNav month={data.month} currentMonth={data.currentMonth} path="/raporlar/butce" />
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/butce/csv?ay=${data.month}`} />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<BudgetReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getContactsReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
|
||||
/** GET /raporlar/cari/csv?tablo=cari|ayni */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getContactsReport(c.userId, c.householdId);
|
||||
if (!r) return forbidden();
|
||||
const h = tr.reports.csvHeaders;
|
||||
const today = todayIstanbul();
|
||||
if (c.params.get("tablo") === "ayni")
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.inKind,
|
||||
r.inKind.map((d) => [
|
||||
d.contactName,
|
||||
d.direction === "receivable" ? tr.reports.contacts.receivable : tr.reports.contacts.debt,
|
||||
d.remainingLabel,
|
||||
d.valueMinor === null ? "" : csvMoney(d.valueMinor),
|
||||
d.date,
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.inKind(today),
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.contacts,
|
||||
r.contacts.map((x) => [x.name, csvMoney(x.balanceMinor), x.dueDate ?? ""]),
|
||||
),
|
||||
tr.reports.csvFiles.contacts(today),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContactsReportView } from "@/features/reports/components/contacts-assets-view";
|
||||
import { CsvButton, ReportFrame, ToolsEnd } from "@/features/reports/components/report-frame";
|
||||
import { getContactsReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
const t = tr.reports.contacts;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/cari — cari bakiyeler ve açık aynî borç/alacaklar. */
|
||||
export default async function ContactsReportPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await getContactsReport(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<ToolsEnd>
|
||||
<CsvButton href="/raporlar/cari/csv?tablo=cari" label={t.csvContacts} />
|
||||
<CsvButton href="/raporlar/cari/csv?tablo=ayni" label={t.csvInKind} />
|
||||
</ToolsEnd>
|
||||
}
|
||||
>
|
||||
<ContactsReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getWasteReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { quantityInput } from "@/shared/stock";
|
||||
import { parsePeriod } from "@/shared/reports";
|
||||
|
||||
/** GET /raporlar/fire/csv?donem=3|6|12 */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getWasteReport(c.userId, c.householdId, parsePeriod(c.params.get("donem")));
|
||||
if (!r) return forbidden();
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
tr.reports.csvHeaders.waste,
|
||||
r.rows.map((x) => [
|
||||
x.name,
|
||||
x.unit,
|
||||
quantityInput(x.wasted),
|
||||
quantityInput(x.purchased),
|
||||
csvPct(x.wastePct),
|
||||
x.costMinor === null ? "" : csvMoney(x.costMinor),
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.waste(todayIstanbul()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
CsvButton,
|
||||
RangeNav,
|
||||
ReportFrame,
|
||||
ToolsEnd,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { WasteReportView } from "@/features/reports/components/shopping-waste-view";
|
||||
import { getWasteReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { parsePeriod, REPORT_PERIODS } from "@/shared/reports";
|
||||
|
||||
const t = tr.reports.waste;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/fire?donem=3|6|12 — fire/zarar raporu. */
|
||||
export default async function WasteReportPage({ searchParams }: PageProps<"/raporlar/fire">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const period = parsePeriod((await searchParams).donem);
|
||||
const data = await getWasteReport(user.id, household.id, period);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<>
|
||||
<RangeNav
|
||||
path="/raporlar/fire"
|
||||
label={tr.reports.periodLabel}
|
||||
value={String(period)}
|
||||
options={REPORT_PERIODS.map((p) => ({
|
||||
value: String(p),
|
||||
label: tr.reports.periods[p]!,
|
||||
}))}
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/fire/csv?donem=${period}`} />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<WasteReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getKakeiboReport } from "@/features/reports/kakeibo";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { KAKEIBO_GROUPS, type SpendingClass } from "@/shared/kakeibo";
|
||||
|
||||
/** GET /raporlar/kakeibo/csv?ay=YYYY-MM — sınıf × kategori tutarları. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getKakeiboReport(c.userId, c.householdId, c.month);
|
||||
if (!r) return forbidden();
|
||||
const k = tr.kakeibo;
|
||||
const label = (cls: SpendingClass) =>
|
||||
cls === "BANK_COST" ? k.bankCost : cls === "UNGROUPED" ? k.ungrouped : k.groups[cls];
|
||||
const order: SpendingClass[] = [...KAKEIBO_GROUPS, "UNGROUPED", "BANK_COST"];
|
||||
const rows = order.flatMap((cls) =>
|
||||
r.now.byClass[cls].map((x) => [label(cls), x.path, csvMoney(x.amountMinor)]),
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(tr.reports.kakeiboReport.csvHeader, rows),
|
||||
tr.reports.kakeiboReport.csvFile(r.month),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { KakeiboReportView } from "@/features/reports/components/kakeibo-view";
|
||||
import {
|
||||
CsvButton,
|
||||
MonthNav,
|
||||
ReportFrame,
|
||||
ToolsEnd,
|
||||
monthLabel,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getKakeiboReport } from "@/features/reports/kakeibo";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
const t = tr.reports.kakeiboReport;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/kakeibo?ay=YYYY-MM — Kakeibo dört grup, banka maliyeti, grupsuzlar. */
|
||||
export default async function KakeiboReportPage({ searchParams }: PageProps<"/raporlar/kakeibo">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||
const data = await getKakeiboReport(user.id, household.id, ay.success ? ay.data : undefined);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description(monthLabel(data.month))}
|
||||
tools={
|
||||
<>
|
||||
<MonthNav month={data.month} currentMonth={data.currentMonth} path="/raporlar/kakeibo" />
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/kakeibo/csv?ay=${data.month}`} />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.includesHidden ? (
|
||||
<p className="text-xs text-muted-foreground">{tr.reports.includesHidden}</p>
|
||||
) : null}
|
||||
<KakeiboReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getCategoryReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
|
||||
/** GET /raporlar/kategoriler/csv?ay=YYYY-MM — üst kategori ve alt kategori satırları. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getCategoryReport(c.userId, c.householdId, c.month);
|
||||
if (!r) return forbidden();
|
||||
const rows = r.breakdown.rows.flatMap((top) => [
|
||||
top,
|
||||
...top.children.map((k) => ({ ...k, parent: top.name })),
|
||||
]);
|
||||
const csv = toCsv(
|
||||
tr.reports.csvHeaders.categories,
|
||||
rows.map((x) => [
|
||||
"parent" in x ? x.parent : x.name,
|
||||
"parent" in x ? x.name : "",
|
||||
csvMoney(x.amountMinor),
|
||||
csvPct(x.sharePct),
|
||||
csvMoney(x.prevMinor),
|
||||
csvMoney(x.changeMinor),
|
||||
csvPct(x.changePct),
|
||||
]),
|
||||
);
|
||||
return csvResponse(csv, tr.reports.csvFiles.categories(r.month));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CategoriesView } from "@/features/reports/components/categories-view";
|
||||
import {
|
||||
CsvButton,
|
||||
ToolsEnd,
|
||||
MonthNav,
|
||||
ReportFrame,
|
||||
monthLabel,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getCategoryReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
const t = tr.reports.categories;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/kategoriler?ay=YYYY-MM — kategori dağılımı. */
|
||||
export default async function CategoryReportPage({
|
||||
searchParams,
|
||||
}: PageProps<"/raporlar/kategoriler">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||
const data = await getCategoryReport(user.id, household.id, ay.success ? ay.data : undefined);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description(monthLabel(data.month))}
|
||||
tools={
|
||||
<>
|
||||
<MonthNav
|
||||
month={data.month}
|
||||
currentMonth={data.currentMonth}
|
||||
path="/raporlar/kategoriler"
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/kategoriler/csv?ay=${data.month}`} />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.current ? <p className="text-xs text-muted-foreground">{t.partialMonth}</p> : null}
|
||||
{data.includesHidden ? (
|
||||
<p className="text-xs text-muted-foreground">{tr.reports.includesHidden}</p>
|
||||
) : null}
|
||||
<CategoriesView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getPeopleAccountsReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
|
||||
/** GET /raporlar/kisi-hesap/csv?ay=YYYY-MM&tablo=kisi|hesap */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getPeopleAccountsReport(c.userId, c.householdId, c.month);
|
||||
if (!r) return forbidden();
|
||||
const h = tr.reports.csvHeaders;
|
||||
if (c.params.get("tablo") === "hesap")
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.accounts,
|
||||
r.accounts.map((a) => [
|
||||
a.name,
|
||||
csvMoney(a.incomeMinor),
|
||||
csvMoney(a.expenseMinor),
|
||||
csvMoney(a.transferInMinor),
|
||||
csvMoney(a.transferOutMinor),
|
||||
csvMoney(a.netMinor),
|
||||
a.count,
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.accounts(r.month),
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.people,
|
||||
r.people.map((p) => [p.name, csvMoney(p.expenseMinor), p.count, csvPct(p.sharePct)]),
|
||||
),
|
||||
tr.reports.csvFiles.people(r.month),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PeopleView } from "@/features/reports/components/people-view";
|
||||
import {
|
||||
CsvButton,
|
||||
ToolsEnd,
|
||||
MonthNav,
|
||||
ReportFrame,
|
||||
monthLabel,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getPeopleAccountsReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
const t = tr.reports.people;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/kisi-hesap?ay=YYYY-MM — kişi bazlı harcama ve hesap bazlı hareket. */
|
||||
export default async function PeopleReportPage({
|
||||
searchParams,
|
||||
}: PageProps<"/raporlar/kisi-hesap">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||
const data = await getPeopleAccountsReport(
|
||||
user.id,
|
||||
household.id,
|
||||
ay.success ? ay.data : undefined,
|
||||
);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description(monthLabel(data.month))}
|
||||
tools={
|
||||
<>
|
||||
<MonthNav
|
||||
month={data.month}
|
||||
currentMonth={data.currentMonth}
|
||||
path="/raporlar/kisi-hesap"
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton
|
||||
href={`/raporlar/kisi-hesap/csv?ay=${data.month}&tablo=kisi`}
|
||||
label={t.csvPeople}
|
||||
/>
|
||||
<CsvButton
|
||||
href={`/raporlar/kisi-hesap/csv?ay=${data.month}&tablo=hesap`}
|
||||
label={t.csvAccounts}
|
||||
/>
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.includesHidden ? (
|
||||
<p className="text-xs text-muted-foreground">{tr.reports.includesHidden}</p>
|
||||
) : null}
|
||||
<PeopleView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
BookOpenCheck,
|
||||
ChartPie,
|
||||
ChevronRight,
|
||||
Coins,
|
||||
HandCoins,
|
||||
ShoppingCart,
|
||||
Tags,
|
||||
Target,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { monthLabel, formatPct } from "@/features/reports/components/report-frame";
|
||||
import { getCategoryReport, getTrendReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
|
||||
const t = tr.reports;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
const REPORTS = [
|
||||
{ href: "/raporlar/kakeibo", key: "kakeibo", icon: BookOpenCheck },
|
||||
{ href: "/raporlar/kategoriler", key: "categories", icon: ChartPie },
|
||||
{ href: "/raporlar/trend", key: "trend", icon: TrendingUp },
|
||||
{ href: "/raporlar/kisi-hesap", key: "people", icon: Users },
|
||||
{ href: "/raporlar/butce", key: "budget", icon: Target },
|
||||
{ href: "/raporlar/cari", key: "contacts", icon: HandCoins },
|
||||
{ href: "/raporlar/birikim", key: "assets", icon: Coins },
|
||||
{ href: "/raporlar/alisveris", key: "shopping", icon: ShoppingCart },
|
||||
{ href: "/raporlar/birim-fiyat", key: "unitPrice", icon: Tags },
|
||||
{ href: "/raporlar/fire", key: "waste", icon: Trash2 },
|
||||
] as const;
|
||||
|
||||
/** /raporlar — bu bütçe ayının özeti ve raporlar. */
|
||||
export default async function ReportsPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [trend, cats] = await Promise.all([
|
||||
getTrendReport(user.id, household.id, 1),
|
||||
getCategoryReport(user.id, household.id),
|
||||
]);
|
||||
if (!trend || !cats) notFound();
|
||||
const m = trend.months[0]!;
|
||||
const top = cats.breakdown.rows.filter((r) => r.amountMinor > 0).slice(0, 3);
|
||||
const stats = [
|
||||
{ label: t.hub.income, value: formatMoney(m.incomeMinor), tone: "text-income" },
|
||||
{ label: t.hub.expense, value: formatMoney(m.expenseMinor), tone: "text-expense" },
|
||||
{
|
||||
label: t.hub.net,
|
||||
value: `${m.netMinor < 0 ? "−" : ""}${formatMoney(Math.abs(m.netMinor))}`,
|
||||
tone: m.netMinor < 0 ? "text-expense" : undefined,
|
||||
},
|
||||
{ label: t.hub.savings, value: formatPct(m.savingsPct) },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.title} description={t.description} />
|
||||
<section aria-labelledby="hub-month" className="rounded-xl border bg-card p-4">
|
||||
<h2 id="hub-month" className="text-sm font-medium">
|
||||
{t.hub.thisMonth}: <span className="capitalize">{monthLabel(m.month)}</span>{" "}
|
||||
<span className="font-normal text-muted-foreground">({t.trend.ongoing})</span>
|
||||
</h2>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label}>
|
||||
<dt className="text-xs text-muted-foreground">{s.label}</dt>
|
||||
<dd className={cn("text-lg font-semibold tabular-nums", s.tone)}>{s.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{top.length ? (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs text-muted-foreground">{t.hub.topCategories}</h3>
|
||||
<ul className="mt-1 flex flex-col gap-1 text-sm">
|
||||
{top.map((r) => (
|
||||
<li key={r.id} className="flex justify-between gap-3">
|
||||
<span className="truncate">{r.name}</span>
|
||||
<span className="tabular-nums">
|
||||
{formatMoney(r.amountMinor)}{" "}
|
||||
<span className="text-muted-foreground">· {formatPct(r.sharePct)}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
{REPORTS.map((r) => {
|
||||
const c = t.hub.cards[r.key];
|
||||
const Icon = r.icon;
|
||||
return (
|
||||
<li key={r.href}>
|
||||
<Link
|
||||
href={r.href}
|
||||
className="flex h-full items-start gap-3 rounded-xl border bg-card p-4 transition-colors hover:bg-accent/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Icon className="size-4.5 text-muted-foreground" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium">{c.title}</span>
|
||||
<span className="block text-sm text-muted-foreground">{c.description}</span>
|
||||
</span>
|
||||
<ChevronRight className="mt-2 size-4 text-muted-foreground" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getTrendReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
|
||||
/** GET /raporlar/trend/csv — son 12 bütçe ayı. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getTrendReport(c.userId, c.householdId);
|
||||
if (!r) return forbidden();
|
||||
const csv = toCsv(
|
||||
tr.reports.csvHeaders.trend,
|
||||
r.months.map((m) => [
|
||||
m.month,
|
||||
csvMoney(m.incomeMinor),
|
||||
csvMoney(m.expenseMinor),
|
||||
csvMoney(m.netMinor),
|
||||
csvPct(m.savingsPct),
|
||||
]),
|
||||
);
|
||||
return csvResponse(csv, tr.reports.csvFiles.trend(todayIstanbul()));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CsvButton, ToolsEnd, ReportFrame } from "@/features/reports/components/report-frame";
|
||||
import { TrendView } from "@/features/reports/components/trend-view";
|
||||
import { getTrendReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
const t = tr.reports.trend;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/trend — son 12 bütçe ayı gelir, gider, net ve tasarruf oranı. */
|
||||
export default async function TrendReportPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await getTrendReport(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<ToolsEnd>
|
||||
<CsvButton href="/raporlar/trend/csv" />
|
||||
</ToolsEnd>
|
||||
}
|
||||
>
|
||||
<TrendView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { TypePriceComparison } from "@/features/prices/components/type-price-comparison";
|
||||
import { getTypePriceComparison } from "@/features/prices/service";
|
||||
import { ProductsSection } from "@/features/products/components/products-section";
|
||||
import { listProducts, listProductTypeOptions } from "@/features/products/service";
|
||||
import { ProductDetailView } from "@/features/stock/components/product-detail-view";
|
||||
import { getProductType, getStockOptions } from "@/features/stock/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
@@ -11,10 +15,29 @@ export const metadata: Metadata = { title: tr.stock.detail.movements };
|
||||
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([
|
||||
const [data, options, products, types, prices] = await Promise.all([
|
||||
getProductType(user.id, household.id, id),
|
||||
getStockOptions(user.id, household.id),
|
||||
listProducts(user.id, household.id, { productTypeId: id, includeArchived: true }),
|
||||
listProductTypeOptions(user.id, household.id),
|
||||
getTypePriceComparison(user.id, household.id, id),
|
||||
]);
|
||||
if (!data || !options) notFound();
|
||||
return <ProductDetailView data={data} categories={options.categories} />;
|
||||
if (!data || !options || !products || !types || !prices) notFound();
|
||||
return (
|
||||
<ProductDetailView
|
||||
data={data}
|
||||
categories={options.categories}
|
||||
products={
|
||||
<>
|
||||
<TypePriceComparison data={prices} />
|
||||
<ProductsSection
|
||||
productTypeId={data.id}
|
||||
products={products.products}
|
||||
types={types}
|
||||
canWrite={products.canWrite}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ProductPriceView } from "@/features/prices/components/product-price-view";
|
||||
import { getProductPrices } from "@/features/prices/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.products.title };
|
||||
|
||||
/** /stok/urunler/:id — ürün (marka/paket): mağaza fiyatları, birim fiyat, fiyat geçmişi. */
|
||||
export default async function ProductPage({ params }: PageProps<"/stok/urunler/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const { id } = await params;
|
||||
const data = await getProductPrices(user.id, household.id, id);
|
||||
if (!data) notFound();
|
||||
return <ProductPriceView data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ProductsCatalog } from "@/features/products/components/products-catalog";
|
||||
import { listProducts, listProductTypeOptions } from "@/features/products/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.products.title };
|
||||
|
||||
/** /stok/urunler — ürün kataloğu (marka/paket), ürün tipine göre gruplu. */
|
||||
export default async function ProductsPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [data, types] = await Promise.all([
|
||||
listProducts(user.id, household.id, { includeArchived: true }),
|
||||
listProductTypeOptions(user.id, household.id),
|
||||
]);
|
||||
if (!data || !types) notFound();
|
||||
return <ProductsCatalog products={data.products} types={types} canWrite={data.canWrite} />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { connection } from "next/server";
|
||||
import { pingDb } from "@/lib/db/mongo";
|
||||
import { getDb, pingDb } from "@/lib/db/mongo";
|
||||
import { APP_COMMIT, APP_VERSION } from "@/lib/version";
|
||||
|
||||
/**
|
||||
@@ -10,10 +10,21 @@ export async function GET() {
|
||||
// Cache Components açıkken GET handler'ı build'de prerender edilmesin.
|
||||
await connection();
|
||||
const dbOk = await pingDb();
|
||||
// Transaction'lar için replica set şart; adı yoksa (standalone) null.
|
||||
let replicaSet: string | null = null;
|
||||
if (dbOk) {
|
||||
try {
|
||||
const hello = await getDb().admin().command({ hello: 1 }, { timeoutMS: 2_000 });
|
||||
replicaSet = typeof hello.setName === "string" ? hello.setName : null;
|
||||
} catch {
|
||||
replicaSet = null;
|
||||
}
|
||||
}
|
||||
return Response.json(
|
||||
{
|
||||
status: dbOk ? "ok" : "degraded",
|
||||
db: dbOk ? "ok" : "unreachable",
|
||||
replicaSet,
|
||||
version: APP_VERSION,
|
||||
commit: APP_COMMIT,
|
||||
time: new Date().toISOString(),
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-series-1: var(--series-1);
|
||||
--color-series-2: var(--series-2);
|
||||
--color-series-3: var(--series-3);
|
||||
--color-series-4: var(--series-4);
|
||||
--color-series-5: var(--series-5);
|
||||
--color-series-6: var(--series-6);
|
||||
--color-series-7: var(--series-7);
|
||||
--color-series-8: var(--series-8);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
@@ -95,6 +103,17 @@
|
||||
--chart-3: var(--expense);
|
||||
--chart-4: var(--warning);
|
||||
--chart-5: oklch(0.65 0.08 230);
|
||||
|
||||
/* Kategorik seriler (mağaza vb. kimlik; sabit sıra, döngü yok). dataviz doğrulayıcısından
|
||||
geçti (açık: kart #fff; 3 yuva 3:1 altında → lejant + tablo zorunlu). */
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--series-3: #1baf7a;
|
||||
--series-4: #eda100;
|
||||
--series-5: #e87ba4;
|
||||
--series-6: #008300;
|
||||
--series-7: #6250d6;
|
||||
--series-8: #e34948;
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* Kullanıcı verisi renkleri (kategori/hesap): anahtar → değişken (shared/appearance.ts) */
|
||||
@@ -156,6 +175,16 @@
|
||||
--chart-4: var(--warning);
|
||||
--chart-5: oklch(0.7 0.07 230);
|
||||
|
||||
/* Koyu yüzey (#101a2f) için aynı tonların koyu adımları; hepsi ≥ 3:1. */
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
|
||||
--swatch-blue: oklch(0.74 0.13 258);
|
||||
--swatch-sky: oklch(0.74 0.13 230);
|
||||
--swatch-teal: oklch(0.74 0.13 185);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Package,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
Store,
|
||||
Tags,
|
||||
Target,
|
||||
UserCog,
|
||||
@@ -27,7 +28,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 = 11.2;
|
||||
export const CURRENT_PHASE = 16;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
@@ -41,8 +42,9 @@ export const MAIN_NAV: NavItem[] = [
|
||||
{ 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: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 },
|
||||
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13.1 },
|
||||
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14.1 },
|
||||
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
|
||||
];
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export function PageHeader({
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
description?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
|
||||
@@ -38,11 +38,17 @@ export function AttachmentPicker({
|
||||
onChange,
|
||||
allowedKinds,
|
||||
onBusyChange,
|
||||
hint,
|
||||
addLabel,
|
||||
}: {
|
||||
value: AttachmentInfo[];
|
||||
onChange: (items: AttachmentInfo[]) => void;
|
||||
allowedKinds: readonly AttachmentKind[];
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
/** Boşken gösterilen ipucu (varsayılan: fiş/fatura). */
|
||||
hint?: string;
|
||||
/** Düğme metni (varsayılan: "Ek ekle"). */
|
||||
addLabel?: string;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(0);
|
||||
@@ -91,14 +97,16 @@ export function AttachmentPicker({
|
||||
disabled={room <= 0 || uploading > 0}
|
||||
>
|
||||
{uploading > 0 ? <Loader2 className="animate-spin" /> : <Paperclip />}
|
||||
{uploading > 0 ? t.uploading : t.add}
|
||||
{uploading > 0 ? t.uploading : (addLabel ?? t.add)}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{value.length
|
||||
? t.count(value.length)
|
||||
: allowedKinds.includes("pdf")
|
||||
? t.addHint.imagePdf
|
||||
: t.addHint.image}
|
||||
: hint
|
||||
? hint
|
||||
: allowedKinds.includes("pdf")
|
||||
? t.addHint.imagePdf
|
||||
: t.addHint.image}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getStorage } from "@/lib/storage";
|
||||
import { isPlausiblePdf, processImage, UnreadableImageError } from "@/lib/uploads/process";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AttachmentModel, type AttachmentOwnerType } from "@/models/attachment";
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import {
|
||||
detectFileType,
|
||||
@@ -267,6 +268,9 @@ export async function readAttachmentForUser(
|
||||
ownerUserId: { $ne: new Types.ObjectId(userId) },
|
||||
});
|
||||
if (hidden) return null;
|
||||
} else if (a.ownerType === "product") {
|
||||
// Ürün görselleri hane ortaktır (arşivli ürün dahil).
|
||||
if (!(await ProductModel.exists({ _id: a.ownerId, householdId: a.householdId }))) return null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: str
|
||||
|
||||
type Pending = { kind: "edit" } | { kind: "replace"; source: "copy" | "actuals" } | null;
|
||||
|
||||
export function BudgetView({ data }: { data: Data }) {
|
||||
export function BudgetView({ data, summary }: { data: Data; summary?: React.ReactNode }) {
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const close = () => setPending(null);
|
||||
@@ -306,6 +306,8 @@ export function BudgetView({ data }: { data: Data }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summary}
|
||||
|
||||
{data.lines.length ? (
|
||||
<ul className="flex flex-col divide-y rounded-xl border bg-card">
|
||||
{data.lines.map((l) => (
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
"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";
|
||||
@@ -19,24 +10,13 @@ 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,
|
||||
@@ -57,7 +37,7 @@ function Stat({
|
||||
);
|
||||
}
|
||||
|
||||
/** Dashboard: ayın günlük planı, kümülatif grafik ve gelirle karşılaştırma. */
|
||||
/** Dashboard: ayın günlük planı ve gelirle karşılaştırma (grafik ana grafikte: Aylık). */
|
||||
export function DailyPlanView({ data }: { data: Data }) {
|
||||
const p = data.plan;
|
||||
if (!data.hasBudget) {
|
||||
@@ -75,14 +55,6 @@ export function DailyPlanView({ data }: { data: Data }) {
|
||||
</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;
|
||||
|
||||
@@ -116,82 +88,6 @@ export function DailyPlanView({ data }: { data: Data }) {
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -60,7 +60,7 @@ export type IncomePlan = {
|
||||
manual: { id: string; name: string; amountMinor: number }[];
|
||||
};
|
||||
|
||||
async function incomePlan(ctx: Ctx, s: Settings, month: string): Promise<IncomePlan> {
|
||||
export async function incomePlan(ctx: Ctx, s: Settings, month: string): Promise<IncomePlan> {
|
||||
const { from, to } = budgetPeriod(month, s.startDay);
|
||||
const [docs, budget] = await Promise.all([
|
||||
TransactionModel.find(baseMatch(ctx, s, "income", from, to))
|
||||
@@ -145,7 +145,7 @@ export async function saveIncomeItems(
|
||||
// --- 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) {
|
||||
export async function expensesByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||||
const pipeline: PipelineStage[] = [
|
||||
{ $match: baseMatch(ctx, s, "expense", from, to) },
|
||||
{
|
||||
@@ -182,6 +182,20 @@ async function expensesByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||||
return { fixed, actual, variable };
|
||||
}
|
||||
|
||||
/** Güne göre beklenen gelir (gerçekleşmiş + planlı gelir işlemleri). */
|
||||
export async function incomeByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||||
const rows = await TransactionModel.aggregate<{ _id: string; sum: number }>([
|
||||
{ $match: baseMatch(ctx, s, "income", from, to) },
|
||||
{
|
||||
$group: {
|
||||
_id: { $dateToString: { date: "$date", format: "%Y-%m-%d", timezone: "Europe/Istanbul" } },
|
||||
sum: { $sum: "$amountMinor" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
return new Map(rows.map((r) => [r._id, r.sum]));
|
||||
}
|
||||
|
||||
export type DailyPlanView = {
|
||||
month: string;
|
||||
period: { from: string; to: string };
|
||||
@@ -199,6 +213,40 @@ export type DailyPlanView = {
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bir bütçe ayının günlük planı: sabit giderler kendi günlerine, değişken bütçe hane ayarına göre
|
||||
* (eşit ya da önceki ayın örüntüsü) dağıtılır.
|
||||
*/
|
||||
export async function monthDailyPlan(
|
||||
ctx: Ctx,
|
||||
s: Settings,
|
||||
month: string,
|
||||
budgetMinor: number,
|
||||
today = todayIstanbul(),
|
||||
): Promise<DailyPlan> {
|
||||
const period = budgetPeriod(month, s.startDay);
|
||||
const byDay = await expensesByDay(ctx, s, period.from, period.to);
|
||||
let weights: number[] | undefined;
|
||||
if (s.dailyMode === "previous_month_pattern") {
|
||||
const prev = budgetPeriod(shiftMonth(month, -1), s.startDay);
|
||||
const prevDays = await expensesByDay(ctx, s, prev.from, prev.to);
|
||||
const len = daysBetween(prev.from, prev.to);
|
||||
weights = patternWeights(
|
||||
Array.from({ length: len }, (_, i) => prevDays.variable.get(addDays(prev.from, i)) ?? 0),
|
||||
daysBetween(period.from, period.to),
|
||||
);
|
||||
}
|
||||
return dailyPlan({
|
||||
from: period.from,
|
||||
to: period.to,
|
||||
today,
|
||||
budgetMinor,
|
||||
fixedByDay: byDay.fixed,
|
||||
actualByDay: byDay.actual,
|
||||
weights,
|
||||
});
|
||||
}
|
||||
|
||||
/** Dashboard günlük planı: içinde bulunulan bütçe ayı. */
|
||||
export async function getDailyPlan(
|
||||
userId: string,
|
||||
@@ -212,34 +260,14 @@ export async function getDailyPlan(
|
||||
const period = budgetPeriod(month, s.startDay);
|
||||
const nextMonth = shiftMonth(month, 1);
|
||||
const mode = s.dailyMode;
|
||||
const [budget, byDay, income, nextIncome, nextBudget] = await Promise.all([
|
||||
const [budget, 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 plan = await monthDailyPlan(ctx, s, month, budget.totals.limitMinor, today);
|
||||
const forecastExpense = budget.totals.forecastMinor;
|
||||
return {
|
||||
month,
|
||||
|
||||
@@ -191,7 +191,11 @@ export async function getBudget(
|
||||
): Promise<BudgetView | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const s = await settingsOf(ctx.m.householdId);
|
||||
return budgetFor(ctx, await settingsOf(ctx.m.householdId), month);
|
||||
}
|
||||
|
||||
/** Bağlam ve ayarlar hazırken bütçe (raporlarda birden çok ay için yeniden yüklemeden). */
|
||||
export async function budgetFor(ctx: Ctx, s: Settings, month?: string): Promise<BudgetView> {
|
||||
const today = todayIstanbul();
|
||||
const currentMonth = periodOfDate(today, s.startDay);
|
||||
const m = month ?? currentMonth;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { IconBadge } from "@/components/appearance/swatch";
|
||||
import { KakeiboBadge } from "./kakeibo-badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
@@ -61,6 +62,9 @@ export function CategoriesManager({
|
||||
[categories, kind, showArchived],
|
||||
);
|
||||
const archivedCount = categories.filter((c) => c.kind === kind && c.archived).length;
|
||||
const ungrouped = categories.filter(
|
||||
(c) => c.kind === "expense" && !c.archived && !c.kakeibo.group && !c.kakeibo.bankCost,
|
||||
).length;
|
||||
|
||||
function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) {
|
||||
startTransition(async () => {
|
||||
@@ -90,6 +94,9 @@ export function CategoriesManager({
|
||||
<span className={cn("min-w-0 flex-1 truncate", node.depth === 0 && "font-medium")}>
|
||||
{node.name}
|
||||
</span>
|
||||
{node.kind === "expense" && !node.archived ? (
|
||||
<KakeiboBadge kakeibo={node.kakeibo} />
|
||||
) : null}
|
||||
{node.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
||||
{canEdit ? (
|
||||
<DropdownMenu modal={false}>
|
||||
@@ -191,6 +198,11 @@ export function CategoriesManager({
|
||||
) : null}
|
||||
</div>
|
||||
{!canEdit ? <p className="text-sm text-muted-foreground">{t.readOnly}</p> : null}
|
||||
{kind === "expense" && ungrouped ? (
|
||||
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||
{tr.kakeibo.ungroupedCount(ungrouped)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tree.length ? (
|
||||
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
||||
import { IconPicker, SwatchPicker } from "@/components/appearance/pickers";
|
||||
import { 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 {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||
import {
|
||||
categoryFormSchema,
|
||||
type CategoryForm,
|
||||
@@ -60,6 +62,8 @@ export function CategoryFormDialog({
|
||||
parentId: c.parentId ?? "",
|
||||
icon: c.icon,
|
||||
color: c.color,
|
||||
kakeiboGroup: c.kakeiboGroup ?? "",
|
||||
bankCost: c.bankCost,
|
||||
});
|
||||
} else {
|
||||
const parent = categories.find((c) => c.id === state.parentId);
|
||||
@@ -69,6 +73,9 @@ export function CategoryFormDialog({
|
||||
parentId: state.parentId ?? "",
|
||||
icon: parent?.icon ?? "tag",
|
||||
color: parent?.color ?? "blue",
|
||||
// Yeni alt kategori grubu üstten devralır.
|
||||
kakeiboGroup: "",
|
||||
bankCost: false,
|
||||
});
|
||||
}
|
||||
}, [state, categories, form]);
|
||||
@@ -105,6 +112,10 @@ export function CategoryFormDialog({
|
||||
}
|
||||
|
||||
const color = useWatch({ control: form.control, name: "color" }) ?? "blue";
|
||||
const k = tr.kakeibo;
|
||||
const parentId = useWatch({ control: form.control, name: "parentId" });
|
||||
const parentGroup = categories.find((c) => c.id === parentId)?.kakeibo.group ?? null;
|
||||
const parentBank = categories.find((c) => c.id === parentId)?.kakeibo.bankCost ?? false;
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
@@ -177,6 +188,60 @@ export function CategoryFormDialog({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{kind === "expense" ? (
|
||||
<>
|
||||
<Controller
|
||||
name="kakeiboGroup"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cat-kakeibo">{k.field}</FieldLabel>
|
||||
<Select
|
||||
value={field.value || ROOT}
|
||||
onValueChange={(v) => field.onChange(v === ROOT ? "" : v)}
|
||||
>
|
||||
<SelectTrigger id="cat-kakeibo" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ROOT}>
|
||||
{parentGroup ? k.inheritWith(k.groups[parentGroup]) : k.inherit}
|
||||
</SelectItem>
|
||||
{KAKEIBO_GROUPS.map((g) => (
|
||||
<SelectItem key={g} value={g}>
|
||||
{k.groups[g]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>
|
||||
{field.value
|
||||
? k.groupHints[field.value as keyof typeof k.groupHints]
|
||||
: k.ungroupedHint}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="bankCost"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="cat-bank"
|
||||
checked={Boolean(field.value) || parentBank}
|
||||
disabled={parentBank}
|
||||
onCheckedChange={(x) => field.onChange(x === true)}
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<FieldLabel htmlFor="cat-bank">{k.bankCost}</FieldLabel>
|
||||
<FieldDescription>{k.bankCostHint}</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Controller
|
||||
name="color"
|
||||
control={form.control}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { KakeiboGroup, ResolvedKakeibo } from "@/shared/kakeibo";
|
||||
|
||||
const k = tr.kakeibo;
|
||||
|
||||
/** Kakeibo grup renkleri: doğrulanmış kategorik palet (series-1…4); etiket her zaman yanında. */
|
||||
export const KAKEIBO_COLOR: Record<KakeiboGroup, string> = {
|
||||
ZORUNLU: "var(--series-1)",
|
||||
ISTEK: "var(--series-2)",
|
||||
KULTUR: "var(--series-3)",
|
||||
BEKLENMEYEN: "var(--series-4)",
|
||||
};
|
||||
|
||||
export function KakeiboDot({
|
||||
group,
|
||||
className,
|
||||
}: {
|
||||
group: KakeiboGroup | null;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-block size-2.5 shrink-0 rounded-full",
|
||||
!group && "border border-dashed border-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
style={group ? { backgroundColor: KAKEIBO_COLOR[group] } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kategorinin etkin Kakeibo grubu; devralınan grup soluk, banka maliyeti ve grupsuz ayrı. */
|
||||
export function KakeiboBadge({ kakeibo }: { kakeibo: ResolvedKakeibo }) {
|
||||
if (kakeibo.bankCost)
|
||||
return (
|
||||
<Badge variant="secondary" title={k.bankCostHint}>
|
||||
{k.bankCost}
|
||||
</Badge>
|
||||
);
|
||||
if (!kakeibo.group)
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1.5 border-dashed text-muted-foreground"
|
||||
title={k.ungroupedHint}
|
||||
>
|
||||
<KakeiboDot group={null} />
|
||||
{k.ungrouped}
|
||||
</Badge>
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("gap-1.5", kakeibo.inherited && "text-muted-foreground")}
|
||||
title={
|
||||
kakeibo.inherited
|
||||
? `${k.groups[kakeibo.group]} (${k.inherited})`
|
||||
: k.groupHints[kakeibo.group]
|
||||
}
|
||||
>
|
||||
<KakeiboDot group={kakeibo.group} />
|
||||
{k.groups[kakeibo.group]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,8 @@ const form = (over: Partial<Parameters<typeof createCategory>[2]> = {}) => ({
|
||||
parentId: null,
|
||||
icon: "tag" as const,
|
||||
color: "blue" as const,
|
||||
kakeiboGroup: null,
|
||||
bankCost: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ import { toObjectId } from "@/lib/object-id";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||
import type { CategoryForm, CategoryKind } from "@/shared/schemas/category";
|
||||
import {
|
||||
DEFAULT_BANK_COST_KEYS,
|
||||
DEFAULT_KAKEIBO_GROUPS,
|
||||
resolveKakeibo,
|
||||
type KakeiboGroup,
|
||||
type ResolvedKakeibo,
|
||||
} from "@/shared/kakeibo";
|
||||
import { foldTr } from "@/shared/search";
|
||||
import { DEFAULT_CATEGORIES } from "./defaults";
|
||||
import {
|
||||
@@ -26,7 +33,16 @@ import {
|
||||
|
||||
const t = tr.categories;
|
||||
|
||||
export type CategoryView = FlatCategory & { icon: IconKey; color: Swatch; isDefault: boolean };
|
||||
export type CategoryView = FlatCategory & {
|
||||
icon: IconKey;
|
||||
color: Swatch;
|
||||
isDefault: boolean;
|
||||
/** Kendi Kakeibo grubu (null = üstten devral). */
|
||||
kakeiboGroup: KakeiboGroup | null;
|
||||
bankCost: boolean;
|
||||
/** Etkin grup ve banka maliyeti (devralma dahil). */
|
||||
kakeibo: ResolvedKakeibo;
|
||||
};
|
||||
|
||||
type Lean = {
|
||||
_id: Types.ObjectId;
|
||||
@@ -39,9 +55,26 @@ type Lean = {
|
||||
color: string;
|
||||
defaultKey: string | null;
|
||||
ancestors: Types.ObjectId[];
|
||||
kakeiboGroup?: KakeiboGroup | null;
|
||||
bankCost?: boolean;
|
||||
};
|
||||
|
||||
const toView = (c: Lean): CategoryView => ({
|
||||
const NONE: ResolvedKakeibo = { group: null, inherited: false, bankCost: false };
|
||||
|
||||
/** Görünümler: etkin Kakeibo grubu tüm kategoriler (arşivliler dahil) üzerinden çözülür. */
|
||||
function toViews(rows: Lean[], all: Lean[] = rows): CategoryView[] {
|
||||
const resolved = resolveKakeibo(
|
||||
all.map((c) => ({
|
||||
id: String(c._id),
|
||||
parentId: c.parentId ? String(c.parentId) : null,
|
||||
kakeiboGroup: c.kakeiboGroup ?? null,
|
||||
bankCost: Boolean(c.bankCost),
|
||||
})),
|
||||
);
|
||||
return rows.map((c) => ({ ...toView(c), kakeibo: resolved.get(String(c._id)) ?? NONE }));
|
||||
}
|
||||
|
||||
const toView = (c: Lean): Omit<CategoryView, "kakeibo"> & { kakeibo?: ResolvedKakeibo } => ({
|
||||
id: String(c._id),
|
||||
kind: c.kind,
|
||||
name: c.name,
|
||||
@@ -51,8 +84,16 @@ const toView = (c: Lean): CategoryView => ({
|
||||
icon: c.icon as IconKey,
|
||||
color: c.color as Swatch,
|
||||
isDefault: Boolean(c.defaultKey),
|
||||
kakeiboGroup: c.kakeiboGroup ?? null,
|
||||
bankCost: Boolean(c.bankCost),
|
||||
});
|
||||
|
||||
/** Kakeibo alanları yalnızca gider kategorisinde; gelirde temizlenir. */
|
||||
const kakeiboFields = (input: CategoryForm) =>
|
||||
input.kind === "expense"
|
||||
? { kakeiboGroup: input.kakeiboGroup, bankCost: input.bankCost }
|
||||
: { kakeiboGroup: null, bankCost: false };
|
||||
|
||||
const violationMessage = (v: MoveViolation) => t.violations[v];
|
||||
|
||||
export async function listCategories(
|
||||
@@ -64,9 +105,9 @@ export async function listCategories(
|
||||
if (!m) return null;
|
||||
const filter: Record<string, unknown> = { householdId: m.householdId };
|
||||
if (opts.kind) filter.kind = opts.kind;
|
||||
if (!opts.includeArchived) filter.archivedAt = null;
|
||||
const rows = await CategoryModel.find(filter).lean<Lean[]>();
|
||||
return rows.map(toView);
|
||||
const all = await CategoryModel.find(filter).lean<Lean[]>();
|
||||
const rows = opts.includeArchived ? all : all.filter((c) => !c.archivedAt);
|
||||
return toViews(rows, all);
|
||||
}
|
||||
|
||||
async function loadKind(householdId: Types.ObjectId, kind: CategoryKind, session?: ClientSession) {
|
||||
@@ -86,6 +127,17 @@ function hasSiblingNamed(all: Lean[], parentId: string | null, name: string, exc
|
||||
);
|
||||
}
|
||||
|
||||
/** Varsayılan kategorinin Kakeibo grubu ve banka maliyeti işareti (Faz 16). */
|
||||
export function defaultKakeibo(key: string): {
|
||||
kakeiboGroup: KakeiboGroup | null;
|
||||
bankCost: boolean;
|
||||
} {
|
||||
return {
|
||||
kakeiboGroup: DEFAULT_KAKEIBO_GROUPS[key] ?? null,
|
||||
bankCost: (DEFAULT_BANK_COST_KEYS as readonly string[]).includes(key),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Varsayılan seti yükler (idempotent: defaultKey'e göre). Yeni hane oluşturulurken aynı
|
||||
* transaction içinde (session) ya da migration/seed'den çağrılır. Eklenen kategori sayısını döner.
|
||||
@@ -117,6 +169,7 @@ export async function installDefaultCategories(
|
||||
color: def.color,
|
||||
order,
|
||||
defaultKey: def.key,
|
||||
...(kind === "expense" ? defaultKakeibo(def.key) : {}),
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
},
|
||||
@@ -164,6 +217,7 @@ export async function createCategory(
|
||||
ancestors: parent ? [...parent.ancestors, parent._id] : [],
|
||||
icon: input.icon,
|
||||
color: input.color,
|
||||
...kakeiboFields(input),
|
||||
order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
@@ -209,6 +263,7 @@ export async function updateCategory(
|
||||
nameKey: foldTr(input.name),
|
||||
icon: input.icon,
|
||||
color: input.color,
|
||||
...kakeiboFields(input),
|
||||
updatedBy: actor,
|
||||
};
|
||||
if (moving) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { OVERVIEW_PERIODS, type OverviewPeriod } from "@/shared/overview";
|
||||
import { getOverview } from "../service";
|
||||
import { OverviewChart } from "./overview-chart";
|
||||
|
||||
type Search = Record<string, string | string[] | undefined>;
|
||||
|
||||
const periodOf = (x: unknown): OverviewPeriod =>
|
||||
OVERVIEW_PERIODS.includes(x as OverviewPeriod) ? (x as OverviewPeriod) : "month";
|
||||
|
||||
/** Dashboard ana grafiği (sunucu): ?periyot=week|month|half|year&gorunum=kumulatif|donemsel. */
|
||||
export async function OverviewCard({
|
||||
userId,
|
||||
householdId,
|
||||
searchParams,
|
||||
}: {
|
||||
userId: string;
|
||||
householdId: string;
|
||||
searchParams: Promise<Search>;
|
||||
}) {
|
||||
const q = await searchParams;
|
||||
const data = await getOverview(userId, householdId, periodOf(q.periyot));
|
||||
if (!data) return null;
|
||||
return <OverviewChart data={data} view={q.gorunum === "donemsel" ? "periodic" : "cumulative"} />;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { cumulative, OVERVIEW_PERIODS, type OverviewPeriod } from "@/shared/overview";
|
||||
import type { OverviewData } from "../service";
|
||||
|
||||
const t = tr.dashboard.overview;
|
||||
export type OverviewView = "cumulative" | "periodic";
|
||||
|
||||
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||
const dayShort = new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" });
|
||||
const weekdayShort = new Intl.DateTimeFormat("tr-TR", { weekday: "short", day: "numeric" });
|
||||
const monthShort = new Intl.DateTimeFormat("tr-TR", { month: "short", year: "2-digit" });
|
||||
const monthLong = new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" });
|
||||
|
||||
const config = {
|
||||
plannedIncome: { label: t.series.plannedIncome, color: "var(--income)" },
|
||||
budget: { label: t.series.budget, color: "var(--budget)" },
|
||||
actual: { label: t.series.actual, color: "var(--expense)" },
|
||||
} satisfies ChartConfig;
|
||||
const SERIES = ["plannedIncome", "budget", "actual"] as const;
|
||||
|
||||
const asDate = (key: string) =>
|
||||
new Date(key.length === 7 ? `${key}-15T12:00:00+03:00` : `${key}T12:00:00+03:00`);
|
||||
|
||||
function bucketLabel(key: string, period: OverviewPeriod) {
|
||||
const d = asDate(key);
|
||||
if (key.length === 7) return monthShort.format(d);
|
||||
return period === "week" ? weekdayShort.format(d) : dayShort.format(d);
|
||||
}
|
||||
|
||||
/** Bölüm 6.13 ana çizgi grafik: periyot seçici ve kümülatif / dönemsel görünüm (URL'de). */
|
||||
export function OverviewChart({ data, view }: { data: OverviewData; view: OverviewView }) {
|
||||
const points = view === "cumulative" ? cumulative(data.points) : data.points;
|
||||
const chart = points.map((p) => ({
|
||||
key: p.key,
|
||||
label: bucketLabel(p.key, data.period),
|
||||
plannedIncome: p.plannedIncome === null ? null : p.plannedIncome / 100,
|
||||
budget: p.budget === null ? null : p.budget / 100,
|
||||
actual: p.actual === null ? null : p.actual / 100,
|
||||
}));
|
||||
const hasGap = data.points.some((p) => p.budget === null);
|
||||
const href = (period: OverviewPeriod, v: OverviewView) =>
|
||||
`?periyot=${period}&gorunum=${v === "cumulative" ? "kumulatif" : "donemsel"}`;
|
||||
const rangeText =
|
||||
data.range.granularity === "month"
|
||||
? `${monthLong.format(asDate(data.range.buckets[0]!))} – ${monthLong.format(asDate(data.range.buckets.at(-1)!))}`
|
||||
: `${formatDate(asDate(data.range.from))} – ${formatDate(asDate(data.range.to))}`;
|
||||
const pill = (active: boolean) =>
|
||||
cn(
|
||||
"rounded-md px-2.5 py-1 text-sm transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
active
|
||||
? "bg-background font-medium shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
);
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader className="flex flex-wrap items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||
<CardDescription>{t.description(rangeText)}</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<nav aria-label={t.periodLabel} className="flex rounded-lg bg-muted p-0.5">
|
||||
{OVERVIEW_PERIODS.map((p) => (
|
||||
<Link
|
||||
key={p}
|
||||
href={href(p, view)}
|
||||
scroll={false}
|
||||
replace
|
||||
aria-current={p === data.period ? "page" : undefined}
|
||||
className={pill(p === data.period)}
|
||||
>
|
||||
{t.periods[p]}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<nav aria-label={t.viewLabel} className="flex rounded-lg bg-muted p-0.5">
|
||||
{(["cumulative", "periodic"] as const).map((v) => (
|
||||
<Link
|
||||
key={v}
|
||||
href={href(data.period, v)}
|
||||
scroll={false}
|
||||
replace
|
||||
aria-current={v === view ? "page" : undefined}
|
||||
className={pill(v === view)}
|
||||
>
|
||||
{t.views[v]}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="aspect-auto h-64 w-full"
|
||||
aria-label={t.chartLabel}
|
||||
role="img"
|
||||
>
|
||||
<LineChart data={chart} margin={{ left: 4, right: 12, top: 8 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={16}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickFormatter={(v: number) => compact.format(v)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const key = payload?.[0]?.payload?.key as string | undefined;
|
||||
if (!key) return "";
|
||||
return key.length === 7
|
||||
? monthLong.format(asDate(key))
|
||||
: formatDate(asDate(key));
|
||||
}}
|
||||
formatter={(value, name) => (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{config[name as keyof typeof config]?.label}
|
||||
</span>
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{formatMoney(Math.round(Number(value) * 100))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
{SERIES.map((k) => (
|
||||
<Line
|
||||
key={k}
|
||||
dataKey={k}
|
||||
type="linear"
|
||||
stroke={`var(--color-${k})`}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={k === "budget" ? "6 3" : undefined}
|
||||
dot={
|
||||
data.range.granularity === "month" || data.period === "week" ? { r: 3 } : false
|
||||
}
|
||||
activeDot={{ r: 5 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
{hasGap ? <p className="text-xs text-muted-foreground">{t.noBudget}</p> : null}
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground">{t.table}</summary>
|
||||
<div className="mt-2 max-h-80 overflow-y-auto">
|
||||
<table className="w-full text-right tabular-nums">
|
||||
<thead className="sticky top-0 bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-1 text-left font-normal">{t.bucket[data.range.granularity]}</th>
|
||||
{SERIES.map((k) => (
|
||||
<th key={k} className="py-1 font-normal">
|
||||
{t.series[k]}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{points.map((p) => (
|
||||
<tr key={p.key}>
|
||||
<td className="py-1 text-left">
|
||||
{p.key.length === 7
|
||||
? monthLong.format(asDate(p.key))
|
||||
: formatDate(asDate(p.key))}
|
||||
</td>
|
||||
{SERIES.map((k) => (
|
||||
<td key={k} className="py-1">
|
||||
{p[k] === null ? "—" : formatMoney(p[k])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import "server-only";
|
||||
import { budgetFor, settingsOf } from "@/features/budgets/service";
|
||||
import { incomeByDay, incomePlan, monthDailyPlan } from "@/features/budgets/plan";
|
||||
import { loadContext } from "@/features/transactions/service";
|
||||
import { budgetPeriod, periodOfDate } from "@/shared/budget";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import {
|
||||
overviewRange,
|
||||
type OverviewPeriod,
|
||||
type OverviewPoint,
|
||||
type OverviewRange,
|
||||
} from "@/shared/overview";
|
||||
|
||||
/**
|
||||
* Dashboard ana grafiği (Bölüm 6.13). Bütçe ve gelir planı motorlarıyla aynı kurallar: bütçe ayı,
|
||||
* taksit modu ve gizli hesap ayarı.
|
||||
*
|
||||
* Aylık noktalar: beklenen gelir (gerçekleşen + planlı + elle), bütçe toplamı (bütçe yoksa boş),
|
||||
* gerçekleşen gider. Günlük noktalar: o günün gelirleri (elle eklenen beklenen gelir dönemin ilk
|
||||
* gününe), günlük plan payı, o günün gerçekleşen gideri; gelecek günlerde gerçekleşen boş.
|
||||
*/
|
||||
export type OverviewData = {
|
||||
period: OverviewPeriod;
|
||||
range: OverviewRange;
|
||||
points: OverviewPoint[];
|
||||
today: string;
|
||||
};
|
||||
|
||||
export async function getOverview(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
period: OverviewPeriod,
|
||||
): Promise<OverviewData | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const s = await settingsOf(ctx.m.householdId);
|
||||
const today = todayIstanbul();
|
||||
const range = overviewRange(period, today, s.startDay);
|
||||
|
||||
if (range.granularity === "month") {
|
||||
const points = await Promise.all(
|
||||
range.buckets.map(async (month) => {
|
||||
const [budget, income] = await Promise.all([
|
||||
budgetFor(ctx, s, month),
|
||||
incomePlan(ctx, s, month),
|
||||
]);
|
||||
return {
|
||||
key: month,
|
||||
plannedIncome: income.expectedMinor,
|
||||
budget: budget.hasBudget ? budget.totals.limitMinor : null,
|
||||
actual: budget.totals.actualMinor,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return { period, range, points, today };
|
||||
}
|
||||
|
||||
// Günlük: aralığın dokunduğu bütçe ayları (hafta iki aya yayılabilir).
|
||||
const months = [...new Set([range.from, range.to].map((d) => periodOfDate(d, s.startDay)))];
|
||||
const planned = new Map<string, number | null>();
|
||||
const actual = new Map<string, number>();
|
||||
const income = new Map<string, number>();
|
||||
for (const month of months) {
|
||||
const [budget, plan] = await Promise.all([budgetFor(ctx, s, month), incomePlan(ctx, s, month)]);
|
||||
const dp = await monthDailyPlan(ctx, s, month, budget.totals.limitMinor, today);
|
||||
for (const d of dp.days) {
|
||||
planned.set(d.date, budget.hasBudget ? d.plannedMinor : null);
|
||||
actual.set(d.date, d.actualMinor);
|
||||
}
|
||||
const first = budgetPeriod(month, s.startDay).from;
|
||||
if (plan.manualMinor) income.set(first, (income.get(first) ?? 0) + plan.manualMinor);
|
||||
}
|
||||
for (const [d, v] of await incomeByDay(ctx, s, range.from, range.to))
|
||||
income.set(d, (income.get(d) ?? 0) + v);
|
||||
|
||||
const points = range.buckets.map((d) => ({
|
||||
key: d,
|
||||
plannedIncome: income.get(d) ?? 0,
|
||||
budget: planned.get(d) ?? null,
|
||||
actual: d <= today ? (actual.get(d) ?? 0) : null,
|
||||
}));
|
||||
return { period, range, points, today };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"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 { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||
import { parseMoney } from "@/shared/money";
|
||||
import {
|
||||
commitImport,
|
||||
createImportAccount,
|
||||
createImportDraft,
|
||||
deleteImportDraft,
|
||||
MAX_IMPORT_BYTES,
|
||||
revertImport,
|
||||
setImportAccount,
|
||||
updateImportRow,
|
||||
} from "./service";
|
||||
|
||||
const t = tr.imports;
|
||||
const id = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
const role = z.enum(["card", "bank", "cash", "extra"]);
|
||||
|
||||
async function ctx() {
|
||||
return getHouseholdActionContext("member");
|
||||
}
|
||||
|
||||
function done<T>(r: ActionResult<T>) {
|
||||
if (r.ok) revalidatePath("/", "layout");
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Dosya yükleme (FormData: file). Yalnızca .xlsx; boyut sınırı Server Action gövdesiyle uyumlu. */
|
||||
export async function uploadImportAction(form: FormData) {
|
||||
const c = await ctx();
|
||||
if (!c) return fail(tr.common.unauthorized);
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File) || file.size === 0) return fail(t.errors.noFile);
|
||||
if (file.size > MAX_IMPORT_BYTES) return fail(t.errors.tooLarge);
|
||||
if (!/\.xlsx$/i.test(file.name)) return fail(t.errors.unreadable);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
return done(await createImportDraft(c.user.id, c.household.id, { name: file.name, buffer }));
|
||||
}
|
||||
|
||||
export async function setImportAccountAction(batchId: unknown, r: unknown, accountId: unknown) {
|
||||
const c = await ctx();
|
||||
const p = z
|
||||
.object({ batchId: id, role, accountId: id })
|
||||
.safeParse({ batchId, role: r, accountId });
|
||||
if (!c || !p.success) return fail(tr.common.notFound);
|
||||
return done(
|
||||
await setImportAccount(
|
||||
c.user.id,
|
||||
c.household.id,
|
||||
p.data.batchId,
|
||||
p.data.role,
|
||||
p.data.accountId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createImportAccountAction(batchId: unknown, r: unknown, input: unknown) {
|
||||
const c = await ctx();
|
||||
const p = z
|
||||
.object({
|
||||
batchId: id,
|
||||
role,
|
||||
name: z.string().trim().min(1, tr.common.required).max(80),
|
||||
opening: z.string().trim(),
|
||||
openingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
})
|
||||
.safeParse({ batchId, role: r, ...(input as object) });
|
||||
if (!c || !p.success) return fail(tr.adminUsers.errors.checkFields);
|
||||
const minor = p.data.opening ? parseMoney(p.data.opening.replace("−", "-")) : 0;
|
||||
if (minor === null) return fail(tr.validation.money);
|
||||
return done(
|
||||
await createImportAccount(c.user.id, c.household.id, p.data.batchId, p.data.role, {
|
||||
name: p.data.name,
|
||||
openingMinor: minor,
|
||||
openingDate: p.data.openingDate,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateImportRowAction(batchId: unknown, edit: unknown) {
|
||||
const c = await ctx();
|
||||
const p = z
|
||||
.object({
|
||||
batchId: id,
|
||||
rowId: id,
|
||||
kind: z.enum(["expense", "income", "skip"]).optional(),
|
||||
categoryId: z
|
||||
.union([id, z.string().regex(/^new:[a-z.-]+$/)])
|
||||
.nullable()
|
||||
.optional(),
|
||||
kakeiboGroup: z.enum(KAKEIBO_GROUPS).nullable().optional(),
|
||||
applyToSame: z.boolean().optional(),
|
||||
})
|
||||
.safeParse({ batchId, ...(edit as object) });
|
||||
if (!c || !p.success) return fail(tr.common.notFound);
|
||||
const { batchId: b, ...rest } = p.data;
|
||||
// Satır düzenlemesi sık; tüm sayfayı yenilemeye gerek yok.
|
||||
return updateImportRow(c.user.id, c.household.id, b, rest);
|
||||
}
|
||||
|
||||
export async function commitImportAction(batchId: unknown) {
|
||||
const c = await ctx();
|
||||
const p = id.safeParse(batchId);
|
||||
if (!c || !p.success) return fail(tr.common.notFound);
|
||||
return done(await commitImport(c.user.id, c.household.id, p.data));
|
||||
}
|
||||
|
||||
export async function revertImportAction(batchId: unknown) {
|
||||
const c = await ctx();
|
||||
const p = id.safeParse(batchId);
|
||||
if (!c || !p.success) return fail(tr.common.notFound);
|
||||
return done(await revertImport(c.user.id, c.household.id, p.data));
|
||||
}
|
||||
|
||||
export async function deleteImportDraftAction(batchId: unknown) {
|
||||
const c = await ctx();
|
||||
const p = id.safeParse(batchId);
|
||||
if (!c || !p.success) return fail(tr.common.notFound);
|
||||
return done(await deleteImportDraft(c.user.id, c.household.id, p.data));
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Loader2,
|
||||
Plus,
|
||||
Undo2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
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 { Field, FieldDescription, 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 { KakeiboSelect } from "@/features/transactions/components/kakeibo-select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AccountType } from "@/shared/accounts";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import type { ImportRole } from "@/shared/import/kakeibo-workbook";
|
||||
import type { KakeiboGroup } from "@/shared/kakeibo";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import {
|
||||
commitImportAction,
|
||||
createImportAccountAction,
|
||||
deleteImportDraftAction,
|
||||
revertImportAction,
|
||||
setImportAccountAction,
|
||||
updateImportRowAction,
|
||||
} from "../actions";
|
||||
import type { ImportDraft, ImportRowView } from "../service";
|
||||
|
||||
const t = tr.imports;
|
||||
const PAGE = 50;
|
||||
const ROLE_TYPES: Record<string, AccountType[]> = {
|
||||
card: ["credit_card"],
|
||||
bank: ["bank"],
|
||||
cash: ["cash"],
|
||||
extra: ["bank", "cash"],
|
||||
};
|
||||
type Filter = "all" | "review" | "ungrouped" | "transfer" | "skip";
|
||||
|
||||
const day = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
|
||||
function Step({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-xl border bg-card p-4">
|
||||
<h2 className="font-medium">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleRow({ draft, role }: { draft: ImportDraft; role: ImportRole }) {
|
||||
const router = useRouter();
|
||||
const preset = draft.presets[role];
|
||||
const current = draft.accounts[role] ?? "";
|
||||
const options = draft.accountOptions.filter((a) => ROLE_TYPES[role]?.includes(a.type));
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const isCard = role === "card";
|
||||
const [v, setV] = useState({
|
||||
name: preset?.name ?? "",
|
||||
opening: preset ? formatMoneyInput(Math.abs(preset.openingMinor)) : "",
|
||||
sign: (preset?.openingMinor ?? 0) < 0 ? "-" : "",
|
||||
openingDate: preset?.openingDate ?? "",
|
||||
});
|
||||
|
||||
async function pick(accountId: string) {
|
||||
const r = await setImportAccountAction(draft.id, role, accountId);
|
||||
if (!r.ok) toast.error(r.error);
|
||||
else router.refresh();
|
||||
}
|
||||
async function create() {
|
||||
setBusy(true);
|
||||
const r = await createImportAccountAction(draft.id, role, {
|
||||
name: v.name,
|
||||
opening: `${v.sign}${v.opening}`,
|
||||
openingDate: v.openingDate,
|
||||
});
|
||||
setBusy(false);
|
||||
if (!r.ok) toast.error(r.error);
|
||||
else {
|
||||
setCreating(false);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium">
|
||||
{current ? (
|
||||
<CheckCircle2 className="size-4 text-income" aria-hidden />
|
||||
) : (
|
||||
<AlertTriangle className="size-4 text-amber-600" aria-hidden />
|
||||
)}
|
||||
{t.roles[role]}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t.roleHints[role]}</p>
|
||||
</div>
|
||||
<Select value={current || undefined} onValueChange={pick}>
|
||||
<SelectTrigger className="w-56" aria-label={t.roles[role]}>
|
||||
<SelectValue placeholder={t.selectAccount} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{draft.canCreateAccounts && preset && !current ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setCreating((x) => !x)}>
|
||||
<Plus />
|
||||
{t.createSuggested}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{creating && preset ? (
|
||||
<div className="grid gap-3 rounded-lg border bg-muted/30 p-3 sm:grid-cols-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`acc-${role}-name`}>{t.accountName}</FieldLabel>
|
||||
<Input
|
||||
id={`acc-${role}-name`}
|
||||
value={v.name}
|
||||
onChange={(e) => setV((s) => ({ ...s, name: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`acc-${role}-open`}>
|
||||
{isCard ? t.openingDebt : t.openingBalance}
|
||||
</FieldLabel>
|
||||
<div className="flex gap-1">
|
||||
{!isCard ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="±"
|
||||
onClick={() => setV((s) => ({ ...s, sign: s.sign ? "" : "-" }))}
|
||||
>
|
||||
{v.sign ? "−" : "+"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Input
|
||||
id={`acc-${role}-open`}
|
||||
inputMode="decimal"
|
||||
value={v.opening}
|
||||
onChange={(e) => setV((s) => ({ ...s, opening: e.target.value }))}
|
||||
className="text-right tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`acc-${role}-date`}>{t.openingDate}</FieldLabel>
|
||||
<Input
|
||||
id={`acc-${role}-date`}
|
||||
type="date"
|
||||
value={v.openingDate}
|
||||
onChange={(e) => setV((s) => ({ ...s, openingDate: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
<FieldDescription className="sm:col-span-2">
|
||||
{isCard ? t.openingHintCard : role === "bank" ? t.openingHintBank : ""}
|
||||
</FieldDescription>
|
||||
<Button className="sm:justify-self-end" onClick={create} disabled={busy}>
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}
|
||||
{tr.common.create}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function RowItem({
|
||||
row,
|
||||
draft,
|
||||
onEdit,
|
||||
}: {
|
||||
row: ImportRowView;
|
||||
draft: ImportDraft;
|
||||
onEdit: (
|
||||
row: ImportRowView,
|
||||
patch: Partial<Pick<ImportRowView, "kind" | "categoryId" | "kakeiboGroup">>,
|
||||
) => void;
|
||||
}) {
|
||||
const cats = draft.categories.filter(
|
||||
(c) => c.kind === (row.kind === "income" ? "income" : "expense"),
|
||||
);
|
||||
const cat = draft.categories.find((c) => c.id === row.categoryId);
|
||||
const editable =
|
||||
!row.duplicate && (row.kind === "expense" || row.kind === "income" || row.kind === "skip");
|
||||
const muted = row.duplicate || row.kind === "skip" || row.kind === "deferred";
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"flex flex-col gap-2 p-3 text-sm",
|
||||
muted && "bg-muted/30 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="w-20 shrink-0 tabular-nums">{day(row.date)}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{row.description || "—"}</span>
|
||||
<span className="mt-0.5 flex flex-wrap gap-1">
|
||||
{row.installment ? (
|
||||
<Badge variant="outline">
|
||||
{t.installment(row.installment.index, row.installment.count)}
|
||||
</Badge>
|
||||
) : null}
|
||||
{row.duplicate ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Copy className="size-3" aria-hidden />
|
||||
{t.duplicate}
|
||||
</Badge>
|
||||
) : null}
|
||||
{row.review && !row.duplicate ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
<AlertTriangle className="size-3" aria-hidden />
|
||||
{t.reviews[row.review] ?? row.review}
|
||||
</Badge>
|
||||
) : null}
|
||||
{row.source.category || row.source.type ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.source.category || row.source.type}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-right font-medium tabular-nums",
|
||||
row.amountMinor < 0 ? "" : "text-income",
|
||||
)}
|
||||
>
|
||||
{row.amountMinor < 0 ? "−" : "+"}
|
||||
{formatMoney(Math.abs(row.amountMinor))}
|
||||
</span>
|
||||
</div>
|
||||
{row.kind === "transfer" ? (
|
||||
<p className="pl-23 text-xs">
|
||||
{t.kinds.transfer}{" "}
|
||||
{row.amountMinor < 0
|
||||
? t.transferTo(t.roles[row.counterRole ?? ""] ?? "")
|
||||
: t.transferFrom(t.roles[row.counterRole ?? ""] ?? "")}
|
||||
</p>
|
||||
) : row.kind === "deferred" ? (
|
||||
<p className="pl-23 text-xs">
|
||||
{t.kinds.deferred}: {t.roles.gold}
|
||||
</p>
|
||||
) : editable ? (
|
||||
<div className="grid gap-2 sm:grid-cols-[8rem_minmax(0,1fr)_12rem] sm:pl-23">
|
||||
<Select
|
||||
value={row.kind}
|
||||
onValueChange={(k) => onEdit(row, { kind: k as ImportRowView["kind"] })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-sm" aria-label={t.columns.kind}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["expense", "income", "skip"] as const).map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{t.kinds[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{row.kind !== "skip" ? (
|
||||
<Select
|
||||
value={row.categoryId ?? undefined}
|
||||
onValueChange={(c) => onEdit(row, { categoryId: c })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-sm" aria-label={t.columns.category}>
|
||||
<SelectValue placeholder={tr.transactions.fields.selectCategory} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{cats.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.path}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{row.kind === "expense" ? (
|
||||
<KakeiboSelect
|
||||
compact
|
||||
value={row.kakeiboGroup && row.kakeiboGroup !== cat?.group ? row.kakeiboGroup : ""}
|
||||
onChange={(g) =>
|
||||
onEdit(row, { kakeiboGroup: (g || cat?.group || null) as KakeiboGroup | null })
|
||||
}
|
||||
suggested={cat?.group ?? null}
|
||||
bankCost={cat?.bankCost ?? false}
|
||||
ariaLabel={t.columns.group}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** /islemler/ice-aktar/:id — hesaplar, kontroller, önizleme ve işleme. */
|
||||
export function ImportWizard({ draft }: { draft: ImportDraft }) {
|
||||
const router = useRouter();
|
||||
const [rows, setRows] = useState(draft.rows);
|
||||
const [sheet, setSheet] = useState<"card" | "bank">(
|
||||
draft.rows.some((r) => r.sheet === "card") ? "card" : "bank",
|
||||
);
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [page, setPage] = useState(0);
|
||||
const [applyToSame, setApplyToSame] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmRevert, setConfirmRevert] = useState(false);
|
||||
const isDraft = draft.status === "draft";
|
||||
const catOf = (id: string | null) => draft.categories.find((c) => c.id === id);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const ofSheet = rows.filter((r) => r.sheet === sheet);
|
||||
switch (filter) {
|
||||
case "review":
|
||||
return ofSheet.filter((r) => r.review && !r.duplicate);
|
||||
case "ungrouped":
|
||||
return ofSheet.filter(
|
||||
(r) =>
|
||||
r.kind === "expense" &&
|
||||
!r.duplicate &&
|
||||
!(r.kakeiboGroup ?? catOf(r.categoryId)?.group) &&
|
||||
!catOf(r.categoryId)?.bankCost,
|
||||
);
|
||||
case "transfer":
|
||||
return ofSheet.filter((r) => r.kind === "transfer");
|
||||
case "skip":
|
||||
return ofSheet.filter((r) => r.kind === "skip" || r.kind === "deferred" || r.duplicate);
|
||||
default:
|
||||
return ofSheet;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows, sheet, filter]);
|
||||
const pageRows = visible.slice(page * PAGE, page * PAGE + PAGE);
|
||||
|
||||
const toCreate = rows.filter((r) => !r.duplicate && r.kind !== "skip" && r.kind !== "deferred");
|
||||
const missingCategory = toCreate.filter(
|
||||
(r) => (r.kind === "expense" || r.kind === "income") && !r.categoryId,
|
||||
);
|
||||
const missingRoles = draft.neededRoles.filter((r) => !draft.accounts[r]);
|
||||
const deferredRows = rows.filter((r) => r.kind === "deferred");
|
||||
const deferred = deferredRows.length;
|
||||
const deferredNet = deferredRows.reduce((sum, r) => sum + r.amountMinor, 0);
|
||||
const duplicates = rows.filter((r) => r.duplicate).length;
|
||||
const okStatements = draft.checks.statements.filter((s) => s.ok).length;
|
||||
|
||||
async function edit(
|
||||
row: ImportRowView,
|
||||
patch: Partial<Pick<ImportRowView, "kind" | "categoryId" | "kakeiboGroup">>,
|
||||
) {
|
||||
const same = (x: ImportRowView) =>
|
||||
applyToSame
|
||||
? x.sheet === row.sheet &&
|
||||
x.description.toLocaleLowerCase("tr-TR") === row.description.toLocaleLowerCase("tr-TR") &&
|
||||
x.kind === row.kind
|
||||
: x.id === row.id;
|
||||
const before = rows;
|
||||
setRows((rs) => rs.map((x) => (same(x) ? { ...x, ...patch, edited: true } : x)));
|
||||
const r = await updateImportRowAction(draft.id, { rowId: row.id, ...patch, applyToSame });
|
||||
if (!r.ok) {
|
||||
setRows(before);
|
||||
toast.error(r.error);
|
||||
}
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
setBusy(true);
|
||||
const r = await commitImportAction(draft.id);
|
||||
setBusy(false);
|
||||
if (!r.ok) toast.error(r.error);
|
||||
else {
|
||||
toast.success(t.committed(r.data.transactions));
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function revert() {
|
||||
setConfirmRevert(false);
|
||||
setBusy(true);
|
||||
const r = await revertImportAction(draft.id);
|
||||
setBusy(false);
|
||||
if (!r.ok) toast.error(r.error);
|
||||
else {
|
||||
toast.success(t.reverted(r.data.transactions));
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDraft() {
|
||||
const r = await deleteImportDraftAction(draft.id);
|
||||
if (!r.ok) toast.error(r.error);
|
||||
else router.push("/islemler/ice-aktar");
|
||||
}
|
||||
|
||||
const filters: Filter[] = ["all", "review", "ungrouped", "transfer", "skip"];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/islemler/ice-aktar">
|
||||
<ArrowLeft />
|
||||
{t.back}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader
|
||||
title={draft.fileName}
|
||||
description={
|
||||
<>
|
||||
{t.statuses[draft.status]} · {t.rowsCount(draft.rowCount)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{!isDraft ? (
|
||||
<Step title={t.statuses[draft.status]!}>
|
||||
{draft.committed ? (
|
||||
<p className="text-sm">
|
||||
{t.committedSummary(
|
||||
draft.committed.transactions,
|
||||
draft.committed.skipped,
|
||||
draft.committed.categoriesCreated,
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/islemler">{t.goTransactions}</Link>
|
||||
</Button>
|
||||
{draft.status === "committed" ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setConfirmRevert(true)}
|
||||
disabled={busy}
|
||||
>
|
||||
<Undo2 />
|
||||
{t.revert}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</Step>
|
||||
) : null}
|
||||
|
||||
{isDraft && draft.neededRoles.length ? (
|
||||
<Step title={t.steps.accounts}>
|
||||
{!draft.canCreateAccounts ? (
|
||||
<p className="text-xs text-muted-foreground">{t.ownerOnly}</p>
|
||||
) : null}
|
||||
<ul className="divide-y">
|
||||
{draft.neededRoles.map((role) => (
|
||||
<RoleRow key={role} draft={draft} role={role} />
|
||||
))}
|
||||
</ul>
|
||||
</Step>
|
||||
) : null}
|
||||
|
||||
<Step title={t.steps.checks}>
|
||||
<ul className="flex flex-col gap-1.5 text-sm">
|
||||
{draft.checks.statements.length ? (
|
||||
<li className="flex items-start gap-2">
|
||||
{okStatements === draft.checks.statements.length ? (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-income" aria-hidden />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 size-4 shrink-0 text-expense" aria-hidden />
|
||||
)}
|
||||
<span>
|
||||
{t.statementsSummary(okStatements, draft.checks.statements.length)}
|
||||
{draft.checks.statements
|
||||
.filter((s) => !s.ok)
|
||||
.map((s) => (
|
||||
<span key={s.cut} className="block text-xs text-expense">
|
||||
{t.statementBad(
|
||||
day(s.cut),
|
||||
formatMoney(s.rowsMinor),
|
||||
s.statementMinor === null ? "—" : formatMoney(s.statementMinor),
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
) : (
|
||||
<li className="text-muted-foreground">{t.noStatements}</li>
|
||||
)}
|
||||
{draft.checks.balance.closingMinor !== null ? (
|
||||
<li className="flex items-start gap-2">
|
||||
{draft.checks.balance.mismatchLine === null ? (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-income" aria-hidden />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 size-4 shrink-0 text-expense" aria-hidden />
|
||||
)}
|
||||
{draft.checks.balance.mismatchLine === null
|
||||
? t.balanceOk(formatMoney(draft.checks.balance.closingMinor))
|
||||
: t.balanceBad(draft.checks.balance.mismatchLine)}
|
||||
</li>
|
||||
) : null}
|
||||
{duplicates ? (
|
||||
<li className="text-muted-foreground">{t.duplicates(duplicates)}</li>
|
||||
) : null}
|
||||
{deferred ? (
|
||||
<li className="text-muted-foreground">
|
||||
{t.deferred(
|
||||
deferred,
|
||||
`${deferredNet < 0 ? "−" : "+"}${formatMoney(Math.abs(deferredNet))}`,
|
||||
)}
|
||||
</li>
|
||||
) : null}
|
||||
{isDraft && draft.newCategories.length ? (
|
||||
<li className="text-muted-foreground">
|
||||
{t.newCategories(draft.newCategories.join(", "))}
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</Step>
|
||||
|
||||
<Step title={t.steps.rows}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tabs
|
||||
value={sheet}
|
||||
onValueChange={(x) => {
|
||||
setSheet(x as "card" | "bank");
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
{(["card", "bank"] as const)
|
||||
.filter((s) => rows.some((r) => r.sheet === s))
|
||||
.map((s) => (
|
||||
<TabsTrigger key={s} value={s}>
|
||||
{t.sheets[s]} ({rows.filter((r) => r.sheet === s).length})
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{filters.map((f) => (
|
||||
<Button
|
||||
key={f}
|
||||
size="sm"
|
||||
variant={filter === f ? "secondary" : "ghost"}
|
||||
onClick={() => {
|
||||
setFilter(f);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
{t.filters[f]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{isDraft ? (
|
||||
<label className="ml-auto flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={applyToSame} onCheckedChange={(x) => setApplyToSame(x === true)} />
|
||||
{t.applyToSame}
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
<ul
|
||||
className={cn("divide-y rounded-lg border", !isDraft && "pointer-events-none opacity-90")}
|
||||
>
|
||||
{pageRows.map((r) => (
|
||||
<RowItem key={r.id} row={r} draft={draft} onEdit={edit} />
|
||||
))}
|
||||
{!pageRows.length ? (
|
||||
<li className="p-6 text-center text-sm text-muted-foreground">{t.empty}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
{visible.length > PAGE ? (
|
||||
<div className="flex items-center justify-end gap-2 text-sm">
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{t.page(page * PAGE + 1, Math.min(visible.length, (page + 1) * PAGE), visible.length)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
{t.prev}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={(page + 1) * PAGE >= visible.length}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t.next}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</Step>
|
||||
|
||||
{isDraft ? (
|
||||
<Step title={t.steps.commit}>
|
||||
<p className="text-sm">
|
||||
{t.commitSummary(toCreate.length, rows.length - toCreate.length)}
|
||||
</p>
|
||||
{missingRoles.map((r) => (
|
||||
<p key={r} className="text-sm text-expense">
|
||||
{t.errors.roleMissing(t.roles[r] ?? r)}
|
||||
</p>
|
||||
))}
|
||||
{missingCategory.length ? (
|
||||
<p className="text-sm text-expense">
|
||||
{t.errors.category(String(missingCategory.length))}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">{t.commitHint}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={commit}
|
||||
disabled={busy || missingRoles.length > 0 || missingCategory.length > 0}
|
||||
>
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}
|
||||
{busy ? t.committing : t.commit}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={removeDraft} disabled={busy}>
|
||||
{t.deleteDraft}
|
||||
</Button>
|
||||
</div>
|
||||
</Step>
|
||||
) : null}
|
||||
|
||||
<AlertDialog open={confirmRevert} onOpenChange={setConfirmRevert}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.revertTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t.revertDescription}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={revert}>
|
||||
{t.revert}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheet, Loader2, Upload } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { uploadImportAction } from "../actions";
|
||||
|
||||
const t = tr.imports;
|
||||
|
||||
/** Kakeibo çalışma kitabı yükleme: dosya seçilince okunur ve taslağa gidilir. */
|
||||
export function UploadCard() {
|
||||
const router = useRouter();
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function onPick(file: File | undefined) {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
const r = await uploadImportAction(form);
|
||||
setBusy(false);
|
||||
if (input.current) input.current.value = "";
|
||||
if (!r.ok) {
|
||||
toast.error(r.error);
|
||||
return;
|
||||
}
|
||||
router.push(`/islemler/ice-aktar/${r.data.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col items-start gap-3 rounded-xl border border-dashed bg-card p-5 sm:flex-row sm:items-center">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<FileSpreadsheet className="size-5 text-muted-foreground" aria-hidden />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 text-sm text-muted-foreground">{t.uploadHint}</p>
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
className="sr-only"
|
||||
id="import-file"
|
||||
onChange={(e) => onPick(e.target.files?.[0])}
|
||||
/>
|
||||
<Button onClick={() => input.current?.click()} disabled={busy}>
|
||||
{busy ? <Loader2 className="animate-spin" /> : <Upload />}
|
||||
{busy ? t.uploading : t.upload}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { listAccounts } from "@/features/accounts/service";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
commitImport,
|
||||
createImportAccount,
|
||||
createImportDraft,
|
||||
getImportDraft,
|
||||
revertImport,
|
||||
updateImportRow,
|
||||
} from "./service";
|
||||
|
||||
/** Yapay Kakeibo çalışma kitabı (gerçek veri değil; yapı gerçek dosyayla aynı). */
|
||||
const serial = (ymd: string) =>
|
||||
Date.UTC(+ymd.slice(0, 4), +ymd.slice(5, 7) - 1, +ymd.slice(8, 10)) / 86_400_000 + 25569;
|
||||
|
||||
function workbook(): Buffer {
|
||||
const card = [
|
||||
[
|
||||
"İşlem tarihi",
|
||||
"Ekstre kesim",
|
||||
"İşyeri",
|
||||
"Kategori",
|
||||
"Kakeibo",
|
||||
"Kart",
|
||||
"Taksit",
|
||||
"Tutar (TL)",
|
||||
],
|
||||
[
|
||||
serial("2026-01-05"),
|
||||
serial("2026-01-28"),
|
||||
"Test Market",
|
||||
"Market & gıda",
|
||||
"Zorunlu",
|
||||
"Fiziksel kart",
|
||||
null,
|
||||
100,
|
||||
],
|
||||
[
|
||||
serial("2026-01-06"),
|
||||
serial("2026-01-28"),
|
||||
"Test Market",
|
||||
"Market & gıda",
|
||||
"İstek",
|
||||
"Fiziksel kart",
|
||||
null,
|
||||
40,
|
||||
],
|
||||
[
|
||||
serial("2025-12-10"),
|
||||
serial("2026-01-28"),
|
||||
"Test Yapı (taksit)",
|
||||
"Ev / tadilat / yapı market",
|
||||
"Beklenmeyen",
|
||||
"Fiziksel kart",
|
||||
"1/3 (300,00 TL)",
|
||||
100,
|
||||
],
|
||||
[
|
||||
serial("2025-12-10"),
|
||||
serial("2026-02-28"),
|
||||
"Test Yapı (taksit)",
|
||||
"Ev / tadilat / yapı market",
|
||||
"Beklenmeyen",
|
||||
"Fiziksel kart",
|
||||
"2/3 (300,00 TL)",
|
||||
100,
|
||||
],
|
||||
[
|
||||
serial("2026-01-07"),
|
||||
serial("2026-01-28"),
|
||||
"Test Esnaf",
|
||||
"Tanımsız esnaf",
|
||||
null,
|
||||
"Fiziksel kart",
|
||||
null,
|
||||
20,
|
||||
],
|
||||
[
|
||||
serial("2026-01-08"),
|
||||
serial("2026-01-28"),
|
||||
"Kampanya iadesi",
|
||||
"Kampanya iadesi",
|
||||
"İstek",
|
||||
"Fiziksel kart",
|
||||
null,
|
||||
-10,
|
||||
],
|
||||
];
|
||||
const bank = [
|
||||
["Tarih", "Açıklama", "Tür", "Tutar (TL)", "Bakiye (TL)", "Ay"],
|
||||
[serial("2025-12-31"), "Devreden bakiye", "Açılış", null, 1000, null],
|
||||
[serial("2026-01-02"), "Yol parası", "Yol parası", 500, 1500, null],
|
||||
[serial("2026-01-03"), "ATM nakit çekim", "Nakit çekim (ATM)", -200, 1300, null],
|
||||
[serial("2026-01-04"), "Kredi kartı borç ödemesi", "Kredi kartı ödemesi", -250, 1050, null],
|
||||
[serial("2026-01-05"), "Giden havale (kişi)", "Havale/EFT (kişi)", -50, 1000, null],
|
||||
[serial("2026-01-06"), "KMH faiz tahakkuku", "KMH faiz ve vergi", -5, 995, null],
|
||||
];
|
||||
const summary = [
|
||||
["Kakeibo özeti"],
|
||||
[
|
||||
"Ekstre kesim",
|
||||
"Zorunlu",
|
||||
"İstek",
|
||||
"Kültür",
|
||||
"Beklenmeyen",
|
||||
"Toplam",
|
||||
"Ekstredeki harcama",
|
||||
"Fark",
|
||||
],
|
||||
[serial("2026-01-28"), 0, 0, 0, 0, 0, 250, 0],
|
||||
[serial("2026-02-28"), 0, 0, 0, 0, 0, 100, 0],
|
||||
];
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), "Özet");
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(card), "Kredi Kartı");
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(bank), "Maaş Hesabı");
|
||||
return XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer;
|
||||
}
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let household = "";
|
||||
let ayse = "";
|
||||
let zeynep = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
// Tertemiz bir hane: Annemler'de ayse izleyici; testte Çakır yerine yeni hesaplar açılır.
|
||||
household = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
zeynep = ids.user("zeynep");
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
describe("Kakeibo çalışma kitabı içe aktarma", () => {
|
||||
let batch = "";
|
||||
const accounts: Record<string, string> = {};
|
||||
|
||||
it("taslak: öneriler, kontroller, ihtiyaç duyulan roller; izleyici aktaramaz", async () => {
|
||||
expect(
|
||||
(await createImportDraft(zeynep, household, { name: "x.xlsx", buffer: workbook() })).ok,
|
||||
).toBe(false);
|
||||
const r = await createImportDraft(ayse, household, {
|
||||
name: "kakeibo.xlsx",
|
||||
buffer: workbook(),
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
batch = r.ok ? r.data.id : "";
|
||||
const d = (await getImportDraft(ayse, household, batch))!;
|
||||
expect(d.rows).toHaveLength(11);
|
||||
expect(d.checks.statements.every((s) => s.ok)).toBe(true);
|
||||
expect(d.checks.balance).toEqual({ mismatchLine: null, closingMinor: 99_500 });
|
||||
expect(d.neededRoles).toEqual(["card", "bank", "cash"]);
|
||||
expect(d.presets.bank).toMatchObject({ openingMinor: 100_000, openingDate: "2026-01-01" });
|
||||
expect(d.presets.card).toMatchObject({ openingMinor: 25_000 });
|
||||
expect(d.newCategories).toContain("Yol parası");
|
||||
// İşlenince oluşacak kategori önizlemede eksik sayılmaz; seçilebilir.
|
||||
const yol = d.rows.find((r) => r.description === "Yol parası")!;
|
||||
expect(yol.categoryId).toBe("new:kakeibo.yol");
|
||||
expect(d.categories.find((c) => c.id === "new:kakeibo.yol")).toMatchObject({ kind: "income" });
|
||||
expect(
|
||||
d.rows.filter((r) => (r.kind === "expense" || r.kind === "income") && !r.categoryId),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("önerilen hesaplar oluşturulup rollere bağlanır", async () => {
|
||||
const d = (await getImportDraft(ayse, household, batch))!;
|
||||
for (const role of ["bank", "card", "cash"] as const) {
|
||||
const p = d.presets[role]!;
|
||||
const r = await createImportAccount(ayse, household, batch, role, {
|
||||
name: `Test ${p.name}`,
|
||||
openingMinor: p.openingMinor,
|
||||
openingDate: p.openingDate,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) accounts[role] = r.data.id;
|
||||
}
|
||||
expect((await getImportDraft(ayse, household, batch))!.accounts).toMatchObject(accounts);
|
||||
});
|
||||
|
||||
it("satır düzenleme: grupsuz havaleye kategori ve grup; aynı işyerine toplu", async () => {
|
||||
const d = (await getImportDraft(ayse, household, batch))!;
|
||||
const havale = d.rows.find((r) => r.description === "Giden havale (kişi)")!;
|
||||
expect(havale.review).toBe("ungrouped");
|
||||
const dining = d.categories.find((c) => c.path === "Eğlence › Dışarıda yemek")!;
|
||||
expect(
|
||||
(await updateImportRow(ayse, household, batch, { rowId: havale.id, categoryId: dining.id }))
|
||||
.ok,
|
||||
).toBe(true);
|
||||
const market = d.rows.find((r) => r.description === "Test Market")!;
|
||||
const multi = await updateImportRow(ayse, household, batch, {
|
||||
rowId: market.id,
|
||||
kakeiboGroup: "ZORUNLU",
|
||||
applyToSame: true,
|
||||
});
|
||||
expect(multi).toMatchObject({ ok: true, data: { updated: 2 } });
|
||||
// Geri dön: ikinci market satırı İstek kalsın (kayıt bazında grup testi için).
|
||||
const second = d.rows.filter((r) => r.description === "Test Market")[1]!;
|
||||
await updateImportRow(ayse, household, batch, { rowId: second.id, kakeiboGroup: "ISTEK" });
|
||||
});
|
||||
|
||||
it("işle: işlemler, transferler, taksit planı, grup, yeni kategoriler; bakiye dosyayla tutar", async () => {
|
||||
const r = await commitImport(ayse, household, batch);
|
||||
expect(r).toMatchObject({ ok: true, data: { transactions: 11, skipped: 0 } });
|
||||
const txs = await TransactionModel.find({
|
||||
importBatchId: { $ne: null },
|
||||
deletedAt: null,
|
||||
}).lean();
|
||||
expect(txs).toHaveLength(11);
|
||||
const cardPay = txs.find((x) => x.payeeName === "Kredi kartı borç ödemesi")!;
|
||||
expect(cardPay).toMatchObject({ type: "transfer", amountMinor: 25_000 });
|
||||
expect(String(cardPay.accountId)).toBe(accounts.bank);
|
||||
expect(String(cardPay.toAccountId)).toBe(accounts.card);
|
||||
const inst = txs.filter((x) => x.installment);
|
||||
expect(inst).toHaveLength(2);
|
||||
expect(String(inst[0]!.installment!.planId)).toBe(String(inst[1]!.installment!.planId));
|
||||
const markets = txs.filter((x) => x.payeeName === "Test Market");
|
||||
expect(markets.map((x) => x.kakeiboGroup).sort()).toEqual(["ISTEK", null]);
|
||||
const refund = txs.find((x) => x.payeeName === "Kampanya iadesi")!;
|
||||
expect(refund.type).toBe("income");
|
||||
expect(await CategoryModel.exists({ defaultKey: "kakeibo.yol" })).toBeTruthy();
|
||||
|
||||
const list = (await listAccounts(ayse, household))!.accounts;
|
||||
const bal = (id: string) => list.find((a) => a.id === id)!.balanceMinor;
|
||||
expect(bal(accounts.bank!)).toBe(99_500); // dosyadaki son bakiye
|
||||
expect(bal(accounts.cash!)).toBe(20_000);
|
||||
// Kart: −250 açılış + 250 ödeme − (100 + 40 + 100 + 100 + 20) + 10 iade.
|
||||
expect(bal(accounts.card!)).toBe(-35_000);
|
||||
});
|
||||
|
||||
it("aynı dosya tekrar yüklenince tüm satırlar daha önce aktarılmış sayılır", async () => {
|
||||
const again = await createImportDraft(ayse, household, {
|
||||
name: "kakeibo.xlsx",
|
||||
buffer: workbook(),
|
||||
});
|
||||
const d = (await getImportDraft(ayse, household, again.ok ? again.data.id : ""))!;
|
||||
expect(d.rows.every((r) => r.duplicate)).toBe(true);
|
||||
expect(d.neededRoles).toEqual([]);
|
||||
expect(await commitImport(ayse, household, d.id)).toMatchObject({
|
||||
ok: true,
|
||||
data: { transactions: 0, skipped: 11 },
|
||||
});
|
||||
});
|
||||
|
||||
it("geri alma işlemleri siler; dosya yeniden aktarılabilir", async () => {
|
||||
expect(await revertImport(ayse, household, batch)).toMatchObject({
|
||||
ok: true,
|
||||
data: { transactions: 11 },
|
||||
});
|
||||
expect(
|
||||
await TransactionModel.countDocuments({ importBatchId: { $ne: null }, deletedAt: null }),
|
||||
).toBe(0);
|
||||
expect((await revertImport(ayse, household, batch)).ok).toBe(false);
|
||||
const fresh = await createImportDraft(ayse, household, {
|
||||
name: "kakeibo.xlsx",
|
||||
buffer: workbook(),
|
||||
});
|
||||
const d = (await getImportDraft(ayse, household, fresh.ok ? fresh.data.id : ""))!;
|
||||
expect(d.rows.some((r) => r.duplicate)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,816 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import { Types } from "mongoose";
|
||||
import * as XLSX from "xlsx";
|
||||
import { createAccount } from "@/features/accounts/service";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { connectMongoose } from "@/lib/db/mongoose";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { ImportBatchModel } from "@/models/import-batch";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { AccountType } from "@/shared/accounts";
|
||||
import { istanbulDayStart } from "@/shared/dates";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import {
|
||||
checkStatements,
|
||||
firstCardPayment,
|
||||
IMPORT_CATEGORIES,
|
||||
parseBankSheet,
|
||||
parseCardSheet,
|
||||
parseStatementTotals,
|
||||
type ImportRole,
|
||||
type ProposedRow,
|
||||
type RowKind,
|
||||
type StatementCheck,
|
||||
} from "@/shared/import/kakeibo-workbook";
|
||||
import { isKakeiboGroup, resolveKakeibo, type KakeiboGroup } from "@/shared/kakeibo";
|
||||
import { accountFormSchema } from "@/shared/schemas/account";
|
||||
import { compareTr, foldTr } from "@/shared/search";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
|
||||
/**
|
||||
* Ekstre içe aktarma (Faz 17a): Kakeibo çalışma kitabı. Okuma: member; hesap oluşturma owner
|
||||
* (hesap servisinin kuralı). Akış: dosya → taslak (öneriler, tekrar işaretleri, kontroller) →
|
||||
* hesap eşleme → satır düzenleme → tek transaction'da işle → gerekirse toplu geri al.
|
||||
*
|
||||
* Kişisel veri: dosyanın başlık alanları (müşteri/kart no) okunmaz; satır açıklamaları yalnızca
|
||||
* hanenin kendi verisinde saklanır. Tekrar engeli için işlemde anahtarın SHA-256 özeti tutulur.
|
||||
*/
|
||||
|
||||
const t = tr.imports;
|
||||
// Server Action gövde sınırı (Next varsayılanı 1 MB) ile uyumlu.
|
||||
export const MAX_IMPORT_BYTES = 1024 * 1024 - 64 * 1024;
|
||||
const ROLES: ImportRole[] = ["card", "bank", "cash", "extra"];
|
||||
const ROLE_TYPES: Record<ImportRole, AccountType[]> = {
|
||||
card: ["credit_card"],
|
||||
bank: ["bank"],
|
||||
cash: ["cash"],
|
||||
extra: ["bank", "cash"],
|
||||
gold: [],
|
||||
};
|
||||
|
||||
type RowDoc = ProposedRow & {
|
||||
_id: Types.ObjectId;
|
||||
categoryId: Types.ObjectId | null;
|
||||
duplicate: boolean;
|
||||
edited: boolean;
|
||||
};
|
||||
|
||||
type BatchDoc = {
|
||||
_id: Types.ObjectId;
|
||||
householdId: Types.ObjectId;
|
||||
fileName: string;
|
||||
fileHash: string;
|
||||
status: "draft" | "committed" | "reverted";
|
||||
accounts: Partial<Record<ImportRole, string>>;
|
||||
rows: RowDoc[];
|
||||
checks: ImportChecks;
|
||||
committed: { transactions: number; skipped: number; categoriesCreated: number } | null;
|
||||
committedAt: Date | null;
|
||||
revertedAt: Date | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type ImportChecks = {
|
||||
statements: StatementCheck[];
|
||||
balance: { mismatchLine: number | null; closingMinor: number | null };
|
||||
opening: {
|
||||
bank: { date: string; balanceMinor: number } | null;
|
||||
card: { date: string; amountMinor: number } | null;
|
||||
};
|
||||
/** Kart satırlarının en erken tarihi (kart açılış tarihi önerisi). */
|
||||
firstCardDate: string | null;
|
||||
firstBankDate: string | null;
|
||||
};
|
||||
|
||||
const hashKey = (key: string) => createHash("sha256").update(key).digest("hex");
|
||||
|
||||
// --- Dosya okuma ---
|
||||
|
||||
/** Kakeibo çalışma kitabını okur. Sayfalar yoksa açık hata döner. */
|
||||
export function readWorkbook(
|
||||
buffer: Buffer,
|
||||
): ActionResult<{ rows: ProposedRow[]; checks: ImportChecks }> {
|
||||
let wb: XLSX.WorkBook;
|
||||
try {
|
||||
wb = XLSX.read(buffer, { type: "buffer", cellDates: false, dense: true });
|
||||
} catch {
|
||||
return fail(t.errors.unreadable);
|
||||
}
|
||||
const sheet = (name: string) => {
|
||||
const ws = wb.Sheets[name];
|
||||
return ws
|
||||
? (XLSX.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null }) as unknown[][])
|
||||
: null;
|
||||
};
|
||||
const cardRows = sheet("Kredi Kartı");
|
||||
const bankRows = sheet("Maaş Hesabı");
|
||||
if (!cardRows && !bankRows) return fail(t.errors.noSheets);
|
||||
const card = cardRows ? parseCardSheet(cardRows) : [];
|
||||
const bank = bankRows ? parseBankSheet(bankRows) : null;
|
||||
const summary = sheet("Özet");
|
||||
const rows = [...card, ...(bank?.rows ?? [])];
|
||||
const dates = (s: "card" | "bank") =>
|
||||
rows
|
||||
.filter((r) => r.sheet === s)
|
||||
.map((r) => r.date)
|
||||
.sort();
|
||||
return ok({
|
||||
rows,
|
||||
checks: {
|
||||
statements: summary ? checkStatements(card, parseStatementTotals(summary)) : [],
|
||||
balance: {
|
||||
mismatchLine: bank?.balanceMismatch?.line ?? null,
|
||||
closingMinor: bank?.closingBalanceMinor ?? null,
|
||||
},
|
||||
opening: { bank: bank?.opening ?? null, card: firstCardPayment(rows) },
|
||||
firstCardDate: dates("card")[0] ?? null,
|
||||
firstBankDate: dates("bank")[0] ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Görünümler ---
|
||||
|
||||
export type ImportRowView = {
|
||||
id: string;
|
||||
sheet: "card" | "bank";
|
||||
line: number;
|
||||
date: string;
|
||||
statementCut: string | null;
|
||||
description: string;
|
||||
amountMinor: number;
|
||||
kind: RowKind;
|
||||
categoryId: string | null;
|
||||
kakeiboGroup: KakeiboGroup | null;
|
||||
counterRole: ImportRole | null;
|
||||
installment: { index: number; count: number; totalMinor: number } | null;
|
||||
source: { category: string; type: string };
|
||||
review: string | null;
|
||||
duplicate: boolean;
|
||||
edited: boolean;
|
||||
};
|
||||
|
||||
export type ImportSummary = {
|
||||
id: string;
|
||||
fileName: string;
|
||||
status: BatchDoc["status"];
|
||||
createdAt: string;
|
||||
committedAt: string | null;
|
||||
revertedAt: string | null;
|
||||
rowCount: number;
|
||||
committed: BatchDoc["committed"];
|
||||
};
|
||||
|
||||
export type AccountPreset = {
|
||||
role: ImportRole;
|
||||
name: string;
|
||||
type: AccountType;
|
||||
openingMinor: number;
|
||||
openingDate: string;
|
||||
};
|
||||
|
||||
export type ImportDraft = ImportSummary & {
|
||||
rows: ImportRowView[];
|
||||
checks: ImportChecks;
|
||||
accounts: Partial<Record<ImportRole, string>>;
|
||||
/** Satırların ihtiyaç duyduğu roller. */
|
||||
neededRoles: ImportRole[];
|
||||
accountOptions: { id: string; name: string; type: AccountType }[];
|
||||
presets: Record<string, AccountPreset>;
|
||||
categories: {
|
||||
id: string;
|
||||
path: string;
|
||||
kind: "expense" | "income";
|
||||
group: KakeiboGroup | null;
|
||||
bankCost: boolean;
|
||||
}[];
|
||||
/** Henüz hanede olmayan ve işlenince oluşturulacak kategoriler. */
|
||||
newCategories: string[];
|
||||
canCreateAccounts: boolean;
|
||||
};
|
||||
|
||||
const summaryOf = (b: BatchDoc): ImportSummary => ({
|
||||
id: String(b._id),
|
||||
fileName: b.fileName,
|
||||
status: b.status,
|
||||
createdAt: b.createdAt.toISOString(),
|
||||
committedAt: b.committedAt?.toISOString() ?? null,
|
||||
revertedAt: b.revertedAt?.toISOString() ?? null,
|
||||
rowCount: b.rows.length,
|
||||
committed: b.committed,
|
||||
});
|
||||
|
||||
type CatLean = {
|
||||
_id: Types.ObjectId;
|
||||
kind: "expense" | "income";
|
||||
name: string;
|
||||
parentId: Types.ObjectId | null;
|
||||
defaultKey: string | null;
|
||||
archivedAt: Date | null;
|
||||
kakeiboGroup?: KakeiboGroup | null;
|
||||
bankCost?: boolean;
|
||||
};
|
||||
|
||||
async function loadCategories(householdId: Types.ObjectId) {
|
||||
const cats = await CategoryModel.find({ householdId }).lean<CatLean[]>();
|
||||
const byId = new Map(cats.map((c) => [String(c._id), c]));
|
||||
const path = (c: CatLean): string => {
|
||||
const p = c.parentId ? byId.get(String(c.parentId)) : undefined;
|
||||
return p ? `${path(p)} › ${c.name}` : c.name;
|
||||
};
|
||||
const resolved = resolveKakeibo(
|
||||
cats.map((c) => ({
|
||||
id: String(c._id),
|
||||
parentId: c.parentId ? String(c.parentId) : null,
|
||||
kakeiboGroup: c.kakeiboGroup ?? null,
|
||||
bankCost: Boolean(c.bankCost),
|
||||
})),
|
||||
);
|
||||
const byKey = new Map(cats.filter((c) => c.defaultKey).map((c) => [c.defaultKey!, c]));
|
||||
return { cats, byId, byKey, path, resolved };
|
||||
}
|
||||
|
||||
const rowCategoryId = (r: RowDoc, byKey: Map<string, CatLean>) =>
|
||||
r.categoryId
|
||||
? String(r.categoryId)
|
||||
: r.categoryKey
|
||||
? byKey.get(r.categoryKey)
|
||||
? String(byKey.get(r.categoryKey)!._id)
|
||||
: null
|
||||
: null;
|
||||
|
||||
/** İşlenirken oluşturulacak kategori için önizlemedeki sözde kimlik. */
|
||||
export const NEW_CATEGORY_PREFIX = "new:";
|
||||
const IMPORT_KEYS = new Set(IMPORT_CATEGORIES.map((x) => x.key));
|
||||
const draftCategoryId = (r: RowDoc, byKey: Map<string, CatLean>) =>
|
||||
rowCategoryId(r, byKey) ??
|
||||
(r.categoryId === null && r.categoryKey && IMPORT_KEYS.has(r.categoryKey)
|
||||
? NEW_CATEGORY_PREFIX + r.categoryKey
|
||||
: null);
|
||||
|
||||
function neededRoles(rows: readonly RowDoc[]): ImportRole[] {
|
||||
const need = new Set<ImportRole>();
|
||||
for (const r of rows) {
|
||||
if (r.kind === "skip" || r.kind === "deferred" || r.duplicate) continue;
|
||||
need.add(r.sheet);
|
||||
if (r.kind === "transfer" && r.counterRole) need.add(r.counterRole);
|
||||
}
|
||||
return ROLES.filter((x) => need.has(x));
|
||||
}
|
||||
|
||||
async function loadBatch(m: Membership, batchId: string) {
|
||||
const id = toObjectId(batchId);
|
||||
if (!id) return null;
|
||||
return ImportBatchModel.findOne({ _id: id, householdId: m.householdId }).lean<BatchDoc>();
|
||||
}
|
||||
|
||||
// --- Okuma ---
|
||||
|
||||
export async function listImports(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<ImportSummary[] | null> {
|
||||
const m = await resolveMembership(userId, householdId, "member");
|
||||
if (!m) return null;
|
||||
const rows = await ImportBatchModel.find({ householdId: m.householdId })
|
||||
.select({ rows: 0 })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(50)
|
||||
.lean<(Omit<BatchDoc, "rows"> & { rows?: undefined })[]>();
|
||||
const counts = await ImportBatchModel.aggregate<{ _id: Types.ObjectId; n: number }>([
|
||||
{ $match: { householdId: m.householdId } },
|
||||
{ $project: { n: { $size: "$rows" } } },
|
||||
]);
|
||||
const n = new Map(counts.map((c) => [String(c._id), c.n]));
|
||||
return rows.map((b) => ({
|
||||
...summaryOf({ ...b, rows: [] } as BatchDoc),
|
||||
rowCount: n.get(String(b._id)) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getImportDraft(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
batchId: string,
|
||||
): Promise<ImportDraft | null> {
|
||||
const m = await resolveMembership(userId, householdId, "member");
|
||||
if (!m) return null;
|
||||
const b = await loadBatch(m, batchId);
|
||||
if (!b) return null;
|
||||
const [cats, accounts] = await Promise.all([
|
||||
loadCategories(m.householdId),
|
||||
AccountModel.find({ householdId: m.householdId, archivedAt: null })
|
||||
.select({ name: 1, type: 1, isPrivate: 1, ownerUserId: 1 })
|
||||
.lean<
|
||||
{
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
type: AccountType;
|
||||
isPrivate: boolean;
|
||||
ownerUserId: Types.ObjectId | null;
|
||||
}[]
|
||||
>(),
|
||||
]);
|
||||
const c = b.checks;
|
||||
const presets: Record<string, AccountPreset> = {
|
||||
bank: {
|
||||
role: "bank",
|
||||
name: t.presets.bank,
|
||||
type: "bank",
|
||||
openingMinor: c.opening.bank?.balanceMinor ?? 0,
|
||||
openingDate: c.opening.bank
|
||||
? addDays(c.opening.bank.date, 1)
|
||||
: (c.firstBankDate ?? "2026-01-01"),
|
||||
},
|
||||
card: {
|
||||
role: "card",
|
||||
name: t.presets.card,
|
||||
type: "credit_card",
|
||||
openingMinor: c.opening.card?.amountMinor ?? 0,
|
||||
openingDate: c.firstCardDate ?? "2026-01-01",
|
||||
},
|
||||
cash: {
|
||||
role: "cash",
|
||||
name: t.presets.cash,
|
||||
type: "cash",
|
||||
openingMinor: 0,
|
||||
openingDate: c.firstBankDate ?? "2026-01-01",
|
||||
},
|
||||
extra: {
|
||||
role: "extra",
|
||||
name: t.presets.extra,
|
||||
type: "bank",
|
||||
openingMinor: 0,
|
||||
openingDate: c.firstBankDate ?? "2026-01-01",
|
||||
},
|
||||
};
|
||||
const visible = accounts.filter((a) => !a.isPrivate || String(a.ownerUserId) === userId);
|
||||
const missingCats = IMPORT_CATEGORIES.filter((x) => !cats.byKey.has(x.key));
|
||||
const missing = missingCats.map((x) => x.name);
|
||||
return {
|
||||
...summaryOf(b),
|
||||
rows: b.rows.map((r) => ({
|
||||
id: String(r._id),
|
||||
sheet: r.sheet,
|
||||
line: r.line,
|
||||
date: r.date,
|
||||
statementCut: r.statementCut,
|
||||
description: r.description,
|
||||
amountMinor: r.amountMinor,
|
||||
kind: r.kind,
|
||||
categoryId: draftCategoryId(r, cats.byKey),
|
||||
kakeiboGroup: isKakeiboGroup(r.kakeiboGroup) ? r.kakeiboGroup : null,
|
||||
counterRole: r.counterRole,
|
||||
installment: r.installment
|
||||
? {
|
||||
index: r.installment.index,
|
||||
count: r.installment.count,
|
||||
totalMinor: r.installment.totalMinor,
|
||||
}
|
||||
: null,
|
||||
source: r.source,
|
||||
review: r.review,
|
||||
duplicate: r.duplicate,
|
||||
edited: r.edited,
|
||||
})),
|
||||
checks: b.checks,
|
||||
accounts: b.accounts ?? {},
|
||||
neededRoles: neededRoles(b.rows),
|
||||
accountOptions: visible
|
||||
.map((a) => ({ id: String(a._id), name: a.name, type: a.type }))
|
||||
.sort((x, y) => compareTr(x.name, y.name)),
|
||||
presets,
|
||||
categories: cats.cats
|
||||
.filter((x) => !x.archivedAt)
|
||||
.map((x) => {
|
||||
const k = cats.resolved.get(String(x._id));
|
||||
return {
|
||||
id: String(x._id),
|
||||
path: cats.path(x),
|
||||
kind: x.kind,
|
||||
group: k?.group ?? null,
|
||||
bankCost: k?.bankCost ?? false,
|
||||
};
|
||||
})
|
||||
.concat(
|
||||
missingCats.map((x) => ({
|
||||
id: NEW_CATEGORY_PREFIX + x.key,
|
||||
path: t.newCategoryName(x.name),
|
||||
kind: x.kind,
|
||||
group: x.kind === "expense" ? x.kakeiboGroup : null,
|
||||
bankCost: false,
|
||||
})),
|
||||
)
|
||||
.sort((x, y) => compareTr(x.path, y.path)),
|
||||
newCategories: missing,
|
||||
canCreateAccounts: hasHouseholdRole(m.role, "owner"),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Yazma ---
|
||||
|
||||
export async function createImportDraft(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
file: { name: string; buffer: Buffer },
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
if (file.buffer.byteLength > MAX_IMPORT_BYTES) return fail(t.errors.tooLarge);
|
||||
const parsed = readWorkbook(file.buffer);
|
||||
if (!parsed.ok) return parsed;
|
||||
const { rows, checks } = parsed.data;
|
||||
if (!rows.length) return fail(t.errors.empty);
|
||||
// Hanede zaten içe aktarılmış satırlar (aynı anahtar) atlanmak üzere işaretlenir.
|
||||
const hashes = rows.map((r) => hashKey(r.key));
|
||||
const existing = new Set(
|
||||
(
|
||||
await TransactionModel.find({ householdId: m.householdId, importKey: { $in: hashes } })
|
||||
.select({ importKey: 1 })
|
||||
.lean<{ importKey: string }[]>()
|
||||
).map((x) => x.importKey),
|
||||
);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const doc = await ImportBatchModel.create({
|
||||
householdId: m.householdId,
|
||||
source: "kakeibo-workbook",
|
||||
fileName: file.name.slice(0, 200),
|
||||
fileHash: createHash("sha256").update(file.buffer).digest("hex"),
|
||||
rows: rows.map((r, i) => ({ ...r, duplicate: existing.has(hashes[i]!) })),
|
||||
checks,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
async function draftFor(actorId: string, householdId: string, batchId: string) {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return { error: fail(tr.common.unauthorized) } as const;
|
||||
const b = await loadBatch(m, batchId);
|
||||
if (!b) return { error: fail(tr.common.notFound) } as const;
|
||||
if (b.status !== "draft") return { error: fail(t.errors.notDraft) } as const;
|
||||
return { error: null, m, b, actor: new Types.ObjectId(actorId) } as const;
|
||||
}
|
||||
|
||||
/** Bir rolü mevcut bir hesaba bağlar. */
|
||||
export async function setImportAccount(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
batchId: string,
|
||||
role: ImportRole,
|
||||
accountId: string,
|
||||
): Promise<ActionResult> {
|
||||
const r = await draftFor(actorId, householdId, batchId);
|
||||
if (r.error) return r.error;
|
||||
const id = toObjectId(accountId);
|
||||
const acc = id
|
||||
? await AccountModel.findOne({ _id: id, householdId: r.m.householdId, archivedAt: null })
|
||||
.select({ type: 1 })
|
||||
.lean<{ type: AccountType }>()
|
||||
: null;
|
||||
if (!acc || !ROLE_TYPES[role].includes(acc.type)) return fail(t.errors.accountType);
|
||||
await ImportBatchModel.updateOne(
|
||||
{ _id: r.b._id },
|
||||
{ $set: { [`accounts.${role}`]: accountId, updatedBy: r.actor } },
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** Önerilen değerlerle hesap oluşturup role bağlar (hesap oluşturma: owner). */
|
||||
export async function createImportAccount(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
batchId: string,
|
||||
role: ImportRole,
|
||||
input: { name: string; openingMinor: number; openingDate: string },
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const draft = await getImportDraft(actorId, householdId, batchId);
|
||||
if (!draft || draft.status !== "draft") return fail(tr.common.notFound);
|
||||
const preset = draft.presets[role];
|
||||
if (!preset) return fail(tr.common.notFound);
|
||||
const isCard = preset.type === "credit_card";
|
||||
const form = accountFormSchema.safeParse({
|
||||
name: input.name,
|
||||
type: preset.type,
|
||||
institution: "",
|
||||
mealCardProvider: null,
|
||||
// Kartta açılış borcu pozitif girilir; hesap servisi negatife çevirir. Bankada eksi bakiye
|
||||
// (KMH) eksi işaretle girilir.
|
||||
openingBalance: formatMoneyInput(isCard ? Math.abs(input.openingMinor) : input.openingMinor),
|
||||
openingDate: input.openingDate,
|
||||
color: isCard ? "purple" : preset.type === "cash" ? "green" : "blue",
|
||||
icon: isCard ? "credit-card" : preset.type === "cash" ? "wallet" : "landmark",
|
||||
ownerUserId: "",
|
||||
isPrivate: false,
|
||||
overdraftEnabled: role === "bank",
|
||||
overdraftLimit: "0",
|
||||
overdraftMonthlyRate: "",
|
||||
overdraftStatementDay: "",
|
||||
cardLimit: "0",
|
||||
cardStatementDay: "28",
|
||||
cardDueKind: "daysAfter",
|
||||
cardDueValue: "12",
|
||||
cardPaymentAccountId: "",
|
||||
cardMinimumRate: "",
|
||||
});
|
||||
if (!form.success) return fail(form.error.issues[0]?.message ?? t.errors.accountType);
|
||||
const created = await createAccount(actorId, householdId, form.data);
|
||||
if (!created.ok) return created;
|
||||
const set = await setImportAccount(actorId, householdId, batchId, role, created.data.id);
|
||||
if (!set.ok) return set;
|
||||
return ok({ id: created.data.id });
|
||||
}
|
||||
|
||||
export type ImportRowEdit = {
|
||||
rowId: string;
|
||||
kind?: "expense" | "income" | "skip";
|
||||
categoryId?: string | null;
|
||||
kakeiboGroup?: KakeiboGroup | null;
|
||||
/** Aynı açıklamalı (işyeri) tüm satırlara uygula. */
|
||||
applyToSame?: boolean;
|
||||
};
|
||||
|
||||
export async function updateImportRow(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
batchId: string,
|
||||
edit: ImportRowEdit,
|
||||
): Promise<ActionResult<{ updated: number }>> {
|
||||
const r = await draftFor(actorId, householdId, batchId);
|
||||
if (r.error) return r.error;
|
||||
const row = r.b.rows.find((x) => String(x._id) === edit.rowId);
|
||||
if (!row) return fail(tr.common.notFound);
|
||||
const newKey = edit.categoryId?.startsWith(NEW_CATEGORY_PREFIX)
|
||||
? edit.categoryId.slice(NEW_CATEGORY_PREFIX.length)
|
||||
: null;
|
||||
if (newKey !== null && !IMPORT_KEYS.has(newKey))
|
||||
return fail(tr.transactions.validation.categoryInvalid);
|
||||
if (edit.categoryId && newKey === null) {
|
||||
const ok = await CategoryModel.exists({
|
||||
_id: new Types.ObjectId(edit.categoryId),
|
||||
householdId: r.m.householdId,
|
||||
archivedAt: null,
|
||||
});
|
||||
if (!ok) return fail(tr.transactions.validation.categoryInvalid);
|
||||
}
|
||||
const targets = edit.applyToSame
|
||||
? r.b.rows.filter(
|
||||
(x) =>
|
||||
x.sheet === row.sheet &&
|
||||
foldTr(x.description) === foldTr(row.description) &&
|
||||
x.kind === row.kind,
|
||||
)
|
||||
: [row];
|
||||
const ops = targets.map((x) => {
|
||||
const s: Record<string, unknown> = { "rows.$[r].edited": true };
|
||||
if (edit.kind) s["rows.$[r].kind"] = edit.kind;
|
||||
if (newKey !== null) {
|
||||
s["rows.$[r].categoryId"] = null;
|
||||
s["rows.$[r].categoryKey"] = newKey;
|
||||
} else if (edit.categoryId !== undefined)
|
||||
s["rows.$[r].categoryId"] = edit.categoryId ? new Types.ObjectId(edit.categoryId) : null;
|
||||
if (edit.kakeiboGroup !== undefined) s["rows.$[r].kakeiboGroup"] = edit.kakeiboGroup;
|
||||
return {
|
||||
updateOne: {
|
||||
filter: { _id: r.b._id },
|
||||
update: { $set: { ...s, updatedBy: r.actor } },
|
||||
arrayFilters: [{ "r._id": x._id }],
|
||||
},
|
||||
};
|
||||
});
|
||||
if (ops.length) await ImportBatchModel.bulkWrite(ops);
|
||||
return ok({ updated: ops.length });
|
||||
}
|
||||
|
||||
/** Partideki satırları tek transaction'da işler. */
|
||||
export async function commitImport(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
batchId: string,
|
||||
): Promise<ActionResult<{ transactions: number; skipped: number }>> {
|
||||
const r = await draftFor(actorId, householdId, batchId);
|
||||
if (r.error) return r.error;
|
||||
const { m, b, actor } = r;
|
||||
const need = neededRoles(b.rows);
|
||||
const accountIds: Partial<Record<ImportRole, Types.ObjectId>> = {};
|
||||
for (const role of need) {
|
||||
const id = b.accounts?.[role] ? toObjectId(b.accounts[role]!) : null;
|
||||
const acc = id
|
||||
? await AccountModel.findOne({ _id: id, householdId: m.householdId, archivedAt: null })
|
||||
.select({ type: 1 })
|
||||
.lean<{ type: AccountType }>()
|
||||
: null;
|
||||
if (!acc || !ROLE_TYPES[role].includes(acc.type))
|
||||
return fail(t.errors.roleMissing(t.roles[role] ?? role));
|
||||
accountIds[role] = id!;
|
||||
}
|
||||
|
||||
const mongoose = await connectMongoose();
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
let categoriesCreated = 0;
|
||||
try {
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
inserted = 0;
|
||||
skipped = 0;
|
||||
categoriesCreated = 0;
|
||||
// Eksik içe aktarma kategorileri (anahtar: kakeibo.*).
|
||||
const existing = await CategoryModel.find({
|
||||
householdId: m.householdId,
|
||||
defaultKey: { $in: IMPORT_CATEGORIES.map((x) => x.key) },
|
||||
})
|
||||
.select({ defaultKey: 1 })
|
||||
.session(session)
|
||||
.lean<{ defaultKey: string }[]>();
|
||||
const have = new Set(existing.map((x) => x.defaultKey));
|
||||
const missing = IMPORT_CATEGORIES.filter((x) => !have.has(x.key));
|
||||
if (missing.length) {
|
||||
await CategoryModel.insertMany(
|
||||
missing.map((x, i) => ({
|
||||
householdId: m.householdId,
|
||||
kind: x.kind,
|
||||
name: x.name,
|
||||
nameKey: foldTr(x.name),
|
||||
parentId: null,
|
||||
ancestors: [],
|
||||
icon: x.icon,
|
||||
color: x.color,
|
||||
order: 100 + i,
|
||||
defaultKey: x.key,
|
||||
kakeiboGroup: x.kind === "expense" ? x.kakeiboGroup : null,
|
||||
bankCost: false,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
})),
|
||||
{ session },
|
||||
);
|
||||
categoriesCreated = missing.length;
|
||||
}
|
||||
const cats = await CategoryModel.find({ householdId: m.householdId })
|
||||
.session(session)
|
||||
.lean<CatLean[]>();
|
||||
const byKey = new Map(cats.filter((c) => c.defaultKey).map((c) => [c.defaultKey!, c]));
|
||||
const byId = new Map(cats.map((c) => [String(c._id), c]));
|
||||
const resolved = resolveKakeibo(
|
||||
cats.map((c) => ({
|
||||
id: String(c._id),
|
||||
parentId: c.parentId ? String(c.parentId) : null,
|
||||
kakeiboGroup: c.kakeiboGroup ?? null,
|
||||
bankCost: Boolean(c.bankCost),
|
||||
})),
|
||||
);
|
||||
const plans = new Map<string, Types.ObjectId>();
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
for (const row of b.rows) {
|
||||
if (row.duplicate || row.kind === "skip" || row.kind === "deferred") {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const own = accountIds[row.sheet]!;
|
||||
const base = {
|
||||
householdId: m.householdId,
|
||||
status: "posted",
|
||||
date: istanbulDayStart(row.date)!,
|
||||
amountMinor: Math.abs(row.amountMinor),
|
||||
payeeName: row.description.slice(0, 80),
|
||||
description: "",
|
||||
searchText: foldTr(row.description),
|
||||
tags: [t.tag],
|
||||
importBatchId: b._id,
|
||||
importKey: hashKey(row.key),
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
};
|
||||
if (row.kind === "transfer") {
|
||||
const other = accountIds[row.counterRole as ImportRole]!;
|
||||
const out = row.amountMinor < 0;
|
||||
docs.push({
|
||||
...base,
|
||||
type: "transfer",
|
||||
accountId: out ? own : other,
|
||||
toAccountId: out ? other : own,
|
||||
categoryId: null,
|
||||
splits: [],
|
||||
effectiveCategoryIds: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const catId = rowCategoryId(row, byKey);
|
||||
const cat = catId ? byId.get(catId) : undefined;
|
||||
const kind = row.kind as "expense" | "income";
|
||||
if (!cat || cat.kind !== kind || cat.archivedAt)
|
||||
throw new ImportError(fail(t.errors.category(row.description || String(row.line))));
|
||||
const group = isKakeiboGroup(row.kakeiboGroup) ? row.kakeiboGroup : null;
|
||||
const categoryGroup = resolved.get(catId!)?.group ?? null;
|
||||
let installment = null;
|
||||
if (row.installment && kind === "expense") {
|
||||
const planId = plans.get(row.installment.key) ?? new Types.ObjectId();
|
||||
plans.set(row.installment.key, planId);
|
||||
installment = { planId, index: row.installment.index, count: row.installment.count };
|
||||
}
|
||||
docs.push({
|
||||
...base,
|
||||
type: kind,
|
||||
accountId: own,
|
||||
toAccountId: null,
|
||||
categoryId: cat._id,
|
||||
splits: [],
|
||||
effectiveCategoryIds: [cat._id],
|
||||
kakeiboGroup: kind === "expense" && group && group !== categoryGroup ? group : null,
|
||||
installment,
|
||||
});
|
||||
}
|
||||
if (docs.length) await TransactionModel.insertMany(docs, { session });
|
||||
inserted = docs.length;
|
||||
await ImportBatchModel.updateOne(
|
||||
{ _id: b._id, status: "draft" },
|
||||
{
|
||||
$set: {
|
||||
status: "committed",
|
||||
committedAt: new Date(),
|
||||
committed: { transactions: inserted, skipped, categoriesCreated },
|
||||
updatedBy: actor,
|
||||
},
|
||||
},
|
||||
{ session },
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ImportError) return error.result;
|
||||
if ((error as { code?: number }).code === 11000) return fail(t.errors.duplicate);
|
||||
throw error;
|
||||
}
|
||||
await writeAudit({
|
||||
action: "import.commit",
|
||||
actorId: actor,
|
||||
householdId: m.householdId,
|
||||
targetType: "importBatch",
|
||||
targetId: String(b._id),
|
||||
diff: { transactions: inserted, skipped, categoriesCreated, fileName: b.fileName },
|
||||
});
|
||||
return ok({ transactions: inserted, skipped });
|
||||
}
|
||||
|
||||
class ImportError extends Error {
|
||||
constructor(readonly result: ActionResult<never>) {
|
||||
super(result.ok ? "" : result.error);
|
||||
}
|
||||
}
|
||||
|
||||
/** İşlenmiş partinin işlemlerini siler (soft delete; anahtar boşaltılır, yeniden içe aktarılabilir). */
|
||||
export async function revertImport(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
batchId: string,
|
||||
): Promise<ActionResult<{ transactions: number }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const b = await loadBatch(m, batchId);
|
||||
if (!b) return fail(tr.common.notFound);
|
||||
if (b.status !== "committed") return fail(t.errors.notCommitted);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const mongoose = await connectMongoose();
|
||||
let n = 0;
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
const res = await TransactionModel.updateMany(
|
||||
{ householdId: m.householdId, importBatchId: b._id, deletedAt: null },
|
||||
{ $set: { deletedAt: new Date(), importKey: null, updatedBy: actor } },
|
||||
{ session },
|
||||
);
|
||||
n = res.modifiedCount;
|
||||
await ImportBatchModel.updateOne(
|
||||
{ _id: b._id },
|
||||
{ $set: { status: "reverted", revertedAt: new Date(), updatedBy: actor } },
|
||||
{ session },
|
||||
);
|
||||
});
|
||||
await writeAudit({
|
||||
action: "import.revert",
|
||||
actorId: actor,
|
||||
householdId: m.householdId,
|
||||
targetType: "importBatch",
|
||||
targetId: String(b._id),
|
||||
diff: { transactions: n, fileName: b.fileName },
|
||||
});
|
||||
return ok({ transactions: n });
|
||||
}
|
||||
|
||||
/** Taslağı siler (yalnızca işlenmemiş). */
|
||||
export async function deleteImportDraft(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
batchId: string,
|
||||
): Promise<ActionResult> {
|
||||
const r = await draftFor(actorId, householdId, batchId);
|
||||
if (r.error) return r.error;
|
||||
await ImportBatchModel.deleteOne({ _id: r.b._id, status: "draft" });
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import { productPriceFormSchema } from "@/shared/schemas/product-price";
|
||||
import { addProductPrice, deleteProductPrice } from "./service";
|
||||
|
||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
|
||||
function fieldErrors(error: z.ZodError) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function run<T, R>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
|
||||
): Promise<ActionResult<R>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const parsed = schema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||
if (result.ok) revalidatePath("/", "layout");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function addProductPriceAction(input: unknown) {
|
||||
return run(productPriceFormSchema, input, (u, h, d) => addProductPrice(u, h, d));
|
||||
}
|
||||
|
||||
export async function deleteProductPriceAction(priceId: unknown) {
|
||||
const id = idSchema.safeParse(priceId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.unknown(), null, (u, h) => deleteProductPrice(u, h, id.data));
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Clock, Tag, Trophy } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { formatUnitPrice, type RankedOffer } from "@/shared/prices";
|
||||
import type { StoreOffer } from "../service";
|
||||
|
||||
const t = tr.productPrices;
|
||||
export const ymdLabel = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
|
||||
/** Mağaza renk işareti (kimlik; metin her zaman yanında). */
|
||||
export function StoreDot({ slot, className }: { slot: number; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("mr-1.5 inline-block size-2.5 shrink-0 rounded-full", className)}
|
||||
style={{ backgroundColor: `var(--series-${slot})` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Teklif rozetleri: en uygun, kampanya, kampanya bitti, eski fiyat. */
|
||||
function OfferBadges({ offer }: { offer: RankedOffer<StoreOffer> }) {
|
||||
const p = offer.price;
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{offer.cheapest ? (
|
||||
<Badge className="gap-1 bg-income text-income-foreground">
|
||||
<Trophy className="size-3" aria-hidden />
|
||||
{t.cheapest}
|
||||
</Badge>
|
||||
) : null}
|
||||
{p.isPromo && !offer.promoEnded ? (
|
||||
<Badge variant="outline" className="gap-1 border-budget/50 text-budget">
|
||||
<Tag className="size-3" aria-hidden />
|
||||
{t.promo}
|
||||
{p.promoUntil ? ` · ${t.promoUntil(ymdLabel(p.promoUntil))}` : ""}
|
||||
</Badge>
|
||||
) : null}
|
||||
{offer.fellBack ? <Badge variant="secondary">{t.fellBack}</Badge> : null}
|
||||
{offer.promoEnded ? <Badge variant="secondary">{t.promoEnded}</Badge> : null}
|
||||
{offer.stale ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||
title={t.staleHint}
|
||||
>
|
||||
<Clock className="size-3" aria-hidden />
|
||||
{t.stale(offer.ageDays)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Birim fiyata göre sıralı teklifler. `showProduct`: ürün tipi karşılaştırmasında marka/paket
|
||||
* ve ürün sayfası bağlantısı.
|
||||
*/
|
||||
export function OffersList({
|
||||
offers,
|
||||
typeUnit,
|
||||
showProduct = false,
|
||||
}: {
|
||||
offers: RankedOffer<StoreOffer>[];
|
||||
typeUnit: string;
|
||||
showProduct?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ol className="divide-y rounded-xl border bg-card">
|
||||
{offers.map((o) => (
|
||||
<li
|
||||
key={o.key}
|
||||
className={cn(
|
||||
"grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1 p-3",
|
||||
o.cheapest && "bg-income/5",
|
||||
)}
|
||||
>
|
||||
<span className="pt-0.5 text-sm text-muted-foreground tabular-nums">{o.rank ?? "–"}</span>
|
||||
<div className="min-w-0">
|
||||
{showProduct ? (
|
||||
<p className="truncate font-medium">
|
||||
<Link href={`/stok/urunler/${o.productId}`} className="hover:underline">
|
||||
{o.productLabel}
|
||||
</Link>{" "}
|
||||
<span className="text-xs font-normal text-muted-foreground">{o.packageLabel}</span>
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className={cn(
|
||||
"flex flex-wrap items-center",
|
||||
showProduct ? "text-sm text-muted-foreground" : "font-medium",
|
||||
)}
|
||||
>
|
||||
<StoreDot slot={o.store.slot} />
|
||||
{o.store.label}
|
||||
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
||||
· {ymdLabel(o.price.date)}
|
||||
</span>
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
<OfferBadges offer={o} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right tabular-nums">
|
||||
<p className="font-semibold">
|
||||
{o.unitPriceMinor === null ? (
|
||||
<span className="text-sm font-normal text-muted-foreground">{t.noConversion}</span>
|
||||
) : (
|
||||
formatUnitPrice(o.unitPriceMinor, typeUnit)
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMoney(o.price.priceMinor)}
|
||||
{o.diffPct ? ` · ${t.diff(o.diffPct)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { parseMoney } from "@/shared/money";
|
||||
import { formatUnitPrice, unitPrice } from "@/shared/prices";
|
||||
import { addProductPriceAction } from "../actions";
|
||||
import type { PriceProductOption } from "../options";
|
||||
import type { StoreRef } from "../service";
|
||||
import { StoreDot } from "./offers-list";
|
||||
|
||||
const t = tr.productPrices;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
|
||||
/** Elle fiyat girişi: ürün (sabit olabilir), mağaza, tarih, paket fiyatı, kampanya. */
|
||||
export function PriceFormDialog({
|
||||
products,
|
||||
stores,
|
||||
productId,
|
||||
onClose,
|
||||
}: {
|
||||
products: PriceProductOption[];
|
||||
stores: StoreRef[];
|
||||
/** Ürün sayfasından açılınca sabit. */
|
||||
productId?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({
|
||||
productId: productId ?? (products.length === 1 ? products[0]!.id : ""),
|
||||
storeId: "",
|
||||
date: todayIstanbul(),
|
||||
price: "",
|
||||
isPromo: false,
|
||||
promoUntil: "",
|
||||
note: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
const product = products.find((p) => p.id === v.productId);
|
||||
const minor = parseMoney(v.price);
|
||||
const preview =
|
||||
product?.inTypeUnit && minor && minor > 0
|
||||
? formatUnitPrice(unitPrice(minor, product.inTypeUnit), product.typeUnit)
|
||||
: null;
|
||||
const f = t.fields;
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await addProductPriceAction(v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={t.addTitle}
|
||||
description={product ? `${product.label} · ${product.packageLabel}` : undefined}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="price-form" disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="price-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
{productId ? null : (
|
||||
<Field data-invalid={Boolean(errors.productId)}>
|
||||
<FieldLabel htmlFor="pp-product">{f.product}</FieldLabel>
|
||||
<Select value={v.productId} onValueChange={(x) => set("productId", x)}>
|
||||
<SelectTrigger
|
||||
id="pp-product"
|
||||
className="w-full"
|
||||
aria-invalid={Boolean(errors.productId)}
|
||||
>
|
||||
<SelectValue placeholder={t.validation.product} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{products.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.label}{" "}
|
||||
<span className="text-xs text-muted-foreground">({p.packageLabel})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.productId ? <FieldError>{errors.productId}</FieldError> : null}
|
||||
</Field>
|
||||
)}
|
||||
<Field data-invalid={Boolean(errors.storeId)}>
|
||||
<FieldLabel htmlFor="pp-store">{f.store}</FieldLabel>
|
||||
<Select value={v.storeId} onValueChange={(x) => set("storeId", x)}>
|
||||
<SelectTrigger
|
||||
id="pp-store"
|
||||
className="w-full"
|
||||
aria-invalid={Boolean(errors.storeId)}
|
||||
>
|
||||
<SelectValue placeholder={t.validation.store} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{stores.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
<StoreDot slot={s.slot} />
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.storeId ? <FieldError>{errors.storeId}</FieldError> : null}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="pp-date">{f.date}</FieldLabel>
|
||||
<Input
|
||||
id="pp-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => set("date", e.target.value)}
|
||||
aria-invalid={Boolean(errors.date)}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.price)}>
|
||||
<FieldLabel htmlFor="pp-price">{f.price}</FieldLabel>
|
||||
<Input
|
||||
id="pp-price"
|
||||
inputMode="decimal"
|
||||
value={v.price}
|
||||
onChange={(e) => set("price", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.price)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.price ? <FieldError>{errors.price}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
{preview ? (
|
||||
<FieldDescription className="-mt-2 text-right tabular-nums">
|
||||
{f.unitPreview(preview)}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="pp-promo"
|
||||
checked={v.isPromo}
|
||||
onCheckedChange={(x) => set("isPromo", x === true)}
|
||||
/>
|
||||
<FieldLabel htmlFor="pp-promo">{f.isPromo}</FieldLabel>
|
||||
</Field>
|
||||
{v.isPromo ? (
|
||||
<Field data-invalid={Boolean(errors.promoUntil)}>
|
||||
<FieldLabel htmlFor="pp-until">{f.promoUntil}</FieldLabel>
|
||||
<Input
|
||||
id="pp-until"
|
||||
type="date"
|
||||
value={v.promoUntil}
|
||||
min={v.date}
|
||||
onChange={(e) => set("promoUntil", e.target.value)}
|
||||
aria-invalid={Boolean(errors.promoUntil)}
|
||||
/>
|
||||
<FieldDescription>{f.promoUntilHint}</FieldDescription>
|
||||
{errors.promoUntil ? <FieldError>{errors.promoUntil}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pp-note">{f.note}</FieldLabel>
|
||||
<Input id="pp-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import { toast } from "sonner";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { AttachmentStrip } from "@/features/attachments/components/attachment-strip";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import { formatUnitPrice } from "@/shared/prices";
|
||||
import { deleteProductPriceAction } from "../actions";
|
||||
import { priceProductOption } from "../options";
|
||||
import type { PriceRow, ProductPriceDetail } from "../service";
|
||||
import { OffersList, StoreDot, ymdLabel } from "./offers-list";
|
||||
import { PriceFormDialog } from "./price-dialog";
|
||||
|
||||
const t = tr.productPrices;
|
||||
|
||||
const shortDate = (ymd: string) =>
|
||||
new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" }).format(
|
||||
new Date(`${ymd}T12:00:00+03:00`),
|
||||
);
|
||||
const compactTl = (minor: number) =>
|
||||
new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 0 }).format(minor / 100) + " ₺";
|
||||
|
||||
/** Mağaza başına paket fiyatı; fiyat yeni kayda kadar geçerli (basamaklı çizgi). */
|
||||
function PriceChart({ data }: { data: ProductPriceDetail }) {
|
||||
const config = Object.fromEntries(
|
||||
data.series.map((s) => [`s${s.id}`, { label: s.label, color: `var(--series-${s.slot})` }]),
|
||||
) satisfies ChartConfig;
|
||||
return (
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="aspect-auto h-64 w-full"
|
||||
role="img"
|
||||
aria-label={t.detail.chartLabel(data.product.label)}
|
||||
>
|
||||
<LineChart data={data.points} margin={{ left: 4, right: 12, top: 8 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={24}
|
||||
tickFormatter={(v: string) => shortDate(v)}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={64}
|
||||
domain={["auto", "auto"]}
|
||||
tickFormatter={(v: number) => compactTl(v)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) =>
|
||||
payload?.[0] ? ymdLabel(String(payload[0].payload.date)) : ""
|
||||
}
|
||||
formatter={(value, name) => (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="flex items-center">
|
||||
<StoreDot slot={data.series.find((s) => `s${s.id}` === name)?.slot ?? 1} />
|
||||
{config[String(name)]?.label}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">{formatMoney(Number(value))}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
{data.series.map((s) => (
|
||||
<Line
|
||||
key={s.id}
|
||||
dataKey={`s${s.id}`}
|
||||
type="stepAfter"
|
||||
stroke={`var(--color-s${s.id})`}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** /stok/urunler/:id — ürün bilgisi, mağazalardaki fiyatlar, fiyat geçmişi ve kayıtlar. */
|
||||
export function ProductPriceView({ data }: { data: ProductPriceDetail }) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [deleting, setDeleting] = useState<PriceRow | null>(null);
|
||||
const p = data.product;
|
||||
const canAdd = data.canWrite && !p.archived && data.stores.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/stok/urunler">
|
||||
<ArrowLeft />
|
||||
{t.detail.back}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader
|
||||
title={p.label}
|
||||
description={
|
||||
<>
|
||||
<Link href={`/stok/${p.productTypeId}`} className="hover:underline">
|
||||
{t.detail.typeLink(p.productTypeName)}
|
||||
</Link>
|
||||
{" · "}
|
||||
{p.packageLabel}
|
||||
{p.barcode ? ` · ${tr.products.barcode(p.barcode)}` : ""}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
canAdd ? (
|
||||
<Button size="sm" onClick={() => setAdding(true)}>
|
||||
<Plus />
|
||||
{t.add}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div className="-mt-2 flex flex-wrap items-center gap-2">
|
||||
{p.archived ? <Badge variant="secondary">{tr.products.archived}</Badge> : null}
|
||||
{p.conversionMissing ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{tr.products.conversionMissing}
|
||||
</Badge>
|
||||
) : null}
|
||||
<AttachmentStrip items={p.images} />
|
||||
</div>
|
||||
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.detail.currentTitle}</CardTitle>
|
||||
<CardDescription>{t.detail.currentDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.offers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t.noPrices}</p>
|
||||
) : (
|
||||
<OffersList offers={data.offers} typeUnit={p.typeUnit} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{data.history.length > 1 ? (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.detail.chartTitle}</CardTitle>
|
||||
<CardDescription>{t.detail.chartDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PriceChart data={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{data.history.length ? (
|
||||
<section className="flex flex-col gap-2" aria-labelledby="price-history-title">
|
||||
<h2 id="price-history-title" className="text-base font-medium">
|
||||
{t.detail.historyTitle}
|
||||
</h2>
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{data.history.map((r) => (
|
||||
<li key={r.id} className="flex items-start gap-3 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center text-sm font-medium">
|
||||
<StoreDot slot={r.store.slot} />
|
||||
{r.store.label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ymdLabel(r.date)}
|
||||
{r.isPromo
|
||||
? ` · ${r.promoEnded ? t.promoEnded : t.promo}${
|
||||
r.promoUntil ? ` (${t.promoUntil(ymdLabel(r.promoUntil))})` : ""
|
||||
}`
|
||||
: ""}
|
||||
{` · ${t.sources[r.source.split(":")[0]!] ?? r.source}`}
|
||||
{r.note ? ` · ${r.note}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-sm tabular-nums">
|
||||
<p className="font-medium">{formatMoney(r.priceMinor)}</p>
|
||||
{r.unitPriceMinor !== null ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatUnitPrice(r.unitPriceMinor, p.typeUnit)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{data.canWrite ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t.detail.deletePrice}
|
||||
onClick={() => setDeleting(r)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{adding ? (
|
||||
<PriceFormDialog
|
||||
products={[priceProductOption(p)]}
|
||||
productId={p.id}
|
||||
stores={data.stores}
|
||||
onClose={() => setAdding(false)}
|
||||
/>
|
||||
) : null}
|
||||
<AlertDialog open={deleting !== null} onOpenChange={(o) => !o && setDeleting(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.detail.deleteTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t.detail.deleteDescription}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const r = deleting;
|
||||
setDeleting(null);
|
||||
if (!r) return;
|
||||
const res = await deleteProductPriceAction(r.id);
|
||||
if (res.ok) toast.success(t.toast.deleted);
|
||||
else toast.error(res.error);
|
||||
}}
|
||||
>
|
||||
{t.detail.deletePrice}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { unitPriceBase } from "@/shared/prices";
|
||||
import type { TypePriceComparison as Data } from "../service";
|
||||
import { OffersList } from "./offers-list";
|
||||
import { PriceFormDialog } from "./price-dialog";
|
||||
|
||||
const t = tr.productPrices;
|
||||
|
||||
/** /stok/:id: markalar ve mağazalar birim fiyata göre; en uygun vurgulu. */
|
||||
export function TypePriceComparison({ data }: { data: Data }) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">{t.comparisonTitle}</CardTitle>
|
||||
<CardDescription>
|
||||
{t.comparisonDescription(unitPriceBase(data.typeUnit).unit)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{data.canWrite && data.products.length && data.stores.length ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
|
||||
<Plus />
|
||||
{t.add}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t.noProducts}</p>
|
||||
) : data.offers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t.noPrices}</p>
|
||||
) : (
|
||||
<OffersList offers={data.offers} typeUnit={data.typeUnit} showProduct />
|
||||
)}
|
||||
</CardContent>
|
||||
{adding ? (
|
||||
<PriceFormDialog
|
||||
products={data.products}
|
||||
stores={data.stores}
|
||||
onClose={() => setAdding(false)}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ProductView } from "@/features/products/service";
|
||||
|
||||
/** Fiyat formu ürün seçeneği (birim fiyat önizlemesi için dönüşümle). İstemcide de kullanılır. */
|
||||
export type PriceProductOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
packageLabel: string;
|
||||
inTypeUnit: number | null;
|
||||
typeUnit: string;
|
||||
};
|
||||
|
||||
export const priceProductOption = (p: ProductView): PriceProductOption => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
packageLabel: p.packageLabel,
|
||||
inTypeUnit: p.inTypeUnit,
|
||||
typeUnit: p.typeUnit,
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getStockAnalysis } from "@/features/stock/service";
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { StoreModel } from "@/models/store";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import { productPriceFormSchema } from "@/shared/schemas/product-price";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
addProductPrice,
|
||||
deleteProductPrice,
|
||||
getProductPrices,
|
||||
getTypePriceComparison,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
const today = todayIstanbul();
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
const ref = (
|
||||
model: typeof ProductModel | typeof StoreModel | typeof ProductTypeModel,
|
||||
seedRef: string,
|
||||
) =>
|
||||
(model as typeof ProductModel)
|
||||
.findOne({ seedRef })
|
||||
.lean()
|
||||
.then((x) => String(x!._id));
|
||||
|
||||
describe("birim fiyat karşılaştırması", () => {
|
||||
it("toz deterjan: birim fiyata göre sıralı, en uygun işaretli, süren kampanya", async () => {
|
||||
const c = (await getTypePriceComparison(
|
||||
ayse,
|
||||
cakir,
|
||||
await ref(ProductTypeModel, "toz-deterjan"),
|
||||
))!;
|
||||
expect(c.typeUnit).toBe("yıkama");
|
||||
const units = c.offers.map((o) => o.unitPriceMinor!);
|
||||
expect(units).toEqual([...units].sort((a, b) => a - b));
|
||||
expect(c.offers[0]!.cheapest).toBe(true);
|
||||
expect(c.offers.filter((o) => o.cheapest)).toHaveLength(1);
|
||||
const ariel = c.offers.find(
|
||||
(o) => o.productLabel.startsWith("Ariel") && o.store.label.startsWith("Migros"),
|
||||
)!;
|
||||
expect(ariel.price).toMatchObject({ isPromo: true, promoUntil: addDays(today, 6) });
|
||||
expect(ariel.promoEnded).toBe(false);
|
||||
// Birim fiyat = paket / 33 yıkama.
|
||||
expect(ariel.unitPriceMinor!).toBeCloseTo((ariel.price.priceMinor * 1000) / 33_000, 6);
|
||||
expect(c.products).toHaveLength(3);
|
||||
expect(
|
||||
await getTypePriceComparison(fatma, cakir, await ref(ProductTypeModel, "toz-deterjan")),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("süresi biten kampanyada normal fiyata dönülür; eski fiyat işaretlenir", async () => {
|
||||
const sut = (await getProductPrices(ayse, cakir, await ref(ProductModel, "pinar-sut")))!;
|
||||
const a101 = sut.offers.find((o) => o.store.label.startsWith("A101"))!;
|
||||
expect(a101).toMatchObject({ fellBack: true, promoEnded: false });
|
||||
expect(a101.price.isPromo).toBe(false);
|
||||
expect(a101.price.date).toBe(addDays(today, -30));
|
||||
expect(sut.history.some((h) => h.isPromo && h.promoEnded)).toBe(true);
|
||||
expect(sut.series.length).toBeGreaterThanOrEqual(3);
|
||||
expect(sut.points.length).toBeGreaterThan(5);
|
||||
|
||||
const reis = (await getProductPrices(ayse, cakir, await ref(ProductModel, "reis-mercimek")))!;
|
||||
expect(reis.offers.find((o) => o.store.label.startsWith("Metro"))!.stale).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fiyat girişi", () => {
|
||||
it("ekle, gelecek tarih ve arşivli mağaza reddi, izleyici yazamaz, sil + denetim", async () => {
|
||||
const productId = await ref(ProductModel, "omo-6kg");
|
||||
const storeId = await ref(StoreModel, "sok");
|
||||
const form = (over: Record<string, unknown> = {}) =>
|
||||
productPriceFormSchema.parse({
|
||||
productId,
|
||||
storeId,
|
||||
date: today,
|
||||
price: "449,90",
|
||||
isPromo: false,
|
||||
promoUntil: "",
|
||||
note: "",
|
||||
...over,
|
||||
});
|
||||
expect(
|
||||
productPriceFormSchema.safeParse({ ...form(), price: "0", isPromo: false }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
productPriceFormSchema.safeParse({
|
||||
productId,
|
||||
storeId,
|
||||
date: today,
|
||||
price: "10",
|
||||
isPromo: true,
|
||||
promoUntil: addDays(today, -1),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
|
||||
expect((await addProductPrice(ayse, cakir, form({ date: addDays(today, 1) }))).ok).toBe(false);
|
||||
expect((await addProductPrice(zeynep, cakir, form())).ok).toBe(false);
|
||||
|
||||
const r = await addProductPrice(ayse, cakir, form());
|
||||
expect(r.ok).toBe(true);
|
||||
let d = (await getProductPrices(ayse, cakir, productId))!;
|
||||
const sok = d.offers.find((o) => o.store.id === storeId)!;
|
||||
expect(sok.price).toMatchObject({ priceMinor: 449_90, date: today, source: "manual" });
|
||||
// 6 kg = 40 yıkama → 11,2475 TL/yıkama.
|
||||
expect(sok.unitPriceMinor).toBeCloseTo(1124.75, 6);
|
||||
|
||||
const priceId = r.ok ? r.data.id : "";
|
||||
expect((await deleteProductPrice(fatma, cakir, priceId)).ok).toBe(false);
|
||||
expect((await deleteProductPrice(ayse, cakir, priceId)).ok).toBe(true);
|
||||
expect(await AuditLogModel.exists({ action: "price.delete", targetId: priceId })).toBeTruthy();
|
||||
d = (await getProductPrices(ayse, cakir, productId))!;
|
||||
expect(d.history.some((h) => h.id === priceId)).toBe(false);
|
||||
|
||||
await StoreModel.updateOne({ _id: storeId }, { $set: { archivedAt: new Date() } });
|
||||
expect(await addProductPrice(ayse, cakir, form())).toMatchObject({
|
||||
ok: false,
|
||||
fieldErrors: { storeId: expect.any(String) },
|
||||
});
|
||||
await StoreModel.updateOne({ _id: storeId }, { $set: { archivedAt: null } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("tüketim analizinde maliyet", () => {
|
||||
it("aylık ortalama tüketim × son birim fiyat", async () => {
|
||||
const a = (await getStockAnalysis(ayse, cakir))!;
|
||||
const deterjan = a.rows.find((r) => r.name === "Toz Deterjan")!;
|
||||
expect(deterjan.unitPrice).not.toBeNull();
|
||||
// Maliyet yuvarlanmamış ortalamadan; gösterilen ortalama yuvarlıdır (≤ %1 fark).
|
||||
const expected = (deterjan.avgMonthlyUsed! * deterjan.unitPrice!.minor) / 1000;
|
||||
expect(Math.abs(deterjan.avgMonthlyCostMinor! - expected) / expected).toBeLessThan(0.01);
|
||||
// Ürünü (marka/paket) olmayan tipte maliyet yok.
|
||||
expect(a.rows.find((r) => r.name === "Domates")!.avgMonthlyCostMinor).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,535 @@
|
||||
import "server-only";
|
||||
import { Types } from "mongoose";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||
import { listProducts, type ProductView } from "@/features/products/service";
|
||||
import { storeLabel } from "@/features/stores/service";
|
||||
import { priceProductOption, type PriceProductOption } from "./options";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { ProductPriceModel } from "@/models/product-price";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { StoreModel } from "@/models/store";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import {
|
||||
effectivePrice,
|
||||
promoEnded,
|
||||
rankOffers,
|
||||
seriesSlot,
|
||||
unitPrice,
|
||||
type PriceSource,
|
||||
type RankedOffer,
|
||||
} from "@/shared/prices";
|
||||
import { packageInTypeUnit } from "@/shared/products";
|
||||
import type { ProductPriceForm } from "@/shared/schemas/product-price";
|
||||
import { compareTr } from "@/shared/search";
|
||||
|
||||
/**
|
||||
* Ürün fiyatları (Bölüm 6.11). Hane bazlı; okuma viewer, yazma member. Birim fiyat ürünün
|
||||
* dönüşümüyle okuma sırasında hesaplanır (saf motor: shared/prices).
|
||||
*/
|
||||
|
||||
const t = tr.productPrices;
|
||||
|
||||
type PriceLean = {
|
||||
_id: Types.ObjectId;
|
||||
productId: Types.ObjectId;
|
||||
storeId: Types.ObjectId;
|
||||
date: string;
|
||||
priceMinor: number;
|
||||
isPromo: boolean;
|
||||
promoUntil: string | null;
|
||||
source: PriceSource;
|
||||
note: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
type StoreLean = {
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
branch: string;
|
||||
archivedAt: Date | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type StoreRef = { id: string; label: string; slot: number; archived: boolean };
|
||||
|
||||
export type PriceRow = {
|
||||
id: string;
|
||||
productId: string;
|
||||
store: StoreRef;
|
||||
date: string;
|
||||
priceMinor: number;
|
||||
isPromo: boolean;
|
||||
promoUntil: string | null;
|
||||
promoEnded: boolean;
|
||||
source: PriceSource;
|
||||
note: string;
|
||||
/** Ürün tipi biriminde 1 birimin fiyatı (kesirli kuruş; dönüşüm yoksa null). */
|
||||
unitPriceMinor: number | null;
|
||||
};
|
||||
|
||||
export type StoreOffer = {
|
||||
/** Teklif anahtarı: ürün + mağaza. */
|
||||
key: string;
|
||||
productId: string;
|
||||
productLabel: string;
|
||||
packageLabel: string;
|
||||
store: StoreRef;
|
||||
price: PriceRow;
|
||||
/** Son kayıt süresi biten kampanyaydı; önceki normal fiyat gösteriliyor. */
|
||||
fellBack: boolean;
|
||||
promoEnded: boolean;
|
||||
ageDays: number;
|
||||
stale: boolean;
|
||||
unitPriceMinor: number | null;
|
||||
};
|
||||
|
||||
export type ProductPriceDetail = {
|
||||
product: ProductView;
|
||||
offers: RankedOffer<StoreOffer>[];
|
||||
/** Tüm kayıtlar, yeniden eskiye. */
|
||||
history: PriceRow[];
|
||||
/** Grafik: mağaza serileri ve tarih noktaları (paket fiyatı, kuruş). */
|
||||
series: StoreRef[];
|
||||
points: Record<string, number | string>[];
|
||||
stores: StoreRef[];
|
||||
canWrite: boolean;
|
||||
today: string;
|
||||
};
|
||||
|
||||
export type TypePriceComparison = {
|
||||
typeUnit: string;
|
||||
offers: RankedOffer<StoreOffer>[];
|
||||
/** Fiyat girişi seçenekleri. */
|
||||
products: PriceProductOption[];
|
||||
stores: StoreRef[];
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
/** Hanenin tüm mağazaları; renk yuvası ekleniş sırasına göre sabit. */
|
||||
async function loadStores(householdId: Types.ObjectId) {
|
||||
const rows = await StoreModel.find({ householdId })
|
||||
.select({ name: 1, branch: 1, archivedAt: 1, createdAt: 1 })
|
||||
.sort({ createdAt: 1, _id: 1 })
|
||||
.lean<StoreLean[]>();
|
||||
return new Map(
|
||||
rows.map((s, i) => [
|
||||
String(s._id),
|
||||
{
|
||||
id: String(s._id),
|
||||
label: storeLabel(s),
|
||||
slot: seriesSlot(i),
|
||||
archived: Boolean(s.archivedAt),
|
||||
} satisfies StoreRef,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const sortRecords = (a: PriceLean, b: PriceLean) =>
|
||||
a.date === b.date ? a.createdAt.getTime() - b.createdAt.getTime() : a.date < b.date ? -1 : 1;
|
||||
|
||||
async function loadPrices(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
|
||||
const rows = await ProductPriceModel.find({
|
||||
householdId,
|
||||
productId: { $in: productIds },
|
||||
deletedAt: null,
|
||||
}).lean<PriceLean[]>();
|
||||
return rows.sort(sortRecords);
|
||||
}
|
||||
|
||||
function toRow(r: PriceLean, store: StoreRef, inTypeUnit: number | null, today: string): PriceRow {
|
||||
return {
|
||||
id: String(r._id),
|
||||
productId: String(r.productId),
|
||||
store,
|
||||
date: r.date,
|
||||
priceMinor: r.priceMinor,
|
||||
isPromo: r.isPromo,
|
||||
promoUntil: r.promoUntil,
|
||||
promoEnded: promoEnded(r, today),
|
||||
source: r.source,
|
||||
note: r.note,
|
||||
unitPriceMinor: inTypeUnit ? unitPrice(r.priceMinor, inTypeUnit) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ürün × mağaza başına geçerli fiyat teklifleri (sıralanmamış). */
|
||||
function offersFor(
|
||||
products: ProductView[],
|
||||
prices: PriceLean[],
|
||||
stores: Map<string, StoreRef>,
|
||||
today: string,
|
||||
): StoreOffer[] {
|
||||
const groups = new Map<string, PriceLean[]>();
|
||||
for (const r of prices) {
|
||||
const key = `${r.productId}:${r.storeId}`;
|
||||
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||
}
|
||||
const byId = new Map(products.map((p) => [p.id, p]));
|
||||
const out: StoreOffer[] = [];
|
||||
for (const [key, records] of groups) {
|
||||
const p = byId.get(String(records[0]!.productId));
|
||||
const store = stores.get(String(records[0]!.storeId));
|
||||
if (!p || !store) continue;
|
||||
const eff = effectivePrice(records, today)!;
|
||||
const row = toRow(eff.record, store, p.inTypeUnit, today);
|
||||
out.push({
|
||||
key,
|
||||
productId: p.id,
|
||||
productLabel: p.label,
|
||||
packageLabel: p.packageLabel,
|
||||
store,
|
||||
price: row,
|
||||
fellBack: eff.fellBack,
|
||||
promoEnded: eff.promoEnded,
|
||||
ageDays: eff.ageDays,
|
||||
stale: eff.stale,
|
||||
unitPriceMinor: row.unitPriceMinor,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const activeStores = (stores: Map<string, StoreRef>) =>
|
||||
[...stores.values()].filter((s) => !s.archived).sort((a, b) => compareTr(a.label, b.label));
|
||||
|
||||
/** /stok/urunler/:id — ürünün mağaza bazında fiyatları, geçmişi ve grafiği. */
|
||||
export async function getProductPrices(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
productId: string,
|
||||
): Promise<ProductPriceDetail | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const id = toObjectId(productId);
|
||||
if (!id) return null;
|
||||
const doc = await ProductModel.findOne({ _id: id, householdId: m.householdId })
|
||||
.select({ productTypeId: 1 })
|
||||
.lean<{ productTypeId: Types.ObjectId }>();
|
||||
if (!doc) return null;
|
||||
const list = await listProducts(userId, householdId, {
|
||||
productTypeId: String(doc.productTypeId),
|
||||
includeArchived: true,
|
||||
});
|
||||
const product = list?.products.find((p) => p.id === productId);
|
||||
if (!product) return null;
|
||||
const today = todayIstanbul();
|
||||
const [stores, prices] = await Promise.all([
|
||||
loadStores(m.householdId),
|
||||
loadPrices(m.householdId, [id]),
|
||||
]);
|
||||
const history = prices
|
||||
.map((r) => toRow(r, stores.get(String(r.storeId))!, product.inTypeUnit, today))
|
||||
.filter((r) => r.store)
|
||||
.reverse();
|
||||
const seriesIds = [...new Set(prices.map((r) => String(r.storeId)))];
|
||||
const series = seriesIds
|
||||
.map((sid) => stores.get(sid)!)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.slot - b.slot);
|
||||
// Aynı gün aynı mağazada birden çok kayıt: son giriş geçerli.
|
||||
const byDate = new Map<string, Record<string, number | string>>();
|
||||
for (const r of prices) {
|
||||
const point = byDate.get(r.date) ?? { date: r.date };
|
||||
point[`s${String(r.storeId)}`] = r.priceMinor;
|
||||
byDate.set(r.date, point);
|
||||
}
|
||||
// Son nokta bugün: her mağazanın geçerli fiyatı (çizgi bugüne uzar; biten kampanyada normal fiyat).
|
||||
const offers = offersFor([product], prices, stores, today);
|
||||
if (offers.length) {
|
||||
const point = byDate.get(today) ?? { date: today };
|
||||
for (const o of offers) point[`s${o.store.id}`] ??= o.price.priceMinor;
|
||||
byDate.set(today, point);
|
||||
}
|
||||
return {
|
||||
product,
|
||||
offers: rankOffers(offers),
|
||||
history,
|
||||
series,
|
||||
points: [...byDate.values()].sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1)),
|
||||
stores: activeStores(stores),
|
||||
canWrite: hasHouseholdRole(m.role, "member"),
|
||||
today,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ürün tipi detayı: tüm marka/paket ve mağazalar birim fiyata göre. */
|
||||
export async function getTypePriceComparison(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
productTypeId: string,
|
||||
): Promise<TypePriceComparison | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const list = await listProducts(userId, householdId, { productTypeId });
|
||||
if (!list) return null;
|
||||
const type = await ProductTypeModel.findOne({
|
||||
_id: new Types.ObjectId(productTypeId),
|
||||
householdId: m.householdId,
|
||||
})
|
||||
.select({ unit: 1 })
|
||||
.lean<{ unit: string }>();
|
||||
if (!type) return null;
|
||||
const today = todayIstanbul();
|
||||
const [stores, prices] = await Promise.all([
|
||||
loadStores(m.householdId),
|
||||
loadPrices(
|
||||
m.householdId,
|
||||
list.products.map((p) => new Types.ObjectId(p.id)),
|
||||
),
|
||||
]);
|
||||
return {
|
||||
typeUnit: type.unit,
|
||||
offers: rankOffers(
|
||||
offersFor(list.products, prices, stores, today).filter((o) => !o.store.archived),
|
||||
),
|
||||
products: list.products.map(priceProductOption),
|
||||
stores: activeStores(stores),
|
||||
canWrite: list.canWrite,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ürün tipi birimine çevrilebilen ürünlerin mağaza başına geçerli fiyatı (planlayıcı girdisi). */
|
||||
export type TypeOffer = {
|
||||
productId: string;
|
||||
productTypeId: string;
|
||||
storeId: string;
|
||||
priceMinor: number;
|
||||
inTypeUnit: number;
|
||||
unitPriceMinor: number;
|
||||
date: string;
|
||||
stale: boolean;
|
||||
isPromo: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ürün tiplerinin tüm (arşivsiz) ürünlerinin mağaza başına geçerli fiyatları. Dönüşümü eksik
|
||||
* ürünler atlanır. `activeOnly`: arşivli ürün ve mağazaları dışarıda bırak.
|
||||
*/
|
||||
export async function loadTypeOffers(
|
||||
householdId: Types.ObjectId,
|
||||
productTypeIds: Types.ObjectId[],
|
||||
{ today = todayIstanbul(), activeOnly = false } = {},
|
||||
): Promise<TypeOffer[]> {
|
||||
if (!productTypeIds.length) return [];
|
||||
const [products, types, archivedStores] = await Promise.all([
|
||||
ProductModel.find({
|
||||
householdId,
|
||||
productTypeId: { $in: productTypeIds },
|
||||
...(activeOnly ? { archivedAt: null } : {}),
|
||||
})
|
||||
.select({ productTypeId: 1, packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
|
||||
.lean<
|
||||
{
|
||||
_id: Types.ObjectId;
|
||||
productTypeId: Types.ObjectId;
|
||||
packageQuantity: number;
|
||||
packageUnit: string;
|
||||
typeQuantity: number | null;
|
||||
}[]
|
||||
>(),
|
||||
ProductTypeModel.find({ householdId, _id: { $in: productTypeIds } })
|
||||
.select({ unit: 1 })
|
||||
.lean<{ _id: Types.ObjectId; unit: string }[]>(),
|
||||
activeOnly
|
||||
? StoreModel.find({ householdId, archivedAt: { $ne: null } })
|
||||
.select({ _id: 1 })
|
||||
.lean<{ _id: Types.ObjectId }[]>()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const skipStore = new Set(archivedStores.map((x) => String(x._id)));
|
||||
const unitOf = new Map(types.map((x) => [String(x._id), x.unit]));
|
||||
const prices = await loadPrices(
|
||||
householdId,
|
||||
products.map((p) => p._id),
|
||||
);
|
||||
const groups = new Map<string, PriceLean[]>();
|
||||
for (const r of prices) {
|
||||
if (skipStore.has(String(r.storeId))) continue;
|
||||
const key = `${r.productId}:${r.storeId}`;
|
||||
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||
}
|
||||
const productById = new Map(products.map((p) => [String(p._id), p]));
|
||||
const out: TypeOffer[] = [];
|
||||
for (const records of groups.values()) {
|
||||
const p = productById.get(String(records[0]!.productId))!;
|
||||
const typeId = String(p.productTypeId);
|
||||
const inType = packageInTypeUnit({
|
||||
packageQuantity: p.packageQuantity,
|
||||
packageUnit: p.packageUnit,
|
||||
typeUnit: unitOf.get(typeId) ?? "",
|
||||
typeQuantity: p.typeQuantity,
|
||||
});
|
||||
if (!inType) continue;
|
||||
const eff = effectivePrice(records, today)!;
|
||||
out.push({
|
||||
productId: String(p._id),
|
||||
productTypeId: typeId,
|
||||
storeId: String(records[0]!.storeId),
|
||||
priceMinor: eff.record.priceMinor,
|
||||
inTypeUnit: inType,
|
||||
unitPriceMinor: unitPrice(eff.record.priceMinor, inType),
|
||||
date: eff.record.date,
|
||||
stale: eff.stale,
|
||||
isPromo: eff.record.isPromo && !eff.promoEnded,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Bir ürün tipinin tüm fiyat kayıtları, ürün tipi biriminde birim fiyatla (dönüşümsüzler hariç). */
|
||||
export async function loadTypePriceHistory(
|
||||
householdId: Types.ObjectId,
|
||||
productTypeId: Types.ObjectId,
|
||||
): Promise<{ date: string; productId: string; storeId: string; unitPriceMinor: number }[]> {
|
||||
const [products, type] = await Promise.all([
|
||||
ProductModel.find({ householdId, productTypeId })
|
||||
.select({ packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
|
||||
.lean<
|
||||
{
|
||||
_id: Types.ObjectId;
|
||||
packageQuantity: number;
|
||||
packageUnit: string;
|
||||
typeQuantity: number | null;
|
||||
}[]
|
||||
>(),
|
||||
ProductTypeModel.findOne({ householdId, _id: productTypeId })
|
||||
.select({ unit: 1 })
|
||||
.lean<{ unit: string }>(),
|
||||
]);
|
||||
if (!type) return [];
|
||||
const inType = new Map(
|
||||
products.map((p) => [
|
||||
String(p._id),
|
||||
packageInTypeUnit({
|
||||
packageQuantity: p.packageQuantity,
|
||||
packageUnit: p.packageUnit,
|
||||
typeUnit: type.unit,
|
||||
typeQuantity: p.typeQuantity,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const prices = await loadPrices(
|
||||
householdId,
|
||||
products.map((p) => p._id),
|
||||
);
|
||||
return prices.flatMap((r) => {
|
||||
const q = inType.get(String(r.productId));
|
||||
return q
|
||||
? [
|
||||
{
|
||||
date: r.date,
|
||||
productId: String(r.productId),
|
||||
storeId: String(r.storeId),
|
||||
unitPriceMinor: unitPrice(r.priceMinor, q),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar).
|
||||
* Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır.
|
||||
*/
|
||||
export async function latestTypeUnitPrices(
|
||||
householdId: Types.ObjectId,
|
||||
productTypeIds: Types.ObjectId[],
|
||||
today = todayIstanbul(),
|
||||
): Promise<Map<string, { unitPriceMinor: number; date: string }>> {
|
||||
const out = new Map<string, { unitPriceMinor: number; date: string }>();
|
||||
for (const o of await loadTypeOffers(householdId, productTypeIds, { today })) {
|
||||
const prev = out.get(o.productTypeId);
|
||||
if (!prev || o.date > prev.date)
|
||||
out.set(o.productTypeId, { unitPriceMinor: o.unitPriceMinor, date: o.date });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Yazma ---
|
||||
|
||||
async function activeRefs(m: Membership, input: Pick<ProductPriceForm, "productId" | "storeId">) {
|
||||
const [product, store] = await Promise.all([
|
||||
ProductModel.exists({
|
||||
_id: new Types.ObjectId(input.productId),
|
||||
householdId: m.householdId,
|
||||
archivedAt: null,
|
||||
}),
|
||||
StoreModel.exists({
|
||||
_id: new Types.ObjectId(input.storeId),
|
||||
householdId: m.householdId,
|
||||
archivedAt: null,
|
||||
}),
|
||||
]);
|
||||
if (!product) return fail(t.validation.product, { productId: t.validation.product });
|
||||
if (!store) return fail(t.validation.store, { storeId: t.validation.store });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fiyat kaydı ekler. Gelecek tarihli fiyat girilemez. */
|
||||
export async function addProductPrice(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: ProductPriceForm,
|
||||
source: PriceSource = "manual",
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
if (input.date > todayIstanbul())
|
||||
return fail(t.validation.futureDate, { date: t.validation.futureDate });
|
||||
const bad = await activeRefs(m, input);
|
||||
if (bad) return bad;
|
||||
const doc = await ProductPriceModel.create({
|
||||
householdId: m.householdId,
|
||||
productId: new Types.ObjectId(input.productId),
|
||||
storeId: new Types.ObjectId(input.storeId),
|
||||
date: input.date,
|
||||
priceMinor: input.priceMinor,
|
||||
isPromo: input.isPromo,
|
||||
promoUntil: input.promoUntil,
|
||||
source,
|
||||
note: input.note,
|
||||
createdBy: new Types.ObjectId(actorId),
|
||||
});
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
export async function deleteProductPrice(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
priceId: string,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(priceId);
|
||||
const row = id
|
||||
? await ProductPriceModel.findOne({
|
||||
_id: id,
|
||||
householdId: m.householdId,
|
||||
deletedAt: null,
|
||||
}).lean<PriceLean>()
|
||||
: null;
|
||||
if (!row) return fail(tr.common.notFound);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await ProductPriceModel.updateOne({ _id: row._id }, { $set: { deletedAt: new Date() } });
|
||||
await writeAudit({
|
||||
action: "price.delete",
|
||||
actorId: actor,
|
||||
householdId: m.householdId,
|
||||
targetType: "productPrice",
|
||||
targetId: priceId,
|
||||
diff: {
|
||||
productId: String(row.productId),
|
||||
storeId: String(row.storeId),
|
||||
date: row.date,
|
||||
priceMinor: row.priceMinor,
|
||||
},
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
export type { PriceProductOption };
|
||||
@@ -0,0 +1,50 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import { productFormSchema } from "@/shared/schemas/product";
|
||||
import { createProduct, setProductArchived, updateProduct } from "./service";
|
||||
|
||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
|
||||
function fieldErrors(error: z.ZodError) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function run<T, R>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
|
||||
): Promise<ActionResult<R>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const parsed = schema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||
if (result.ok) revalidatePath("/", "layout");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Yeni (id boş) veya mevcut ürün. */
|
||||
export async function saveProductAction(productId: unknown, input: unknown) {
|
||||
if (productId) {
|
||||
const id = idSchema.safeParse(productId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(productFormSchema, input, (u, h, d) => updateProduct(u, h, id.data, d));
|
||||
}
|
||||
return run(productFormSchema, input, createProduct);
|
||||
}
|
||||
|
||||
export async function setProductArchivedAction(productId: unknown, archived: unknown) {
|
||||
const id = idSchema.safeParse(productId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.boolean(), archived, (u, h, a) => setProductArchived(u, h, id.data, a));
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { AttachmentPicker } from "@/features/attachments/components/attachment-picker";
|
||||
import type { AttachmentInfo } from "@/features/attachments/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { autoConvertible, packageInTypeUnit } from "@/shared/products";
|
||||
import { formatQuantity, parseQuantity } from "@/shared/quantity";
|
||||
import { quantityInput, STOCK_UNITS } from "@/shared/stock";
|
||||
import { saveProductAction } from "../actions";
|
||||
import type { ProductTypeOption, ProductView } from "../service";
|
||||
|
||||
const t = tr.products;
|
||||
const CUSTOM = "__custom__";
|
||||
type Errors = Record<string, string | undefined>;
|
||||
|
||||
/** Yeni ürün / düzenle: marka, tanım, barkod, paket, gerekirse dönüşüm, görseller. */
|
||||
export function ProductFormDialog({
|
||||
product,
|
||||
types,
|
||||
defaultTypeId,
|
||||
onClose,
|
||||
}: {
|
||||
product: ProductView | null;
|
||||
types: ProductTypeOption[];
|
||||
defaultTypeId?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const initialType = product?.productTypeId ?? defaultTypeId ?? "";
|
||||
const typeUnitOf = (id: string) => types.find((x) => x.id === id)?.unit ?? "";
|
||||
const unitChoices = (typeUnit: string) => [
|
||||
...new Set([typeUnit, ...STOCK_UNITS].filter(Boolean)),
|
||||
];
|
||||
const initialUnit = product?.packageUnit ?? typeUnitOf(initialType) ?? "adet";
|
||||
const known = unitChoices(typeUnitOf(initialType)).includes(initialUnit);
|
||||
|
||||
const [v, setV] = useState({
|
||||
productTypeId: initialType,
|
||||
brand: product?.brand ?? "",
|
||||
name: product?.name ?? "",
|
||||
barcode: product?.barcode ?? "",
|
||||
packageQuantity: product ? quantityInput(product.packageQuantity) : "1",
|
||||
unitChoice: known ? initialUnit || "adet" : CUSTOM,
|
||||
customUnit: known ? "" : initialUnit,
|
||||
typeQuantity: product?.typeQuantity ? quantityInput(product.typeQuantity) : "",
|
||||
note: product?.note ?? "",
|
||||
});
|
||||
const [images, setImages] = useState<AttachmentInfo[]>(product?.images ?? []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
|
||||
const typeUnit = typeUnitOf(v.productTypeId);
|
||||
const packageUnit = v.unitChoice === CUSTOM ? v.customUnit.trim() : v.unitChoice;
|
||||
const needsConversion =
|
||||
Boolean(typeUnit && packageUnit) && !autoConvertible(packageUnit, typeUnit);
|
||||
const pkgQty = parseQuantity(v.packageQuantity);
|
||||
const pkgText = pkgQty ? formatQuantity(pkgQty, packageUnit) : `1 ${packageUnit}`;
|
||||
const autoValue =
|
||||
pkgQty && typeUnit && !needsConversion
|
||||
? packageInTypeUnit({ packageQuantity: pkgQty, packageUnit, typeUnit, typeQuantity: null })
|
||||
: null;
|
||||
const f = t.fields;
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveProductAction(product?.id ?? null, {
|
||||
productTypeId: v.productTypeId,
|
||||
brand: v.brand,
|
||||
name: v.name,
|
||||
barcode: v.barcode,
|
||||
packageQuantity: v.packageQuantity,
|
||||
packageUnit,
|
||||
typeQuantity: needsConversion ? v.typeQuantity : "",
|
||||
note: v.note,
|
||||
imageIds: images.map((i) => i.id),
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={product ? t.editTitle : t.createTitle}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="product-variant-form" disabled={busy || uploading}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="product-variant-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.productTypeId)}>
|
||||
<FieldLabel htmlFor="pr-type">{f.productType}</FieldLabel>
|
||||
<Select
|
||||
value={v.productTypeId}
|
||||
onValueChange={(x) => {
|
||||
set("productTypeId", x);
|
||||
if (!product && v.unitChoice !== CUSTOM) set("unitChoice", typeUnitOf(x) || "adet");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="pr-type"
|
||||
className="w-full"
|
||||
aria-invalid={Boolean(errors.productTypeId)}
|
||||
>
|
||||
<SelectValue placeholder={t.validation.productType} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{types.map((x) => (
|
||||
<SelectItem key={x.id} value={x.id}>
|
||||
{x.name} <span className="text-xs text-muted-foreground">({x.unit})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.productTypeId ? <FieldError>{errors.productTypeId}</FieldError> : null}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.brand)}>
|
||||
<FieldLabel htmlFor="pr-brand">{f.brand}</FieldLabel>
|
||||
<Input
|
||||
id="pr-brand"
|
||||
value={v.brand}
|
||||
placeholder={f.brandPlaceholder}
|
||||
onChange={(e) => set("brand", e.target.value)}
|
||||
aria-invalid={Boolean(errors.brand)}
|
||||
autoFocus={!product}
|
||||
/>
|
||||
{errors.brand ? <FieldError>{errors.brand}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pr-name">{f.name}</FieldLabel>
|
||||
<Input
|
||||
id="pr-name"
|
||||
value={v.name}
|
||||
placeholder={f.namePlaceholder}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.packageQuantity)}>
|
||||
<FieldLabel htmlFor="pr-qty">{f.packageQuantity}</FieldLabel>
|
||||
<Input
|
||||
id="pr-qty"
|
||||
inputMode="decimal"
|
||||
value={v.packageQuantity}
|
||||
onChange={(e) => set("packageQuantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.packageQuantity)}
|
||||
/>
|
||||
{errors.packageQuantity ? <FieldError>{errors.packageQuantity}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.packageUnit)}>
|
||||
<FieldLabel htmlFor="pr-unit">{f.packageUnit}</FieldLabel>
|
||||
<Select value={v.unitChoice} onValueChange={(x) => set("unitChoice", x)}>
|
||||
<SelectTrigger id="pr-unit" className="w-full">
|
||||
<SelectValue>
|
||||
{v.unitChoice === CUSTOM ? tr.stock.fields.customUnit : v.unitChoice}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unitChoices(typeUnit).map((u) => (
|
||||
<SelectItem key={u} value={u}>
|
||||
{u}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM}>{tr.stock.fields.customUnitOption}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{v.unitChoice === CUSTOM ? (
|
||||
<Input
|
||||
value={v.customUnit}
|
||||
placeholder={tr.stock.fields.customUnitPlaceholder}
|
||||
aria-label={tr.stock.fields.customUnit}
|
||||
onChange={(e) => set("customUnit", e.target.value)}
|
||||
aria-invalid={Boolean(errors.packageUnit)}
|
||||
/>
|
||||
) : null}
|
||||
{errors.packageUnit ? <FieldError>{errors.packageUnit}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
{needsConversion ? (
|
||||
<Field data-invalid={Boolean(errors.typeQuantity)}>
|
||||
<FieldLabel htmlFor="pr-conv">{f.typeQuantity(typeUnit)}</FieldLabel>
|
||||
<Input
|
||||
id="pr-conv"
|
||||
inputMode="decimal"
|
||||
value={v.typeQuantity}
|
||||
onChange={(e) => set("typeQuantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.typeQuantity)}
|
||||
/>
|
||||
<FieldDescription>{f.typeQuantityHint(pkgText, typeUnit)}</FieldDescription>
|
||||
{errors.typeQuantity ? <FieldError>{errors.typeQuantity}</FieldError> : null}
|
||||
</Field>
|
||||
) : autoValue !== null && packageUnit !== typeUnit ? (
|
||||
<FieldDescription className="-mt-2">
|
||||
{f.autoConversion(formatQuantity(autoValue, typeUnit))}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
<Field data-invalid={Boolean(errors.barcode)}>
|
||||
<FieldLabel htmlFor="pr-barcode">{f.barcode}</FieldLabel>
|
||||
<Input
|
||||
id="pr-barcode"
|
||||
inputMode="numeric"
|
||||
value={v.barcode}
|
||||
onChange={(e) => set("barcode", e.target.value)}
|
||||
className="tabular-nums"
|
||||
aria-invalid={Boolean(errors.barcode)}
|
||||
/>
|
||||
<FieldDescription>{f.barcodeHint}</FieldDescription>
|
||||
{errors.barcode ? <FieldError>{errors.barcode}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.imageIds)}>
|
||||
<FieldLabel>{f.images}</FieldLabel>
|
||||
<AttachmentPicker
|
||||
value={images}
|
||||
onChange={setImages}
|
||||
allowedKinds={["image"]}
|
||||
onBusyChange={setUploading}
|
||||
addLabel={f.addImage}
|
||||
hint={f.imagesHint}
|
||||
/>
|
||||
{errors.imageIds ? <FieldError>{errors.imageIds}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pr-note">{f.note}</FieldLabel>
|
||||
<Input id="pr-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
AlertTriangle,
|
||||
EllipsisVertical,
|
||||
ImageIcon,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { AttachmentThumb } from "@/features/attachments/components/attachment-strip";
|
||||
import { AttachmentViewer } from "@/features/attachments/components/attachment-viewer";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { setProductArchivedAction } from "../actions";
|
||||
import type { ProductTypeOption, ProductView } from "../service";
|
||||
import { ProductFormDialog } from "./product-dialog";
|
||||
|
||||
const t = tr.products;
|
||||
|
||||
/** Ürün satırları: görsel, marka/tanım, paket, barkod; düzenle/arşivle. */
|
||||
export function ProductList({
|
||||
products,
|
||||
types,
|
||||
canWrite,
|
||||
showType = false,
|
||||
}: {
|
||||
products: ProductView[];
|
||||
types: ProductTypeOption[];
|
||||
canWrite: boolean;
|
||||
/** Katalogda ürün tipi bağlantısı. */
|
||||
showType?: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState<ProductView | null>(null);
|
||||
const [archiving, setArchiving] = useState<ProductView | null>(null);
|
||||
const [viewer, setViewer] = useState<ProductView | null>(null);
|
||||
|
||||
async function setArchived(p: ProductView, archived: boolean) {
|
||||
const r = await setProductArchivedAction(p.id, archived);
|
||||
if (r.ok) toast.success(archived ? t.toast.archived : t.toast.unarchived);
|
||||
else toast.error(r.error);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{products.map((p) => {
|
||||
const cover = p.images[0];
|
||||
return (
|
||||
<li
|
||||
key={p.id}
|
||||
className={cn("flex items-center gap-3 p-3", p.archived && "opacity-60")}
|
||||
>
|
||||
{cover ? (
|
||||
<AttachmentThumb item={cover} onClick={() => setViewer(p)} className="size-14" />
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-14 shrink-0 items-center justify-center rounded-md border border-dashed bg-muted/40"
|
||||
>
|
||||
<ImageIcon className="size-5 text-muted-foreground/60" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
<Link href={`/stok/urunler/${p.id}`} className="hover:underline">
|
||||
{p.label}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="truncate text-sm text-muted-foreground tabular-nums">
|
||||
{p.packageLabel}
|
||||
{showType ? (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href={`/stok/${p.productTypeId}`} className="hover:underline">
|
||||
{p.productTypeName}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{p.barcode ? <span className="tabular-nums">{t.barcode(p.barcode)}</span> : null}
|
||||
{p.images.length > 1 ? <span>{t.images(p.images.length)}</span> : null}
|
||||
{p.archived ? <Badge variant="secondary">{t.archived}</Badge> : null}
|
||||
{p.conversionMissing ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||
title={t.conversionMissingHint}
|
||||
>
|
||||
<AlertTriangle className="size-3" aria-hidden />
|
||||
{t.conversionMissing}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`${p.label}: ${t.actions.more}`}
|
||||
>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem disabled={p.archived} onSelect={() => setEditing(p)}>
|
||||
<Pencil />
|
||||
{t.actions.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{p.archived ? (
|
||||
<DropdownMenuItem onSelect={() => setArchived(p, false)}>
|
||||
<ArchiveRestore />
|
||||
{t.actions.unarchive}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => setArchiving(p)}>
|
||||
<Archive />
|
||||
{t.actions.archive}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{editing ? (
|
||||
<ProductFormDialog product={editing} types={types} onClose={() => setEditing(null)} />
|
||||
) : null}
|
||||
<AttachmentViewer
|
||||
items={viewer?.images ?? []}
|
||||
startIndex={0}
|
||||
open={viewer !== null}
|
||||
onOpenChange={(o) => !o && setViewer(null)}
|
||||
/>
|
||||
<AlertDialog open={archiving !== null} onOpenChange={(o) => !o && setArchiving(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{archiving ? t.archiveDescription(archiving.label) : null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const p = archiving;
|
||||
setArchiving(null);
|
||||
if (p) await setArchived(p, true);
|
||||
}}
|
||||
>
|
||||
{t.actions.archive}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Plus, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { foldTr } from "@/shared/search";
|
||||
import type { ProductTypeOption, ProductView } from "../service";
|
||||
import { ProductFormDialog } from "./product-dialog";
|
||||
import { ProductList } from "./product-list";
|
||||
|
||||
const t = tr.products;
|
||||
|
||||
/** /stok/urunler: tüm ürünler ürün tipine göre gruplu; marka, tanım veya barkodla arama. */
|
||||
export function ProductsCatalog({
|
||||
products,
|
||||
types,
|
||||
canWrite,
|
||||
}: {
|
||||
products: ProductView[];
|
||||
types: ProductTypeOption[];
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const q = foldTr(query.trim());
|
||||
const digits = query.replace(/\s/g, "");
|
||||
const match = (p: ProductView) =>
|
||||
(showArchived || !p.archived) &&
|
||||
(!q ||
|
||||
foldTr(`${p.label} ${p.productTypeName}`).includes(q) ||
|
||||
(/^\d+$/.test(digits) && (p.barcode ?? "").includes(digits)));
|
||||
const by = new Map<string, { name: string; items: ProductView[] }>();
|
||||
for (const p of products.filter(match)) {
|
||||
const g = by.get(p.productTypeId) ?? { name: p.productTypeName, items: [] };
|
||||
g.items.push(p);
|
||||
by.set(p.productTypeId, g);
|
||||
}
|
||||
return [...by.entries()];
|
||||
}, [products, query, showArchived]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/stok">
|
||||
<ArrowLeft />
|
||||
{t.back}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
actions={
|
||||
canWrite && types.length ? (
|
||||
<Button size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus />
|
||||
{t.new}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-48 flex-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t.search}
|
||||
aria-label={t.search}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
{products.some((p) => p.archived) ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={showArchived} onCheckedChange={(x) => setShowArchived(x === true)} />
|
||||
{t.showArchived}
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{products.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.empty}
|
||||
</p>
|
||||
) : groups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t.noMatch}</p>
|
||||
) : (
|
||||
groups.map(([typeId, g]) => (
|
||||
<section key={typeId} className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">
|
||||
<Link href={`/stok/${typeId}`} className="hover:underline">
|
||||
{g.name}
|
||||
</Link>
|
||||
</h2>
|
||||
<ProductList products={g.items} types={types} canWrite={canWrite} />
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
{creating ? (
|
||||
<ProductFormDialog product={null} types={types} onClose={() => setCreating(false)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import type { ProductTypeOption, ProductView } from "../service";
|
||||
import { ProductFormDialog } from "./product-dialog";
|
||||
import { ProductList } from "./product-list";
|
||||
|
||||
const t = tr.products;
|
||||
|
||||
/** /stok/:id içinde: bu ürün tipinin markaları ve paketleri. */
|
||||
export function ProductsSection({
|
||||
productTypeId,
|
||||
products,
|
||||
types,
|
||||
canWrite,
|
||||
}: {
|
||||
productTypeId: string;
|
||||
products: ProductView[];
|
||||
types: ProductTypeOption[];
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
return (
|
||||
<section className="flex flex-col gap-2" aria-labelledby="products-title">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="products-title" className="text-base font-medium">
|
||||
{t.sectionTitle}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t.sectionDescription}</p>
|
||||
</div>
|
||||
{canWrite && types.some((x) => x.id === productTypeId) ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setCreating(true)}>
|
||||
<Plus />
|
||||
{t.new}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{products.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.emptyType}
|
||||
</p>
|
||||
) : (
|
||||
<ProductList products={products} types={types} canWrite={canWrite} />
|
||||
)}
|
||||
{creating ? (
|
||||
<ProductFormDialog
|
||||
product={null}
|
||||
types={types}
|
||||
defaultTypeId={productTypeId}
|
||||
onClose={() => setCreating(false)}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { readAttachmentForUser, uploadAttachment } from "@/features/attachments/service";
|
||||
import { env } from "@/lib/env";
|
||||
import { AttachmentModel } from "@/models/attachment";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { productFormSchema, type ProductFormInput } from "@/shared/schemas/product";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
createProduct,
|
||||
listProducts,
|
||||
listProductTypeOptions,
|
||||
setProductArchived,
|
||||
updateProduct,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let emre = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
await rm(env().UPLOAD_DIR, { recursive: true, force: true });
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
emre = ids.user("emre");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
});
|
||||
afterAll(async () => {
|
||||
await teardownDb();
|
||||
await rm(env().UPLOAD_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const typeId = (ref: string) =>
|
||||
ProductTypeModel.findOne({ seedRef: ref })
|
||||
.lean()
|
||||
.then((x) => String(x!._id));
|
||||
|
||||
const png = () =>
|
||||
sharp({ create: { width: 40, height: 30, channels: 3, background: "#cc6633" } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
async function form(over: Partial<ProductFormInput> = {}) {
|
||||
return productFormSchema.parse({
|
||||
productTypeId: await typeId("toz-deterjan"),
|
||||
brand: "Persil",
|
||||
name: "Matik",
|
||||
barcode: "",
|
||||
packageQuantity: "4,5",
|
||||
packageUnit: "kg",
|
||||
typeQuantity: "30",
|
||||
note: "",
|
||||
imageIds: [],
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ürünler (marka/paket)", () => {
|
||||
it("seed: dönüşümler ve paket açıklaması; izolasyon", async () => {
|
||||
const all = (await listProducts(ayse, cakir))!;
|
||||
expect(all.products).toHaveLength(22);
|
||||
const ariel = all.products.find((p) => p.brand === "Ariel")!;
|
||||
expect(ariel).toMatchObject({
|
||||
productTypeName: "Toz Deterjan",
|
||||
typeUnit: "yıkama",
|
||||
inTypeUnit: 33_000,
|
||||
packageLabel: "5 kg · 33 yıkama",
|
||||
conversionMissing: false,
|
||||
});
|
||||
const icim = all.products.find((p) => p.brand === "İçim")!;
|
||||
expect(icim).toMatchObject({ typeQuantity: null, inTypeUnit: 500 });
|
||||
const peynir = (await listProducts(ayse, cakir, {
|
||||
productTypeId: await typeId("beyaz-peynir"),
|
||||
}))!;
|
||||
expect(peynir.products.map((p) => p.packageLabel)).toEqual(["500 g · 0,5 kg"]);
|
||||
expect(await listProducts(fatma, cakir)).toBeNull();
|
||||
expect((await listProductTypeOptions(ayse, cakir))!.length).toBe(19);
|
||||
});
|
||||
|
||||
it("dönüşüm zorunlu; kendiliğinden çevrilebilende yok sayılır; barkod tek", async () => {
|
||||
const missing = await createProduct(ayse, cakir, await form({ typeQuantity: "" }));
|
||||
expect(missing).toMatchObject({ ok: false, fieldErrors: { typeQuantity: expect.any(String) } });
|
||||
|
||||
const ok = await createProduct(ayse, cakir, await form({ barcode: "8690 0000 0099 9" }));
|
||||
expect(ok.ok).toBe(true);
|
||||
const sut = await createProduct(
|
||||
ayse,
|
||||
cakir,
|
||||
await form({
|
||||
productTypeId: await typeId("sut"),
|
||||
brand: "Torku",
|
||||
packageQuantity: "1000",
|
||||
packageUnit: "ml",
|
||||
typeQuantity: "7",
|
||||
}),
|
||||
);
|
||||
expect(sut.ok).toBe(true);
|
||||
const list = (await listProducts(ayse, cakir))!.products;
|
||||
expect(list.find((p) => p.brand === "Persil")).toMatchObject({
|
||||
barcode: "8690000000999",
|
||||
inTypeUnit: 30_000,
|
||||
});
|
||||
expect(list.find((p) => p.brand === "Torku")).toMatchObject({
|
||||
typeQuantity: null,
|
||||
inTypeUnit: 1_000,
|
||||
});
|
||||
|
||||
const dup = await createProduct(
|
||||
ayse,
|
||||
cakir,
|
||||
await form({ brand: "Başka", barcode: "8690000000999" }),
|
||||
);
|
||||
expect(dup).toMatchObject({ ok: false, fieldErrors: { barcode: expect.any(String) } });
|
||||
expect(productFormSchema.safeParse({ ...(await form()), barcode: "12a" }).success).toBe(false);
|
||||
expect((await createProduct(zeynep, cakir, await form({ brand: "İzleyici" }))).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("görseller: bağlanır, sıralanır, çıkarılır; okuma hane üyeleriyle sınırlı", async () => {
|
||||
const a1 = await uploadAttachment(ayse, cakir, { name: "on.png", bytes: await png() });
|
||||
const a2 = await uploadAttachment(ayse, cakir, { name: "arka.png", bytes: await png() });
|
||||
const id1 = a1.ok ? a1.data.id : "";
|
||||
const id2 = a2.ok ? a2.data.id : "";
|
||||
const r = await createProduct(
|
||||
ayse,
|
||||
cakir,
|
||||
await form({ brand: "Peros", imageIds: [id2, id1] }),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
const pid = r.ok ? r.data.id : "";
|
||||
let p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||
expect(p.images.map((i) => i.id)).toEqual([id2, id1]);
|
||||
|
||||
// Aynı hanenin başka üyesi görür; başka hane görmez.
|
||||
expect(await readAttachmentForUser(emre, id1, "thumb")).not.toBeNull();
|
||||
expect(await readAttachmentForUser(fatma, id1, "full")).toBeNull();
|
||||
|
||||
// Başkasının yüklediği bekleyen görsel bağlanamaz.
|
||||
const foreign = await uploadAttachment(emre, cakir, { name: "x.png", bytes: await png() });
|
||||
const bad = await updateProduct(
|
||||
ayse,
|
||||
cakir,
|
||||
pid,
|
||||
await form({ brand: "Peros", imageIds: [id1, foreign.ok ? foreign.data.id : ""] }),
|
||||
);
|
||||
expect(bad.ok).toBe(false);
|
||||
p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||
expect(p.images).toHaveLength(2);
|
||||
|
||||
expect(
|
||||
(await updateProduct(ayse, cakir, pid, await form({ brand: "Peros", imageIds: [id1] }))).ok,
|
||||
).toBe(true);
|
||||
expect(await AttachmentModel.exists({ _id: id2, deletedAt: { $ne: null } })).toBeTruthy();
|
||||
p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||
expect(p.images.map((i) => i.id)).toEqual([id1]);
|
||||
});
|
||||
|
||||
it("arşiv: listeden çıkar, düzenlenemez, barkod çakışırsa geri alınamaz", async () => {
|
||||
const r = await createProduct(ayse, cakir, await form({ brand: "Arşiv", barcode: "12345678" }));
|
||||
const id = r.ok ? r.data.id : "";
|
||||
expect((await setProductArchived(ayse, cakir, id, true)).ok).toBe(true);
|
||||
expect((await listProducts(ayse, cakir))!.products.some((p) => p.id === id)).toBe(false);
|
||||
expect(
|
||||
(await listProducts(ayse, cakir, { includeArchived: true }))!.products.find(
|
||||
(p) => p.id === id,
|
||||
)!.archived,
|
||||
).toBe(true);
|
||||
expect((await updateProduct(ayse, cakir, id, await form({ brand: "Arşiv" }))).ok).toBe(false);
|
||||
expect(
|
||||
(await createProduct(ayse, cakir, await form({ brand: "Yeni", barcode: "12345678" }))).ok,
|
||||
).toBe(true);
|
||||
expect((await setProductArchived(ayse, cakir, id, false)).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user