Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abf2ea6dd7 | |||
| 0c5d959058 | |||
| d8f442c1aa | |||
| 39aa19d168 | |||
| 21b3715d35 | |||
| b5078d9251 | |||
| a274d29728 | |||
| 62023ef35d | |||
| 3fa1e949e2 | |||
| 6de6d07c66 | |||
| 660c6d5f2d | |||
| c53fe80241 | |||
| b022c1c3b0 | |||
| 9a60085d14 |
+60
-1
@@ -6,6 +6,60 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
|||||||
|
|
||||||
## [Yayınlanmamış]
|
## [Yayınlanmamış]
|
||||||
|
|
||||||
|
## [0.10.0] - 2026-11-06
|
||||||
|
|
||||||
|
Faz 10: aylık bütçe, gelir planı ve günlük plan (10a ve 10b alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 10b: gelir planı ve günlük plan
|
||||||
|
|
||||||
|
- Gelir planı (`/butce` altında): gerçekleşen gelirler, henüz gerçekleşmemiş planlı (düzenli) gelirler ve elle eklenen beklenen gelirler (`budgets.incomeItems`, ör. prim); beklenen toplam. "Plana göre elde kalacak" (beklenen gelir − bütçe) ve "Bu tempoda elde kalacak" (beklenen gelir − ay sonu tahmini gider).
|
||||||
|
- Günlük plan (dashboard): ayın bütçesi günlere yayılır; tarihe bağlı giderler (düzenli işlemler, taksitler) kendi günlerine, kalan değişken bütçe günlere eşit veya hane ayarına göre önceki ayın günlük örüntüsüyle (kuruşu kuruşuna toplam korunur). "Bugün ayın %X'i geçti, bütçenin %Y'si harcandı", bugün için harcanabilir tutar (önceki günlerden kalan/aşan dahil), bugüne kadar planlanan ve harcanan, ay sonunda elde kalacak.
|
||||||
|
- Kümülatif planlanan / gerçekleşen gider grafiği, beklenen gelir referans çizgisi ve gün tablosu; sonraki ayın beklenen geliri ile bütçesi karşılaştırması.
|
||||||
|
- Dashboard özet kartları: "Bu ay gelir", "Bu ay gider" ve "Kalan" gerçek veriyle (bütçe ayına göre).
|
||||||
|
- Testler: dağıtım ve kuruş yuvarlama, örüntü uyarlama, günlük plan ve bugünkü pay, taban hızlı tahmin (birim); gelir planı, gizli hesap ayarı, elle kalemler, yetki, günlük plan tutarlılığı ve örüntü modu (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 10b
|
||||||
|
|
||||||
|
- Ay sonu tahmini: değişken giderin günlük hızı, bu ayın hızı ile önceki dönemin değişken harcama hızının karışımı (ay ilerledikçe bu ayın ağırlığı artar); ay başındaki tek bir büyük harcama artık tüm aya yayılmıyor. Geçmişi olmayan hanede yalnızca bu ayın hızı.
|
||||||
|
|
||||||
|
### Düzeltildi — Faz 10b
|
||||||
|
|
||||||
|
- Bütçede "sabit gider" (düzenli işlem / taksit) ayrımı her işlemi sabit sayıyordu (`installment` boşken alan yolu eksik değer verir ve `null`'a eşit sayılmaz); ay sonu tahmini değişken gideri hiç yansıtmıyordu. `$ifNull` ile düzeltildi, regresyon testi eklendi.
|
||||||
|
|
||||||
|
### Eklendi — Faz 10a: aylık bütçe
|
||||||
|
|
||||||
|
- Bütçe sayfası (`/butce?ay=YYYY-MM`): ay gezintisi, dönem sınırları hane ayarı `budgetMonthStartDay`'e göre (ör. 15'i → 15 Kasım – 14 Aralık).
|
||||||
|
- Kategori limitleri (`budgets`): her gider kategorisine limit; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır ve toplam bütçede iki kez sayılmaz. Limitsiz alt kategoriler "Üst kategori limitinde" olarak gösterilir; hiçbir limitin kapsamadığı harcama "Bütçesiz harcama" toplamında.
|
||||||
|
- Gerçekleşen / limit / kalan, yüzde çubuğu (ayın geçen kısmı işaretli), %80'de uyarı, %100'de "Limit doldu", aşımda "Limit aşıldı".
|
||||||
|
- Ay sonu tahmini: sabit giderler (düzenli işlem, taksit) olduğu gibi + kalan planlı giderler + değişken giderin günlük hızıyla ay sonuna yansıtılması; tahmin limiti aşıyorsa işaret.
|
||||||
|
- Bölünmüş işlemler kalem bazında; taksitler hane ayarına göre her biri kendi ayına (varsayılan) veya tamamı alış ayına. Başkasının "yalnızca ben" hesabından giderler hane ayarına göre yalnızca tutar olarak sayılır.
|
||||||
|
- "Önceki aydan kopyala" ve "Önceki ayın harcamalarından oluştur" (ana kategori bazında, 100 TL'ye yukarı yuvarlanır); mevcut limitler varsa onay istenir.
|
||||||
|
- Limit düzenleyici: tüm etkin gider kategorileri ağaç sırasıyla, anlık toplam bütçe.
|
||||||
|
- Dashboard: %80'i geçilen veya aşılan kategori sayısıyla uygulama içi bütçe uyarısı.
|
||||||
|
- Seed: Çakır Ailesi'nde önceki ve bu ay için harcamalardan oluşturulmuş bütçeler (bu ay Market limiti düşük, Elektrik alt limiti).
|
||||||
|
- `010` migration'ı: `budgets` index'i (hane × ay benzersiz).
|
||||||
|
- Testler: dönem sınırları, eşikler, tahmin, üst/alt limit toplama, bütçesiz harcama, sıra (birim); limitler, bölünmüş işlem, planlı, gizli hesap ayarı, taksit modları, başlangıç günü, kopyala/oluştur, yetki (entegrasyon).
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-11-06
|
||||||
|
|
||||||
|
Faz 9: birikim ve varlık takibi, fiyat tablosu (elle), kâr/zarar, net varlık (9a ve 9b alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 9b: dönemsel rapor ve net varlık
|
||||||
|
|
||||||
|
- Dönemsel rapor (`/birikim` toplam, `/birikim/:id` varlık bazında): 3 ay / 6 ay / 12 ay / tümü (`?donem=`); dönem başı değer, yatırılan TL, dönem sonu değer, satış geliri, gelen/çıkan hediye ve dönem kâr/zararı (TL ve %, dönemde bağlanan sermayeye oranla). Formül sayfada yazılıdır.
|
||||||
|
- Aylık değer grafiği (shadcn charts / Recharts): güncel değer (düz çizgi) ve maliyet (kesikli, basamaklı), lejant, ipucu ve tablo görünümü. Fiyatı olmayan varlık toplam rapora katılmaz ve belirtilir.
|
||||||
|
- Net varlık (`/birikim` üstünde): hesap bakiyeleri + birikim (alış fiyatı) + cari alacaklar + aynî alacaklar (alış) − kart/KMH borçları − cari borçlar − aynî borçlar (satış). Gizli varlık ve cariler yalnızca sahibinin net varlığına girer; gizli hesap bakiyeleri hane ayarına göre.
|
||||||
|
- Dashboard: "Toplam hesap bakiyesi", "Kart borçları" ve "Net varlık" kartları gerçek veriyle (gelir/gider kartları Faz 14).
|
||||||
|
- Aynî kayıt ↔ varlık bağlantısı: "Ödeme gerçekleşti → Aynı varlıkla" diyaloğunda isteğe bağlı kutucuk. Borç ödemesi seçilen varlıktan ortalama maliyetle düşer; alacak tahsilatı varlığa borç tarihindeki birim fiyatla eklenir. Ödeme ve varlık hareketi tek transaction'da; varlık yetersizse hiçbir şey yazılmaz. Bağlı hareket Birikim'den tek başına silinemez; aynî kayıt silinince bağ çözülür.
|
||||||
|
- Fiyat okuma yardımcıları ayrı modülde (`features/in-kind/prices.ts`).
|
||||||
|
- Testler: dönem noktaları, örnek senaryo ("10 gr'ı 25.000'e aldım, her ay ekledim"), başlangıç değeri, fiyat yokluğu, toplam (birim); dönem raporu, gizli varlık ve fiyatsız varlık, net varlık formülü ve değişmezleri (makas, cari borç), aynî borç/alacak bağlantısı, yetersiz varlıkta geri alma, tür uyuşmazlığı (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 9b
|
||||||
|
|
||||||
|
- "Aynı varlıkla" alacak tahsilatında anlamsız olan "varlığı almak için harcanan" tutar alanı kaldırıldı (yalnızca borç ödemesinde gösterilir).
|
||||||
|
- `recharts` bağımlılığı ve `components/ui/chart.tsx` eklendi.
|
||||||
|
- CHANGELOG sürüm karşılaştırma bağlantıları 0.6.0–0.9.0 için tamamlandı.
|
||||||
|
|
||||||
### Eklendi — Faz 9a: varlık lotları ve kâr/zarar
|
### Eklendi — Faz 9a: varlık lotları ve kâr/zarar
|
||||||
|
|
||||||
- Birikim sayfası (`/birikim`): altın, döviz ve ziynet varlıkları (`assets`); toplam güncel değer, toplam maliyet, gerçekleşmemiş ve gerçekleşmiş kâr/zarar; elde kalmayan varlıklar ayrı grupta. Fiyatı olmayan varlık toplam değere katılmaz ve belirtilir.
|
- Birikim sayfası (`/birikim`): altın, döviz ve ziynet varlıkları (`assets`); toplam güncel değer, toplam maliyet, gerçekleşmemiş ve gerçekleşmiş kâr/zarar; elde kalmayan varlıklar ayrı grupta. Fiyatı olmayan varlık toplam değere katılmaz ve belirtilir.
|
||||||
@@ -264,7 +318,12 @@ Faz 1: Framework, klasör yapısı ve altyapı.
|
|||||||
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
|
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
|
||||||
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
|
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
|
||||||
|
|
||||||
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.5.0...HEAD
|
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.10.0...HEAD
|
||||||
|
[0.10.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.9.0...v0.10.0
|
||||||
|
[0.9.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.8.0...v0.9.0
|
||||||
|
[0.8.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.7.0...v0.8.0
|
||||||
|
[0.7.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.6.0...v0.7.0
|
||||||
|
[0.6.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.5.0...v0.6.0
|
||||||
[0.5.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...v0.5.0
|
[0.5.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...v0.5.0
|
||||||
[0.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0
|
[0.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0
|
||||||
[0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0
|
[0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0
|
||||||
|
|||||||
@@ -320,6 +320,8 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
|||||||
- **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır.
|
- **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır.
|
||||||
- **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir.
|
- **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir.
|
||||||
- **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir.
|
- **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir.
|
||||||
|
- **Faz 9 kararları (2026-11-05):** 9a varlık lotları ve kâr/zarar, 9b dönemsel rapor ve net varlık; `v0.9.0` 9b sonunda. Varlıklar hane ortaktır; "yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür. Varlık alım/satışı hesaba bağlıysa transfer olarak kaydedilir (gelir/gider raporlarına girmez). Aynî kayıt "aynı varlıkla" kapanırken isteğe bağlı olarak varlıktan düşülür / varlığa eklenir.
|
||||||
|
- **Faz 10 kararları (2026-11-06):** 10a aylık bütçe, 10b gelir planı ve günlük plan; `v0.10.0` 10b sonunda. Limit her gider kategorisine verilebilir; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır (toplamda iki kez sayılmaz). Başkasının "yalnızca ben" hesabından yapılan harcama hane ayarına (gizli hesapları toplamlara kat) göre yalnızca tutar olarak bütçeye girer.
|
||||||
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+372
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.8.0",
|
"version": "0.10.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.8.0",
|
"version": "0.10.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^7.0.2",
|
"@hookform/resolvers": "^7.0.2",
|
||||||
"@tanstack/react-table": "^9.3.1",
|
"@tanstack/react-table": "^9.3.1",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-hook-form": "^7.91.0",
|
"react-hook-form": "^7.91.0",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"shadcn": "^4.23.0",
|
"shadcn": "^4.23.0",
|
||||||
"sharp": "^0.35.5",
|
"sharp": "^0.35.5",
|
||||||
@@ -4051,6 +4052,46 @@
|
|||||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-m/PxMwRe0xLL6XUsAgy0YjInklHcfi9uMwFhJk+QINqjxqPvyc89T5cMjszA5bDmTE0gwGc7zr9LRO4gL+mhaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.2.9",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.2.9.tgz",
|
||||||
|
"integrity": "sha512-IQ4UEtXjZjyefyWjtLEDXUSrF6T2hwjKPi6BIJl3bY/2XXW24d6cNGdXJSBOA/+KNnuiXkZY6DR0C3efolhitA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||||
"version": "1.2.16",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
|
||||||
@@ -4964,6 +5005,69 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"assertion-error": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -5022,6 +5126,12 @@
|
|||||||
"@types/react": "^19.3.0"
|
"@types/react": "^19.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/validate-npm-package-name": {
|
"node_modules/@types/validate-npm-package-name": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||||
@@ -6573,6 +6683,127 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.5.tgz",
|
||||||
|
"integrity": "sha512-Th8qGXoBalmWsstpkuhmBuTsBQIFqYOJtjlBvNVvBfJ8E3uYusBUwUR8HU20U0qKKQkgCvKyR1YjiozWadk5QA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-lfab8x0Bj0UO7SNsY8dZSbNIA6ipbkE3X0NG9nqjCu6CfCHZseuDnOSJY7hidKrjpP++i1M2qT+FlAdPKYRilA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-B5znKqRmKROAN4oHWQ50W/qisVjA9GXh2WMsVKdFVp4DxWmcrOsweqgUOUVsv5th96kwaZlokU6SlV4J3gak1w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-+F+/49swqmGuQJmSKnVMIDH/QkEFV5li9QRj0xm/KhyAUBv099O5PrvpQFxBp24PYph17Mnvt9mjKQV0ii0nMA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-IRzGU62DtxhvsnSTWMQIdnoZ6gA+BCWwBon1EJI6SjoN595799b6z/QRBzXfFhE8iupUPp3042yBNo0STPqnzQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-jsbgV8zIT38ReB9O7X7gcLndTQRONSqNNd+oGIaTBdUug4r5jFmp0N6RY9R5GWbdUDucn0qoTGyfjapT4tDhcg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-66cWobCNzcMvlfoaDHCxuIv9TBUWG1BLOWdv9kHHFuCr1JdqAWPvTmsaEYDD5NRMyWZ5+7u0GqiCDfMXMqh3hg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-dm2o+lbKdzg9tY7GEag/dB9hcbodLfwkE/DkvqbELJXaMz0yKzWWhZqhbI7CKubukA7HnRyz0avdtvi7EPxPvw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-Ze7rjrhMzdeulh0/hjcfFj7XLAHOfowtEMfsoG6DU866HPynQNW7Wfen6hzXcqKTv8XzxvF6Hzr5XD6bMHr2ug==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-22XaqjyCjpqgda20erhsOjGmC3vvYwTNYniKAd45W5UWLDF43moeOuV9qYpRqobIlsnK0UhnKZjUUJm3VgORUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-1Il1aliroVSErrv5aulRBxAY+7+DnaoLIWGd1C6SsBTWDeobWiP/V69vwb8vT6abR1kKwaNe1196pmaQ1fK90A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -6666,6 +6897,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||||
@@ -7140,6 +7377,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.53.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.1.tgz",
|
||||||
|
"integrity": "sha512-VhMc6IXmexu8iYFfL2aaAww6IvJTdHlqBLruLoEUs33YD4IcP5DdZcLOybMsMmmLsiy1c8PsiLsrM0DdIuuRUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks",
|
||||||
|
"tests/types",
|
||||||
|
"tests/browser-compat"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.2",
|
"version": "0.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
@@ -7658,6 +7907,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/execa": {
|
"node_modules/execa": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
||||||
@@ -8282,6 +8537,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -8324,6 +8589,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.7.6",
|
"version": "10.7.6",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz",
|
||||||
@@ -10715,9 +10989,31 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.1.tgz",
|
||||||
|
"integrity": "sha512-vxMLT0lalUjbZPkoTepWyKAZbjIIFssNx/CEL9lTfwQBU6XpkGSmCNdcCIrkOBUgB68uftZMnbXVb6DwGnhRKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
@@ -10803,6 +11099,51 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||||
|
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -10856,6 +11197,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.7",
|
"version": "2.0.0-next.7",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||||
@@ -12405,6 +12752,28 @@
|
|||||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.4.1.tgz",
|
||||||
|
"integrity": "sha512-geV4p2GVfVM9lgm9ZRS3pvsbAPS+BUGhM9W9nPzPHTKi+/+KjNrFXdgP6KkG+XspNZuvcXmchcrtklfuQ4gniw==",
|
||||||
|
"license": "MIT AND ISC AND BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.3.5",
|
"version": "8.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.8.0",
|
"version": "0.10.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-hook-form": "^7.91.0",
|
"react-hook-form": "^7.91.0",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"shadcn": "^4.23.0",
|
"shadcn": "^4.23.0",
|
||||||
"sharp": "^0.35.5",
|
"sharp": "^0.35.5",
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { BudgetModel } from "@/models/budget";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "010-budgets",
|
||||||
|
description: "budgets index'i (hane × ay benzersiz)",
|
||||||
|
async up() {
|
||||||
|
await BudgetModel.createIndexes();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -9,10 +9,11 @@ import { migration as m006 } from "./006-card-statements";
|
|||||||
import { migration as m007 } from "./007-contacts";
|
import { migration as m007 } from "./007-contacts";
|
||||||
import { migration as m008 } from "./008-in-kind";
|
import { migration as m008 } from "./008-in-kind";
|
||||||
import { migration as m009 } from "./009-assets";
|
import { migration as m009 } from "./009-assets";
|
||||||
|
import { migration as m010 } from "./010-budgets";
|
||||||
import type { Migration } from "./types";
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||||
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008, m009];
|
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Types } from "mongoose";
|
||||||
|
import { budgetFromActuals, saveBudget } from "@/features/budgets/service";
|
||||||
|
import { BudgetModel } from "@/models/budget";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { HouseholdModel } from "@/models/household";
|
||||||
|
import { periodOfDate, shiftMonth } from "@/shared/budget";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { budgetLimitsSchema } from "@/shared/schemas/budget";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Örnek bütçeler (Faz 10a): Çakır Ailesi'nde önceki ve bu ay, önceki ayların harcamalarından
|
||||||
|
* oluşturulur. Bu ay "Market" limiti biraz düşürülür ve "Elektrik" alt limiti eklenir; böylece
|
||||||
|
* uyarı ve alt limit örnekleri görünür. İdempotent: bütçesi olan aya dokunulmaz.
|
||||||
|
*/
|
||||||
|
export async function seedBudgets(
|
||||||
|
householdIds: Map<string, Types.ObjectId>,
|
||||||
|
userIds: Map<string, string>,
|
||||||
|
): Promise<number> {
|
||||||
|
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||||
|
const by = userIds.get("ayse")!;
|
||||||
|
const hh = String(householdId);
|
||||||
|
const h = await HouseholdModel.findById(householdId).select({ settings: 1 }).lean();
|
||||||
|
const current = periodOfDate(todayIstanbul(), h?.settings?.budgetMonthStartDay ?? 1);
|
||||||
|
let created = 0;
|
||||||
|
for (const month of [shiftMonth(current, -1), current]) {
|
||||||
|
if (await BudgetModel.exists({ householdId, month })) continue;
|
||||||
|
const r = await budgetFromActuals(by, hh, month);
|
||||||
|
if (!r.ok) continue;
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
if (created && (await BudgetModel.exists({ householdId, month: current }))) {
|
||||||
|
const doc = (await BudgetModel.findOne({ householdId, month: current }).lean())!;
|
||||||
|
const cats = await CategoryModel.find({
|
||||||
|
householdId,
|
||||||
|
defaultKey: { $in: ["market", "faturalar.elektrik"] },
|
||||||
|
}).lean();
|
||||||
|
const id = (key: string) => String(cats.find((c) => c.defaultKey === key)?._id ?? "");
|
||||||
|
const limits = doc.limits.map((l) => ({
|
||||||
|
categoryId: String(l.categoryId),
|
||||||
|
amount: formatMoneyInput(
|
||||||
|
String(l.categoryId) === id("market") ? Math.round(l.amountMinor * 0.7) : l.amountMinor,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
if (id("faturalar.elektrik"))
|
||||||
|
limits.push({ categoryId: id("faturalar.elektrik"), amount: "900" });
|
||||||
|
await saveBudget(by, hh, budgetLimitsSchema.parse({ month: current, limits }));
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ describe("seed", () => {
|
|||||||
expect(summary.created.accounts).toBe(7);
|
expect(summary.created.accounts).toBe(7);
|
||||||
expect(summary.created.transactions).toBeGreaterThan(150);
|
expect(summary.created.transactions).toBeGreaterThan(150);
|
||||||
expect(summary.created.assets).toBe(4);
|
expect(summary.created.assets).toBe(4);
|
||||||
|
expect(summary.created.budgets).toBe(2);
|
||||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||||
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
||||||
});
|
});
|
||||||
@@ -51,6 +52,7 @@ describe("seed", () => {
|
|||||||
contacts: 0,
|
contacts: 0,
|
||||||
inKind: 0,
|
inKind: 0,
|
||||||
assets: 0,
|
assets: 0,
|
||||||
|
budgets: 0,
|
||||||
});
|
});
|
||||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { storedEmail } from "@/shared/account-email";
|
|||||||
import { runMigrations } from "../migrations";
|
import { runMigrations } from "../migrations";
|
||||||
import { seedContacts } from "./contacts";
|
import { seedContacts } from "./contacts";
|
||||||
import { seedAssets } from "./assets";
|
import { seedAssets } from "./assets";
|
||||||
|
import { seedBudgets } from "./budgets";
|
||||||
import { seedInKind } from "./in-kind";
|
import { seedInKind } from "./in-kind";
|
||||||
import { seedInstallments } from "./installments";
|
import { seedInstallments } from "./installments";
|
||||||
import { seedRecurring } from "./recurring";
|
import { seedRecurring } from "./recurring";
|
||||||
@@ -53,6 +54,7 @@ export type SeedSummary = {
|
|||||||
contacts: number;
|
contacts: number;
|
||||||
inKind: number;
|
inKind: number;
|
||||||
assets: number;
|
assets: number;
|
||||||
|
budgets: number;
|
||||||
};
|
};
|
||||||
accounts: SeedAccountRow[];
|
accounts: SeedAccountRow[];
|
||||||
};
|
};
|
||||||
@@ -75,6 +77,7 @@ const DOMAIN_COLLECTIONS = [
|
|||||||
"inKindDebts",
|
"inKindDebts",
|
||||||
"assets",
|
"assets",
|
||||||
"assetMovements",
|
"assetMovements",
|
||||||
|
"budgets",
|
||||||
];
|
];
|
||||||
|
|
||||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||||
@@ -129,6 +132,7 @@ export async function runSeed({
|
|||||||
contacts: 0,
|
contacts: 0,
|
||||||
inKind: 0,
|
inKind: 0,
|
||||||
assets: 0,
|
assets: 0,
|
||||||
|
budgets: 0,
|
||||||
},
|
},
|
||||||
accounts: [],
|
accounts: [],
|
||||||
};
|
};
|
||||||
@@ -309,6 +313,8 @@ export async function runSeed({
|
|||||||
if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`);
|
if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`);
|
||||||
summary.created.assets = await seedAssets(householdIds, userIdMap);
|
summary.created.assets = await seedAssets(householdIds, userIdMap);
|
||||||
if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`);
|
if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`);
|
||||||
|
summary.created.budgets = await seedBudgets(householdIds, userIdMap);
|
||||||
|
if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`);
|
||||||
|
|
||||||
// 7) Geliştirme hesapları tablosu
|
// 7) Geliştirme hesapları tablosu
|
||||||
const all = [
|
const all = [
|
||||||
|
|||||||
@@ -1,17 +1,36 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { AssetDetailView } from "@/features/assets/components/asset-detail-view";
|
import { AssetDetailView } from "@/features/assets/components/asset-detail-view";
|
||||||
import { getAsset } from "@/features/assets/service";
|
import { PeriodReportView } from "@/features/assets/components/period-report";
|
||||||
|
import {
|
||||||
|
getAsset,
|
||||||
|
getPeriodReport,
|
||||||
|
REPORT_RANGES,
|
||||||
|
type ReportRange,
|
||||||
|
} from "@/features/assets/service";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: tr.assets.history };
|
export const metadata: Metadata = { title: tr.assets.history };
|
||||||
|
|
||||||
/** /birikim/:id — varlık hareketleri (gizli varlık yalnızca sahibine; aksi 404). */
|
/** /birikim/:id — varlık hareketleri ve dönemsel rapor (gizli varlık yalnızca sahibine; aksi 404). */
|
||||||
export default async function AssetPage({ params }: PageProps<"/birikim/[id]">) {
|
export default async function AssetPage({ params, searchParams }: PageProps<"/birikim/[id]">) {
|
||||||
const { user, household } = await requireActiveHousehold("viewer");
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const data = await getAsset(user.id, household.id, id);
|
const donem = (await searchParams).donem;
|
||||||
if (!data) notFound();
|
const range: ReportRange = REPORT_RANGES.includes(donem as ReportRange)
|
||||||
return <AssetDetailView key={id} data={data} />;
|
? (donem as ReportRange)
|
||||||
|
: "6";
|
||||||
|
const [data, report] = await Promise.all([
|
||||||
|
getAsset(user.id, household.id, id),
|
||||||
|
getPeriodReport(user.id, household.id, range, id),
|
||||||
|
]);
|
||||||
|
if (!data || !report) notFound();
|
||||||
|
return (
|
||||||
|
<AssetDetailView
|
||||||
|
key={id}
|
||||||
|
data={data}
|
||||||
|
report={<PeriodReportView report={report} range={range} basePath={`/birikim/${id}`} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,45 @@ import type { Metadata } from "next";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { AssetsView } from "@/features/assets/components/assets-view";
|
import { AssetsView } from "@/features/assets/components/assets-view";
|
||||||
import { listAssets } from "@/features/assets/service";
|
import { PeriodReportView } from "@/features/assets/components/period-report";
|
||||||
|
import {
|
||||||
|
getPeriodReport,
|
||||||
|
listAssets,
|
||||||
|
REPORT_RANGES,
|
||||||
|
type ReportRange,
|
||||||
|
} from "@/features/assets/service";
|
||||||
|
import { NetWorthCard } from "@/features/net-worth/components/net-worth-card";
|
||||||
|
import { getNetWorth } from "@/features/net-worth/service";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: tr.assets.title };
|
export const metadata: Metadata = { title: tr.assets.title };
|
||||||
|
|
||||||
/** /birikim — altın, döviz ve ziynet varlıkları; ortalama maliyet ve kâr/zarar. */
|
function parseRange(value: string | string[] | undefined): ReportRange {
|
||||||
export default async function AssetsPage() {
|
return REPORT_RANGES.includes(value as ReportRange) ? (value as ReportRange) : "6";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /birikim — net varlık, varlıklar (ortalama maliyet, K/Z) ve dönemsel rapor. */
|
||||||
|
export default async function AssetsPage({ searchParams }: PageProps<"/birikim">) {
|
||||||
const { user, household } = await requireActiveHousehold("viewer");
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
const data = await listAssets(user.id, household.id);
|
const range = parseRange((await searchParams).donem);
|
||||||
if (!data) notFound();
|
const [data, report, netWorth] = await Promise.all([
|
||||||
|
listAssets(user.id, household.id),
|
||||||
|
getPeriodReport(user.id, household.id, range),
|
||||||
|
getNetWorth(user.id, household.id),
|
||||||
|
]);
|
||||||
|
if (!data || !report || !netWorth) notFound();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={tr.assets.title} description={tr.assets.description} />
|
<PageHeader title={tr.assets.title} description={tr.assets.description} />
|
||||||
|
<section aria-labelledby="net-worth-title" className="flex flex-col gap-2">
|
||||||
|
<h2 id="net-worth-title" className="text-base font-medium">
|
||||||
|
{tr.assets.netWorth.title}
|
||||||
|
</h2>
|
||||||
|
<NetWorthCard data={netWorth} />
|
||||||
|
</section>
|
||||||
<AssetsView items={data.items} totals={data.totals} canWrite={data.canWrite} />
|
<AssetsView items={data.items} totals={data.totals} canWrite={data.canWrite} />
|
||||||
|
<PeriodReportView report={report} range={range} basePath="/birikim" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { BudgetView } from "@/features/budgets/components/budget-view";
|
||||||
|
import { IncomePlanSection } from "@/features/budgets/components/income-plan";
|
||||||
|
import { getIncomePlan } from "@/features/budgets/plan";
|
||||||
|
import { getBudget } from "@/features/budgets/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.budget.title };
|
||||||
|
|
||||||
|
/** /butce?ay=YYYY-MM — aylık bütçe (varsayılan: içinde bulunulan bütçe ayı). */
|
||||||
|
export default async function BudgetPage({ searchParams }: PageProps<"/butce">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||||
|
const data = await getBudget(user.id, household.id, ay.success ? ay.data : undefined);
|
||||||
|
if (!data) notFound();
|
||||||
|
const income = await getIncomePlan(user.id, household.id, data.month);
|
||||||
|
if (!income) notFound();
|
||||||
|
const monthLabel = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
}).format(new Date(`${data.month}-15T12:00:00+03:00`));
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title={tr.budget.title} description={tr.budget.description} />
|
||||||
|
<BudgetView key={data.month} data={data} />
|
||||||
|
<IncomePlanSection
|
||||||
|
key={`income-${data.month}`}
|
||||||
|
month={data.month}
|
||||||
|
monthLabel={monthLabel}
|
||||||
|
income={income}
|
||||||
|
limitMinor={data.totals.limitMinor}
|
||||||
|
forecastMinor={data.totals.forecastMinor}
|
||||||
|
hasBudget={data.hasBudget}
|
||||||
|
canWrite={data.canWrite}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||||
|
import { DailyPlanCard } from "@/features/budgets/components/daily-plan-card";
|
||||||
|
import { BudgetWarningBanner } from "@/features/budgets/components/warning-banner";
|
||||||
|
import { getIncomePlan } from "@/features/budgets/plan";
|
||||||
|
import { getBudget } from "@/features/budgets/service";
|
||||||
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
||||||
|
import { getNetWorth } from "@/features/net-worth/service";
|
||||||
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
@@ -16,22 +22,37 @@ type SummaryCard = {
|
|||||||
key: keyof typeof tr.dashboard.cards;
|
key: keyof typeof tr.dashboard.cards;
|
||||||
tone?: string;
|
tone?: string;
|
||||||
amountMinor: number | null;
|
amountMinor: number | null;
|
||||||
|
href?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** FAZ 14: gerçek özet verisi (gelir, gider, bakiye, kart borcu, net varlık). */
|
/**
|
||||||
async function getSummary(): Promise<SummaryCard[]> {
|
* Özet kartları (içinde bulunulan bütçe ayı): gerçekleşen gelir, gider ve farkı; hesap bakiyesi,
|
||||||
|
* kart borcu ve net varlık.
|
||||||
|
*/
|
||||||
|
async function getSummary(userId: string, householdId: string): Promise<SummaryCard[]> {
|
||||||
|
const [nw, budget] = await Promise.all([
|
||||||
|
getNetWorth(userId, householdId),
|
||||||
|
getBudget(userId, householdId),
|
||||||
|
]);
|
||||||
|
const income = budget ? await getIncomePlan(userId, householdId, budget.month) : null;
|
||||||
|
const inc = income?.actualMinor ?? null;
|
||||||
|
const exp = budget?.totals.actualMinor ?? null;
|
||||||
return [
|
return [
|
||||||
{ key: "monthIncome", tone: "text-income", amountMinor: null },
|
{ key: "monthIncome", tone: "text-income", amountMinor: inc, href: "/butce" },
|
||||||
{ key: "monthExpense", tone: "text-expense", amountMinor: null },
|
{ key: "monthExpense", tone: "text-expense", amountMinor: exp, href: "/butce" },
|
||||||
{ key: "monthRemaining", tone: "text-budget", amountMinor: null },
|
{
|
||||||
{ key: "totalBalance", amountMinor: null },
|
key: "monthRemaining",
|
||||||
{ key: "cardDebt", tone: "text-expense", amountMinor: null },
|
tone: "text-budget",
|
||||||
{ key: "netWorth", amountMinor: null },
|
amountMinor: inc !== null && exp !== null ? inc - exp : null,
|
||||||
|
},
|
||||||
|
{ key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" },
|
||||||
|
{ key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null },
|
||||||
|
{ key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function SummaryCards() {
|
async function SummaryCards({ userId, householdId }: { userId: string; householdId: string }) {
|
||||||
const cards = await getSummary();
|
const cards = await getSummary(userId, householdId);
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
{cards.map((c) => (
|
{cards.map((c) => (
|
||||||
@@ -39,7 +60,15 @@ async function SummaryCards() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardDescription>{tr.dashboard.cards[c.key]}</CardDescription>
|
<CardDescription>{tr.dashboard.cards[c.key]}</CardDescription>
|
||||||
<CardTitle className={cn("text-lg tabular-nums", c.tone)}>
|
<CardTitle className={cn("text-lg tabular-nums", c.tone)}>
|
||||||
{c.amountMinor === null ? "—" : formatMoney(c.amountMinor)}
|
{c.amountMinor === null ? (
|
||||||
|
"—"
|
||||||
|
) : c.href ? (
|
||||||
|
<Link href={c.href} className="hover:underline">
|
||||||
|
{formatMoney(c.amountMinor)}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
formatMoney(c.amountMinor)
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{c.amountMinor === null ? (
|
{c.amountMinor === null ? (
|
||||||
@@ -64,11 +93,17 @@ export default async function DashboardPage() {
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<OverdueBanner userId={user.id} householdId={household.id} />
|
<OverdueBanner userId={user.id} householdId={household.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<BudgetWarningBanner userId={user.id} householdId={household.id} />
|
||||||
|
</Suspense>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ContactsDueBanner userId={user.id} householdId={household.id} />
|
<ContactsDueBanner userId={user.id} householdId={household.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||||
<SummaryCards />
|
<SummaryCards userId={user.id} householdId={household.id} />
|
||||||
|
</Suspense>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DailyPlanCard userId={user.id} householdId={household.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export type NavItem = {
|
|||||||
|
|
||||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||||
export const CURRENT_PHASE = 9.1;
|
export const CURRENT_PHASE = 10.2;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export const MAIN_NAV: NavItem[] = [
|
|||||||
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 },
|
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 },
|
||||||
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
|
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
|
||||||
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 },
|
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 },
|
||||||
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
|
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 },
|
||||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
|
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
|
||||||
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
|
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
|
||||||
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
|
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as RechartsPrimitive from "recharts";
|
||||||
|
import type { TooltipValueType } from "recharts";
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
|
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
|
||||||
|
type TooltipNameType = number | string;
|
||||||
|
|
||||||
|
export type ChartConfig = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
initialDimension = INITIAL_DIMENSION,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||||
|
initialDimension?: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: "line" | "dot" | "dashed";
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
} & Omit<
|
||||||
|
RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>,
|
||||||
|
"accessibilityLayer"
|
||||||
|
>) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string" ? (config[label]?.label ?? label) : itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||||
|
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor = color ?? item.payload?.fill ?? item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
|
indicator === "dot" && "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label ?? item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value != null && (
|
||||||
|
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||||
|
{typeof item.value === "number"
|
||||||
|
? item.value.toLocaleString()
|
||||||
|
: String(item.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.dataKey ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
};
|
||||||
@@ -3,10 +3,17 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { fail, type ActionResult } from "@/lib/action-result";
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
import { getHouseholdActionContext } from "@/lib/dal";
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||||
import { addMovement, createAsset, deleteAsset, deleteMovement, updateAsset } from "./service";
|
import {
|
||||||
|
addMovement,
|
||||||
|
createAsset,
|
||||||
|
deleteAsset,
|
||||||
|
deleteMovement,
|
||||||
|
listAssets,
|
||||||
|
updateAsset,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||||
|
|
||||||
@@ -60,3 +67,21 @@ export async function deleteMovementAction(movementId: unknown) {
|
|||||||
if (!id.success) return fail(tr.common.notFound);
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
return run(z.unknown(), null, (u, h) => deleteMovement(u, h, id.data));
|
return run(z.unknown(), null, (u, h) => deleteMovement(u, h, id.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AssetOption = { id: string; name: string; assetKey: string; quantity: number };
|
||||||
|
|
||||||
|
/** Aynî ödeme diyaloğu için görünür varlıklar (ad, tür, eldeki miktar). */
|
||||||
|
export async function getAssetOptionsAction(): Promise<ActionResult<AssetOption[]>> {
|
||||||
|
const ctx = await getHouseholdActionContext("viewer");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const data = await listAssets(ctx.user.id, ctx.household.id);
|
||||||
|
if (!data) return fail(tr.common.unauthorized);
|
||||||
|
return ok(
|
||||||
|
data.items.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
assetKey: a.assetKey,
|
||||||
|
quantity: a.summary.quantity,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,14 @@ type Pending =
|
|||||||
| null;
|
| null;
|
||||||
|
|
||||||
/** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */
|
/** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */
|
||||||
export function AssetDetailView({ data }: { data: AssetDetail }) {
|
export function AssetDetailView({
|
||||||
|
data,
|
||||||
|
report,
|
||||||
|
}: {
|
||||||
|
data: AssetDetail;
|
||||||
|
/** Dönemsel rapor (sunucuda hazırlanır). */
|
||||||
|
report?: React.ReactNode;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [pending, setPending] = useState<Pending>(null);
|
const [pending, setPending] = useState<Pending>(null);
|
||||||
const close = () => setPending(null);
|
const close = () => setPending(null);
|
||||||
@@ -171,6 +178,8 @@ export function AssetDetailView({ data }: { data: AssetDetail }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{report}
|
||||||
|
|
||||||
<section className="flex flex-col gap-2" aria-labelledby="movements-title">
|
<section className="flex flex-col gap-2" aria-labelledby="movements-title">
|
||||||
<h2 id="movements-title" className="text-base font-medium">
|
<h2 id="movements-title" className="text-base font-medium">
|
||||||
{t.history}
|
{t.history}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import type { PeriodReport } from "@/shared/portfolio";
|
||||||
|
import type { ReportRange } from "../service";
|
||||||
|
import { ProfitLoss, Stat } from "./assets-view";
|
||||||
|
|
||||||
|
const t = tr.assets.report;
|
||||||
|
const RANGES: ReportRange[] = ["3", "6", "12", "tum"];
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
const tickFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
});
|
||||||
|
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
value: { label: t.value, color: "var(--budget)" },
|
||||||
|
cost: { label: t.cost, color: "var(--muted-foreground)" },
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
/** Dönem seçici + özet + aylık değer/maliyet grafiği + tablo görünümü. */
|
||||||
|
export function PeriodReportView({
|
||||||
|
report,
|
||||||
|
range,
|
||||||
|
basePath,
|
||||||
|
}: {
|
||||||
|
report: PeriodReport & { excluded: number };
|
||||||
|
range: ReportRange;
|
||||||
|
basePath: string;
|
||||||
|
}) {
|
||||||
|
const data = report.points.map((p) => ({
|
||||||
|
date: p.date,
|
||||||
|
label: tickFmt.format(new Date(`${p.date}T12:00:00+03:00`)),
|
||||||
|
// Kuruş → TL (eksen ve ipucu TL gösterir).
|
||||||
|
value: p.valueMinor === null ? null : p.valueMinor / 100,
|
||||||
|
cost: p.costMinor / 100,
|
||||||
|
}));
|
||||||
|
const hasData = report.points.some((p) => p.costMinor > 0 || (p.valueMinor ?? 0) > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||||
|
<CardDescription>{t.period(d(report.from), d(report.to))}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<nav aria-label={t.rangeLabel} className="flex rounded-lg bg-muted p-0.5 text-sm">
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<Link
|
||||||
|
key={r}
|
||||||
|
href={`${basePath}?donem=${r}`}
|
||||||
|
scroll={false}
|
||||||
|
aria-current={r === range ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-2.5 py-1 text-muted-foreground transition-colors",
|
||||||
|
r === range && "bg-background font-medium text-foreground shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.ranges[r]}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label={t.startValue}>
|
||||||
|
{report.startValueMinor !== null ? formatMoney(report.startValueMinor) : t.noPrice}
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.invested}>{formatMoney(report.investedMinor)}</Stat>
|
||||||
|
<Stat label={t.endValue}>
|
||||||
|
{report.endValueMinor !== null ? formatMoney(report.endValueMinor) : t.noPrice}
|
||||||
|
</Stat>
|
||||||
|
<Stat label={t.profit}>
|
||||||
|
<ProfitLoss minor={report.profitMinor} bp={report.profitBp} />
|
||||||
|
</Stat>
|
||||||
|
{report.proceedsMinor ? (
|
||||||
|
<Stat label={t.proceeds}>{formatMoney(report.proceedsMinor)}</Stat>
|
||||||
|
) : null}
|
||||||
|
{report.giftInMinor ? (
|
||||||
|
<Stat label={t.giftIn}>{formatMoney(report.giftInMinor)}</Stat>
|
||||||
|
) : null}
|
||||||
|
{report.giftOutMinor ? (
|
||||||
|
<Stat label={t.giftOut}>{formatMoney(report.giftOutMinor)}</Stat>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t.formula}</p>
|
||||||
|
{report.excluded ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.excluded(report.excluded)}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasData ? (
|
||||||
|
<>
|
||||||
|
<ChartContainer
|
||||||
|
config={config}
|
||||||
|
className="aspect-auto h-56 w-full sm:h-64"
|
||||||
|
aria-label={t.chartLabel}
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<LineChart data={data} margin={{ left: 4, right: 12, top: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={16}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={48}
|
||||||
|
tickFormatter={(v: number) => compact.format(v)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const date = payload?.[0]?.payload?.date as string | undefined;
|
||||||
|
return date ? d(date) : "";
|
||||||
|
}}
|
||||||
|
formatter={(value, name) => (
|
||||||
|
<div className="flex w-full items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{config[name as keyof typeof config]?.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
|
{formatMoney(Math.round(Number(value) * 100))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
<Line
|
||||||
|
dataKey="cost"
|
||||||
|
type="stepAfter"
|
||||||
|
stroke="var(--color-cost)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="value"
|
||||||
|
type="linear"
|
||||||
|
stroke="var(--color-value)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="cursor-pointer text-xs text-muted-foreground">{t.table}</summary>
|
||||||
|
<table className="mt-2 w-full text-right tabular-nums">
|
||||||
|
<thead className="text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 text-left font-normal">{t.date}</th>
|
||||||
|
<th className="py-1 font-normal">{t.value}</th>
|
||||||
|
<th className="py-1 font-normal">{t.cost}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{report.points.map((p) => (
|
||||||
|
<tr key={p.date}>
|
||||||
|
<td className="py-1 text-left">{d(p.date)}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{p.valueMinor !== null ? formatMoney(p.valueMinor) : t.noPrice}
|
||||||
|
</td>
|
||||||
|
<td className="py-1">{formatMoney(p.costMinor)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.empty}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import mongoose, { Types } from "mongoose";
|
import mongoose, { Types } from "mongoose";
|
||||||
import { hasHouseholdRole } from "@/features/households/roles";
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
import { priceOn, type PriceQuote } from "@/features/in-kind/service";
|
import { buyPriceLookup, priceOn, type PriceQuote } from "@/features/in-kind/prices";
|
||||||
import { loadContext, type Ctx } from "@/features/transactions/service";
|
import { loadContext, type Ctx } from "@/features/transactions/service";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
@@ -14,10 +14,14 @@ import { formatAssetQuantity, valueOf, type AssetKey } from "@/shared/assets";
|
|||||||
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
import {
|
import {
|
||||||
holdingSummary,
|
holdingSummary,
|
||||||
|
periodReport,
|
||||||
runMovements,
|
runMovements,
|
||||||
|
sumReports,
|
||||||
type HoldingSummary,
|
type HoldingSummary,
|
||||||
type MovementKind,
|
type MovementKind,
|
||||||
|
type PeriodReport,
|
||||||
} from "@/shared/portfolio";
|
} from "@/shared/portfolio";
|
||||||
|
import { addDays, addMonths } from "@/shared/recurrence";
|
||||||
import { QUANTITY_SCALE } from "@/shared/quantity";
|
import { QUANTITY_SCALE } from "@/shared/quantity";
|
||||||
import type { AssetForm, MovementForm } from "@/shared/schemas/asset";
|
import type { AssetForm, MovementForm } from "@/shared/schemas/asset";
|
||||||
import { compareTr, foldTr } from "@/shared/search";
|
import { compareTr, foldTr } from "@/shared/search";
|
||||||
@@ -482,3 +486,58 @@ export async function deleteMovement(
|
|||||||
});
|
});
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Dönemsel rapor ---
|
||||||
|
|
||||||
|
export const REPORT_RANGES = ["3", "6", "12", "tum"] as const;
|
||||||
|
export type ReportRange = (typeof REPORT_RANGES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seçilen aralıkta (son N ay veya ilk hareketten bugüne) yatırılan TL, dönem sonu değer,
|
||||||
|
* kâr/zarar ve aylık değer noktaları. `assetId` verilirse yalnızca o varlık, yoksa tüm görünür
|
||||||
|
* varlıkların toplamı.
|
||||||
|
*/
|
||||||
|
export async function getPeriodReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
range: ReportRange,
|
||||||
|
assetId?: string,
|
||||||
|
): Promise<(PeriodReport & { excluded: number }) | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
let docs: AssetDoc[];
|
||||||
|
if (assetId) {
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return null;
|
||||||
|
docs = [a];
|
||||||
|
} else {
|
||||||
|
docs = (
|
||||||
|
await AssetModel.find({ householdId: ctx.m.householdId, deletedAt: null }).lean<AssetDoc[]>()
|
||||||
|
).filter((a) => visibleAsset(ctx, a._id));
|
||||||
|
}
|
||||||
|
const movements = await loadMovements(
|
||||||
|
ctx.m.householdId,
|
||||||
|
docs.map((a) => a._id),
|
||||||
|
);
|
||||||
|
const to = todayIstanbul();
|
||||||
|
let from: string;
|
||||||
|
if (range === "tum") {
|
||||||
|
const first = [...movements.values()]
|
||||||
|
.flat()
|
||||||
|
.map((m) => m.date)
|
||||||
|
.sort()[0];
|
||||||
|
from = first && first < to ? first : addDays(to, -1);
|
||||||
|
} else from = addMonths(to, -Number(range));
|
||||||
|
const price = await buyPriceLookup(
|
||||||
|
ctx.m.householdId,
|
||||||
|
[...new Set(docs.map((a) => a.assetKey))],
|
||||||
|
to,
|
||||||
|
);
|
||||||
|
const reports = docs.map((a) =>
|
||||||
|
periodReport(movements.get(String(a._id)) ?? [], (date) => price(a.assetKey, date), from, to),
|
||||||
|
);
|
||||||
|
if (assetId) return { ...reports[0]!, excluded: 0 };
|
||||||
|
// Toplamda fiyatı eksik varlıklar (kâr hesaplanamayan) dışarıda bırakılır ve sayılır.
|
||||||
|
const priced = reports.filter((r) => r.profitMinor !== null);
|
||||||
|
return { ...sumReports(priced, from, to), excluded: reports.length - priced.length };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
|
import { budgetLimitsSchema, budgetMonthSchema, incomeItemsSchema } from "@/shared/schemas/budget";
|
||||||
|
import { saveIncomeItems } from "./plan";
|
||||||
|
import { budgetFromActuals, copyPreviousBudget, saveBudget } from "./service";
|
||||||
|
|
||||||
|
function fieldErrors(error: z.ZodError) {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const issue of error.issues) {
|
||||||
|
const key = issue.path.join(".");
|
||||||
|
if (key && !out[key]) out[key] = issue.message;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run<T>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
input: unknown,
|
||||||
|
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<unknown>>,
|
||||||
|
): Promise<ActionResult<unknown>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const parsed = schema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||||
|
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||||
|
if (result.ok) revalidatePath("/", "layout");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBudgetAction(input: unknown) {
|
||||||
|
return run(budgetLimitsSchema, input, saveBudget);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyPreviousBudgetAction(month: unknown) {
|
||||||
|
return run(budgetMonthSchema, month, copyPreviousBudget);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function budgetFromActualsAction(month: unknown) {
|
||||||
|
return run(budgetMonthSchema, month, budgetFromActuals);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveIncomeItemsAction(input: unknown) {
|
||||||
|
return run(incomeItemsSchema, input, saveIncomeItems);
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Copy,
|
||||||
|
EllipsisVertical,
|
||||||
|
History,
|
||||||
|
Pencil,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { IconBadge } from "@/components/appearance/swatch";
|
||||||
|
import { MoneyInput } from "@/components/money-input";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { shiftMonth, type BudgetStatus } from "@/shared/budget";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { formatMoneyInput, parseMoney } from "@/shared/money";
|
||||||
|
import { budgetFromActualsAction, copyPreviousBudgetAction, saveBudgetAction } from "../actions";
|
||||||
|
import type { BudgetLineView, BudgetView as Data } from "../service";
|
||||||
|
|
||||||
|
const t = tr.budget;
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
});
|
||||||
|
const monthLabel = (m: string) => monthFmt.format(new Date(`${m}-15T12:00:00+03:00`));
|
||||||
|
|
||||||
|
const BAR: Record<BudgetStatus, string> = {
|
||||||
|
ok: "bg-budget",
|
||||||
|
warning: "bg-amber-500",
|
||||||
|
over: "bg-expense",
|
||||||
|
none: "bg-muted-foreground/40",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Yüzde çubuğu; `marker` ayın geçen kısmını (yüzde) gösterir. */
|
||||||
|
function Bar({
|
||||||
|
ratioBp,
|
||||||
|
status,
|
||||||
|
marker,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
ratioBp: number | null;
|
||||||
|
status: BudgetStatus;
|
||||||
|
marker?: number;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const pct = ratioBp === null ? 0 : Math.min(100, ratioBp / 100);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={label}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={Math.round(ratioBp === null ? 0 : ratioBp / 100)}
|
||||||
|
>
|
||||||
|
<div className={cn("h-full rounded-full", BAR[status])} style={{ width: `${pct}%` }} />
|
||||||
|
{marker !== undefined && marker > 0 && marker < 100 ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute top-0 h-full w-0.5 bg-foreground/60"
|
||||||
|
style={{ left: `${marker}%` }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({
|
||||||
|
status,
|
||||||
|
forecastOver,
|
||||||
|
exceeded,
|
||||||
|
}: {
|
||||||
|
status: BudgetStatus;
|
||||||
|
forecastOver: boolean;
|
||||||
|
/** Limit aşıldı mı (tam %100'de "doldu"). */
|
||||||
|
exceeded: boolean;
|
||||||
|
}) {
|
||||||
|
if (status === "over")
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<AlertTriangle className="size-3" />
|
||||||
|
{exceeded ? t.status.over : t.status.full}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
if (status === "warning")
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-3" />
|
||||||
|
{t.status.warning}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
if (forecastOver)
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-muted-foreground">
|
||||||
|
{t.status.forecastOver}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className={cn("text-lg font-semibold tabular-nums", tone)}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pending = { kind: "edit" } | { kind: "replace"; source: "copy" | "actuals" } | null;
|
||||||
|
|
||||||
|
export function BudgetView({ data }: { data: Data }) {
|
||||||
|
const [pending, setPending] = useState<Pending>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const close = () => setPending(null);
|
||||||
|
const tt = data.totals;
|
||||||
|
const monthPct = Math.round((data.elapsedDays * 100) / data.totalDays);
|
||||||
|
const warnings = data.lines.filter((l) => l.status === "warning" || l.status === "over");
|
||||||
|
|
||||||
|
async function generate(source: "copy" | "actuals") {
|
||||||
|
setBusy(true);
|
||||||
|
const r =
|
||||||
|
source === "copy"
|
||||||
|
? await copyPreviousBudgetAction(data.month)
|
||||||
|
: await budgetFromActualsAction(data.month);
|
||||||
|
setBusy(false);
|
||||||
|
close();
|
||||||
|
if (r.ok) toast.success(source === "copy" ? t.toast.copied : t.toast.created);
|
||||||
|
else toast.error(r.error);
|
||||||
|
}
|
||||||
|
const askOrRun = (source: "copy" | "actuals") =>
|
||||||
|
data.hasBudget ? setPending({ kind: "replace", source }) : generate(source);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<Link href={`/butce?ay=${shiftMonth(data.month, -1)}`} aria-label={t.prevMonth}>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-36 text-center">
|
||||||
|
<p className="font-medium capitalize">{monthLabel(data.month)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.period(d(data.period.from), d(data.period.to))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<Link href={`/butce?ay=${shiftMonth(data.month, 1)}`} aria-label={t.nextMonth}>
|
||||||
|
<ChevronRight />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{data.month !== data.currentMonth ? (
|
||||||
|
<Button variant="link" size="sm" asChild>
|
||||||
|
<Link href="/butce">{t.thisMonth}</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{data.canWrite ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={() => setPending({ kind: "edit" })}>
|
||||||
|
<Pencil />
|
||||||
|
{t.edit}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="outline" aria-label={tr.recurring.actions.more}>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={!data.hasPrevious || busy}
|
||||||
|
onSelect={() => askOrRun("copy")}
|
||||||
|
>
|
||||||
|
<Copy />
|
||||||
|
{t.copyPrevious}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem disabled={busy} onSelect={() => askOrRun("actuals")}>
|
||||||
|
<History />
|
||||||
|
{t.fromActuals}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card size="sm">
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat
|
||||||
|
label={t.totals.limit}
|
||||||
|
value={data.hasBudget ? formatMoney(tt.limitMinor) : "—"}
|
||||||
|
/>
|
||||||
|
<Stat label={t.totals.actual} value={formatMoney(tt.actualMinor)} tone="text-expense" />
|
||||||
|
<Stat
|
||||||
|
label={tt.remainingMinor < 0 ? t.totals.over : t.totals.remaining}
|
||||||
|
value={data.hasBudget ? formatMoney(Math.abs(tt.remainingMinor)) : "—"}
|
||||||
|
tone={tt.remainingMinor < 0 ? "text-expense" : "text-budget"}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={t.totals.forecast}
|
||||||
|
value={formatMoney(tt.forecastMinor)}
|
||||||
|
tone={tt.forecastOver ? "text-expense" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{data.hasBudget ? (
|
||||||
|
<Bar
|
||||||
|
ratioBp={tt.ratioBp}
|
||||||
|
status={tt.status}
|
||||||
|
marker={monthPct}
|
||||||
|
label={`${t.totals.actual} / ${t.totals.limit}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{t.totals.monthProgress(data.elapsedDays, data.totalDays, String(monthPct))}
|
||||||
|
{tt.ratioBp !== null ? ` · %${Math.round(tt.ratioBp / 100)}` : ""}
|
||||||
|
</span>
|
||||||
|
{tt.plannedMinor ? <span>{t.totals.planned(formatMoney(tt.plannedMinor))}</span> : null}
|
||||||
|
{data.hasBudget && tt.unbudgetedMinor ? (
|
||||||
|
<span>
|
||||||
|
{t.totals.unbudgeted}: {formatMoney(tt.unbudgetedMinor)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{data.includesHidden ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.includesHidden}</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{warnings.length ? (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||||
|
{t.warnings(warnings.length)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!data.hasBudget ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed p-6 text-center">
|
||||||
|
<p className="font-medium">{t.empty}</p>
|
||||||
|
<p className="max-w-md text-sm text-muted-foreground">{t.emptyHint}</p>
|
||||||
|
{data.canWrite ? (
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
<Button size="sm" onClick={() => setPending({ kind: "edit" })}>
|
||||||
|
<Pencil />
|
||||||
|
{t.edit}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!data.hasPrevious || busy}
|
||||||
|
onClick={() => generate("copy")}
|
||||||
|
>
|
||||||
|
<Copy />
|
||||||
|
{t.copyPrevious}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => generate("actuals")}
|
||||||
|
>
|
||||||
|
<History />
|
||||||
|
{t.fromActuals}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data.lines.length ? (
|
||||||
|
<ul className="flex flex-col divide-y rounded-xl border bg-card">
|
||||||
|
{data.lines.map((l) => (
|
||||||
|
<Line key={l.categoryId} line={l} marker={monthPct} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : data.hasBudget ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.noSpending}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{pending?.kind === "edit" ? <EditDialog data={data} onClose={close} /> : null}
|
||||||
|
<AlertDialog open={pending?.kind === "replace"} onOpenChange={(o) => !o && close()}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t.replaceTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t.replaceDescription}
|
||||||
|
{pending?.kind === "replace" && pending.source === "actuals"
|
||||||
|
? ` ${t.fromActualsHint}`
|
||||||
|
: ""}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => pending?.kind === "replace" && generate(pending.source)}
|
||||||
|
>
|
||||||
|
{t.replace}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Line({ line: l, marker }: { line: BudgetLineView; marker: number }) {
|
||||||
|
const hasLimit = l.limitMinor !== null;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className="flex items-start gap-3 p-3"
|
||||||
|
style={{ paddingLeft: `${0.75 + l.depth * 1.25}rem` }}
|
||||||
|
>
|
||||||
|
<IconBadge icon={l.icon} color={l.color} />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
|
||||||
|
<p className="flex min-w-0 items-center gap-2 font-medium">
|
||||||
|
<span className="truncate">{l.name}</span>
|
||||||
|
<StatusBadge
|
||||||
|
status={l.status}
|
||||||
|
forecastOver={l.forecastOver}
|
||||||
|
exceeded={(l.remainingMinor ?? 0) < 0}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm tabular-nums">
|
||||||
|
{hasLimit
|
||||||
|
? t.line.ofLimit(formatMoney(l.actualMinor), formatMoney(l.limitMinor!))
|
||||||
|
: formatMoney(l.actualMinor)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{hasLimit ? (
|
||||||
|
<Bar
|
||||||
|
ratioBp={l.ratioBp}
|
||||||
|
status={l.status}
|
||||||
|
marker={marker}
|
||||||
|
label={`${l.name}: ${t.line.ofLimit(formatMoney(l.actualMinor), formatMoney(l.limitMinor!))}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<p className="flex flex-wrap gap-x-3 text-xs text-muted-foreground">
|
||||||
|
{hasLimit ? (
|
||||||
|
<span className={cn(l.remainingMinor! < 0 && "text-expense")}>
|
||||||
|
{l.remainingMinor! < 0
|
||||||
|
? t.line.over(formatMoney(-l.remainingMinor!))
|
||||||
|
: t.line.remaining(formatMoney(l.remainingMinor!))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>{l.covered ? t.status.covered : t.status.none}</span>
|
||||||
|
)}
|
||||||
|
{l.plannedMinor ? <span>{t.line.planned(formatMoney(l.plannedMinor))}</span> : null}
|
||||||
|
{l.forecastMinor !== l.actualMinor ? (
|
||||||
|
<span className={cn(l.forecastOver && "text-expense")}>
|
||||||
|
{t.line.forecast(formatMoney(l.forecastMinor))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditDialog({ data, onClose }: { data: Data; onClose: () => void }) {
|
||||||
|
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
data.editor.categories.map((c) => {
|
||||||
|
const v = data.editor.limits[c.id];
|
||||||
|
return [c.id, v ? formatMoneyInput(v) : ""];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Toplam: limiti olan ve üstünde limit olmayan kategoriler (alt limitler iki kez sayılmaz).
|
||||||
|
let total = 0;
|
||||||
|
const stack: { depth: number; limited: boolean }[] = [];
|
||||||
|
for (const c of data.editor.categories) {
|
||||||
|
while (stack.length && stack.at(-1)!.depth >= c.depth) stack.pop();
|
||||||
|
const covered = stack.some((s) => s.limited);
|
||||||
|
const v = parseMoney(values[c.id] ?? "") ?? 0;
|
||||||
|
if (v > 0 && !covered) total += v;
|
||||||
|
stack.push({ depth: c.depth, limited: v > 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await saveBudgetAction({
|
||||||
|
month: data.month,
|
||||||
|
limits: Object.entries(values).map(([categoryId, amount]) => ({ categoryId, amount })),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.saved);
|
||||||
|
onClose();
|
||||||
|
} else setError(r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={t.editTitle(monthLabel(data.month))}
|
||||||
|
description={t.editHint}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<span className="mr-auto self-center text-sm font-medium tabular-nums">
|
||||||
|
{t.editTotal(formatMoney(total))}
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="budget-form" disabled={busy}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="budget-form" onSubmit={submit} noValidate className="flex flex-col">
|
||||||
|
{data.editor.categories.map((c) => (
|
||||||
|
<label
|
||||||
|
key={c.id}
|
||||||
|
className="flex items-center justify-between gap-3 border-b py-1.5 last:border-b-0"
|
||||||
|
style={{ paddingLeft: `${c.depth * 1.25}rem` }}
|
||||||
|
>
|
||||||
|
<span className={cn("min-w-0 truncate text-sm", c.depth === 0 && "font-medium")}>
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
<div className="w-36 shrink-0">
|
||||||
|
<MoneyInput
|
||||||
|
id={`limit-${c.id}`}
|
||||||
|
value={values[c.id] ?? ""}
|
||||||
|
onChange={(x) => setValues((s) => ({ ...s, [c.id]: x }))}
|
||||||
|
aria-label={c.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{error ? <p className="pt-2 text-sm text-destructive">{error}</p> : null}
|
||||||
|
</form>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { getDailyPlan } from "../plan";
|
||||||
|
import { DailyPlanView } from "./daily-plan-view";
|
||||||
|
|
||||||
|
/** Dashboard günlük plan kartı (sunucu): veriyi yükler, görünümü istemciye verir. */
|
||||||
|
export async function DailyPlanCard({
|
||||||
|
userId,
|
||||||
|
householdId,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
householdId: string;
|
||||||
|
}) {
|
||||||
|
const data = await getDailyPlan(userId, householdId);
|
||||||
|
if (!data) return null;
|
||||||
|
return <DailyPlanView data={data} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import type { DailyPlanView as Data } from "../plan";
|
||||||
|
|
||||||
|
const t = tr.budget.daily;
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
const dayFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
});
|
||||||
|
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
});
|
||||||
|
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||||
|
const signed = (minor: number) => `${minor < 0 ? "−" : ""}${formatMoney(Math.abs(minor))}`;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
planned: { label: t.cumPlanned, color: "var(--budget)" },
|
||||||
|
actual: { label: t.cumActual, color: "var(--expense)" },
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: string;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className={cn("text-lg font-semibold tabular-nums", tone)}>{value}</p>
|
||||||
|
{hint ? <p className="text-[11px] leading-tight text-muted-foreground">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard: ayın günlük planı, kümülatif grafik ve gelirle karşılaştırma. */
|
||||||
|
export function DailyPlanView({ data }: { data: Data }) {
|
||||||
|
const p = data.plan;
|
||||||
|
if (!data.hasBudget) {
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||||
|
<CardDescription>{t.noBudget}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
|
<Link href="/butce">{tr.budget.goToBudget}</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const chart = p.days.map((day, i) => ({
|
||||||
|
date: day.date,
|
||||||
|
label: dayFmt.format(new Date(`${day.date}T12:00:00+03:00`)),
|
||||||
|
planned: day.cumPlannedMinor / 100,
|
||||||
|
// Gerçekleşen yalnızca bugüne kadar çizilir.
|
||||||
|
actual: p.todayIndex === -1 || i <= p.todayIndex ? day.cumActualMinor / 100 : null,
|
||||||
|
}));
|
||||||
|
const income = data.income.expectedMinor / 100;
|
||||||
|
const nextDiff =
|
||||||
|
data.next.budgetMinor === null ? null : data.next.expectedIncomeMinor - data.next.budgetMinor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{p.spentPct !== null ? t.headline(p.monthPct, p.spentPct) : null}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<Stat
|
||||||
|
label={t.today}
|
||||||
|
value={signed(p.todayAllowanceMinor)}
|
||||||
|
tone={p.todayAllowanceMinor < 0 ? "text-expense" : "text-budget"}
|
||||||
|
hint={t.todayHint}
|
||||||
|
/>
|
||||||
|
<Stat label={t.plannedToDate} value={formatMoney(p.plannedToDateMinor)} />
|
||||||
|
<Stat
|
||||||
|
label={t.actualToDate}
|
||||||
|
value={formatMoney(p.actualToDateMinor)}
|
||||||
|
tone={p.actualToDateMinor > p.plannedToDateMinor ? "text-expense" : undefined}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={t.projectedLeft}
|
||||||
|
value={signed(data.projectedLeftMinor)}
|
||||||
|
tone={data.projectedLeftMinor < 0 ? "text-expense" : "text-income"}
|
||||||
|
hint={tr.budget.income.projectedLeftHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChartContainer
|
||||||
|
config={config}
|
||||||
|
className="aspect-auto h-56 w-full"
|
||||||
|
aria-label={t.chartLabel}
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<LineChart data={chart} margin={{ left: 4, right: 12, top: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={24}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={48}
|
||||||
|
tickFormatter={(v: number) => compact.format(v)}
|
||||||
|
/>
|
||||||
|
{income > 0 ? (
|
||||||
|
<ReferenceLine
|
||||||
|
y={income}
|
||||||
|
stroke="var(--income)"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
label={{
|
||||||
|
value: `${t.income} ${compact.format(income)}`,
|
||||||
|
position: "insideTopLeft",
|
||||||
|
fill: "var(--muted-foreground)",
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const date = payload?.[0]?.payload?.date as string | undefined;
|
||||||
|
return date ? d(date) : "";
|
||||||
|
}}
|
||||||
|
formatter={(value, name) => (
|
||||||
|
<div className="flex w-full items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{config[name as keyof typeof config]?.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
|
{formatMoney(Math.round(Number(value) * 100))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
<Line
|
||||||
|
dataKey="planned"
|
||||||
|
type="linear"
|
||||||
|
stroke="var(--color-planned)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="actual"
|
||||||
|
type="linear"
|
||||||
|
stroke="var(--color-actual)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.modes[data.mode]} {p.fixedMinor ? t.fixedNote(formatMoney(p.fixedMinor)) : ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-3 text-sm">
|
||||||
|
<p className="mb-2 font-medium capitalize">
|
||||||
|
{t.next(monthFmt.format(new Date(`${data.next.month}-15T12:00:00+03:00`)))}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Stat label={t.nextIncome} value={formatMoney(data.next.expectedIncomeMinor)} />
|
||||||
|
<Stat
|
||||||
|
label={t.nextBudget}
|
||||||
|
value={
|
||||||
|
data.next.budgetMinor === null ? t.nextNoBudget : formatMoney(data.next.budgetMinor)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={t.nextDiff}
|
||||||
|
value={nextDiff === null ? "—" : signed(nextDiff)}
|
||||||
|
tone={nextDiff !== null && nextDiff < 0 ? "text-expense" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="cursor-pointer text-xs text-muted-foreground">{t.table}</summary>
|
||||||
|
<div className="mt-2 max-h-80 overflow-y-auto">
|
||||||
|
<table className="w-full text-right tabular-nums">
|
||||||
|
<thead className="sticky top-0 bg-card text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 text-left font-normal">{t.day}</th>
|
||||||
|
<th className="py-1 font-normal">{t.planned}</th>
|
||||||
|
<th className="py-1 font-normal">{t.actual}</th>
|
||||||
|
<th className="py-1 font-normal">{t.cumPlanned}</th>
|
||||||
|
<th className="py-1 font-normal">{t.cumActual}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{p.days.map((day, i) => (
|
||||||
|
<tr key={day.date} className={cn(i === p.todayIndex && "bg-muted font-medium")}>
|
||||||
|
<td className="py-1 text-left">{d(day.date)}</td>
|
||||||
|
<td className="py-1">{formatMoney(day.plannedMinor)}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{p.todayIndex === -1 || i <= p.todayIndex
|
||||||
|
? formatMoney(day.actualMinor)
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-1">{formatMoney(day.cumPlannedMinor)}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{p.todayIndex === -1 || i <= p.todayIndex
|
||||||
|
? formatMoney(day.cumActualMinor)
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MoneyInput } from "@/components/money-input";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import { saveIncomeItemsAction } from "../actions";
|
||||||
|
import type { IncomePlan } from "../plan";
|
||||||
|
|
||||||
|
const t = tr.budget.income;
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: string;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className={cn("font-semibold tabular-nums", tone)}>{value}</p>
|
||||||
|
{hint ? <p className="text-[11px] text-muted-foreground">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signed = (minor: number) => `${minor < 0 ? "−" : ""}${formatMoney(Math.abs(minor))}`;
|
||||||
|
|
||||||
|
/** /butce: ayın gelir planı ve gelir − gider karşılaştırması. */
|
||||||
|
export function IncomePlanSection({
|
||||||
|
month,
|
||||||
|
monthLabel,
|
||||||
|
income,
|
||||||
|
limitMinor,
|
||||||
|
forecastMinor,
|
||||||
|
hasBudget,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
month: string;
|
||||||
|
monthLabel: string;
|
||||||
|
income: IncomePlan;
|
||||||
|
limitMinor: number;
|
||||||
|
forecastMinor: number;
|
||||||
|
hasBudget: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const projected = income.expectedMinor - forecastMinor;
|
||||||
|
const planned = income.expectedMinor - limitMinor;
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||||
|
<CardDescription>{t.description}</CardDescription>
|
||||||
|
</div>
|
||||||
|
{canWrite ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.edit}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label={t.actual} value={formatMoney(income.actualMinor)} tone="text-income" />
|
||||||
|
<Stat label={t.pending} value={formatMoney(income.pendingMinor)} />
|
||||||
|
<Stat label={t.manual} value={formatMoney(income.manualMinor)} />
|
||||||
|
<Stat label={t.expected} value={formatMoney(income.expectedMinor)} tone="text-income" />
|
||||||
|
{hasBudget ? (
|
||||||
|
<Stat
|
||||||
|
label={t.plannedLeft}
|
||||||
|
value={signed(planned)}
|
||||||
|
tone={planned < 0 ? "text-expense" : undefined}
|
||||||
|
hint={t.plannedLeftHint}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Stat
|
||||||
|
label={t.projectedLeft}
|
||||||
|
value={signed(projected)}
|
||||||
|
tone={projected < 0 ? "text-expense" : undefined}
|
||||||
|
hint={t.projectedLeftHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{income.entries.length || income.manual.length ? (
|
||||||
|
<ul className="divide-y rounded-lg border text-sm">
|
||||||
|
{income.entries.map((e) => (
|
||||||
|
<li key={e.id} className="flex items-center justify-between gap-2 p-2">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{d(e.date)} · {e.label}
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
<Badge variant={e.status === "posted" ? "secondary" : "outline"}>
|
||||||
|
{e.status === "posted" ? t.received : t.planned}
|
||||||
|
</Badge>
|
||||||
|
<span className="tabular-nums">{formatMoney(e.amountMinor)}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{income.manual.map((m) => (
|
||||||
|
<li key={m.id} className="flex items-center justify-between gap-2 p-2">
|
||||||
|
<span className="min-w-0 truncate">{m.name}</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
<Badge variant="outline">{t.manual}</Badge>
|
||||||
|
<span className="tabular-nums">{formatMoney(m.amountMinor)}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.empty}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
{editing ? (
|
||||||
|
<ManualDialog
|
||||||
|
month={month}
|
||||||
|
monthLabel={monthLabel}
|
||||||
|
items={income.manual}
|
||||||
|
onClose={() => setEditing(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManualDialog({
|
||||||
|
month,
|
||||||
|
monthLabel,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
month: string;
|
||||||
|
monthLabel: string;
|
||||||
|
items: IncomePlan["manual"];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState(() =>
|
||||||
|
items.length
|
||||||
|
? items.map((i) => ({ key: i.id, name: i.name, amount: formatMoneyInput(i.amountMinor) }))
|
||||||
|
: [{ key: "new-0", name: "", amount: "" }],
|
||||||
|
);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await saveIncomeItemsAction({
|
||||||
|
month,
|
||||||
|
items: rows
|
||||||
|
.filter((x) => x.name.trim() || x.amount.trim())
|
||||||
|
.map((x) => ({ name: x.name, amount: x.amount })),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.saved);
|
||||||
|
onClose();
|
||||||
|
} else setErrors({ ...(r.fieldErrors ?? {}), root: r.fieldErrors ? "" : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={t.editTitle(monthLabel)}
|
||||||
|
description={t.editHint}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="income-form" disabled={busy}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="income-form" onSubmit={submit} noValidate className="flex flex-col gap-2">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div key={row.key} className="flex items-start gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Input
|
||||||
|
value={row.name}
|
||||||
|
placeholder={t.itemName}
|
||||||
|
aria-label={t.itemName}
|
||||||
|
aria-invalid={Boolean(errors[`items.${i}.name`])}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRows((s) => s.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{errors[`items.${i}.name`] ? (
|
||||||
|
<p className="text-xs text-destructive">{errors[`items.${i}.name`]}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="w-36 shrink-0">
|
||||||
|
<MoneyInput
|
||||||
|
value={row.amount}
|
||||||
|
aria-label={t.itemAmount}
|
||||||
|
onChange={(v) =>
|
||||||
|
setRows((s) => s.map((x, j) => (j === i ? { ...x, amount: v } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{errors[`items.${i}.amount`] ? (
|
||||||
|
<p className="text-xs text-destructive">{errors[`items.${i}.amount`]}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t.removeItem}
|
||||||
|
onClick={() => setRows((s) => s.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
disabled={rows.length >= 30}
|
||||||
|
onClick={() => setRows((s) => [...s, { key: `new-${Date.now()}`, name: "", amount: "" }])}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
{t.addItem}
|
||||||
|
</Button>
|
||||||
|
{errors.root ? <p className="text-sm text-destructive">{errors.root}</p> : null}
|
||||||
|
</form>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { countBudgetWarnings } from "../service";
|
||||||
|
|
||||||
|
/** Dashboard: bu ay %80'i geçilen veya aşılan bütçe kategorileri (uygulama içi uyarı). */
|
||||||
|
export async function BudgetWarningBanner({
|
||||||
|
userId,
|
||||||
|
householdId,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
householdId: string;
|
||||||
|
}) {
|
||||||
|
const n = await countBudgetWarnings(userId, householdId);
|
||||||
|
if (!n) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="flex flex-wrap items-center gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
|
||||||
|
<span className="flex-1">{tr.budget.warnings(n)}</span>
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link href="/butce">{tr.budget.goToBudget}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { HouseholdModel } from "@/models/household";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import { incomeItemsSchema } from "@/shared/schemas/budget";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { getDailyPlan, getIncomePlan, saveIncomeItems } from "./plan";
|
||||||
|
import { getBudget } from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const month = periodOfDate(today);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
emre = ids.user("emre");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
async function income(
|
||||||
|
date: string,
|
||||||
|
amountMinor: number,
|
||||||
|
status: "posted" | "planned",
|
||||||
|
accountId?: Types.ObjectId,
|
||||||
|
) {
|
||||||
|
const actor = new Types.ObjectId(ayse);
|
||||||
|
const acc =
|
||||||
|
accountId ??
|
||||||
|
(await AccountModel.findOne({ householdId: cakir, name: "Ziraat Maaş" }).lean())!._id;
|
||||||
|
await TransactionModel.create({
|
||||||
|
householdId: new Types.ObjectId(cakir),
|
||||||
|
type: "income",
|
||||||
|
status,
|
||||||
|
date: istanbulDayStart(date)!,
|
||||||
|
accountId: acc,
|
||||||
|
amountMinor,
|
||||||
|
description: "Test gelir",
|
||||||
|
recurringRuleId: status === "planned" ? new Types.ObjectId() : null,
|
||||||
|
occurrenceIndex: status === "planned" ? 0 : null,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gelir planı", () => {
|
||||||
|
it("gerçekleşen + bekleyen planlı + elle eklenen = beklenen", async () => {
|
||||||
|
const p0 = (await getIncomePlan(ayse, cakir, month))!;
|
||||||
|
const { to } = budgetPeriod(month);
|
||||||
|
await income(to, 3_000_00, "planned");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await saveIncomeItems(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
incomeItemsSchema.parse({ month, items: [{ name: "Prim", amount: "5.000" }] }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(true);
|
||||||
|
const p = (await getIncomePlan(ayse, cakir, month))!;
|
||||||
|
expect(p.pendingMinor).toBe(p0.pendingMinor + 3_000_00);
|
||||||
|
expect(p.manual).toEqual([expect.objectContaining({ name: "Prim", amountMinor: 5_000_00 })]);
|
||||||
|
expect(p.expectedMinor).toBe(p.actualMinor + p.pendingMinor + 5_000_00);
|
||||||
|
expect(p.entries.some((e) => e.status === "planned" && e.amountMinor === 3_000_00)).toBe(true);
|
||||||
|
// Elle eklenenler bütçe limitlerini bozmaz.
|
||||||
|
expect((await getBudget(ayse, cakir, month))!.editor.limits).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gizli hesaba gelen gelir hane ayarına göre; izleyici yazamaz; başka hane göremez", async () => {
|
||||||
|
const actor = new Types.ObjectId(emre);
|
||||||
|
const secret = await AccountModel.create({
|
||||||
|
householdId: new Types.ObjectId(cakir),
|
||||||
|
name: "Emre Gizli Gelir",
|
||||||
|
type: "bank",
|
||||||
|
openingBalanceMinor: 0,
|
||||||
|
openingDate: istanbulDayStart(shiftMonth(month, -2) + "-01")!,
|
||||||
|
isPrivate: true,
|
||||||
|
ownerUserId: actor,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
const a0 = (await getIncomePlan(ayse, cakir, month))!.actualMinor;
|
||||||
|
await income(today, 1_000_00, "posted", secret._id);
|
||||||
|
expect((await getIncomePlan(ayse, cakir, month))!.actualMinor).toBe(a0 + 1_000_00);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.includePrivateAccountsInTotals": false } },
|
||||||
|
);
|
||||||
|
expect((await getIncomePlan(ayse, cakir, month))!.actualMinor).toBe(a0);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.includePrivateAccountsInTotals": true } },
|
||||||
|
);
|
||||||
|
// Sahibi kendi gizli hesabını her zaman görür.
|
||||||
|
expect((await getIncomePlan(emre, cakir, month))!.actualMinor).toBe(a0 + 1_000_00);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await saveIncomeItems(zeynep, cakir, incomeItemsSchema.parse({ month, items: [] }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(await getIncomePlan(fatma, cakir, month)).toBeNull();
|
||||||
|
expect(incomeItemsSchema.safeParse({ month, items: [{ name: "", amount: "1" }] }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
incomeItemsSchema.safeParse({ month, items: [{ name: "X", amount: "0" }] }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("günlük plan", () => {
|
||||||
|
it("plan toplamı bütçeye eşit; bugüne kadarki gerçekleşen bütçe motoruyla tutarlı; elde kalacak", async () => {
|
||||||
|
const v = (await getDailyPlan(ayse, cakir))!;
|
||||||
|
const b = (await getBudget(ayse, cakir))!;
|
||||||
|
expect(v.month).toBe(month);
|
||||||
|
expect(v.hasBudget).toBe(true);
|
||||||
|
const last = v.plan.days.at(-1)!;
|
||||||
|
expect(last.cumPlannedMinor).toBe(Math.max(b.totals.limitMinor, v.plan.fixedMinor));
|
||||||
|
expect(v.plan.days).toHaveLength(b.totalDays);
|
||||||
|
expect(v.plan.todayIndex).toBe(b.elapsedDays - 1);
|
||||||
|
// Bugünden sonraki gerçekleşmiş işlem yok: bugüne kadar harcanan = ayın gerçekleşeni.
|
||||||
|
expect(v.plan.actualToDateMinor).toBe(b.totals.actualMinor);
|
||||||
|
expect(v.projectedLeftMinor).toBe(v.income.expectedMinor - b.totals.forecastMinor);
|
||||||
|
expect(v.plannedLeftMinor).toBe(v.income.expectedMinor - b.totals.limitMinor);
|
||||||
|
expect(v.next.month).toBe(shiftMonth(month, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("önceki ay örüntüsü modu: dağılım değişir, toplam korunur; bütçesiz hane", async () => {
|
||||||
|
const even = (await getDailyPlan(ayse, cakir))!;
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.dailyPlanMode": "previous_month_pattern" } },
|
||||||
|
);
|
||||||
|
const pattern = (await getDailyPlan(ayse, cakir))!;
|
||||||
|
await HouseholdModel.updateOne({ _id: cakir }, { $set: { "settings.dailyPlanMode": "even" } });
|
||||||
|
expect(pattern.mode).toBe("previous_month_pattern");
|
||||||
|
expect(pattern.plan.days.at(-1)!.cumPlannedMinor).toBe(even.plan.days.at(-1)!.cumPlannedMinor);
|
||||||
|
expect(pattern.plan.days.map((d) => d.plannedMinor)).not.toEqual(
|
||||||
|
even.plan.days.map((d) => d.plannedMinor),
|
||||||
|
);
|
||||||
|
|
||||||
|
const annemler = ids.household("Annemler");
|
||||||
|
const a = (await getDailyPlan(fatma, annemler))!;
|
||||||
|
expect(a.hasBudget).toBe(false);
|
||||||
|
expect(await getDailyPlan(fatma, cakir)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types, type PipelineStage } from "mongoose";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { loadContext, type Ctx } from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { BudgetModel } from "@/models/budget";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { budgetPeriod, daysBetween, periodOfDate, shiftMonth } from "@/shared/budget";
|
||||||
|
import { dailyPlan, patternWeights, type DailyPlan } from "@/shared/daily-plan";
|
||||||
|
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import type { IncomeItemsForm } from "@/shared/schemas/budget";
|
||||||
|
import { getBudget, settingsOf, type Settings } from "./service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gelir planı ve günlük plan (Bölüm 6.9).
|
||||||
|
*
|
||||||
|
* Gelir planı: dönemdeki gerçekleşmiş gelirler + henüz gerçekleşmemiş planlı gelirler (düzenli
|
||||||
|
* işlem tekrarları) + elle eklenen beklenen gelirler. Elle eklenen kalem gelir işlemi olarak
|
||||||
|
* girildiğinde kullanıcı kalemi kaldırır (aksi halde iki kez sayılır).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
function baseMatch(ctx: Ctx, s: Settings, type: "income" | "expense", from: string, to: string) {
|
||||||
|
const m: Record<string, unknown> = {
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
type,
|
||||||
|
deletedAt: null,
|
||||||
|
status: { $in: ["posted", "planned"] },
|
||||||
|
date: {
|
||||||
|
$gte: istanbulDayStart(from)!,
|
||||||
|
$lt: new Date(istanbulDayStart(to)!.getTime() + DAY_MS),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!s.includePrivate && ctx.hiddenAccountIds.length)
|
||||||
|
m.accountId = { $nin: ctx.hiddenAccountIds };
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IncomeEntry = {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
label: string;
|
||||||
|
amountMinor: number;
|
||||||
|
status: "posted" | "planned";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IncomePlan = {
|
||||||
|
/** Gerçekleşen gelir. */
|
||||||
|
actualMinor: number;
|
||||||
|
/** Henüz gerçekleşmemiş planlı gelir. */
|
||||||
|
pendingMinor: number;
|
||||||
|
manualMinor: number;
|
||||||
|
/** Beklenen toplam = gerçekleşen + planlı + elle. */
|
||||||
|
expectedMinor: number;
|
||||||
|
/** Planlı/düzenli gelir kalemleri (gerçekleşmiş düzenli gelirler dahil). */
|
||||||
|
entries: IncomeEntry[];
|
||||||
|
manual: { id: string; name: string; amountMinor: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function incomePlan(ctx: Ctx, s: Settings, month: string): Promise<IncomePlan> {
|
||||||
|
const { from, to } = budgetPeriod(month, s.startDay);
|
||||||
|
const [docs, budget] = await Promise.all([
|
||||||
|
TransactionModel.find(baseMatch(ctx, s, "income", from, to))
|
||||||
|
.select({
|
||||||
|
date: 1,
|
||||||
|
status: 1,
|
||||||
|
amountMinor: 1,
|
||||||
|
description: 1,
|
||||||
|
payeeName: 1,
|
||||||
|
categoryId: 1,
|
||||||
|
recurringRuleId: 1,
|
||||||
|
})
|
||||||
|
.sort({ date: 1 })
|
||||||
|
.lean(),
|
||||||
|
BudgetModel.findOne({ householdId: ctx.m.householdId, month }).lean(),
|
||||||
|
]);
|
||||||
|
let actual = 0;
|
||||||
|
let pending = 0;
|
||||||
|
const entries: IncomeEntry[] = [];
|
||||||
|
for (const d of docs) {
|
||||||
|
if (d.status === "posted") actual += d.amountMinor;
|
||||||
|
else pending += d.amountMinor;
|
||||||
|
if (d.status === "planned" || d.recurringRuleId) {
|
||||||
|
const cat = d.categoryId ? ctx.categoryMap.get(String(d.categoryId)) : undefined;
|
||||||
|
entries.push({
|
||||||
|
id: String(d._id),
|
||||||
|
date: toIstanbulYmd(d.date),
|
||||||
|
label: d.payeeName || d.description || cat?.name || tr.transactions.types.income,
|
||||||
|
amountMinor: d.amountMinor,
|
||||||
|
status: d.status === "posted" ? "posted" : "planned",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const manual = (budget?.incomeItems ?? []).map((i) => ({
|
||||||
|
id: String(i._id),
|
||||||
|
name: i.name,
|
||||||
|
amountMinor: i.amountMinor,
|
||||||
|
}));
|
||||||
|
const manualMinor = manual.reduce((a, i) => a + i.amountMinor, 0);
|
||||||
|
return {
|
||||||
|
actualMinor: actual,
|
||||||
|
pendingMinor: pending,
|
||||||
|
manualMinor,
|
||||||
|
expectedMinor: actual + pending + manualMinor,
|
||||||
|
entries,
|
||||||
|
manual,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIncomePlan(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
month: string,
|
||||||
|
): Promise<IncomePlan | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
return incomePlan(ctx, await settingsOf(ctx.m.householdId), month);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveIncomeItems(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: IncomeItemsForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await BudgetModel.updateOne(
|
||||||
|
{ householdId: ctx.m.householdId, month: input.month },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
incomeItems: input.items.map((i) => ({ name: i.name, amountMinor: i.amount })),
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
$setOnInsert: { createdBy: actor, limits: [] },
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Günlük plan ---
|
||||||
|
|
||||||
|
/** Güne göre gider: sabit (düzenli/taksit; gerçekleşmiş + planlı) ve gerçekleşen (tümü). */
|
||||||
|
async function expensesByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||||||
|
const pipeline: PipelineStage[] = [
|
||||||
|
{ $match: baseMatch(ctx, s, "expense", from, to) },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: {
|
||||||
|
d: { $dateToString: { date: "$date", format: "%Y-%m-%d", timezone: "Europe/Istanbul" } },
|
||||||
|
s: "$status",
|
||||||
|
f: {
|
||||||
|
$or: [
|
||||||
|
{ $ne: [{ $ifNull: ["$recurringRuleId", null] }, null] },
|
||||||
|
{ $ne: [{ $ifNull: ["$installment.planId", null] }, null] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sum: { $sum: "$amountMinor" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const rows = await TransactionModel.aggregate<{
|
||||||
|
_id: { d: string; s: string; f: boolean };
|
||||||
|
sum: number;
|
||||||
|
}>(pipeline);
|
||||||
|
const fixed = new Map<string, number>();
|
||||||
|
const actual = new Map<string, number>();
|
||||||
|
const variable = new Map<string, number>();
|
||||||
|
const add = (m: Map<string, number>, k: string, v: number) => m.set(k, (m.get(k) ?? 0) + v);
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r._id.f) add(fixed, r._id.d, r.sum);
|
||||||
|
if (r._id.s === "posted") {
|
||||||
|
add(actual, r._id.d, r.sum);
|
||||||
|
if (!r._id.f) add(variable, r._id.d, r.sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { fixed, actual, variable };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DailyPlanView = {
|
||||||
|
month: string;
|
||||||
|
period: { from: string; to: string };
|
||||||
|
hasBudget: boolean;
|
||||||
|
plan: DailyPlan;
|
||||||
|
mode: "even" | "previous_month_pattern";
|
||||||
|
income: IncomePlan;
|
||||||
|
/** Bütçe ayı sonu tahmini gider (bütçe motorundan). */
|
||||||
|
forecastExpenseMinor: number;
|
||||||
|
/** Bu tempoda ay sonunda elde kalacak: beklenen gelir − tahmini gider. */
|
||||||
|
projectedLeftMinor: number;
|
||||||
|
/** Plana göre elde kalacak: beklenen gelir − bütçe. */
|
||||||
|
plannedLeftMinor: number;
|
||||||
|
next: { month: string; expectedIncomeMinor: number; budgetMinor: number | null };
|
||||||
|
canWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dashboard günlük planı: içinde bulunulan bütçe ayı. */
|
||||||
|
export async function getDailyPlan(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<DailyPlanView | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const s = await settingsOf(ctx.m.householdId);
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const month = periodOfDate(today, s.startDay);
|
||||||
|
const period = budgetPeriod(month, s.startDay);
|
||||||
|
const nextMonth = shiftMonth(month, 1);
|
||||||
|
const mode = s.dailyMode;
|
||||||
|
const [budget, byDay, income, nextIncome, nextBudget] = await Promise.all([
|
||||||
|
getBudget(userId, householdId, month),
|
||||||
|
expensesByDay(ctx, s, period.from, period.to),
|
||||||
|
incomePlan(ctx, s, month),
|
||||||
|
incomePlan(ctx, s, nextMonth),
|
||||||
|
getBudget(userId, householdId, nextMonth),
|
||||||
|
]);
|
||||||
|
if (!budget) return null;
|
||||||
|
|
||||||
|
let weights: number[] | undefined;
|
||||||
|
if (mode === "previous_month_pattern") {
|
||||||
|
const prev = budgetPeriod(shiftMonth(month, -1), s.startDay);
|
||||||
|
const prevDays = await expensesByDay(ctx, s, prev.from, prev.to);
|
||||||
|
const len = daysBetween(prev.from, prev.to);
|
||||||
|
weights = patternWeights(
|
||||||
|
Array.from({ length: len }, (_, i) => prevDays.variable.get(addDays(prev.from, i)) ?? 0),
|
||||||
|
daysBetween(period.from, period.to),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const plan = dailyPlan({
|
||||||
|
from: period.from,
|
||||||
|
to: period.to,
|
||||||
|
today,
|
||||||
|
budgetMinor: budget.totals.limitMinor,
|
||||||
|
fixedByDay: byDay.fixed,
|
||||||
|
actualByDay: byDay.actual,
|
||||||
|
weights,
|
||||||
|
});
|
||||||
|
const forecastExpense = budget.totals.forecastMinor;
|
||||||
|
return {
|
||||||
|
month,
|
||||||
|
period,
|
||||||
|
hasBudget: budget.hasBudget,
|
||||||
|
plan,
|
||||||
|
mode,
|
||||||
|
income,
|
||||||
|
forecastExpenseMinor: forecastExpense,
|
||||||
|
projectedLeftMinor: income.expectedMinor - forecastExpense,
|
||||||
|
plannedLeftMinor: income.expectedMinor - budget.totals.limitMinor,
|
||||||
|
next: {
|
||||||
|
month: nextMonth,
|
||||||
|
expectedIncomeMinor: nextIncome.expectedMinor,
|
||||||
|
budgetMinor: nextBudget?.hasBudget ? nextBudget.totals.limitMinor : null,
|
||||||
|
},
|
||||||
|
canWrite: hasHouseholdRole(ctx.m.role, "member"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { createCategory } from "@/features/categories/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { BudgetModel } from "@/models/budget";
|
||||||
|
import { HouseholdModel } from "@/models/household";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { budgetPeriod, shiftMonth } from "@/shared/budget";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { budgetLimitsSchema } from "@/shared/schemas/budget";
|
||||||
|
import { categoryFormSchema } from "@/shared/schemas/category";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { budgetFromActuals, copyPreviousBudget, getBudget, saveBudget } from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
// Seed verisinin olmadığı geçmiş bir ay: toplamlar yalnızca bu testin işlemlerinden oluşur.
|
||||||
|
const M = shiftMonth(todayIstanbul().slice(0, 7), -24);
|
||||||
|
const P = shiftMonth(M, -1);
|
||||||
|
const cat = { ev: "", kira: "", aidat: "", keyif: "" };
|
||||||
|
type CatKey = keyof typeof cat;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
emre = ids.user("emre");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
const mk = async (key: CatKey, name: string, parent = "") => {
|
||||||
|
const r = await createCategory(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
categoryFormSchema.parse({
|
||||||
|
kind: "expense",
|
||||||
|
name,
|
||||||
|
parentId: parent,
|
||||||
|
icon: "tag",
|
||||||
|
color: "blue",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
cat[key] = r.ok ? r.data.id : "";
|
||||||
|
};
|
||||||
|
await mk("ev", "Test Ev");
|
||||||
|
await mk("kira", "Test Kira", cat.ev);
|
||||||
|
await mk("aidat", "Test Aidat", cat.ev);
|
||||||
|
await mk("keyif", "Test Keyif");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
const acc = (name: string) =>
|
||||||
|
AccountModel.findOne({ householdId: cakir, name })
|
||||||
|
.lean()
|
||||||
|
.then((a) => a!._id);
|
||||||
|
|
||||||
|
async function expense(input: {
|
||||||
|
date: string;
|
||||||
|
amountMinor: number;
|
||||||
|
category?: string;
|
||||||
|
splits?: { category: string; amountMinor: number }[];
|
||||||
|
account?: string;
|
||||||
|
status?: "posted" | "planned";
|
||||||
|
recurring?: boolean;
|
||||||
|
installment?: { planId: Types.ObjectId; index: number; count: number };
|
||||||
|
}) {
|
||||||
|
const actor = new Types.ObjectId(ayse);
|
||||||
|
const splits = (input.splits ?? []).map((s) => ({
|
||||||
|
categoryId: new Types.ObjectId(s.category),
|
||||||
|
amountMinor: s.amountMinor,
|
||||||
|
}));
|
||||||
|
await TransactionModel.create({
|
||||||
|
householdId: new Types.ObjectId(cakir),
|
||||||
|
type: "expense",
|
||||||
|
status: input.status ?? "posted",
|
||||||
|
date: istanbulDayStart(input.date)!,
|
||||||
|
accountId: await acc(input.account ?? "Cüzdan"),
|
||||||
|
amountMinor: input.amountMinor,
|
||||||
|
categoryId: input.category ? new Types.ObjectId(input.category) : null,
|
||||||
|
splits,
|
||||||
|
effectiveCategoryIds: input.category
|
||||||
|
? [new Types.ObjectId(input.category)]
|
||||||
|
: splits.map((s) => s.categoryId),
|
||||||
|
recurringRuleId: input.recurring ? new Types.ObjectId() : null,
|
||||||
|
occurrenceIndex: input.recurring ? 0 : null,
|
||||||
|
installment: input.installment ?? null,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const limits = (month: string, list: Partial<Record<CatKey, string>>) =>
|
||||||
|
budgetLimitsSchema.parse({
|
||||||
|
month,
|
||||||
|
limits: Object.entries(list).map(([k, amount]) => ({ categoryId: cat[k as CatKey], amount })),
|
||||||
|
});
|
||||||
|
const line = async (key: CatKey, month = M, user = ayse) =>
|
||||||
|
(await getBudget(user, cakir, month))!.lines.find((l) => l.categoryId === cat[key]);
|
||||||
|
|
||||||
|
describe("aylık bütçe", () => {
|
||||||
|
const { from, to } = budgetPeriod(M);
|
||||||
|
|
||||||
|
it("limitler; üst limit alt kategorileri kapsar; bölünmüş işlem kalem bazında; planlı ayrı", async () => {
|
||||||
|
await expense({ date: from, amountMinor: 15_000_00, category: cat.kira, recurring: true });
|
||||||
|
await expense({
|
||||||
|
date: addDays(from, 5),
|
||||||
|
amountMinor: 3_000_00,
|
||||||
|
splits: [
|
||||||
|
{ category: cat.aidat, amountMinor: 1_200_00 },
|
||||||
|
{ category: cat.keyif, amountMinor: 1_800_00 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expense({ date: to, amountMinor: 500_00, category: cat.aidat, status: "planned" });
|
||||||
|
expect((await saveBudget(ayse, cakir, limits(M, { ev: "17.000", kira: "15.000" }))).ok).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const b = (await getBudget(ayse, cakir, M))!;
|
||||||
|
expect(b.hasBudget).toBe(true);
|
||||||
|
// Geçmiş ay: tüm günler geçti.
|
||||||
|
expect(b.elapsedDays).toBe(b.totalDays);
|
||||||
|
expect(b.totals.limitMinor).toBe(17_000_00);
|
||||||
|
expect(b.totals.actualMinor).toBe(18_000_00);
|
||||||
|
expect(b.totals.unbudgetedMinor).toBe(1_800_00);
|
||||||
|
expect(await line("ev")).toMatchObject({
|
||||||
|
actualMinor: 16_200_00,
|
||||||
|
plannedMinor: 500_00,
|
||||||
|
forecastMinor: 16_700_00,
|
||||||
|
status: "warning",
|
||||||
|
forecastOver: false,
|
||||||
|
});
|
||||||
|
expect(await line("kira")).toMatchObject({ status: "over", remainingMinor: 0 });
|
||||||
|
expect(await line("keyif")).toMatchObject({ limitMinor: null, actualMinor: 1_800_00 });
|
||||||
|
// Boş limit kaldırılır.
|
||||||
|
await saveBudget(ayse, cakir, limits(M, { ev: "17.000", kira: "" }));
|
||||||
|
expect((await line("kira"))!.limitMinor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gizli hesap harcaması hane ayarına göre", async () => {
|
||||||
|
const actor = new Types.ObjectId(emre);
|
||||||
|
const secret = await AccountModel.create({
|
||||||
|
householdId: new Types.ObjectId(cakir),
|
||||||
|
name: "Emre Gizli Test",
|
||||||
|
type: "cash",
|
||||||
|
openingBalanceMinor: 0,
|
||||||
|
openingDate: istanbulDayStart(addDays(from, -10))!,
|
||||||
|
isPrivate: true,
|
||||||
|
ownerUserId: actor,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
await expense({
|
||||||
|
date: addDays(from, 2),
|
||||||
|
amountMinor: 700_00,
|
||||||
|
category: cat.keyif,
|
||||||
|
account: "Emre Gizli Test",
|
||||||
|
});
|
||||||
|
expect((await line("keyif"))!.actualMinor).toBe(2_500_00);
|
||||||
|
const b = (await getBudget(ayse, cakir, M))!;
|
||||||
|
expect(b.includesHidden).toBe(true);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.includePrivateAccountsInTotals": false } },
|
||||||
|
);
|
||||||
|
expect((await line("keyif"))!.actualMinor).toBe(1_800_00);
|
||||||
|
// Sahibi her zaman görür.
|
||||||
|
expect((await line("keyif", M, emre))!.actualMinor).toBe(2_500_00);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.includePrivateAccountsInTotals": true } },
|
||||||
|
);
|
||||||
|
expect(secret).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("taksit modu: her taksit kendi ayına veya tamamı alış ayına", async () => {
|
||||||
|
const planId = new Types.ObjectId();
|
||||||
|
const next = budgetPeriod(shiftMonth(M, 1));
|
||||||
|
await expense({
|
||||||
|
date: addDays(from, 10),
|
||||||
|
amountMinor: 1_000_00,
|
||||||
|
category: cat.aidat,
|
||||||
|
installment: { planId, index: 1, count: 2 },
|
||||||
|
});
|
||||||
|
await expense({
|
||||||
|
date: addDays(next.from, 10),
|
||||||
|
amountMinor: 1_000_00,
|
||||||
|
category: cat.aidat,
|
||||||
|
installment: { planId, index: 2, count: 2 },
|
||||||
|
status: "planned",
|
||||||
|
});
|
||||||
|
const before = (await line("aidat"))!.actualMinor;
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.installmentBudgetMode": "purchase_month" } },
|
||||||
|
);
|
||||||
|
expect((await line("aidat"))!.actualMinor).toBe(before + 1_000_00);
|
||||||
|
// Tamamı alış ayında sayıldığı için sonraki ayda görünmez.
|
||||||
|
expect((await line("aidat", shiftMonth(M, 1)))?.actualMinor ?? 0).toBe(0);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.installmentBudgetMode": "per_installment" } },
|
||||||
|
);
|
||||||
|
// Her taksit kendi ayında (geçmiş ay olduğu için vadesi gelen taksit gerçekleşmiştir).
|
||||||
|
expect((await line("aidat", shiftMonth(M, 1)))!.actualMinor).toBe(1_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tahmin: değişken gider tempoyla, sabit gider olduğu gibi (gelecek ayda değişken sayılmaz)", async () => {
|
||||||
|
const F = shiftMonth(todayIstanbul().slice(0, 7), 24);
|
||||||
|
const f = budgetPeriod(F);
|
||||||
|
await expense({ date: f.from, amountMinor: 900_00, category: cat.keyif });
|
||||||
|
await expense({ date: f.from, amountMinor: 4_000_00, category: cat.kira, recurring: true });
|
||||||
|
const keyif = (await line("keyif", F))!;
|
||||||
|
expect(keyif.actualMinor).toBe(900_00);
|
||||||
|
// Henüz başlamamış dönem, geçmiş yok: gerçekleşen değişken olduğu gibi, tempo eklenmez.
|
||||||
|
expect(keyif.forecastMinor).toBe(900_00);
|
||||||
|
expect((await line("kira", F))!.forecastMinor).toBe(4_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("içinde bulunulan ayda değişken gider tempoyla büyür, sabit gider büyümez", async () => {
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const cur = today.slice(0, 7);
|
||||||
|
if (budgetPeriod(cur).to === today) return; // ayın son günü: kalan gün yok
|
||||||
|
await expense({ date: today, amountMinor: 1_000_00, category: cat.keyif });
|
||||||
|
await expense({ date: today, amountMinor: 2_000_00, category: cat.kira, recurring: true });
|
||||||
|
const keyif = (await line("keyif", cur))!;
|
||||||
|
expect(keyif.actualMinor).toBe(1_000_00);
|
||||||
|
expect(keyif.forecastMinor).toBeGreaterThan(1_000_00);
|
||||||
|
expect((await line("kira", cur))!.forecastMinor).toBe(2_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bütçe ayı başlangıç günü dönemi kaydırır", async () => {
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.budgetMonthStartDay": 15 } },
|
||||||
|
);
|
||||||
|
const b = (await getBudget(ayse, cakir, M))!;
|
||||||
|
expect(b.period).toEqual(budgetPeriod(M, 15));
|
||||||
|
// Ayın 1'indeki kira artık önceki bütçe ayında.
|
||||||
|
expect((await line("kira"))?.actualMinor ?? 0).toBe(0);
|
||||||
|
await HouseholdModel.updateOne({ _id: cakir }, { $set: { "settings.budgetMonthStartDay": 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("önceki aydan kopyala ve önceki ayın harcamalarından oluştur", async () => {
|
||||||
|
const N = shiftMonth(M, 1);
|
||||||
|
expect((await copyPreviousBudget(ayse, cakir, P)).ok).toBe(false);
|
||||||
|
expect((await copyPreviousBudget(ayse, cakir, N)).ok).toBe(true);
|
||||||
|
expect((await getBudget(ayse, cakir, N))!.editor.limits[cat.ev]).toBe(17_000_00);
|
||||||
|
// M'nin harcamaları: Ev kökü 16.200 + taksit 1.000 → 17.200; Keyif 2.500 (gizli dahil).
|
||||||
|
expect((await budgetFromActuals(ayse, cakir, N)).ok).toBe(true);
|
||||||
|
const l = (await BudgetModel.findOne({ householdId: cakir, month: N }).lean())!.limits;
|
||||||
|
const get = (k: CatKey) => l.find((x) => String(x.categoryId) === cat[k])?.amountMinor;
|
||||||
|
expect(get("ev")).toBe(17_200_00);
|
||||||
|
expect(get("keyif")).toBe(2_500_00);
|
||||||
|
expect(get("kira")).toBeUndefined();
|
||||||
|
expect((await budgetFromActuals(ayse, cakir, shiftMonth(M, -5))).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yetki: izleyici yazamaz; başka hane göremez; geçersiz kategori reddedilir", async () => {
|
||||||
|
expect((await getBudget(zeynep, cakir, M))!.canWrite).toBe(false);
|
||||||
|
expect((await saveBudget(zeynep, cakir, limits(M, { ev: "1" }))).ok).toBe(false);
|
||||||
|
expect(await getBudget(fatma, cakir, M)).toBeNull();
|
||||||
|
const annemler = ids.household("Annemler");
|
||||||
|
expect((await saveBudget(fatma, annemler, limits(M, { ev: "1" }))).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types, type PipelineStage } from "mongoose";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { loadContext, type Ctx } from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { BudgetModel } from "@/models/budget";
|
||||||
|
import { HouseholdModel } from "@/models/household";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
|
import {
|
||||||
|
budgetPeriod,
|
||||||
|
computeBudget,
|
||||||
|
daysBetween,
|
||||||
|
periodOfDate,
|
||||||
|
roundUpLimit,
|
||||||
|
shiftMonth,
|
||||||
|
type BudgetLine,
|
||||||
|
type BudgetTotals,
|
||||||
|
type CategoryAmounts,
|
||||||
|
} from "@/shared/budget";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import type { BudgetLimitsForm } from "@/shared/schemas/budget";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aylık bütçe (Bölüm 6.9). Okuma: viewer; yazma: member.
|
||||||
|
*
|
||||||
|
* Gerçekleşen: dönemdeki gerçekleşmiş (posted) giderler; bölünmüş işlemler kalem bazında.
|
||||||
|
* Planlı: dönemdeki planlı giderler (düzenli işlem tekrarları, gelecek taksitler).
|
||||||
|
* Taksit modu (hane ayarı): her taksit kendi ayına (varsayılan) veya tamamı alış ayına.
|
||||||
|
* Başkasının "yalnızca ben" hesabından giderler hane ayarına göre (yalnızca tutar) sayılır.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.budget;
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
export type Settings = {
|
||||||
|
startDay: number;
|
||||||
|
installmentMode: "per_installment" | "purchase_month";
|
||||||
|
includePrivate: boolean;
|
||||||
|
dailyMode: "even" | "previous_month_pattern";
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function settingsOf(householdId: Types.ObjectId): Promise<Settings> {
|
||||||
|
const h = await HouseholdModel.findById(householdId).select({ settings: 1 }).lean();
|
||||||
|
return {
|
||||||
|
startDay: h?.settings?.budgetMonthStartDay ?? 1,
|
||||||
|
installmentMode: h?.settings?.installmentBudgetMode ?? "per_installment",
|
||||||
|
includePrivate: h?.settings?.includePrivateAccountsInTotals ?? true,
|
||||||
|
dailyMode: h?.settings?.dailyPlanMode ?? "even",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kategori bazında (kendi) sabit/değişken gerçekleşen ve planlı gider. */
|
||||||
|
export async function expenseAmounts(
|
||||||
|
ctx: Ctx,
|
||||||
|
s: Settings,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): Promise<Map<string, CategoryAmounts>> {
|
||||||
|
const start = istanbulDayStart(from)!;
|
||||||
|
const end = new Date(istanbulDayStart(to)!.getTime() + DAY_MS);
|
||||||
|
const base: Record<string, unknown> = {
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
type: "expense",
|
||||||
|
deletedAt: null,
|
||||||
|
};
|
||||||
|
if (!s.includePrivate && ctx.hiddenAccountIds.length)
|
||||||
|
base.accountId = { $nin: ctx.hiddenAccountIds };
|
||||||
|
|
||||||
|
const itemsStage: PipelineStage = {
|
||||||
|
$project: {
|
||||||
|
status: 1,
|
||||||
|
fixed: {
|
||||||
|
$or: [
|
||||||
|
{ $ne: [{ $ifNull: ["$recurringRuleId", null] }, null] },
|
||||||
|
{ $ne: [{ $ifNull: ["$installment.planId", null] }, null] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
$cond: [
|
||||||
|
{ $gt: [{ $size: { $ifNull: ["$splits", []] } }, 0] },
|
||||||
|
"$splits",
|
||||||
|
[{ categoryId: "$categoryId", amountMinor: "$amountMinor" }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const group: PipelineStage[] = [
|
||||||
|
itemsStage,
|
||||||
|
{ $unwind: "$items" },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: { c: "$items.categoryId", s: "$status", f: "$fixed" },
|
||||||
|
sum: { $sum: "$items.amountMinor" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const match: Record<string, unknown> = {
|
||||||
|
...base,
|
||||||
|
status: { $in: ["posted", "planned"] },
|
||||||
|
date: { $gte: start, $lt: end },
|
||||||
|
};
|
||||||
|
if (s.installmentMode === "purchase_month") match["installment.planId"] = { $in: [null] };
|
||||||
|
const rows = await TransactionModel.aggregate<{
|
||||||
|
_id: { c: Types.ObjectId | null; s: string; f: boolean };
|
||||||
|
sum: number;
|
||||||
|
}>([{ $match: match }, ...group]);
|
||||||
|
|
||||||
|
const out = new Map<string, CategoryAmounts>();
|
||||||
|
const add = (cat: string, k: keyof CategoryAmounts, v: number) => {
|
||||||
|
const cur = out.get(cat) ?? { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 };
|
||||||
|
cur[k] += v;
|
||||||
|
out.set(cat, cur);
|
||||||
|
};
|
||||||
|
for (const r of rows) {
|
||||||
|
const cat = r._id.c ? String(r._id.c) : "";
|
||||||
|
if (r._id.s === "planned") add(cat, "plannedMinor", r.sum);
|
||||||
|
else add(cat, r._id.f ? "fixedMinor" : "variableMinor", r.sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tamamı alış ayına: ilk taksiti bu dönemde olan planların tüm taksitleri gerçekleşmiş sayılır.
|
||||||
|
if (s.installmentMode === "purchase_month") {
|
||||||
|
const plans = await TransactionModel.aggregate<{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
first: Date;
|
||||||
|
items: { categoryId: Types.ObjectId | null; amountMinor: number }[][];
|
||||||
|
}>([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
...base,
|
||||||
|
status: { $in: ["posted", "planned"] },
|
||||||
|
"installment.planId": { $ne: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: "$installment.planId",
|
||||||
|
first: { $min: "$date" },
|
||||||
|
items: {
|
||||||
|
$push: {
|
||||||
|
$cond: [
|
||||||
|
{ $gt: [{ $size: { $ifNull: ["$splits", []] } }, 0] },
|
||||||
|
"$splits",
|
||||||
|
[{ categoryId: "$categoryId", amountMinor: "$amountMinor" }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $match: { first: { $gte: start, $lt: end } } },
|
||||||
|
]);
|
||||||
|
for (const p of plans)
|
||||||
|
for (const list of p.items)
|
||||||
|
for (const it of list)
|
||||||
|
add(it.categoryId ? String(it.categoryId) : "", "fixedMinor", it.amountMinor);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BudgetLineView = BudgetLine & { icon: IconKey; color: Swatch; path: string };
|
||||||
|
|
||||||
|
export type BudgetView = {
|
||||||
|
month: string;
|
||||||
|
currentMonth: string;
|
||||||
|
period: { from: string; to: string };
|
||||||
|
elapsedDays: number;
|
||||||
|
totalDays: number;
|
||||||
|
lines: BudgetLineView[];
|
||||||
|
totals: BudgetTotals;
|
||||||
|
hasBudget: boolean;
|
||||||
|
/** Limit düzenleyici: etkin gider kategorileri (ağaç sırası) ve mevcut limitler. */
|
||||||
|
editor: {
|
||||||
|
categories: { id: string; name: string; depth: number }[];
|
||||||
|
limits: Record<string, number>;
|
||||||
|
};
|
||||||
|
hasPrevious: boolean;
|
||||||
|
includesHidden: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function expenseCategories(ctx: Ctx) {
|
||||||
|
return ctx.flatCategories.filter((c) => c.kind === "expense");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBudget(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
month?: string,
|
||||||
|
): Promise<BudgetView | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const s = await settingsOf(ctx.m.householdId);
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const currentMonth = periodOfDate(today, s.startDay);
|
||||||
|
const m = month ?? currentMonth;
|
||||||
|
const period = budgetPeriod(m, s.startDay);
|
||||||
|
const totalDays = daysBetween(period.from, period.to);
|
||||||
|
const elapsedDays =
|
||||||
|
today < period.from ? 0 : today > period.to ? totalDays : daysBetween(period.from, today);
|
||||||
|
|
||||||
|
const [doc, prev, amounts] = await Promise.all([
|
||||||
|
BudgetModel.findOne({ householdId: ctx.m.householdId, month: m }).lean(),
|
||||||
|
BudgetModel.exists({
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
month: shiftMonth(m, -1),
|
||||||
|
"limits.0": { $exists: true },
|
||||||
|
}),
|
||||||
|
expenseAmounts(ctx, s, period.from, period.to),
|
||||||
|
]);
|
||||||
|
const limits = new Map((doc?.limits ?? []).map((l) => [String(l.categoryId), l.amountMinor]));
|
||||||
|
// Taban hız (bitmemiş aylar için): önceki dönemin değişken gideri, kategori bazında.
|
||||||
|
let baselineDays: number | undefined;
|
||||||
|
if (elapsedDays < totalDays) {
|
||||||
|
const prev = budgetPeriod(shiftMonth(m, -1), s.startDay);
|
||||||
|
const prevAmounts = await expenseAmounts(ctx, s, prev.from, prev.to);
|
||||||
|
let any = 0;
|
||||||
|
for (const [id, a] of prevAmounts) {
|
||||||
|
if (a.variableMinor <= 0) continue;
|
||||||
|
any += a.variableMinor;
|
||||||
|
const cur = amounts.get(id) ?? { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 };
|
||||||
|
amounts.set(id, { ...cur, baselineMinor: a.variableMinor });
|
||||||
|
}
|
||||||
|
if (any > 0) baselineDays = daysBetween(prev.from, prev.to);
|
||||||
|
}
|
||||||
|
const cats = expenseCategories(ctx);
|
||||||
|
// Arşivli kategori yalnızca limiti veya harcaması varsa görünür.
|
||||||
|
const visibleCats = cats.filter((c) => !c.archived || limits.has(c.id) || amounts.has(c.id));
|
||||||
|
const known = new Set(visibleCats.map((c) => c.id));
|
||||||
|
// Kategorisiz / bilinmeyen kategorideki gider "Kategorisiz" satırında toplanır.
|
||||||
|
const orphan = { fixedMinor: 0, variableMinor: 0, plannedMinor: 0, baselineMinor: 0 };
|
||||||
|
for (const [id, a] of amounts)
|
||||||
|
if (!known.has(id)) {
|
||||||
|
orphan.fixedMinor += a.fixedMinor;
|
||||||
|
orphan.variableMinor += a.variableMinor;
|
||||||
|
orphan.plannedMinor += a.plannedMinor;
|
||||||
|
orphan.baselineMinor += a.baselineMinor ?? 0;
|
||||||
|
}
|
||||||
|
const ORPHAN = "__none__";
|
||||||
|
const inputCats = visibleCats.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
parentId: c.parentId,
|
||||||
|
name: c.name,
|
||||||
|
order: c.order,
|
||||||
|
}));
|
||||||
|
if (orphan.fixedMinor + orphan.variableMinor + orphan.plannedMinor > 0) {
|
||||||
|
inputCats.push({ id: ORPHAN, parentId: null, name: t.uncategorized, order: 1e9 });
|
||||||
|
amounts.set(ORPHAN, orphan);
|
||||||
|
}
|
||||||
|
const { lines, totals } = computeBudget({
|
||||||
|
categories: inputCats,
|
||||||
|
limits,
|
||||||
|
amounts,
|
||||||
|
elapsedDays,
|
||||||
|
totalDays,
|
||||||
|
baselineDays,
|
||||||
|
});
|
||||||
|
const editorCats = cats.filter((c) => !c.archived);
|
||||||
|
// Düzenleyici için ağaç sırası ve derinlik (tüm etkin kategoriler, limit 0 ile).
|
||||||
|
const ordered = computeBudget({
|
||||||
|
categories: editorCats.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
parentId: c.parentId,
|
||||||
|
name: c.name,
|
||||||
|
order: c.order,
|
||||||
|
})),
|
||||||
|
limits: new Map(editorCats.map((c) => [c.id, 0])),
|
||||||
|
amounts: new Map(),
|
||||||
|
elapsedDays: 0,
|
||||||
|
totalDays: 1,
|
||||||
|
}).lines;
|
||||||
|
|
||||||
|
return {
|
||||||
|
month: m,
|
||||||
|
currentMonth,
|
||||||
|
period,
|
||||||
|
elapsedDays,
|
||||||
|
totalDays,
|
||||||
|
lines: lines.map((l) => {
|
||||||
|
const c = ctx.categoryMap.get(l.categoryId);
|
||||||
|
return {
|
||||||
|
...l,
|
||||||
|
icon: (c?.icon ?? "tag") as IconKey,
|
||||||
|
color: (c?.color ?? "slate") as Swatch,
|
||||||
|
path: c?.path ?? l.name,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
totals,
|
||||||
|
hasBudget: limits.size > 0,
|
||||||
|
editor: {
|
||||||
|
categories: ordered.map((l) => ({ id: l.categoryId, name: l.name, depth: l.depth })),
|
||||||
|
limits: Object.fromEntries(limits),
|
||||||
|
},
|
||||||
|
hasPrevious: Boolean(prev),
|
||||||
|
includesHidden: s.includePrivate && ctx.hiddenAccountIds.length > 0,
|
||||||
|
canWrite: hasHouseholdRole(ctx.m.role, "member"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeLimits(
|
||||||
|
ctx: Ctx,
|
||||||
|
actorId: string,
|
||||||
|
month: string,
|
||||||
|
limits: { categoryId: string; amountMinor: number }[],
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const valid = new Set(
|
||||||
|
expenseCategories(ctx)
|
||||||
|
.filter((c) => !c.archived)
|
||||||
|
.map((c) => c.id),
|
||||||
|
);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const l of limits) {
|
||||||
|
if (!valid.has(l.categoryId) || seen.has(l.categoryId)) return fail(t.errors.category);
|
||||||
|
seen.add(l.categoryId);
|
||||||
|
}
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await BudgetModel.updateOne(
|
||||||
|
{ householdId: ctx.m.householdId, month },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
limits: limits.map((l) => ({
|
||||||
|
categoryId: new Types.ObjectId(l.categoryId),
|
||||||
|
amountMinor: l.amountMinor,
|
||||||
|
})),
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
$setOnInsert: { createdBy: actor },
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBudget(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: BudgetLimitsForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
return writeLimits(ctx, actorId, input.month, input.limits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Önceki ayın limitlerini kopyalar (arşivlenmiş kategoriler atlanır). */
|
||||||
|
export async function copyPreviousBudget(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
month: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const prev = await BudgetModel.findOne({
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
month: shiftMonth(month, -1),
|
||||||
|
}).lean();
|
||||||
|
if (!prev?.limits.length) return fail(t.errors.noPrevious);
|
||||||
|
const active = new Set(
|
||||||
|
expenseCategories(ctx)
|
||||||
|
.filter((c) => !c.archived)
|
||||||
|
.map((c) => c.id),
|
||||||
|
);
|
||||||
|
return writeLimits(
|
||||||
|
ctx,
|
||||||
|
actorId,
|
||||||
|
month,
|
||||||
|
prev.limits
|
||||||
|
.map((l) => ({ categoryId: String(l.categoryId), amountMinor: l.amountMinor }))
|
||||||
|
.filter((l) => active.has(l.categoryId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Önceki ayın gerçekleşen giderlerinden limit: ana kategori (kök) bazında toplam, 100 TL'ye
|
||||||
|
* yukarı yuvarlanır. Kategorisiz gider atlanır.
|
||||||
|
*/
|
||||||
|
export async function budgetFromActuals(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
month: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const s = await settingsOf(ctx.m.householdId);
|
||||||
|
const period = budgetPeriod(shiftMonth(month, -1), s.startDay);
|
||||||
|
const amounts = await expenseAmounts(ctx, s, period.from, period.to);
|
||||||
|
const cats = expenseCategories(ctx);
|
||||||
|
const byId = new Map(cats.map((c) => [c.id, c]));
|
||||||
|
const rootOf = (id: string) => {
|
||||||
|
let cur = byId.get(id);
|
||||||
|
let guard = 0;
|
||||||
|
while (cur?.parentId && byId.has(cur.parentId) && guard++ < 6) cur = byId.get(cur.parentId);
|
||||||
|
return cur;
|
||||||
|
};
|
||||||
|
const totals = new Map<string, number>();
|
||||||
|
for (const [id, a] of amounts) {
|
||||||
|
const root = rootOf(id);
|
||||||
|
if (!root || root.archived) continue;
|
||||||
|
totals.set(root.id, (totals.get(root.id) ?? 0) + a.fixedMinor + a.variableMinor);
|
||||||
|
}
|
||||||
|
const limits = [...totals]
|
||||||
|
.filter(([, v]) => v > 0)
|
||||||
|
.map(([categoryId, v]) => ({ categoryId, amountMinor: roundUpLimit(v) }));
|
||||||
|
if (!limits.length) return fail(t.errors.noPreviousSpending);
|
||||||
|
return writeLimits(ctx, actorId, month, limits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard uyarısı: bu ayın %80'i geçilmiş veya aşılmış limitli kategori sayısı. */
|
||||||
|
export async function countBudgetWarnings(userId: string, householdId: string): Promise<number> {
|
||||||
|
const b = await getBudget(userId, householdId);
|
||||||
|
if (!b?.hasBudget) return 0;
|
||||||
|
return b.lines.filter((l) => l.status === "warning" || l.status === "over").length;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { getAssetOptionsAction, type AssetOption } from "@/features/assets/actions";
|
||||||
import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
|
import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
|
||||||
import { AccountSelect } from "@/features/transactions/components/transaction-form";
|
import { AccountSelect } from "@/features/transactions/components/transaction-form";
|
||||||
import type { TransactionFormOptions } from "@/features/transactions/service";
|
import type { TransactionFormOptions } from "@/features/transactions/service";
|
||||||
@@ -79,6 +81,21 @@ export function useAccounts() {
|
|||||||
return options?.accounts ?? null;
|
return options?.accounts ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kaydın varlık türündeki birikim varlıkları (ödeme diyaloğu açıldığında). */
|
||||||
|
function useAssetOptions(assetKey: AssetKey) {
|
||||||
|
const [options, setOptions] = useState<AssetOption[] | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getAssetOptionsAction().then((r) => {
|
||||||
|
if (!cancelled && r.ok) setOptions(r.data.filter((a) => a.assetKey === assetKey));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [assetKey]);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -602,16 +619,23 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
|
|||||||
});
|
});
|
||||||
const [errors, setErrors] = useState<Errors>({});
|
const [errors, setErrors] = useState<Errors>({});
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const assets = useAssetOptions(item.assetKey);
|
||||||
|
const [link, setLink] = useState(false);
|
||||||
|
const [assetId, setAssetId] = useState("");
|
||||||
|
const linked = method === "asset" && link;
|
||||||
|
const linkAssetId = assetId || assets?.[0]?.id || "";
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
const amount = linked ? "" : v.amount;
|
||||||
const r = await recordPaymentAction(item.id, {
|
const r = await recordPaymentAction(item.id, {
|
||||||
method,
|
method,
|
||||||
date: v.date,
|
date: v.date,
|
||||||
quantity: method === "cash" ? "" : v.quantity,
|
quantity: method === "cash" ? "" : v.quantity,
|
||||||
amount: v.amount,
|
amount,
|
||||||
accountId: v.amount ? v.accountId || accounts?.[0]?.id || "" : "",
|
accountId: amount ? v.accountId || accounts?.[0]?.id || "" : "",
|
||||||
|
assetId: linked ? linkAssetId : "",
|
||||||
note: v.note,
|
note: v.note,
|
||||||
});
|
});
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -659,6 +683,7 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
|
|||||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||||
</Field>
|
</Field>
|
||||||
) : null}
|
) : null}
|
||||||
|
{linked || (method === "asset" && !debt) ? null : (
|
||||||
<Field data-invalid={Boolean(errors.amount)}>
|
<Field data-invalid={Boolean(errors.amount)}>
|
||||||
<FieldLabel htmlFor="pay-amount">
|
<FieldLabel htmlFor="pay-amount">
|
||||||
{method === "cash" ? f.amount : f.assetAmount}
|
{method === "cash" ? f.amount : f.assetAmount}
|
||||||
@@ -670,6 +695,7 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
|
|||||||
/>
|
/>
|
||||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||||
</Field>
|
</Field>
|
||||||
|
)}
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel>
|
<FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
@@ -684,7 +710,49 @@ function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void
|
|||||||
{method === "cash" ? (
|
{method === "cash" ? (
|
||||||
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
|
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
|
||||||
) : null}
|
) : null}
|
||||||
{method === "cash" || v.amount ? (
|
{method === "asset" ? (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={link}
|
||||||
|
onCheckedChange={(x) => setLink(x === true)}
|
||||||
|
disabled={!assets?.length}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{debt ? t.link.debt : t.link.receivable}
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{assets && !assets.length
|
||||||
|
? t.link.none
|
||||||
|
: debt
|
||||||
|
? t.link.debtHint
|
||||||
|
: t.link.receivableHint}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{link && assets?.length ? (
|
||||||
|
<Field data-invalid={Boolean(errors.assetId)}>
|
||||||
|
<FieldLabel htmlFor="pay-asset" className="sr-only">
|
||||||
|
{t.link.asset}
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={linkAssetId} onValueChange={setAssetId}>
|
||||||
|
<SelectTrigger id="pay-asset" className="w-full">
|
||||||
|
<SelectValue>{assets.find((a) => a.id === linkAssetId)?.name}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{assets.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.name} · {formatAssetQuantity(item.assetKey, a.quantity)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.assetId ? <FieldError>{errors.assetId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{(method === "cash" || v.amount) && !linked ? (
|
||||||
<Field data-invalid={Boolean(errors.accountId)}>
|
<Field data-invalid={Boolean(errors.accountId)}>
|
||||||
<FieldLabel htmlFor="pay-account">{f.account}</FieldLabel>
|
<FieldLabel htmlFor="pay-account">{f.account}</FieldLabel>
|
||||||
<AccountSelect
|
<AccountSelect
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import "server-only";
|
||||||
|
import type { Types } from "mongoose";
|
||||||
|
import { AssetPriceModel } from "@/models/asset-price";
|
||||||
|
import type { AssetKey } from "@/shared/assets";
|
||||||
|
|
||||||
|
/** Fiyat tablosu okuma yardımcıları (aynî kayıtlar ve varlık takibi ortak kullanır). */
|
||||||
|
|
||||||
|
export type PriceQuote = { buyMinor: number; sellMinor: number; date: string };
|
||||||
|
|
||||||
|
/** Varlığın verilen tarihteki (veya öncesindeki en yakın) fiyatı. */
|
||||||
|
export async function priceOn(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
assetKey: AssetKey,
|
||||||
|
date: string,
|
||||||
|
): Promise<PriceQuote | null> {
|
||||||
|
const p = await AssetPriceModel.findOne({ householdId, assetKey, date: { $lte: date } })
|
||||||
|
.sort({ date: -1 })
|
||||||
|
.lean();
|
||||||
|
return p ? { buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Varlıkların `to` tarihine kadarki tüm fiyatları; dönen fonksiyon bir tarihteki (veya
|
||||||
|
* öncesindeki en yakın) alış fiyatını verir (yoksa null).
|
||||||
|
*/
|
||||||
|
export async function buyPriceLookup(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
assetKeys: AssetKey[],
|
||||||
|
to: string,
|
||||||
|
): Promise<(assetKey: AssetKey, date: string) => number | null> {
|
||||||
|
const rows = await AssetPriceModel.find({
|
||||||
|
householdId,
|
||||||
|
assetKey: { $in: assetKeys },
|
||||||
|
date: { $lte: to },
|
||||||
|
})
|
||||||
|
.sort({ date: 1 })
|
||||||
|
.lean();
|
||||||
|
const by = new Map<string, { date: string; buyMinor: number }[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const list = by.get(r.assetKey) ?? [];
|
||||||
|
list.push({ date: r.date, buyMinor: r.buyMinor });
|
||||||
|
by.set(r.assetKey, list);
|
||||||
|
}
|
||||||
|
return (assetKey, date) => {
|
||||||
|
const list = by.get(assetKey);
|
||||||
|
if (!list) return null;
|
||||||
|
// İkili arama: date'ten küçük-eşit son kayıt.
|
||||||
|
let lo = 0;
|
||||||
|
let hi = list.length - 1;
|
||||||
|
let found = -1;
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (list[mid]!.date <= date) {
|
||||||
|
found = mid;
|
||||||
|
lo = mid + 1;
|
||||||
|
} else hi = mid - 1;
|
||||||
|
}
|
||||||
|
return found === -1 ? null : list[found]!.buyMinor;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import { toObjectId } from "@/lib/object-id";
|
|||||||
import { AssetPriceModel } from "@/models/asset-price";
|
import { AssetPriceModel } from "@/models/asset-price";
|
||||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||||
import { TransactionModel } from "@/models/transaction";
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { addMovement } from "@/features/assets/service";
|
||||||
|
import { AssetMovementModel } from "@/models/asset-movement";
|
||||||
|
import { priceOn, type PriceQuote } from "./prices";
|
||||||
import {
|
import {
|
||||||
ASSET_KEYS,
|
ASSET_KEYS,
|
||||||
formatAssetQuantity,
|
formatAssetQuantity,
|
||||||
@@ -38,19 +41,7 @@ const t = tr.inKind;
|
|||||||
|
|
||||||
// --- Fiyat tablosu ---
|
// --- Fiyat tablosu ---
|
||||||
|
|
||||||
export type PriceQuote = { buyMinor: number; sellMinor: number; date: string };
|
export { priceOn, type PriceQuote } from "./prices";
|
||||||
|
|
||||||
/** Varlığın verilen tarihteki (veya öncesindeki en yakın) fiyatı. */
|
|
||||||
export async function priceOn(
|
|
||||||
householdId: Types.ObjectId,
|
|
||||||
assetKey: AssetKey,
|
|
||||||
date: string,
|
|
||||||
): Promise<PriceQuote | null> {
|
|
||||||
const p = await AssetPriceModel.findOne({ householdId, assetKey, date: { $lte: date } })
|
|
||||||
.sort({ date: -1 })
|
|
||||||
.lean();
|
|
||||||
return p ? { buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] };
|
export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] };
|
||||||
|
|
||||||
@@ -381,6 +372,11 @@ export async function deleteInKind(
|
|||||||
{ _id: d._id },
|
{ _id: d._id },
|
||||||
{ $set: { deletedAt: new Date(), updatedBy: actor } },
|
{ $set: { deletedAt: new Date(), updatedBy: actor } },
|
||||||
);
|
);
|
||||||
|
// Bağlı varlık hareketleri serbest kalır (Birikim sayfasından yönetilebilir).
|
||||||
|
await AssetMovementModel.updateMany(
|
||||||
|
{ householdId: ctx.m.householdId, inKindDebtId: d._id },
|
||||||
|
{ $set: { inKindDebtId: null } },
|
||||||
|
);
|
||||||
await writeAudit({
|
await writeAudit({
|
||||||
action: "inkind.delete",
|
action: "inkind.delete",
|
||||||
actorId: actor,
|
actorId: actor,
|
||||||
@@ -491,7 +487,15 @@ export async function recordPayment(
|
|||||||
contactName,
|
contactName,
|
||||||
);
|
);
|
||||||
|
|
||||||
await mongoose.connection.transaction(async (session) => {
|
if (input.assetId) {
|
||||||
|
const asset = ctx.assetMap.get(input.assetId);
|
||||||
|
if (!asset || asset.hidden || asset.deleted)
|
||||||
|
return fail(tr.common.notFound, { assetId: tr.common.notFound });
|
||||||
|
if (asset.assetKey !== d.assetKey)
|
||||||
|
return fail(t.link.assetMismatch, { assetId: t.link.assetMismatch });
|
||||||
|
}
|
||||||
|
|
||||||
|
const writePayment = async (session: mongoose.ClientSession) => {
|
||||||
let transactionId: Types.ObjectId | null = null;
|
let transactionId: Types.ObjectId | null = null;
|
||||||
if (amountMinor > 0 && account) {
|
if (amountMinor > 0 && account) {
|
||||||
const out = d.direction === "debt";
|
const out = d.direction === "debt";
|
||||||
@@ -549,10 +553,50 @@ export async function recordPayment(
|
|||||||
},
|
},
|
||||||
session,
|
session,
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
// Varlık bağlantısı: borç ödemesi varlıktan çıkış, alacak tahsilatı varlığa giriş.
|
||||||
|
if (input.assetId) {
|
||||||
|
const r = await addMovement(
|
||||||
|
actorId,
|
||||||
|
householdId,
|
||||||
|
input.assetId,
|
||||||
|
{
|
||||||
|
kind: d.direction === "debt" ? "gift_out" : "gift_in",
|
||||||
|
date: input.date,
|
||||||
|
quantity,
|
||||||
|
unitPrice: d.direction === "debt" ? null : (d.unitPriceMinor ?? null),
|
||||||
|
amount: null,
|
||||||
|
accountId: null,
|
||||||
|
storage: "",
|
||||||
|
note: t.link.movementNote(contactName),
|
||||||
|
},
|
||||||
|
{ inKindDebtId: d._id, session },
|
||||||
|
);
|
||||||
|
if (!r.ok) throw new LinkError(r.error, r.fieldErrors?.quantity);
|
||||||
|
}
|
||||||
|
await writePayment(session);
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LinkError)
|
||||||
|
return fail(error.message, error.quantity ? { quantity: error.quantity } : undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return ok({ closed });
|
return ok({ closed });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Varlık hareketi eklenemedi: transaction geri alınır, hata kullanıcıya döner. */
|
||||||
|
class LinkError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly quantity?: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Cari listesi için açık aynî kayıt özetleri (cari kimliği → etiketler). */
|
/** Cari listesi için açık aynî kayıt özetleri (cari kimliği → etiketler). */
|
||||||
export async function openInKindByContact(
|
export async function openInKindByContact(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import type { NetWorth } from "../service";
|
||||||
|
|
||||||
|
const t = tr.assets.netWorth;
|
||||||
|
|
||||||
|
/** Net varlık dökümü: artılar ve eksiler, altta toplam. */
|
||||||
|
export function NetWorthCard({ data }: { data: NetWorth }) {
|
||||||
|
const rows: { label: string; minor: number; sign: 1 | -1 }[] = [
|
||||||
|
{ label: t.accounts, minor: data.accountsMinor, sign: 1 },
|
||||||
|
{ label: t.assets, minor: data.assetsMinor, sign: 1 },
|
||||||
|
{ label: t.contactReceivable, minor: data.contactReceivableMinor, sign: 1 },
|
||||||
|
{ label: t.inKindReceivable, minor: data.inKindReceivableMinor, sign: 1 },
|
||||||
|
{ label: t.cardDebt, minor: data.cardDebtMinor, sign: -1 },
|
||||||
|
{ label: t.contactPayable, minor: data.contactPayableMinor, sign: -1 },
|
||||||
|
{ label: t.inKindPayable, minor: data.inKindPayableMinor, sign: -1 },
|
||||||
|
];
|
||||||
|
const plus = rows.filter((r) => r.sign === 1);
|
||||||
|
const minus = rows.filter((r) => r.sign === -1);
|
||||||
|
const list = (items: typeof rows) => (
|
||||||
|
<dl className="flex flex-col text-sm">
|
||||||
|
{items.map((r) => (
|
||||||
|
<div key={r.label} className="flex items-center justify-between gap-2 py-1">
|
||||||
|
<dt className="text-muted-foreground">{r.label}</dt>
|
||||||
|
<dd className="tabular-nums">
|
||||||
|
{r.sign < 0 && r.minor ? "−" : ""}
|
||||||
|
{formatMoney(r.minor)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-3xl font-semibold tracking-tight tabular-nums",
|
||||||
|
data.totalMinor < 0 && "text-expense",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMoney(data.totalMinor)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{t.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-x-8 gap-y-1 border-t pt-2 sm:grid-cols-2">
|
||||||
|
{list(plus)}
|
||||||
|
{list(minus)}
|
||||||
|
</div>
|
||||||
|
{data.missingPrice ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.missingPrice(data.missingPrice)}</p>
|
||||||
|
) : null}
|
||||||
|
{data.includesHidden ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.includesHidden}</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { addMovement, createAsset, getAsset, getPeriodReport } from "@/features/assets/service";
|
||||||
|
import { createContactEntry, createContact } from "@/features/contacts/service";
|
||||||
|
import {
|
||||||
|
createInKind,
|
||||||
|
deleteInKind,
|
||||||
|
listInKind,
|
||||||
|
recordPayment,
|
||||||
|
savePrice,
|
||||||
|
} from "@/features/in-kind/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { AssetModel } from "@/models/asset";
|
||||||
|
import { AssetMovementModel } from "@/models/asset-movement";
|
||||||
|
import { AssetPriceModel } from "@/models/asset-price";
|
||||||
|
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays, addMonths } from "@/shared/recurrence";
|
||||||
|
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||||
|
import { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact";
|
||||||
|
import {
|
||||||
|
assetPriceFormSchema,
|
||||||
|
inKindFormSchema,
|
||||||
|
paymentFormSchema,
|
||||||
|
} from "@/shared/schemas/in-kind";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { getNetWorth } from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
const today = todayIstanbul();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
emre = ids.user("emre");
|
||||||
|
// Seed varlık/fiyat/aynî örnekleri senaryoları etkilemesin.
|
||||||
|
await Promise.all([
|
||||||
|
AssetPriceModel.deleteMany({}),
|
||||||
|
AssetModel.deleteMany({}),
|
||||||
|
AssetMovementModel.deleteMany({}),
|
||||||
|
InKindDebtModel.deleteMany({}),
|
||||||
|
TransactionModel.deleteMany({
|
||||||
|
$or: [{ assetId: { $ne: null } }, { inKindDebtId: { $ne: null } }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
const acc = (name: string) =>
|
||||||
|
AccountModel.findOne({ householdId: cakir, name })
|
||||||
|
.lean()
|
||||||
|
.then((a) => String(a!._id));
|
||||||
|
const price = (assetKey: string, date: string, buy: string, sell = "") =>
|
||||||
|
savePrice(ayse, cakir, assetPriceFormSchema.parse({ assetKey, date, buy, sell }));
|
||||||
|
const mv = (input: Record<string, unknown>) => movementFormSchema.parse({ date: today, ...input });
|
||||||
|
async function asset(name: string, assetKey = "gram_gold", isPrivate = false, by = ayse) {
|
||||||
|
const r = await createAsset(by, cakir, assetFormSchema.parse({ assetKey, name, isPrivate }));
|
||||||
|
return r.ok ? r.data.id : "";
|
||||||
|
}
|
||||||
|
async function contact(name: string) {
|
||||||
|
const r = await createContact(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
contactFormSchema.parse({ name, openingDate: addDays(today, -200) }),
|
||||||
|
);
|
||||||
|
return r.ok ? r.data.id : "";
|
||||||
|
}
|
||||||
|
const netWorth = async (user = ayse) => (await getNetWorth(user, cakir))!;
|
||||||
|
|
||||||
|
describe("dönemsel rapor", () => {
|
||||||
|
let gold = "";
|
||||||
|
|
||||||
|
it("son 6 ay: yatırılan, dönem sonu değer ve kâr; aylık noktalar", async () => {
|
||||||
|
const m6 = addMonths(today, -6);
|
||||||
|
await price("gram_gold", addDays(m6, -10), "2.500", "2.550");
|
||||||
|
await price("gram_gold", addMonths(today, -3), "2.800", "2.850");
|
||||||
|
await price("gram_gold", today, "3.000", "3.050");
|
||||||
|
gold = await asset("Rapor altın");
|
||||||
|
// Dönem öncesi 2 gr (başlangıç değeri), dönem içinde 10 gr + 1 gr.
|
||||||
|
await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "buy", date: addDays(m6, -5), quantity: "2", amount: "5.000" }),
|
||||||
|
);
|
||||||
|
await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "buy", date: addDays(m6, 3), quantity: "10", amount: "25.000" }),
|
||||||
|
);
|
||||||
|
await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "buy", date: addMonths(today, -2), quantity: "1", amount: "2.850" }),
|
||||||
|
);
|
||||||
|
const r = (await getPeriodReport(ayse, cakir, "6", gold))!;
|
||||||
|
expect(r.from).toBe(m6);
|
||||||
|
expect(r.startValueMinor).toBe(5_000_00);
|
||||||
|
expect(r.investedMinor).toBe(27_850_00);
|
||||||
|
expect(r.endValueMinor).toBe(39_000_00);
|
||||||
|
expect(r.profitMinor).toBe(39_000_00 - 5_000_00 - 27_850_00);
|
||||||
|
expect(r.points[0]!.date).toBe(m6);
|
||||||
|
expect(r.points.at(-1)).toMatchObject({
|
||||||
|
date: today,
|
||||||
|
valueMinor: 39_000_00,
|
||||||
|
costMinor: 32_850_00,
|
||||||
|
});
|
||||||
|
expect(r.points.length).toBeGreaterThanOrEqual(7);
|
||||||
|
// "Tümü" ilk hareketten başlar; başlangıç değeri 0, kâr = değer − maliyet.
|
||||||
|
const all = (await getPeriodReport(ayse, cakir, "tum", gold))!;
|
||||||
|
expect(all.startValueMinor).toBe(0);
|
||||||
|
expect(all.profitMinor).toBe(39_000_00 - 32_850_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toplam rapor gizli varlığı başkasına katmaz; fiyatsız varlık dışarıda sayılır", async () => {
|
||||||
|
const secret = await asset("Gizli altın", "gram_gold", true, emre);
|
||||||
|
await addMovement(emre, cakir, secret, mv({ kind: "buy", quantity: "5", amount: "15.000" }));
|
||||||
|
const silver = await asset("Gümüş", "silver_gram");
|
||||||
|
await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
silver,
|
||||||
|
mv({ kind: "gift_in", quantity: "100", amount: "4.000" }),
|
||||||
|
);
|
||||||
|
const forAyse = (await getPeriodReport(ayse, cakir, "tum"))!;
|
||||||
|
expect(forAyse.investedMinor).toBe(32_850_00);
|
||||||
|
expect(forAyse.excluded).toBe(1);
|
||||||
|
const forEmre = (await getPeriodReport(emre, cakir, "tum"))!;
|
||||||
|
expect(forEmre.investedMinor).toBe(32_850_00 + 15_000_00);
|
||||||
|
expect(await getPeriodReport(ayse, cakir, "6", secret)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("net varlık", () => {
|
||||||
|
it("formül: hesaplar − kart borcu + varlıklar + alacaklar − borçlar", async () => {
|
||||||
|
const n = await netWorth();
|
||||||
|
expect(n.totalMinor).toBe(
|
||||||
|
n.accountsMinor -
|
||||||
|
n.cardDebtMinor +
|
||||||
|
n.assetsMinor +
|
||||||
|
n.contactReceivableMinor -
|
||||||
|
n.contactPayableMinor +
|
||||||
|
n.inKindReceivableMinor -
|
||||||
|
n.inKindPayableMinor,
|
||||||
|
);
|
||||||
|
// Gümüşün fiyatı yok → katılmaz ve sayılır.
|
||||||
|
expect(n.missingPrice).toBe(1);
|
||||||
|
// Emre kendi gizli altınını da görür: 5 × 3.000.
|
||||||
|
expect((await netWorth(emre)).assetsMinor - n.assetsMinor).toBe(15_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("satış fiyatından alım net varlığı yalnızca makas kadar düşürür; cariden borç almak değiştirmez", async () => {
|
||||||
|
const gold = await asset("Makas altın");
|
||||||
|
const n0 = (await netWorth()).totalMinor;
|
||||||
|
await addMovement(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
gold,
|
||||||
|
mv({ kind: "buy", quantity: "1", accountId: await acc("Ziraat Maaş") }),
|
||||||
|
);
|
||||||
|
expect((await netWorth()).totalMinor).toBe(n0 - 50_00);
|
||||||
|
|
||||||
|
const ali = await contact("Net Ali");
|
||||||
|
const n1 = (await netWorth()).totalMinor;
|
||||||
|
await createContactEntry(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
ali,
|
||||||
|
contactEntrySchema.parse({
|
||||||
|
kind: "borrow",
|
||||||
|
accountId: await acc("Cüzdan"),
|
||||||
|
amount: "1.000",
|
||||||
|
date: today,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const n2 = await netWorth();
|
||||||
|
expect(n2.totalMinor).toBe(n1);
|
||||||
|
expect(n2.contactPayableMinor).toBeGreaterThanOrEqual(1_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aynî borç satış, aynî alacak alış fiyatıyla düşülür/eklenir", async () => {
|
||||||
|
const hasan = await contact("Net Hasan");
|
||||||
|
const n0 = (await netWorth()).totalMinor;
|
||||||
|
await createInKind(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
hasan,
|
||||||
|
inKindFormSchema.parse({
|
||||||
|
direction: "debt",
|
||||||
|
assetKey: "gram_gold",
|
||||||
|
quantity: "2",
|
||||||
|
date: today,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect((await netWorth()).totalMinor).toBe(n0 - 6_100_00);
|
||||||
|
await createInKind(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
hasan,
|
||||||
|
inKindFormSchema.parse({
|
||||||
|
direction: "receivable",
|
||||||
|
assetKey: "gram_gold",
|
||||||
|
quantity: "1",
|
||||||
|
date: today,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect((await netWorth()).totalMinor).toBe(n0 - 6_100_00 + 3_000_00);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("aynî kayıt ↔ varlık bağlantısı", () => {
|
||||||
|
it("borç aynı varlıkla ödenirken varlıktan düşer; yetersizse hiçbir şey yazılmaz", async () => {
|
||||||
|
const kasa = await asset("Kasa altın");
|
||||||
|
await addMovement(ayse, cakir, kasa, mv({ kind: "buy", quantity: "3", amount: "9.000" }));
|
||||||
|
const veli = await contact("Bağ Veli");
|
||||||
|
const debt = await createInKind(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
veli,
|
||||||
|
inKindFormSchema.parse({
|
||||||
|
direction: "debt",
|
||||||
|
assetKey: "gram_gold",
|
||||||
|
quantity: "5",
|
||||||
|
date: today,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const debtId = debt.ok ? debt.data.id : "";
|
||||||
|
|
||||||
|
// 4 gr ödemek istiyor ama kasada 3 gr var → reddedilir, ödeme yazılmaz.
|
||||||
|
const fail = await recordPayment(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
debtId,
|
||||||
|
paymentFormSchema.parse({ method: "asset", date: today, quantity: "4", assetId: kasa }),
|
||||||
|
);
|
||||||
|
expect(fail.ok).toBe(false);
|
||||||
|
expect((await InKindDebtModel.findById(debtId).lean())!.payments).toHaveLength(0);
|
||||||
|
|
||||||
|
const r = await recordPayment(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
debtId,
|
||||||
|
paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: kasa }),
|
||||||
|
);
|
||||||
|
expect(r).toMatchObject({ ok: true, data: { closed: false } });
|
||||||
|
const a = (await getAsset(ayse, cakir, kasa))!;
|
||||||
|
expect(a.summary.quantity).toBe(1_000);
|
||||||
|
expect(a.movements[0]).toMatchObject({ kind: "gift_out", quantity: 2_000, fromInKind: true });
|
||||||
|
expect((await listInKind(ayse, cakir, veli))![0]!.paidQuantity).toBe(2_000);
|
||||||
|
|
||||||
|
// Kayıt silinince hareket serbest kalır.
|
||||||
|
await deleteInKind(ayse, cakir, debtId);
|
||||||
|
expect((await getAsset(ayse, cakir, kasa))!.movements[0]!.fromInKind).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("alacak aynı varlıkla tahsil edilince varlığa borç tarihindeki fiyatla eklenir", async () => {
|
||||||
|
const ziynet = await asset("Ziynet kutusu", "quarter_gold");
|
||||||
|
const ayten = await contact("Bağ Ayten");
|
||||||
|
const rec = await createInKind(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
ayten,
|
||||||
|
inKindFormSchema.parse({
|
||||||
|
direction: "receivable",
|
||||||
|
assetKey: "quarter_gold",
|
||||||
|
quantity: "2",
|
||||||
|
date: addDays(today, -30),
|
||||||
|
unitPrice: "5.000",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const recId = rec.ok ? rec.data.id : "";
|
||||||
|
const gold = await asset("Yanlış tür");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await recordPayment(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
recId,
|
||||||
|
paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: gold }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
paymentFormSchema.safeParse({
|
||||||
|
method: "cash",
|
||||||
|
date: today,
|
||||||
|
amount: "1",
|
||||||
|
accountId: await acc("Cüzdan"),
|
||||||
|
assetId: ziynet,
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
const r = await recordPayment(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
recId,
|
||||||
|
paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: ziynet }),
|
||||||
|
);
|
||||||
|
expect(r).toMatchObject({ ok: true, data: { closed: true } });
|
||||||
|
const a = (await getAsset(ayse, cakir, ziynet))!;
|
||||||
|
expect(a.summary).toMatchObject({ quantity: 2_000, costMinor: 10_000_00, investedMinor: 0 });
|
||||||
|
expect(a.movements[0]).toMatchObject({ kind: "gift_in", fromInKind: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { listAccounts } from "@/features/accounts/service";
|
||||||
|
import { listAssets } from "@/features/assets/service";
|
||||||
|
import { listContacts } from "@/features/contacts/service";
|
||||||
|
import { listInKind } from "@/features/in-kind/service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Net varlık (Bölüm 6.6): hesap bakiyeleri (kart borçları eksi) + varlıkların güncel değeri
|
||||||
|
* (alış) + cari alacaklar − cari borçlar + aynî alacaklar (alış) − aynî borçlar (satış).
|
||||||
|
* Görünürlük kaynak listelerin kurallarını izler (gizli varlık/cari sayılmaz; gizli hesap
|
||||||
|
* bakiyeleri hane ayarına göre toplama girer).
|
||||||
|
*/
|
||||||
|
export type NetWorth = {
|
||||||
|
accountsMinor: number;
|
||||||
|
cardDebtMinor: number;
|
||||||
|
assetsMinor: number;
|
||||||
|
contactReceivableMinor: number;
|
||||||
|
contactPayableMinor: number;
|
||||||
|
inKindReceivableMinor: number;
|
||||||
|
inKindPayableMinor: number;
|
||||||
|
totalMinor: number;
|
||||||
|
/** Fiyatı olmadığı için katılmayan varlık + aynî kayıt sayısı. */
|
||||||
|
missingPrice: number;
|
||||||
|
/** Başkasının gizli hesabının bakiyesi toplama dahil (hane ayarı). */
|
||||||
|
includesHidden: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getNetWorth(userId: string, householdId: string): Promise<NetWorth | null> {
|
||||||
|
const [accounts, assets, contacts, inKind] = await Promise.all([
|
||||||
|
listAccounts(userId, householdId),
|
||||||
|
listAssets(userId, householdId),
|
||||||
|
listContacts(userId, householdId),
|
||||||
|
listInKind(userId, householdId),
|
||||||
|
]);
|
||||||
|
if (!accounts || !assets || !contacts || !inKind) return null;
|
||||||
|
let inKindReceivableMinor = 0;
|
||||||
|
let inKindPayableMinor = 0;
|
||||||
|
let missingPrice = assets.totals.missingPrice;
|
||||||
|
for (const v of inKind) {
|
||||||
|
if (v.closedAt || v.progress.remainingQuantity === 0) continue;
|
||||||
|
const value = v.progress.currentValueMinor;
|
||||||
|
if (value === null) {
|
||||||
|
missingPrice++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (v.direction === "debt") inKindPayableMinor += value;
|
||||||
|
else inKindReceivableMinor += value;
|
||||||
|
}
|
||||||
|
const r = {
|
||||||
|
accountsMinor: accounts.totals.assetsMinor,
|
||||||
|
cardDebtMinor: -accounts.totals.cardDebtMinor,
|
||||||
|
assetsMinor: assets.totals.valueMinor,
|
||||||
|
contactReceivableMinor: contacts.totals.owesMeMinor,
|
||||||
|
contactPayableMinor: contacts.totals.iOweMinor,
|
||||||
|
inKindReceivableMinor,
|
||||||
|
inKindPayableMinor,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
totalMinor:
|
||||||
|
r.accountsMinor -
|
||||||
|
r.cardDebtMinor +
|
||||||
|
r.assetsMinor +
|
||||||
|
r.contactReceivableMinor -
|
||||||
|
r.contactPayableMinor +
|
||||||
|
r.inKindReceivableMinor -
|
||||||
|
r.inKindPayableMinor,
|
||||||
|
missingPrice,
|
||||||
|
includesHidden: accounts.totals.includesHidden,
|
||||||
|
};
|
||||||
|
}
|
||||||
+176
-1
@@ -639,6 +639,19 @@ export const tr = {
|
|||||||
price: "Alış veya satış fiyatı girin",
|
price: "Alış veya satış fiyatı girin",
|
||||||
amount: "Tutar girin",
|
amount: "Tutar girin",
|
||||||
account: "Hesap seçin",
|
account: "Hesap seçin",
|
||||||
|
assetOnlyWithAsset: "Varlık bağlantısı yalnızca aynı varlıkla ödemede kullanılır.",
|
||||||
|
noAmountWithAsset: "Varlıktan ödemede TL tutar girilmez.",
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
debt: "Birikimdeki varlıktan düş",
|
||||||
|
receivable: "Birikimdeki varlığa ekle",
|
||||||
|
debtHint: "Ödenen miktar seçilen varlıktan ortalama maliyetle çıkar.",
|
||||||
|
receivableHint:
|
||||||
|
"Tahsil edilen miktar seçilen varlığa eklenir; maliyet borç tarihindeki birim fiyattır.",
|
||||||
|
asset: "Varlık",
|
||||||
|
none: "Bu türde varlık yok; önce Birikim sayfasından ekleyin.",
|
||||||
|
movementNote: (name: string) => `Aynî kayıt: ${name}`,
|
||||||
|
assetMismatch: "Seçilen varlık bu kaydın varlık türünde değil.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
assets: {
|
assets: {
|
||||||
@@ -713,6 +726,43 @@ export const tr = {
|
|||||||
},
|
},
|
||||||
history: "Hareketler",
|
history: "Hareketler",
|
||||||
noMovements: "Henüz hareket yok.",
|
noMovements: "Henüz hareket yok.",
|
||||||
|
report: {
|
||||||
|
title: "Dönemsel rapor",
|
||||||
|
ranges: { "3": "3 ay", "6": "6 ay", "12": "12 ay", tum: "Tümü" },
|
||||||
|
rangeLabel: "Dönem",
|
||||||
|
period: (from: string, to: string) => `${from} – ${to}`,
|
||||||
|
startValue: "Dönem başı değer",
|
||||||
|
invested: "Yatırılan",
|
||||||
|
endValue: "Dönem sonu değer",
|
||||||
|
profit: "Dönem kâr/zararı",
|
||||||
|
proceeds: "Satış geliri",
|
||||||
|
giftIn: "Gelen hediye",
|
||||||
|
giftOut: "Çıkan hediye",
|
||||||
|
formula:
|
||||||
|
"Kâr/zarar = dönem sonu değer + satış + çıkan hediye − dönem başı değer − yatırılan − gelen hediye.",
|
||||||
|
value: "Güncel değer",
|
||||||
|
cost: "Maliyet",
|
||||||
|
chartLabel: "Aylık değer ve maliyet",
|
||||||
|
table: "Tablo olarak göster",
|
||||||
|
date: "Tarih",
|
||||||
|
noPrice: "Fiyat yok",
|
||||||
|
excluded: (n: number) => `${n} varlık fiyatı olmadığı için rapora katılmadı.`,
|
||||||
|
empty: "Bu dönemde gösterilecek varlık yok.",
|
||||||
|
},
|
||||||
|
netWorth: {
|
||||||
|
title: "Net varlık",
|
||||||
|
description: "Hesaplar + birikim + alacaklar − borçlar (TL ve aynî).",
|
||||||
|
accounts: "Hesap bakiyeleri",
|
||||||
|
cardDebt: "Kart ve KMH borçları",
|
||||||
|
assets: "Birikim (altın, döviz)",
|
||||||
|
contactReceivable: "Cari alacaklar",
|
||||||
|
contactPayable: "Cari borçlar",
|
||||||
|
inKindReceivable: "Aynî alacaklar",
|
||||||
|
inKindPayable: "Aynî borçlar",
|
||||||
|
total: "Net varlık",
|
||||||
|
missingPrice: (n: number) => `${n} kalemin fiyatı yok; toplama katılmadı.`,
|
||||||
|
includesHidden: "Hane ayarı gereği diğer üyelerin gizli hesap bakiyeleri dahil.",
|
||||||
|
},
|
||||||
table: {
|
table: {
|
||||||
date: "Tarih",
|
date: "Tarih",
|
||||||
kind: "Hareket",
|
kind: "Hareket",
|
||||||
@@ -754,6 +804,131 @@ export const tr = {
|
|||||||
noAccountForGift: "Hediye hareketinde hesap seçilmez.",
|
noAccountForGift: "Hediye hareketinde hesap seçilmez.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
budget: {
|
||||||
|
title: "Bütçe",
|
||||||
|
description:
|
||||||
|
"Aylık kategori limitleri, gerçekleşen harcama ve ay sonu tahmini. Üst kategori limiti alt kategorileri kapsar.",
|
||||||
|
prevMonth: "Önceki ay",
|
||||||
|
nextMonth: "Sonraki ay",
|
||||||
|
thisMonth: "Bu ay",
|
||||||
|
period: (from: string, to: string) => `${from} – ${to}`,
|
||||||
|
totals: {
|
||||||
|
limit: "Bütçe",
|
||||||
|
actual: "Harcanan",
|
||||||
|
remaining: "Kalan",
|
||||||
|
over: "Aşım",
|
||||||
|
forecast: "Ay sonu tahmini",
|
||||||
|
planned: (amount: string) => `Planlı: ${amount}`,
|
||||||
|
unbudgeted: "Bütçesiz harcama",
|
||||||
|
monthProgress: (day: number, total: number, pct: string) =>
|
||||||
|
`Ayın ${day}/${total}. günü (%${pct}).`,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
ok: "Yolunda",
|
||||||
|
warning: "%80 geçildi",
|
||||||
|
over: "Limit aşıldı",
|
||||||
|
full: "Limit doldu",
|
||||||
|
none: "Limitsiz",
|
||||||
|
covered: "Üst kategori limitinde",
|
||||||
|
forecastOver: "Tahmin limiti aşıyor",
|
||||||
|
},
|
||||||
|
line: {
|
||||||
|
ofLimit: (actual: string, limit: string) => `${actual} / ${limit}`,
|
||||||
|
remaining: (amount: string) => `${amount} kaldı`,
|
||||||
|
over: (amount: string) => `${amount} aşıldı`,
|
||||||
|
forecast: (amount: string) => `Tahmin ${amount}`,
|
||||||
|
planned: (amount: string) => `Planlı ${amount}`,
|
||||||
|
},
|
||||||
|
uncategorized: "Kategorisiz",
|
||||||
|
empty: "Bu ay için bütçe yok.",
|
||||||
|
emptyHint:
|
||||||
|
"Limitleri elle girebilir, önceki ayın bütçesini kopyalayabilir veya önceki ayın harcamalarından oluşturabilirsiniz.",
|
||||||
|
noSpending: "Bu ay henüz gider yok.",
|
||||||
|
edit: "Limitleri düzenle",
|
||||||
|
editTitle: (month: string) => `Bütçe limitleri · ${month}`,
|
||||||
|
editHint:
|
||||||
|
"Boş bırakılan kategori limitsizdir. Alt kategori limiti üst kategorinin içinde bir alt sınırdır.",
|
||||||
|
editTotal: (amount: string) => `Toplam bütçe: ${amount}`,
|
||||||
|
copyPrevious: "Önceki aydan kopyala",
|
||||||
|
fromActuals: "Önceki ayın harcamalarından oluştur",
|
||||||
|
fromActualsHint:
|
||||||
|
"Ana kategorilerin önceki ay harcaması 100 TL'ye yukarı yuvarlanarak limit olur.",
|
||||||
|
replaceTitle: "Mevcut limitler değiştirilsin mi?",
|
||||||
|
replaceDescription: "Bu ayın tüm limitleri seçilen kaynakla değiştirilir.",
|
||||||
|
replace: "Değiştir",
|
||||||
|
includesHidden:
|
||||||
|
"Hane ayarı gereği diğer üyelerin gizli hesaplarından yapılan harcamalar dahil.",
|
||||||
|
warnings: (n: number) => `${n} kategoride bütçenin %80'i geçildi veya limit aşıldı.`,
|
||||||
|
goToBudget: "Bütçeye git",
|
||||||
|
income: {
|
||||||
|
title: "Gelir planı",
|
||||||
|
description:
|
||||||
|
"Gerçekleşen gelirler, planlı (düzenli) gelirler ve elle eklenen beklenen gelirler.",
|
||||||
|
actual: "Gerçekleşen",
|
||||||
|
pending: "Bekleyen planlı",
|
||||||
|
manual: "Elle eklenen",
|
||||||
|
expected: "Beklenen toplam",
|
||||||
|
plannedLeft: "Plana göre elde kalacak",
|
||||||
|
projectedLeft: "Bu tempoda elde kalacak",
|
||||||
|
plannedLeftHint: "Beklenen gelir − bütçe",
|
||||||
|
projectedLeftHint: "Beklenen gelir − ay sonu tahmini gider",
|
||||||
|
received: "Alındı",
|
||||||
|
planned: "Planlı",
|
||||||
|
empty: "Bu ay için planlı gelir yok.",
|
||||||
|
edit: "Beklenen gelir ekle",
|
||||||
|
editTitle: (month: string) => `Elle eklenen gelirler · ${month}`,
|
||||||
|
editHint:
|
||||||
|
"Planlı işlemi olmayan beklenen gelirler (ör. prim). Gelir gerçekleşip işlem olarak girildiğinde kalemi buradan kaldırın; aksi halde iki kez sayılır.",
|
||||||
|
itemName: "Açıklama",
|
||||||
|
itemAmount: "Tutar",
|
||||||
|
addItem: "Kalem ekle",
|
||||||
|
removeItem: "Kalemi kaldır",
|
||||||
|
saved: "Gelir planı kaydedildi.",
|
||||||
|
},
|
||||||
|
daily: {
|
||||||
|
title: "Günlük plan",
|
||||||
|
headline: (monthPct: number, spentPct: number) =>
|
||||||
|
`Bugün ayın %${monthPct}'si geçti, bütçenin %${spentPct}'i harcandı.`,
|
||||||
|
today: "Bugün için",
|
||||||
|
todayHint: "Bugünün planı + önceki günlerden kalan (veya aşan) tutar",
|
||||||
|
plannedToDate: "Bugüne kadar planlanan",
|
||||||
|
actualToDate: "Bugüne kadar harcanan",
|
||||||
|
projectedLeft: "Bu tempoda ay sonunda elde kalacak",
|
||||||
|
chartLabel: "Kümülatif planlanan ve gerçekleşen gider",
|
||||||
|
cumPlanned: "Planlanan (kümülatif)",
|
||||||
|
cumActual: "Gerçekleşen (kümülatif)",
|
||||||
|
income: "Beklenen gelir",
|
||||||
|
modes: {
|
||||||
|
even: "Değişken bütçe günlere eşit dağıtıldı.",
|
||||||
|
previous_month_pattern: "Değişken bütçe önceki ayın günlük örüntüsüne göre dağıtıldı.",
|
||||||
|
},
|
||||||
|
fixedNote: (amount: string) => `Tarihe bağlı giderler kendi günlerinde: ${amount}.`,
|
||||||
|
table: "Günleri tablo olarak göster",
|
||||||
|
day: "Gün",
|
||||||
|
planned: "Plan",
|
||||||
|
actual: "Gerçekleşen",
|
||||||
|
noBudget: "Günlük plan için bu ayın bütçesini oluşturun.",
|
||||||
|
next: (month: string) => `Sonraki ay (${month})`,
|
||||||
|
nextIncome: "Beklenen gelir",
|
||||||
|
nextBudget: "Bütçe",
|
||||||
|
nextDiff: "Fark",
|
||||||
|
nextNoBudget: "Bütçe yok",
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
saved: "Bütçe kaydedildi.",
|
||||||
|
copied: "Önceki ayın limitleri kopyalandı.",
|
||||||
|
created: "Limitler önceki ayın harcamalarından oluşturuldu.",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
noPrevious: "Önceki ay için bütçe yok.",
|
||||||
|
noPreviousSpending: "Önceki ayda gider yok.",
|
||||||
|
category: "Geçersiz veya arşivlenmiş kategori.",
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
month: "Geçersiz ay",
|
||||||
|
category: "Kategori seçin",
|
||||||
|
},
|
||||||
|
},
|
||||||
prices: {
|
prices: {
|
||||||
title: "Fiyat tablosu",
|
title: "Fiyat tablosu",
|
||||||
description:
|
description:
|
||||||
@@ -972,7 +1147,7 @@ export const tr = {
|
|||||||
dashboard: {
|
dashboard: {
|
||||||
title: "Ana sayfa",
|
title: "Ana sayfa",
|
||||||
welcome: (name: string) => `Hoş geldin, ${name}`,
|
welcome: (name: string) => `Hoş geldin, ${name}`,
|
||||||
welcomeDescription: "Bu ayın özetini burada göreceksin. Veriler sonraki fazlarda bağlanacak.",
|
welcomeDescription: "Bu ayın özeti, günlük plan ve uyarılar.",
|
||||||
cards: {
|
cards: {
|
||||||
monthIncome: "Bu ay gelir",
|
monthIncome: "Bu ay gelir",
|
||||||
monthExpense: "Bu ay gider",
|
monthExpense: "Bu ay gider",
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aylık bütçe (Bölüm 6.9): hane × bütçe ayı ("YYYY-MM") başına kategori limitleri.
|
||||||
|
* Ayın sınırları hane ayarı `budgetMonthStartDay`'e göre hesaplanır (belgede saklanmaz).
|
||||||
|
*/
|
||||||
|
const limitSchema = new Schema(
|
||||||
|
{
|
||||||
|
categoryId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
amountMinor: { type: Number, required: true, min: 0 },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Gelir planına elle eklenen beklenen gelir (planlı işlemi olmayan, ör. prim). */
|
||||||
|
const incomeItemSchema = new Schema(
|
||||||
|
{
|
||||||
|
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||||
|
amountMinor: { type: Number, required: true, min: 1 },
|
||||||
|
},
|
||||||
|
{ _id: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const budgetSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
month: { type: String, required: true, match: /^\d{4}-(0[1-9]|1[0-2])$/ },
|
||||||
|
limits: { type: [limitSchema], default: [] },
|
||||||
|
incomeItems: { type: [incomeItemSchema], default: [] },
|
||||||
|
seedRef: { type: String, default: null },
|
||||||
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true, collection: "budgets" },
|
||||||
|
);
|
||||||
|
|
||||||
|
budgetSchema.index({ householdId: 1, month: 1 }, { unique: true });
|
||||||
|
|
||||||
|
export type Budget = InferSchemaType<typeof budgetSchema>;
|
||||||
|
|
||||||
|
export const BudgetModel: Model<Budget> =
|
||||||
|
(models.Budget as Model<Budget> | undefined) ?? model<Budget>("Budget", budgetSchema);
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
budgetPeriod,
|
||||||
|
computeBudget,
|
||||||
|
daysBetween,
|
||||||
|
forecast,
|
||||||
|
periodOfDate,
|
||||||
|
roundUpLimit,
|
||||||
|
shiftMonth,
|
||||||
|
statusOf,
|
||||||
|
type CategoryAmounts,
|
||||||
|
} from "./budget";
|
||||||
|
|
||||||
|
describe("bütçe dönemi", () => {
|
||||||
|
it("takvim ayı ve 15'inde başlayan ay", () => {
|
||||||
|
expect(budgetPeriod("2026-11")).toEqual({ from: "2026-11-01", to: "2026-11-30" });
|
||||||
|
expect(budgetPeriod("2026-12", 15)).toEqual({ from: "2026-12-15", to: "2027-01-14" });
|
||||||
|
expect(budgetPeriod("2027-01", 28)).toEqual({ from: "2027-01-28", to: "2027-02-27" });
|
||||||
|
expect(periodOfDate("2026-11-14", 15)).toBe("2026-10");
|
||||||
|
expect(periodOfDate("2026-11-15", 15)).toBe("2026-11");
|
||||||
|
expect(periodOfDate("2027-01-03", 15)).toBe("2026-12");
|
||||||
|
expect(periodOfDate("2026-11-30")).toBe("2026-11");
|
||||||
|
expect(shiftMonth("2026-12", 1)).toBe("2027-01");
|
||||||
|
expect(shiftMonth("2026-01", -1)).toBe("2025-12");
|
||||||
|
expect(daysBetween("2026-11-01", "2026-11-30")).toBe(30);
|
||||||
|
expect(daysBetween("2026-12-15", "2027-01-14")).toBe(31);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("durum eşikleri %80 ve %100", () => {
|
||||||
|
expect(statusOf(79_99, 100_00)).toBe("ok");
|
||||||
|
expect(statusOf(80_00, 100_00)).toBe("warning");
|
||||||
|
expect(statusOf(100_00, 100_00)).toBe("over");
|
||||||
|
expect(statusOf(5, null)).toBe("none");
|
||||||
|
expect(statusOf(1, 0)).toBe("over");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ay sonu tahmini: sabit + planlı + değişken tempo", () => {
|
||||||
|
const a: CategoryAmounts = {
|
||||||
|
fixedMinor: 1_000_00,
|
||||||
|
variableMinor: 3_000_00,
|
||||||
|
plannedMinor: 500_00,
|
||||||
|
};
|
||||||
|
// Geçmiş yok: 10/30 gün geçti, değişken 3.000 → 9.000
|
||||||
|
expect(forecast(a, 10, 30)).toBe(10_500_00);
|
||||||
|
expect(forecast(a, 30, 30)).toBe(4_500_00);
|
||||||
|
// Henüz başlamamış dönem: gerçekleşmiş değişken olduğu gibi, tempo yok.
|
||||||
|
expect(forecast(a, 0, 30)).toBe(4_500_00);
|
||||||
|
expect(roundUpLimit(1_234_56)).toBe(1_300_00);
|
||||||
|
expect(roundUpLimit(1_200_00)).toBe(1_200_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("taban hız: önceki ayın temposu ay başında ağır basar", () => {
|
||||||
|
// Ayın 2'sinde 6.000'lik tek harcama; önceki ay 30 günde 9.000 (günde 300).
|
||||||
|
const a: CategoryAmounts = {
|
||||||
|
fixedMinor: 0,
|
||||||
|
variableMinor: 6_000_00,
|
||||||
|
plannedMinor: 0,
|
||||||
|
baselineMinor: 9_000_00,
|
||||||
|
};
|
||||||
|
// Yalnızca bu ay: 6.000 × 30/2 = 90.000.
|
||||||
|
expect(forecast(a, 2, 30)).toBe(90_000_00);
|
||||||
|
// Karışım: hız = 2/30 × 3.000 + 28/30 × 300 = 480 → 6.000 + 28 × 480
|
||||||
|
expect(forecast(a, 2, 30, 30)).toBe(6_000_00 + 28 * 480_00);
|
||||||
|
// Gelecek ay: yalnızca taban; geçmiş ay: gerçekleşen.
|
||||||
|
expect(forecast({ ...a, variableMinor: 0 }, 0, 30, 30)).toBe(9_000_00);
|
||||||
|
expect(forecast(a, 30, 30, 30)).toBe(6_000_00);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bütçe toplama", () => {
|
||||||
|
const categories = [
|
||||||
|
{ id: "market", parentId: null, name: "Market", order: 1 },
|
||||||
|
{ id: "faturalar", parentId: null, name: "Faturalar", order: 2 },
|
||||||
|
{ id: "elektrik", parentId: "faturalar", name: "Elektrik", order: 1 },
|
||||||
|
{ id: "su", parentId: "faturalar", name: "Su", order: 2 },
|
||||||
|
{ id: "eglence", parentId: null, name: "Eğlence", order: 3 },
|
||||||
|
{ id: "sinema", parentId: "eglence", name: "Sinema", order: 1 },
|
||||||
|
{ id: "giyim", parentId: null, name: "Giyim", order: 4 },
|
||||||
|
];
|
||||||
|
const amt = (variable: number, fixed = 0, planned = 0): CategoryAmounts => ({
|
||||||
|
fixedMinor: fixed,
|
||||||
|
variableMinor: variable,
|
||||||
|
plannedMinor: planned,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("üst limit alt kategorileri kapsar; alt limit iki kez sayılmaz; bütçesiz harcama", () => {
|
||||||
|
const r = computeBudget({
|
||||||
|
categories,
|
||||||
|
limits: new Map([
|
||||||
|
["market", 10_000_00],
|
||||||
|
["faturalar", 3_000_00],
|
||||||
|
["elektrik", 1_500_00],
|
||||||
|
["sinema", 500_00],
|
||||||
|
]),
|
||||||
|
amounts: new Map([
|
||||||
|
["market", amt(8_500_00)],
|
||||||
|
["elektrik", amt(0, 1_600_00)],
|
||||||
|
["su", amt(0, 400_00, 300_00)],
|
||||||
|
["faturalar", amt(100_00)],
|
||||||
|
["sinema", amt(200_00)],
|
||||||
|
["eglence", amt(300_00)],
|
||||||
|
["giyim", amt(1_000_00)],
|
||||||
|
]),
|
||||||
|
elapsedDays: 30,
|
||||||
|
totalDays: 30,
|
||||||
|
});
|
||||||
|
// Toplam limit: market + faturalar + sinema (elektrik faturaların içinde).
|
||||||
|
expect(r.totals.limitMinor).toBe(13_500_00);
|
||||||
|
expect(r.totals.actualMinor).toBe(12_100_00);
|
||||||
|
// Bütçesiz: eğlencenin kendi harcaması + giyim.
|
||||||
|
expect(r.totals.unbudgetedMinor).toBe(1_300_00);
|
||||||
|
const line = (id: string) => r.lines.find((l) => l.categoryId === id)!;
|
||||||
|
expect(line("faturalar")).toMatchObject({
|
||||||
|
actualMinor: 2_100_00,
|
||||||
|
plannedMinor: 300_00,
|
||||||
|
status: "ok",
|
||||||
|
ratioBp: 7000,
|
||||||
|
});
|
||||||
|
expect(line("elektrik")).toMatchObject({ status: "over", remainingMinor: -100_00 });
|
||||||
|
expect(line("market").status).toBe("warning");
|
||||||
|
expect(line("eglence")).toMatchObject({
|
||||||
|
limitMinor: null,
|
||||||
|
actualMinor: 500_00,
|
||||||
|
status: "none",
|
||||||
|
});
|
||||||
|
// Sıra: ağaç sırası, derinlik.
|
||||||
|
expect(r.lines.map((l) => `${l.depth}:${l.categoryId}`)).toEqual([
|
||||||
|
"0:market",
|
||||||
|
"0:faturalar",
|
||||||
|
"1:elektrik",
|
||||||
|
"1:su",
|
||||||
|
"0:eglence",
|
||||||
|
"1:sinema",
|
||||||
|
"0:giyim",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tahmin limiti aşıyor uyarısı; limit yoksa durum none", () => {
|
||||||
|
const r = computeBudget({
|
||||||
|
categories,
|
||||||
|
limits: new Map([["market", 10_000_00]]),
|
||||||
|
amounts: new Map([["market", amt(4_000_00)]]),
|
||||||
|
elapsedDays: 10,
|
||||||
|
totalDays: 30,
|
||||||
|
});
|
||||||
|
expect(r.totals).toMatchObject({ status: "ok", forecastMinor: 12_000_00, forecastOver: true });
|
||||||
|
const empty = computeBudget({
|
||||||
|
categories,
|
||||||
|
limits: new Map(),
|
||||||
|
amounts: new Map(),
|
||||||
|
elapsedDays: 1,
|
||||||
|
totalDays: 30,
|
||||||
|
});
|
||||||
|
expect(empty.totals.status).toBe("none");
|
||||||
|
expect(empty.lines).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { addDays, addMonths } from "./recurrence";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aylık bütçe hesap motoru (Bölüm 6.9). Tutarlar kuruş, tarihler İstanbul günü "YYYY-MM-DD".
|
||||||
|
*
|
||||||
|
* Bütçe ayı hane ayarı `budgetMonthStartDay` ile başlar (Faz 0): başlangıç günü 15 ise
|
||||||
|
* "2026-11" dönemi 15 Kasım – 14 Aralık'tır; 1 ise takvim ayıdır.
|
||||||
|
*
|
||||||
|
* Limitler (Faz 10 kararı): her gider kategorisine verilebilir. Üst kategori limiti alt
|
||||||
|
* kategorilerin harcamasını da kapsar; alt kategori limiti üstün içinde bir alt sınırdır ve
|
||||||
|
* toplam bütçede iki kez sayılmaz.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const WARNING_BP = 8_000;
|
||||||
|
export const OVER_BP = 10_000;
|
||||||
|
|
||||||
|
/** "YYYY-MM" dönemi: ilk ve son gün (ikisi de dahil). */
|
||||||
|
export function budgetPeriod(month: string, startDay = 1): { from: string; to: string } {
|
||||||
|
const from = `${month}-${String(startDay).padStart(2, "0")}`;
|
||||||
|
const to = addDays(addMonths(from, 1, startDay), -1);
|
||||||
|
return { from, to };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tarihin düştüğü bütçe ayı. */
|
||||||
|
export function periodOfDate(date: string, startDay = 1): string {
|
||||||
|
const day = Number(date.slice(8, 10));
|
||||||
|
const month = date.slice(0, 7);
|
||||||
|
return day >= startDay ? month : addMonths(`${month}-01`, -1).slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shiftMonth(month: string, n: number): string {
|
||||||
|
return addMonths(`${month}-01`, n).slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** İki gün arasındaki gün sayısı (ikisi de dahil). */
|
||||||
|
export function daysBetween(from: string, to: string): number {
|
||||||
|
const ms = Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`);
|
||||||
|
return Math.round(ms / 86_400_000) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BudgetStatus = "none" | "ok" | "warning" | "over";
|
||||||
|
|
||||||
|
export function statusOf(actualMinor: number, limitMinor: number | null): BudgetStatus {
|
||||||
|
if (limitMinor === null) return "none";
|
||||||
|
if (limitMinor <= 0) return actualMinor > 0 ? "over" : "ok";
|
||||||
|
const bp = (actualMinor * 10_000) / limitMinor;
|
||||||
|
return bp >= OVER_BP ? "over" : bp >= WARNING_BP ? "warning" : "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BudgetCategoryInput = {
|
||||||
|
id: string;
|
||||||
|
parentId: string | null;
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Kategori bazında kendi (alt kategoriler hariç) tutarlar. */
|
||||||
|
export type CategoryAmounts = {
|
||||||
|
/** Düzenli işlem veya taksitten gelen gerçekleşmiş gider (tempo tahminine katılmaz). */
|
||||||
|
fixedMinor: number;
|
||||||
|
/** Diğer gerçekleşmiş gider. */
|
||||||
|
variableMinor: number;
|
||||||
|
/** Dönemin kalanındaki planlı (henüz gerçekleşmemiş) gider. */
|
||||||
|
plannedMinor: number;
|
||||||
|
/** Önceki dönemin değişken gideri (tahmin için taban hız; yoksa 0). */
|
||||||
|
baselineMinor?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BudgetLine = {
|
||||||
|
categoryId: string;
|
||||||
|
name: string;
|
||||||
|
depth: number;
|
||||||
|
limitMinor: number | null;
|
||||||
|
/** Kendi limiti yok ama bir üst kategorinin limiti kapsıyor. */
|
||||||
|
covered: boolean;
|
||||||
|
/** Alt kategoriler dahil gerçekleşen. */
|
||||||
|
actualMinor: number;
|
||||||
|
plannedMinor: number;
|
||||||
|
forecastMinor: number;
|
||||||
|
remainingMinor: number | null;
|
||||||
|
ratioBp: number | null;
|
||||||
|
status: BudgetStatus;
|
||||||
|
/** Tahmin limiti aşıyor (henüz aşılmamış olsa da). */
|
||||||
|
forecastOver: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BudgetTotals = {
|
||||||
|
limitMinor: number;
|
||||||
|
actualMinor: number;
|
||||||
|
plannedMinor: number;
|
||||||
|
forecastMinor: number;
|
||||||
|
remainingMinor: number;
|
||||||
|
/** Hiçbir limitin kapsamadığı gerçekleşen gider. */
|
||||||
|
unbudgetedMinor: number;
|
||||||
|
ratioBp: number | null;
|
||||||
|
status: BudgetStatus;
|
||||||
|
forecastOver: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ay sonu tahmini: sabit giderler olduğu gibi + kalan planlı giderler + değişken gider ve kalan
|
||||||
|
* günler için günlük hız tahmini.
|
||||||
|
*
|
||||||
|
* Günlük hız, bu ayın hızı ile önceki dönemin değişken harcama hızının (taban) karışımıdır; ay
|
||||||
|
* ilerledikçe bu ayın ağırlığı artar (geçen gün / toplam gün). Böylece ay başındaki tek bir büyük
|
||||||
|
* harcama tüm aya yayılmaz. Taban yoksa (`baselineDays` verilmemiş: hanenin geçmişi yok) yalnızca
|
||||||
|
* bu ayın hızı kullanılır. Geçmiş ayda tahmin = gerçekleşen.
|
||||||
|
*/
|
||||||
|
export function forecast(
|
||||||
|
a: CategoryAmounts,
|
||||||
|
elapsedDays: number,
|
||||||
|
totalDays: number,
|
||||||
|
baselineDays?: number,
|
||||||
|
): number {
|
||||||
|
const remaining = Math.max(0, totalDays - Math.max(0, elapsedDays));
|
||||||
|
const current = elapsedDays > 0 ? a.variableMinor / elapsedDays : 0;
|
||||||
|
let pace = current;
|
||||||
|
if (baselineDays && baselineDays > 0) {
|
||||||
|
const w = Math.min(1, Math.max(0, elapsedDays) / totalDays);
|
||||||
|
pace = w * current + (1 - w) * ((a.baselineMinor ?? 0) / baselineDays);
|
||||||
|
}
|
||||||
|
return a.fixedMinor + a.plannedMinor + a.variableMinor + Math.round(pace * remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeBudget(input: {
|
||||||
|
categories: BudgetCategoryInput[];
|
||||||
|
limits: Map<string, number>;
|
||||||
|
amounts: Map<string, CategoryAmounts>;
|
||||||
|
elapsedDays: number;
|
||||||
|
totalDays: number;
|
||||||
|
/** Önceki dönemin gün sayısı (taban hız için); hanenin geçmişi yoksa verilmez. */
|
||||||
|
baselineDays?: number;
|
||||||
|
}): { lines: BudgetLine[]; totals: BudgetTotals } {
|
||||||
|
const { categories, limits, amounts, elapsedDays, totalDays, baselineDays } = input;
|
||||||
|
const byId = new Map(categories.map((c) => [c.id, c]));
|
||||||
|
const children = new Map<string, BudgetCategoryInput[]>();
|
||||||
|
const roots: BudgetCategoryInput[] = [];
|
||||||
|
for (const c of categories) {
|
||||||
|
if (c.parentId && byId.has(c.parentId)) {
|
||||||
|
const list = children.get(c.parentId) ?? [];
|
||||||
|
list.push(c);
|
||||||
|
children.set(c.parentId, list);
|
||||||
|
} else roots.push(c);
|
||||||
|
}
|
||||||
|
const sort = (list: BudgetCategoryInput[]) =>
|
||||||
|
list.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr"));
|
||||||
|
const zero: CategoryAmounts = {
|
||||||
|
fixedMinor: 0,
|
||||||
|
variableMinor: 0,
|
||||||
|
plannedMinor: 0,
|
||||||
|
baselineMinor: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alt ağaç toplamları.
|
||||||
|
const subtree = new Map<string, CategoryAmounts>();
|
||||||
|
const sumOf = (c: BudgetCategoryInput, seen: Set<string>): CategoryAmounts => {
|
||||||
|
if (seen.has(c.id)) return zero;
|
||||||
|
seen.add(c.id);
|
||||||
|
const own = amounts.get(c.id) ?? zero;
|
||||||
|
const total = { ...own, baselineMinor: own.baselineMinor ?? 0 };
|
||||||
|
for (const k of children.get(c.id) ?? []) {
|
||||||
|
const s = sumOf(k, seen);
|
||||||
|
total.fixedMinor += s.fixedMinor;
|
||||||
|
total.variableMinor += s.variableMinor;
|
||||||
|
total.plannedMinor += s.plannedMinor;
|
||||||
|
total.baselineMinor += s.baselineMinor ?? 0;
|
||||||
|
}
|
||||||
|
subtree.set(c.id, total);
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const r of roots) sumOf(r, seen);
|
||||||
|
|
||||||
|
const lines: BudgetLine[] = [];
|
||||||
|
let limitTotal = 0;
|
||||||
|
let unbudgeted = 0;
|
||||||
|
const walk = (list: BudgetCategoryInput[], depth: number, covered: boolean) => {
|
||||||
|
for (const c of sort(list)) {
|
||||||
|
const limit = limits.get(c.id) ?? null;
|
||||||
|
const s = subtree.get(c.id) ?? zero;
|
||||||
|
const own = amounts.get(c.id) ?? zero;
|
||||||
|
if (limit !== null && !covered) limitTotal += limit;
|
||||||
|
const isCovered = covered || limit !== null;
|
||||||
|
if (!isCovered) unbudgeted += own.fixedMinor + own.variableMinor;
|
||||||
|
const actual = s.fixedMinor + s.variableMinor;
|
||||||
|
const fc = forecast(s, elapsedDays, totalDays, baselineDays);
|
||||||
|
if (limit !== null || actual > 0 || s.plannedMinor > 0) {
|
||||||
|
lines.push({
|
||||||
|
categoryId: c.id,
|
||||||
|
name: c.name,
|
||||||
|
depth,
|
||||||
|
limitMinor: limit,
|
||||||
|
covered: limit === null && covered,
|
||||||
|
actualMinor: actual,
|
||||||
|
plannedMinor: s.plannedMinor,
|
||||||
|
forecastMinor: fc,
|
||||||
|
remainingMinor: limit === null ? null : limit - actual,
|
||||||
|
ratioBp: limit && limit > 0 ? Math.round((actual * 10_000) / limit) : null,
|
||||||
|
status: statusOf(actual, limit),
|
||||||
|
forecastOver: limit !== null && fc > limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
walk(children.get(c.id) ?? [], depth + 1, isCovered);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(roots, 0, false);
|
||||||
|
|
||||||
|
let actual = 0;
|
||||||
|
let planned = 0;
|
||||||
|
let fc = 0;
|
||||||
|
for (const r of roots) {
|
||||||
|
const s = subtree.get(r.id) ?? zero;
|
||||||
|
actual += s.fixedMinor + s.variableMinor;
|
||||||
|
planned += s.plannedMinor;
|
||||||
|
fc += forecast(s, elapsedDays, totalDays, baselineDays);
|
||||||
|
}
|
||||||
|
const hasLimit = limits.size > 0;
|
||||||
|
return {
|
||||||
|
lines,
|
||||||
|
totals: {
|
||||||
|
limitMinor: limitTotal,
|
||||||
|
actualMinor: actual,
|
||||||
|
plannedMinor: planned,
|
||||||
|
forecastMinor: fc,
|
||||||
|
remainingMinor: limitTotal - actual,
|
||||||
|
unbudgetedMinor: unbudgeted,
|
||||||
|
ratioBp: limitTotal > 0 ? Math.round((actual * 10_000) / limitTotal) : null,
|
||||||
|
status: hasLimit ? statusOf(actual, limitTotal) : "none",
|
||||||
|
forecastOver: hasLimit && fc > limitTotal,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Limit önerisi için yukarı yuvarlama (100 TL'lik adım). */
|
||||||
|
export function roundUpLimit(minor: number, stepMinor = 100_00): number {
|
||||||
|
return Math.ceil(minor / stepMinor) * stepMinor;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { dailyPlan, distribute, patternWeights } from "./daily-plan";
|
||||||
|
|
||||||
|
describe("dağıtım", () => {
|
||||||
|
it("toplam korunur, kalan kuruşlar en büyük kesirlere", () => {
|
||||||
|
expect(distribute(100, [1, 1, 1])).toEqual([34, 33, 33]);
|
||||||
|
expect(distribute(10, [1, 0, 3])).toEqual([3, 0, 7]);
|
||||||
|
expect(distribute(5, [0, 0])).toEqual([3, 2]);
|
||||||
|
expect(distribute(0, [1, 2])).toEqual([0, 0]);
|
||||||
|
expect(distribute(7, [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("önceki ayın örüntüsü gün sayısına uyarlanır", () => {
|
||||||
|
expect(patternWeights([5, 0, 2], 4)).toEqual([5, 0, 2, 2]);
|
||||||
|
expect(patternWeights([5, 0, 2, 1], 2)).toEqual([5, 0]);
|
||||||
|
expect(patternWeights([], 2)).toEqual([1, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("günlük plan", () => {
|
||||||
|
// Aralık: 31 gün. Bütçe 31.000 + kira 10.000 (1'inde) + fatura 3.100 (15'inde).
|
||||||
|
const base = {
|
||||||
|
from: "2026-12-01",
|
||||||
|
to: "2026-12-31",
|
||||||
|
budgetMinor: 44_100_00,
|
||||||
|
fixedByDay: new Map([
|
||||||
|
["2026-12-01", 10_000_00],
|
||||||
|
["2026-12-15", 3_100_00],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("sabit giderler kendi gününe, değişken bütçe eşit; kümülatif ve yüzde", () => {
|
||||||
|
const p = dailyPlan({
|
||||||
|
...base,
|
||||||
|
today: "2026-12-13",
|
||||||
|
actualByDay: new Map([
|
||||||
|
["2026-12-01", 10_500_00],
|
||||||
|
["2026-12-05", 9_000_00],
|
||||||
|
["2026-12-13", 2_000_00],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
expect(p.variableMinor).toBe(31_000_00);
|
||||||
|
expect(p.days[0]!.plannedMinor).toBe(11_000_00);
|
||||||
|
expect(p.days[1]!.plannedMinor).toBe(1_000_00);
|
||||||
|
expect(p.days[14]!.plannedMinor).toBe(4_100_00);
|
||||||
|
expect(p.days.at(-1)!.cumPlannedMinor).toBe(44_100_00);
|
||||||
|
expect(p.todayIndex).toBe(12);
|
||||||
|
// 13/31 gün → %42; 21.500 / 44.100 → %49
|
||||||
|
expect(p.monthPct).toBe(42);
|
||||||
|
expect(p.spentPct).toBe(49);
|
||||||
|
expect(p.plannedToDateMinor).toBe(23_000_00);
|
||||||
|
expect(p.actualToDateMinor).toBe(21_500_00);
|
||||||
|
// Bugün: 1.000 + (12 günlük plan 22.000 − harcanan 19.500 = 2.500)
|
||||||
|
expect(p.todayAllowanceMinor).toBe(3_500_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sabit giderler bütçeyi aşarsa değişken 0; dönem dışı bugün; ağırlıklı dağıtım", () => {
|
||||||
|
const over = dailyPlan({
|
||||||
|
...base,
|
||||||
|
budgetMinor: 5_000_00,
|
||||||
|
today: "2027-01-05",
|
||||||
|
actualByDay: new Map(),
|
||||||
|
});
|
||||||
|
expect(over.variableMinor).toBe(0);
|
||||||
|
expect(over.todayIndex).toBe(-1);
|
||||||
|
expect(over.monthPct).toBe(100);
|
||||||
|
const weights = Array.from({ length: 31 }, (_, i) => (i % 7 === 5 ? 3 : 1));
|
||||||
|
const w = dailyPlan({ ...base, today: "2026-11-20", actualByDay: new Map(), weights });
|
||||||
|
expect(w.monthPct).toBe(0);
|
||||||
|
expect(w.days[5]!.plannedMinor).toBeGreaterThan(w.days[6]!.plannedMinor);
|
||||||
|
expect(w.days.at(-1)!.cumPlannedMinor).toBe(44_100_00);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { daysBetween } from "./budget";
|
||||||
|
import { addDays } from "./recurrence";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Günlük plan (Bölüm 6.9). Ayın bütçesi günlere yayılır: belirli tarihe bağlı giderler (düzenli
|
||||||
|
* işlemler, taksitler) kendi günlerine; kalan değişken bütçe günlere eşit (varsayılan) veya önceki
|
||||||
|
* ayın günlük harcama örüntüsüne göre (hane ayarı) dağıtılır. Tutarlar kuruş.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `total`ı ağırlıklara göre tamsayı paylara böler; toplam tam olarak `total` olur. */
|
||||||
|
export function distribute(total: number, weights: number[]): number[] {
|
||||||
|
const n = weights.length;
|
||||||
|
if (n === 0) return [];
|
||||||
|
const sum = weights.reduce((a, w) => a + Math.max(0, w), 0);
|
||||||
|
const w = sum > 0 ? weights.map((x) => Math.max(0, x)) : weights.map(() => 1);
|
||||||
|
const wsum = sum > 0 ? sum : n;
|
||||||
|
const exact = w.map((x) => (total * x) / wsum);
|
||||||
|
const out = exact.map(Math.floor);
|
||||||
|
let rest = total - out.reduce((a, b) => a + b, 0);
|
||||||
|
// En büyük kalanlara birer kuruş (eşitlikte erken gün önce).
|
||||||
|
const order = exact
|
||||||
|
.map((x, i) => ({ i, frac: x - Math.floor(x) }))
|
||||||
|
.sort((a, b) => b.frac - a.frac || a.i - b.i);
|
||||||
|
for (let k = 0; rest > 0 && k < order.length; k++, rest--) out[order[k]!.i]! += 1;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Önceki dönemin günlük değişken harcamasını bu dönemin gün sayısına uyarlar (gün sırasıyla). */
|
||||||
|
export function patternWeights(previous: number[], totalDays: number): number[] {
|
||||||
|
if (!previous.length) return Array.from({ length: totalDays }, () => 1);
|
||||||
|
return Array.from({ length: totalDays }, (_, i) => previous[Math.min(i, previous.length - 1)]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlanDay = {
|
||||||
|
date: string;
|
||||||
|
/** O günün planı: sabit giderler + değişken pay. */
|
||||||
|
plannedMinor: number;
|
||||||
|
fixedMinor: number;
|
||||||
|
actualMinor: number;
|
||||||
|
cumPlannedMinor: number;
|
||||||
|
cumActualMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DailyPlan = {
|
||||||
|
days: PlanDay[];
|
||||||
|
budgetMinor: number;
|
||||||
|
fixedMinor: number;
|
||||||
|
variableMinor: number;
|
||||||
|
/** Bugünün sırası (0'dan); dönem dışındaysa -1. */
|
||||||
|
todayIndex: number;
|
||||||
|
/** Ayın geçen kısmı ve bütçenin harcanan kısmı (yüzde, tamsayı). */
|
||||||
|
monthPct: number;
|
||||||
|
spentPct: number | null;
|
||||||
|
/** Bugüne kadar planlanan ve gerçekleşen (kümülatif). */
|
||||||
|
plannedToDateMinor: number;
|
||||||
|
actualToDateMinor: number;
|
||||||
|
/** Bugün için kalan: bugünün planı + önceki günlerden kalan/aşan fark. */
|
||||||
|
todayAllowanceMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function dailyPlan(input: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
today: string;
|
||||||
|
budgetMinor: number;
|
||||||
|
/** Tarihe bağlı (sabit) giderler, güne göre. */
|
||||||
|
fixedByDay: Map<string, number>;
|
||||||
|
/** Gerçekleşen tüm giderler, güne göre. */
|
||||||
|
actualByDay: Map<string, number>;
|
||||||
|
/** Değişken bütçe ağırlıkları (gün sayısı kadar); verilmezse eşit. */
|
||||||
|
weights?: number[];
|
||||||
|
}): DailyPlan {
|
||||||
|
const total = daysBetween(input.from, input.to);
|
||||||
|
const dates = Array.from({ length: total }, (_, i) => addDays(input.from, i));
|
||||||
|
let fixed = 0;
|
||||||
|
for (const d of dates) fixed += input.fixedByDay.get(d) ?? 0;
|
||||||
|
const variable = Math.max(0, input.budgetMinor - fixed);
|
||||||
|
const shares = distribute(variable, input.weights ?? dates.map(() => 1));
|
||||||
|
let cumP = 0;
|
||||||
|
let cumA = 0;
|
||||||
|
const days = dates.map((date, i): PlanDay => {
|
||||||
|
const f = input.fixedByDay.get(date) ?? 0;
|
||||||
|
const planned = f + shares[i]!;
|
||||||
|
const actual = input.actualByDay.get(date) ?? 0;
|
||||||
|
cumP += planned;
|
||||||
|
cumA += actual;
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
plannedMinor: planned,
|
||||||
|
fixedMinor: f,
|
||||||
|
actualMinor: actual,
|
||||||
|
cumPlannedMinor: cumP,
|
||||||
|
cumActualMinor: cumA,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const todayIndex =
|
||||||
|
input.today < input.from || input.today > input.to
|
||||||
|
? -1
|
||||||
|
: daysBetween(input.from, input.today) - 1;
|
||||||
|
const passed = input.today < input.from ? 0 : input.today > input.to ? total : todayIndex + 1;
|
||||||
|
const upTo = passed > 0 ? days[passed - 1]! : null;
|
||||||
|
const before = todayIndex > 0 ? days[todayIndex - 1]! : null;
|
||||||
|
const todayDay = todayIndex >= 0 ? days[todayIndex]! : null;
|
||||||
|
const actualToDate = upTo?.cumActualMinor ?? 0;
|
||||||
|
return {
|
||||||
|
days,
|
||||||
|
budgetMinor: input.budgetMinor,
|
||||||
|
fixedMinor: fixed,
|
||||||
|
variableMinor: variable,
|
||||||
|
todayIndex,
|
||||||
|
monthPct: Math.round((passed * 100) / total),
|
||||||
|
spentPct: input.budgetMinor > 0 ? Math.round((actualToDate * 100) / input.budgetMinor) : null,
|
||||||
|
plannedToDateMinor: upTo?.cumPlannedMinor ?? 0,
|
||||||
|
actualToDateMinor: actualToDate,
|
||||||
|
todayAllowanceMinor: todayDay
|
||||||
|
? todayDay.plannedMinor + (before ? before.cumPlannedMinor - before.cumActualMinor : 0)
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { formatBp, holdingSummary, runMovements, type MovementInput } from "./portfolio";
|
import {
|
||||||
|
formatBp,
|
||||||
|
holdingSummary,
|
||||||
|
periodPoints,
|
||||||
|
periodReport,
|
||||||
|
runMovements,
|
||||||
|
sumReports,
|
||||||
|
type DatedMovement,
|
||||||
|
type MovementInput,
|
||||||
|
} from "./portfolio";
|
||||||
|
|
||||||
const buy = (quantity: number, amountMinor: number): MovementInput => ({
|
const buy = (quantity: number, amountMinor: number): MovementInput => ({
|
||||||
kind: "buy",
|
kind: "buy",
|
||||||
@@ -70,3 +79,67 @@ describe("ağırlıklı ortalama maliyet", () => {
|
|||||||
expect(formatBp(0)).toBe("%0");
|
expect(formatBp(0)).toBe("%0");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("dönemsel rapor", () => {
|
||||||
|
it("noktalar: başlangıç, ay sonları, bitiş", () => {
|
||||||
|
expect(periodPoints("2026-05-06", "2026-08-15")).toEqual([
|
||||||
|
"2026-05-06",
|
||||||
|
"2026-05-31",
|
||||||
|
"2026-06-30",
|
||||||
|
"2026-07-31",
|
||||||
|
"2026-08-15",
|
||||||
|
]);
|
||||||
|
expect(periodPoints("2026-05-31", "2026-06-30")).toEqual(["2026-05-31", "2026-06-30"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Örnek: 10 gr'ı 25.000'e aldım, her ay 1 gr ekledim; 6 ay sonra ne kazandım?
|
||||||
|
const prices: Record<string, number> = {
|
||||||
|
"2026-01-01": 2_500_00,
|
||||||
|
"2026-02-01": 2_600_00,
|
||||||
|
"2026-03-01": 2_700_00,
|
||||||
|
"2026-07-01": 3_000_00,
|
||||||
|
};
|
||||||
|
const priceAt = (date: string) => {
|
||||||
|
const keys = Object.keys(prices)
|
||||||
|
.filter((k) => k <= date)
|
||||||
|
.sort();
|
||||||
|
return keys.length ? prices[keys.at(-1)!]! : null;
|
||||||
|
};
|
||||||
|
const ms: DatedMovement[] = [
|
||||||
|
{ kind: "buy", date: "2026-01-01", quantity: 10_000, amountMinor: 25_000_00 },
|
||||||
|
{ kind: "buy", date: "2026-02-01", quantity: 1_000, amountMinor: 2_600_00 },
|
||||||
|
{ kind: "buy", date: "2026-03-01", quantity: 1_000, amountMinor: 2_700_00 },
|
||||||
|
{ kind: "gift_out", date: "2026-07-01", quantity: 1_000, amountMinor: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("örnek senaryo: yatırılan, dönem sonu değer, kâr", () => {
|
||||||
|
const r = periodReport(ms, priceAt, "2026-01-01", "2026-07-02");
|
||||||
|
expect(r.startValueMinor).toBe(0);
|
||||||
|
expect(r.investedMinor).toBe(30_300_00);
|
||||||
|
// 11 gr × 3.000 = 33.000; çıkan 1 gr o günkü değeri 3.000
|
||||||
|
expect(r.endValueMinor).toBe(33_000_00);
|
||||||
|
expect(r.giftOutMinor).toBe(3_000_00);
|
||||||
|
expect(r.profitMinor).toBe(5_700_00);
|
||||||
|
expect(r.profitBp).toBe(1881);
|
||||||
|
expect(r.points.map((p) => p.valueMinor)).toEqual([
|
||||||
|
0, 25_000_00, 28_600_00, 32_400_00, 32_400_00, 32_400_00, 32_400_00, 33_000_00,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("başlangıçta eldeki değer dönem içi kârdan düşülür; fiyat yoksa null", () => {
|
||||||
|
const r = periodReport(ms, priceAt, "2026-03-15", "2026-07-02");
|
||||||
|
expect(r.startValueMinor).toBe(32_400_00);
|
||||||
|
expect(r.investedMinor).toBe(0);
|
||||||
|
expect(r.profitMinor).toBe(33_000_00 + 3_000_00 - 32_400_00);
|
||||||
|
expect(periodReport(ms, () => null, "2026-01-01", "2026-07-02").profitMinor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toplam", () => {
|
||||||
|
const a = periodReport(ms, priceAt, "2026-01-01", "2026-07-02");
|
||||||
|
const t = sumReports([a, a], "2026-01-01", "2026-07-02");
|
||||||
|
expect(t.investedMinor).toBe(60_600_00);
|
||||||
|
expect(t.profitMinor).toBe(11_400_00);
|
||||||
|
expect(t.points.at(-1)!.valueMinor).toBe(66_000_00);
|
||||||
|
expect(sumReports([], "2026-01-01", "2026-01-31").points).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { QUANTITY_SCALE } from "./quantity";
|
import { QUANTITY_SCALE } from "./quantity";
|
||||||
|
import { addDays, addMonths } from "./recurrence";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Varlık takibi hesap motoru (Bölüm 6.6): ağırlıklı ortalama maliyet, gerçekleşmiş ve
|
* Varlık takibi hesap motoru (Bölüm 6.6): ağırlıklı ortalama maliyet, gerçekleşmiş ve
|
||||||
@@ -144,3 +145,141 @@ export function formatBp(bp: number): string {
|
|||||||
const s = (Math.abs(bp) / 100).toLocaleString("tr-TR", { maximumFractionDigits: 2 });
|
const s = (Math.abs(bp) / 100).toLocaleString("tr-TR", { maximumFractionDigits: 2 });
|
||||||
return `${bp < 0 ? "−" : bp > 0 ? "+" : ""}%${s}`;
|
return `${bp < 0 ? "−" : bp > 0 ? "+" : ""}%${s}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Dönemsel rapor (Bölüm 6.6) ---
|
||||||
|
|
||||||
|
export type DatedMovement = MovementInput & { date: string };
|
||||||
|
|
||||||
|
/** Dönem noktaları: başlangıç, aradaki ay sonları ve bitiş ("YYYY-MM-DD", artan, tekil). */
|
||||||
|
export function periodPoints(from: string, to: string): string[] {
|
||||||
|
const out = [from];
|
||||||
|
let monthEnd = addMonths(`${from.slice(0, 7)}-01`, 0, "last");
|
||||||
|
while (monthEnd < to) {
|
||||||
|
if (monthEnd > from) out.push(monthEnd);
|
||||||
|
monthEnd = addMonths(addDays(monthEnd, 1), 0, "last");
|
||||||
|
}
|
||||||
|
if (to > from) out.push(to);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PeriodPoint = { date: string; valueMinor: number | null; costMinor: number };
|
||||||
|
|
||||||
|
export type PeriodReport = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
/** Dönem başındaki (başlangıç gününden önceki hareketlerle) değer. */
|
||||||
|
startValueMinor: number | null;
|
||||||
|
endValueMinor: number | null;
|
||||||
|
/** Dönemdeki alımlarda ödenen. */
|
||||||
|
investedMinor: number;
|
||||||
|
/** Dönemdeki satışlardan alınan. */
|
||||||
|
proceedsMinor: number;
|
||||||
|
/** Dönemde gelen hediyelerin girişteki değeri. */
|
||||||
|
giftInMinor: number;
|
||||||
|
/** Dönemde çıkan hediyelerin o günkü değeri (fiyat yoksa maliyeti). */
|
||||||
|
giftOutMinor: number;
|
||||||
|
/**
|
||||||
|
* Dönem kârı: bitiş değeri + satış + çıkan hediye − başlangıç değeri − alım − gelen hediye.
|
||||||
|
* Fiyat eksikse null.
|
||||||
|
*/
|
||||||
|
profitMinor: number | null;
|
||||||
|
/** Kârın, dönemde bağlanan sermayeye (başlangıç + alım + gelen hediye) oranı (baz puan). */
|
||||||
|
profitBp: number | null;
|
||||||
|
points: PeriodPoint[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bir varlığın dönem raporu. `movements` kronolojik sıralı; `priceAt(date)` o tarihteki (veya
|
||||||
|
* öncesindeki en yakın) değerleme fiyatı (alış), yoksa null.
|
||||||
|
*/
|
||||||
|
export function periodReport(
|
||||||
|
movements: DatedMovement[],
|
||||||
|
priceAt: (date: string) => number | null,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): PeriodReport {
|
||||||
|
const valueAt = (date: string, inclusive: boolean) => {
|
||||||
|
const run = runMovements(movements.filter((m) => (inclusive ? m.date <= date : m.date < date)));
|
||||||
|
const price = priceAt(date);
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
value:
|
||||||
|
run.quantity === 0
|
||||||
|
? 0
|
||||||
|
: price === null
|
||||||
|
? null
|
||||||
|
: Math.round((run.quantity * price) / QUANTITY_SCALE),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const start = valueAt(from, false);
|
||||||
|
const end = valueAt(to, true);
|
||||||
|
const all = runMovements(movements);
|
||||||
|
let invested = 0;
|
||||||
|
let proceeds = 0;
|
||||||
|
let giftIn = 0;
|
||||||
|
let giftOut = 0;
|
||||||
|
movements.forEach((m, i) => {
|
||||||
|
if (m.date < from || m.date > to) return;
|
||||||
|
if (m.kind === "buy") invested += m.amountMinor;
|
||||||
|
else if (m.kind === "sell") proceeds += m.amountMinor;
|
||||||
|
else if (m.kind === "gift_in") giftIn += m.amountMinor;
|
||||||
|
else {
|
||||||
|
const price = priceAt(m.date);
|
||||||
|
giftOut +=
|
||||||
|
price === null
|
||||||
|
? all.steps[i]!.costOutMinor
|
||||||
|
: Math.round((m.quantity * price) / QUANTITY_SCALE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const profitMinor =
|
||||||
|
start.value === null || end.value === null
|
||||||
|
? null
|
||||||
|
: end.value + proceeds + giftOut - start.value - invested - giftIn;
|
||||||
|
const base = (start.value ?? 0) + invested + giftIn;
|
||||||
|
const points = periodPoints(from, to).map((date, i) => {
|
||||||
|
const p = i === 0 ? start : valueAt(date, true);
|
||||||
|
return { date, valueMinor: p.value, costMinor: p.run.costMinor };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
startValueMinor: start.value,
|
||||||
|
endValueMinor: end.value,
|
||||||
|
investedMinor: invested,
|
||||||
|
proceedsMinor: proceeds,
|
||||||
|
giftInMinor: giftIn,
|
||||||
|
giftOutMinor: giftOut,
|
||||||
|
profitMinor,
|
||||||
|
profitBp: profitMinor === null || base <= 0 ? null : Math.round((profitMinor * 10_000) / base),
|
||||||
|
points,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Birden çok varlığın raporlarını toplar (noktalar aynı tarihlerde). Eksik fiyatlı değerler atlanır. */
|
||||||
|
export function sumReports(reports: PeriodReport[], from: string, to: string): PeriodReport {
|
||||||
|
const add = (a: number | null, b: number | null) => (a === null || b === null ? null : a + b);
|
||||||
|
const points = periodPoints(from, to).map((date, i) => ({
|
||||||
|
date,
|
||||||
|
valueMinor: reports.reduce<number | null>((a, r) => add(a, r.points[i]?.valueMinor ?? null), 0),
|
||||||
|
costMinor: reports.reduce((a, r) => a + (r.points[i]?.costMinor ?? 0), 0),
|
||||||
|
}));
|
||||||
|
const sum = (k: "investedMinor" | "proceedsMinor" | "giftInMinor" | "giftOutMinor") =>
|
||||||
|
reports.reduce((a, r) => a + r[k], 0);
|
||||||
|
const startValueMinor = reports.reduce<number | null>((a, r) => add(a, r.startValueMinor), 0);
|
||||||
|
const endValueMinor = reports.reduce<number | null>((a, r) => add(a, r.endValueMinor), 0);
|
||||||
|
const profitMinor = reports.reduce<number | null>((a, r) => add(a, r.profitMinor), 0);
|
||||||
|
const base = (startValueMinor ?? 0) + sum("investedMinor") + sum("giftInMinor");
|
||||||
|
return {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
startValueMinor,
|
||||||
|
endValueMinor,
|
||||||
|
investedMinor: sum("investedMinor"),
|
||||||
|
proceedsMinor: sum("proceedsMinor"),
|
||||||
|
giftInMinor: sum("giftInMinor"),
|
||||||
|
giftOutMinor: sum("giftOutMinor"),
|
||||||
|
profitMinor,
|
||||||
|
profitBp: profitMinor === null || base <= 0 ? null : Math.round((profitMinor * 10_000) / base),
|
||||||
|
points,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "../../i18n/tr";
|
||||||
|
import { nonNegativeMoneySchema } from "./account";
|
||||||
|
|
||||||
|
/** Bütçe ayı "YYYY-MM". */
|
||||||
|
export const budgetMonthSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-(0[1-9]|1[0-2])$/, tr.budget.validation.month);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limit formu: kategori → tutar metni. Boş veya 0 limit kaldırılır (kategori bütçesiz olur).
|
||||||
|
*/
|
||||||
|
export const budgetLimitsSchema = z.object({
|
||||||
|
month: budgetMonthSchema,
|
||||||
|
limits: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
categoryId: z.string().regex(/^[a-f0-9]{24}$/i, tr.budget.validation.category),
|
||||||
|
amount: nonNegativeMoneySchema,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(500)
|
||||||
|
.transform((list) =>
|
||||||
|
list
|
||||||
|
.filter((l) => l.amount > 0)
|
||||||
|
.map((l) => ({ categoryId: l.categoryId, amountMinor: l.amount })),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
export type BudgetLimitsForm = z.output<typeof budgetLimitsSchema>;
|
||||||
|
|
||||||
|
/** Gelir planı: elle eklenen beklenen gelirler (liste tümüyle değiştirilir). */
|
||||||
|
export const incomeItemsSchema = z.object({
|
||||||
|
month: budgetMonthSchema,
|
||||||
|
items: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1, tr.common.required).max(80, tr.validation.nameMax),
|
||||||
|
amount: nonNegativeMoneySchema.refine(
|
||||||
|
(n) => n > 0,
|
||||||
|
tr.transactions.validation.amountPositive,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(30),
|
||||||
|
});
|
||||||
|
export type IncomeItemsForm = z.output<typeof incomeItemsSchema>;
|
||||||
@@ -101,9 +101,15 @@ export const paymentFormSchema = z
|
|||||||
quantity: z.preprocess(emptyToNull, quantity.nullable()),
|
quantity: z.preprocess(emptyToNull, quantity.nullable()),
|
||||||
amount: optionalMoney,
|
amount: optionalMoney,
|
||||||
accountId: z.preprocess(emptyToNull, objectId.nullable()),
|
accountId: z.preprocess(emptyToNull, objectId.nullable()),
|
||||||
|
/** Aynı varlıkla ödemede bağlanacak varlık (Faz 9b): borçta varlıktan düşer, alacakta eklenir. */
|
||||||
|
assetId: z.preprocess(emptyToNull, objectId.nullable()).default(null),
|
||||||
note: z.string().trim().max(300).default(""),
|
note: z.string().trim().max(300).default(""),
|
||||||
})
|
})
|
||||||
.superRefine((x, ctx) => {
|
.superRefine((x, ctx) => {
|
||||||
|
if (x.assetId && x.method !== "asset")
|
||||||
|
ctx.addIssue({ code: "custom", path: ["assetId"], message: k.assetOnlyWithAsset });
|
||||||
|
if (x.assetId && x.amount)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["amount"], message: k.noAmountWithAsset });
|
||||||
if (x.method === "asset" && x.quantity === null)
|
if (x.method === "asset" && x.quantity === null)
|
||||||
ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity });
|
ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity });
|
||||||
if (x.method === "cash") {
|
if (x.method === "cash") {
|
||||||
|
|||||||
Reference in New Issue
Block a user