Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b11972fda3 | |||
| 19536f45de | |||
| c0b114efeb | |||
| afc6da1606 | |||
| 18c892bdcd | |||
| add845684c | |||
| 092db5a8c2 | |||
| 6ce8ffa4a5 | |||
| 1bdcedefdc | |||
| 7d7dd61c60 | |||
| 42561b570a | |||
| 4b5def9452 | |||
| c3c3c43bd1 | |||
| 8623c5a02e | |||
| d9a22551e1 | |||
| 0c421a2d34 | |||
| 29d7b29cfa | |||
| bc2a8a3bde | |||
| d77a2efcb8 |
@@ -2,6 +2,7 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -10,6 +11,7 @@ next-env.d.ts
|
||||
.env*
|
||||
!.env.example
|
||||
uploads
|
||||
import
|
||||
|
||||
# Geliştirme araçları
|
||||
.git
|
||||
|
||||
@@ -46,3 +46,7 @@ next-env.d.ts
|
||||
|
||||
# local tooling
|
||||
/scratch/
|
||||
/dist
|
||||
|
||||
# Kişisel ekstre dosyaları (içe aktarma için) — asla repoya girmez
|
||||
/import/
|
||||
|
||||
@@ -5,3 +5,4 @@ coverage
|
||||
uploads
|
||||
next-env.d.ts
|
||||
docs/PROMPT.md
|
||||
dist/
|
||||
|
||||
@@ -6,6 +6,71 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
||||
|
||||
## [Yayınlanmamış]
|
||||
|
||||
## [1.1.0] - 2026-11-07
|
||||
|
||||
Faz 16: Kakeibo K1 — dört harcama grubu.
|
||||
|
||||
### Eklendi
|
||||
|
||||
- Kakeibo grupları: gider kategorilerine **Zorunlu / İstek / Kültür / Beklenmeyen** grubu; alt kategori grubu üst kategoriden devralır. Kategori formunda grup seçimi ve açıklaması; kategori listesinde grup rozeti (devralınan soluk) ve grupsuz kategori uyarısı.
|
||||
- **Banka maliyeti:** KMH faizi, KKDF/BSMV, kart ve hesap ücretleri Kakeibo harcamasına ve dört gruba girmez, ayrı gösterilir (kategori düzeyinde işaret, alt ağaç devralır).
|
||||
- İşlem formunda **Kakeibo grubu**: varsayılan kategoriden ("Kategoriden: Zorunlu"), bu kayıt için değiştirilebilir; bölünmüş işlemde kalem başına.
|
||||
- Kakeibo raporu (`/raporlar/kakeibo`): Kakeibo harcaması ve grup payları (yığılmış çubuk), grup başına tutar, pay, önceki aya göre değişim ve kategori kırılımı; banka maliyeti ve grupsuz harcamalar ayrı; CSV. Bütçe sayfasında seçili ayın Kakeibo özeti.
|
||||
- Migration `016-kakeibo-groups`: varsayılan kategoriler beceri eşlemesine göre doldurulur (kullanıcı kategorileri grupsuz kalır, mevcut seçimlere dokunulmaz, kategori silinmez). Migration'lar geri alınabilir: `npm run migrate -- --down <ad>`.
|
||||
- Testler: grup devralma, banka maliyeti, kayıt bazında grup, grup özeti ve sıfıra bölme (birim); varsayılan gruplar, rapor toplamı = bütçenin gerçekleşeni, grup değiştirme, kategori güncelleme, migration geri alma/yeniden uygulama (entegrasyon).
|
||||
|
||||
### Güvenlik
|
||||
|
||||
- `import/` klasörü (kişisel ekstre dosyaları) git'e ve Docker imajına girmez.
|
||||
|
||||
## [1.0.0] - 2026-11-07
|
||||
|
||||
Faz 15: canlıya alma (Dokploy + Gitea, `butcem.ta1sph.website`).
|
||||
|
||||
### Eklendi
|
||||
|
||||
- Konteyner başlatma adımı (`docker-entrypoint.sh` → `scripts/bootstrap.mjs`): MongoDB replica set ve PRIMARY kontrolü (yoksa açık hata mesajıyla durur), bekleyen migration'lar, `SEED_ADMIN_*` tanımlıysa ilk admin hesabı (varsa dokunulmaz). esbuild ile tek dosyaya derlenir; imajda `tsx` gerekmez. Acil durumda `SKIP_BOOTSTRAP=1`.
|
||||
- `/api/health` yanıtında `replicaSet` alanı (transaction desteğinin canlıda doğrulanması için).
|
||||
- `docs/DEPLOY.md`: Dokploy uygulama kurulumu, ortam değişkenleri, volume, domain/HTTPS, Gitea webhook ile otomatik deploy, güncelleme/geri alma, yedekleme, geri yükleme ve sorun giderme; her komut açıklamalı.
|
||||
- `docs/MONGODB-KURULUM.md`: mongo:7 servisinde replica set kontrolü, keyfile ile tek düğümlü replica set'e geçiş, uygulama kullanıcısı yetkileri, bağlantı dizesi, transaction denemesi.
|
||||
- `scripts/smoke-test.sh`: sağlık (veritabanı, replica set, sürüm), güvenlik başlıkları, HTTP→HTTPS, oturumsuz erişim engeli ve isteğe bağlı giriş kontrolü; veri değiştirmez.
|
||||
- `scripts/backup/`: günlük yedek betiği (mongodump + uploads arşivi, SHA-256 özetleri, 7 gün saklama, parola komut satırında görünmez), onaylı geri yükleme betiği ve ayar örneği.
|
||||
|
||||
### Değişti
|
||||
|
||||
- Docker imajı: `ENTRYPOINT` başlatma adımıyla; sağlık kontrolü başlangıç payı 60 sn.
|
||||
- Uygulama mongo:7.0 ile uyumlu (tüm entegrasyon testleri mongo:7 replica set'e karşı geçti).
|
||||
|
||||
## [0.14.0] - 2026-11-07
|
||||
|
||||
Faz 14: dashboard ana grafiği ve raporlar (14a ve 14b alt fazları).
|
||||
|
||||
### Eklendi — Faz 14b: cari, birikim, alışveriş, birim fiyat ve fire raporları
|
||||
|
||||
- Cari ve aynî borçlar (`/raporlar/cari`): bakiyesi olan cariler (bana borçlu / ben borçluyum, vade durumu), açık aynî borç ve alacaklar kalan miktar ve güncel TL değeriyle; toplamlar.
|
||||
- Birikim kâr/zarar (`/raporlar/birikim`): varlık başına miktar, maliyet, güncel değer, gerçekleşmemiş ve gerçekleşen K/Z; son 3/6/12 ay veya tümü için dönem özeti (başlangıç değeri, yatırılan, satıştan alınan, bitiş değeri, dönem kârı).
|
||||
- Alışveriş (`/raporlar/alisveris`): tamamlanan listelerde planlanan / gerçekleşen / fark, eşleşme sayıları ve farkın nedenleri; mağaza bazlı harcama (alıcısı mağaza adıyla başlayan giderler, şubeler birlikte, "Alışverişi tamamla"dan gelen kısım ayrıca).
|
||||
- Birim fiyat geçmişi (`/raporlar/birim-fiyat`): ürün tipi seçimi, son 12 bütçe ayında aylık en düşük ve ortalama birim fiyat (grafik ve tablo), mağazalarda dönemin en düşük birim fiyatı.
|
||||
- Fire / zarar (`/raporlar/fire`): ürün tipi başına fire, alım, fire/alım oranı, nedenlere göre dağılım ve tahmini maliyet (fire × son bilinen birim fiyat).
|
||||
- Hepsinde CSV dışa aktarma; rapor ana sayfasında yeni rapor kartları.
|
||||
- Testler: mağaza adı eşleşmesi ve aylık birim fiyat özeti (birim); cari/aynî, birikim, alışveriş (tamamlanan liste dahil), birim fiyat ve fire raporları (entegrasyon).
|
||||
|
||||
### Eklendi — Faz 14a: dashboard ana grafiği ve finans raporları
|
||||
|
||||
- Dashboard ana çizgi grafiği: Planlanan gelir (yeşil), Planlanan gider / bütçe (mavi, kesikli), Gerçekleşen gider (kırmızı). Periyotlar: Haftalık (bu hafta, günlük), Aylık (bütçe ayı, günlük), Altı aylık ve Yıllık (bütçe ayları). Kümülatif / dönemsel görünüm; seçim URL'de (`?periyot=…&gorunum=…`). Bütçesi olmayan dönemde bütçe çizgisi boş, gelecek günlerde gerçekleşen çizilmez; grafiğin altında tablo görünümü.
|
||||
- Raporlar (`/raporlar`): bu bütçe ayının özeti (gelir, gider, net, tasarruf oranı, en çok harcanan kategoriler) ve rapor sayfaları; her raporda ay seçici ve CSV dışa aktarma (Türkçe Excel uyumlu).
|
||||
- Kategori dağılımı: halka grafik ve tablo; üst kategori toplamları ve alt kategori kırılımı, toplam gidere oran (%14,3 gibi), önceki aya göre tutar ve yüzde değişim. Dilim renkleri kategorinin kendi rengi; 7'den fazla kategori "Diğer"de toplanır.
|
||||
- Aylık trend: son 12 bütçe ayı gelir ve gider sütunları, ayrı grafikte tasarruf oranı (devam eden ay grafikte gösterilmez), net ve 12 ay toplamı.
|
||||
- Kişi ve hesap bazlı: gideri kaydeden üyeye göre harcama ve pay; hesap başına gelir, gider, gelen/giden transfer ve net değişim. Başka üyelerin "yalnızca ben" hesapları hesap tablosunda hiç görünmez.
|
||||
- Bütçe ve gerçekleşen: kategori bazında limit, gerçekleşen, kalan ve kullanım; son 6 ayın limit/gerçekleşen karşılaştırması.
|
||||
- Kurallar bütçeyle aynı: bütçe ayı, gerçekleşmiş işlemler, bölünmüş işlemler kalem bazında, taksit modu ve gizli hesap giderleri hane ayarına göre.
|
||||
- Testler: periyot aralıkları ve kümülatif dönüşüm, kategori payı/değişim/tasarruf oranı ve alt kategori kırılımı (birim); rapor toplamlarının bütçeyle tutarlılığı, gizli hesap görünürlüğü, dashboard periyotları (entegrasyon).
|
||||
|
||||
### Değişti — Faz 14a
|
||||
|
||||
- Dashboard'daki günlük plan kartı istatistik ve tabloya indi; kümülatif ay grafiği ana grafiğin "Aylık" görünümüne taşındı.
|
||||
- Bütçe, gelir planı ve günlük plan hesapları bağlam alan yardımcılara ayrıldı (raporlarda birden çok ay için yeniden kullanılır).
|
||||
|
||||
## [0.13.0] - 2026-11-06
|
||||
|
||||
Faz 13: alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu ve alışveriş sonrası giriş (13a ve 13b alt fazları).
|
||||
|
||||
+8
-3
@@ -2,6 +2,7 @@
|
||||
# BÜTÇEM — çok aşamalı üretim imajı (Next.js standalone çıktı).
|
||||
# Build: docker build --build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
|
||||
# Çalıştır: docker run -p 3000:3000 --env-file .env.production -v butcem-uploads:/app/uploads butcem
|
||||
# Başlarken: replica set kontrolü, bekleyen migration'lar, ilk admin (docker-entrypoint.sh).
|
||||
|
||||
ARG NODE_VERSION=26
|
||||
|
||||
@@ -22,7 +23,8 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
APP_COMMIT=${APP_COMMIT}
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
# Uygulama + başlatma betiği (migration ve ilk admin; tek dosya, tsx gerekmez).
|
||||
RUN npm run build && npm run build:scripts
|
||||
|
||||
# 3) Çalışma zamanı — yalnızca standalone çıktı, root olmayan kullanıcı.
|
||||
FROM node:${NODE_VERSION}-alpine AS runner
|
||||
@@ -43,12 +45,15 @@ RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs \
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/dist/bootstrap.mjs ./scripts/bootstrap.mjs
|
||||
COPY --chown=nextjs:nodejs --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
VOLUME ["/app/uploads"]
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
# start-period: ilk deploy'da migration'lar için pay.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
|
||||
@@ -52,19 +52,20 @@ Giriş kullanıcı adı **veya** e-posta ile yapılır. Kayıt kapalıdır; kull
|
||||
|
||||
## Scriptler
|
||||
|
||||
| Komut | Açıklama |
|
||||
| ----------------------------- | --------------------------------- |
|
||||
| `npm run dev` | Geliştirme sunucusu |
|
||||
| `npm run build` / `start` | Üretim build'i / sunucusu |
|
||||
| `npm run lint` | ESLint |
|
||||
| `npm run typecheck` | `tsc --noEmit` |
|
||||
| `npm run test` | Vitest birim testleri |
|
||||
| `npm run format` | Prettier |
|
||||
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
|
||||
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
|
||||
| `npm run test:integration` | Entegrasyon testleri (MongoDB) |
|
||||
| `npm run migrate` | Bekleyen migration'lar |
|
||||
| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil |
|
||||
| Komut | Açıklama |
|
||||
| ----------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `npm run dev` | Geliştirme sunucusu |
|
||||
| `npm run build` / `start` | Üretim build'i / sunucusu |
|
||||
| `npm run lint` | ESLint |
|
||||
| `npm run typecheck` | `tsc --noEmit` |
|
||||
| `npm run test` | Vitest birim testleri |
|
||||
| `npm run format` | Prettier |
|
||||
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
|
||||
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
|
||||
| `npm run test:integration` | Entegrasyon testleri (MongoDB) |
|
||||
| `npm run migrate` | Bekleyen migration'lar |
|
||||
| `npm run bootstrap` | Canlı başlatma adımı (replica set kontrolü + migration; production'da ilk admin) |
|
||||
| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil |
|
||||
|
||||
## Haneler ve yetki
|
||||
|
||||
@@ -83,4 +84,13 @@ docker build --build-arg APP_VERSION=$(node -p "require('./package.json').versio
|
||||
--build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
|
||||
```
|
||||
|
||||
Canlı ortam (Dokploy, `butcem.ta1sph.website`) kurulumu Faz 15'te `docs/DEPLOY.md` içinde anlatılacak.
|
||||
Konteyner başlarken `docker-entrypoint.sh` önce başlatma adımını (`scripts/bootstrap.mjs`: replica
|
||||
set kontrolü, bekleyen migration'lar, `SEED_ADMIN_*` tanımlıysa ilk admin) sonra uygulamayı çalıştırır.
|
||||
|
||||
## Canlı ortam
|
||||
|
||||
- Kurulum, ortam değişkenleri, domain, volume, otomatik deploy, yedekleme ve geri yükleme:
|
||||
[`docs/DEPLOY.md`](docs/DEPLOY.md)
|
||||
- MongoDB replica set ve uygulama kullanıcısı: [`docs/MONGODB-KURULUM.md`](docs/MONGODB-KURULUM.md)
|
||||
- Duman testi: `scripts/smoke-test.sh https://butcem.ta1sph.website`
|
||||
- Yedek / geri yükleme betikleri: `scripts/backup/`
|
||||
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
# BÜTÇEM konteyner girişi: önce başlatma (replica set kontrolü, migration'lar, ilk admin),
|
||||
# başarılıysa uygulama. Başlatma başarısız olursa konteyner durur; Dokploy günlüğünde sebep görünür.
|
||||
# Acil durumda atlamak için: SKIP_BOOTSTRAP=1
|
||||
set -e
|
||||
if [ "${SKIP_BOOTSTRAP:-0}" != "1" ]; then
|
||||
node /app/scripts/bootstrap.mjs
|
||||
fi
|
||||
exec node /app/server.js
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
# Canlıya alma (Dokploy + Gitea)
|
||||
|
||||
BÜTÇEM'i Atlas sunucusunda Dokploy ile `https://butcem.ta1sph.website` adresinde yayınlamak,
|
||||
güncellemek, yedeklemek ve geri yüklemek için adım adım kılavuz.
|
||||
|
||||
**Mimari:** Gitea (`main` dalı) → webhook → Dokploy, Dockerfile ile imaj build eder → konteyner
|
||||
başlarken veritabanını hazırlar (replica set kontrolü, migration'lar, ilk admin) → Next.js
|
||||
uygulaması 3000 portunda → Traefik HTTPS ile dışarı açar. MongoDB aynı Dokploy ağında, dışa portsuz.
|
||||
Yüklenen dosyalar `/app/uploads` volume'unda.
|
||||
|
||||
> Sunucu komutları Atlas'ta çalıştırılır. Her komutun altında ne yaptığı yazıyor. `<…>` ile
|
||||
> gösterilen yerleri kendi değerlerinizle değiştirin.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ön koşullar
|
||||
|
||||
- [ ] MongoDB servisi **replica set** olarak çalışıyor ve `butcem_app` kullanıcısı hazır →
|
||||
[`MONGODB-KURULUM.md`](MONGODB-KURULUM.md) (1–5. adımlar).
|
||||
- [ ] Gitea'da BÜTÇEM reposu (`main` dalı güncel; `git push origin main`).
|
||||
- [ ] DNS: `butcem.ta1sph.website` için Atlas'ın genel IP'sine **A kaydı** (ve gerekiyorsa
|
||||
modemde 80/443 yönlendirmesi Traefik'e).
|
||||
|
||||
```bash
|
||||
dig +short butcem.ta1sph.website
|
||||
```
|
||||
|
||||
Alan adının çözüldüğü IP'yi gösterir; Atlas'ın genel IP'si olmalı.
|
||||
|
||||
## 2. Dokploy'da uygulama
|
||||
|
||||
Dokploy › proje › **Create Service › Application**, ad: `butcem`.
|
||||
|
||||
### 2.1 Kaynak (General)
|
||||
|
||||
- **Provider:** Gitea (Dokploy › Settings › Git'te Gitea bağlantısı tanımlıysa) ya da **Git**
|
||||
(repo URL'si + Dokploy'un SSH anahtarı Gitea'da deploy key olarak ekli).
|
||||
- **Repository / Branch:** BÜTÇEM reposu, `main`.
|
||||
- **Build Type:** `Dockerfile` (yol: `Dockerfile`, context: `.`).
|
||||
- **Build Args** (isteğe bağlı): `APP_COMMIT=<kısa-commit>` — sağlık kontrolünde görünür; boşsa
|
||||
`unknown` yazar. Sürüm `package.json`'dan otomatik gelir.
|
||||
|
||||
### 2.2 Ortam değişkenleri (Environment)
|
||||
|
||||
| Değişken | Değer | Not |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `MONGODB_URI` | `mongodb://butcem_app:<parola>@<iç-host>:27017/butcem?authSource=<admin\|butcem>&replicaSet=rs0&directConnection=true` | Ayrıntı: MONGODB-KURULUM.md › 5 |
|
||||
| `BETTER_AUTH_SECRET` | 32+ karakter rastgele | Aşağıdaki komutla üretin; **değiştirirseniz tüm oturumlar kapanır** |
|
||||
| `BETTER_AUTH_URL` | `https://butcem.ta1sph.website` | Sonunda `/` yok |
|
||||
| `SEED_ADMIN_EMAIL` | yönetici e-postası | İlk açılışta admin oluşturulur |
|
||||
| `SEED_ADMIN_PASSWORD` | güçlü parola (8+) | İlk girişten sonra parolayı değiştirip bu değişkeni silebilirsiniz |
|
||||
| `MAX_UPLOAD_MB` | `10` | İsteğe bağlı |
|
||||
|
||||
`NODE_ENV=production`, `PORT=3000` ve `UPLOAD_DIR=/app/uploads` imajda tanımlıdır; eklemeyin.
|
||||
`SEED_USER_PASSWORD` canlıda kullanılmaz (örnek kullanıcı oluşturulmaz).
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
`BETTER_AUTH_SECRET` için 32 baytlık rastgele bir değer üretir.
|
||||
|
||||
### 2.3 Volume (Advanced › Volumes / Mounts)
|
||||
|
||||
- **Volume Mount** · ad: `butcem-uploads` · bağlama yolu: **`/app/uploads`**.
|
||||
Fiş, ürün görseli ve ek dosyalar burada tutulur; imaj yeniden oluşturulduğunda kaybolmaz.
|
||||
(Bind mount yerine adlandırılmış volume kullanın; imajdaki dizin izinleri — uid 1001 — volume'a
|
||||
ilk oluşturulurken aktarılır.)
|
||||
|
||||
### 2.4 Domain (Domains)
|
||||
|
||||
- **Host:** `butcem.ta1sph.website` · **Path:** `/` · **Container Port:** `3000`
|
||||
- **HTTPS:** açık · **Certificate:** Let's Encrypt
|
||||
|
||||
### 2.5 İlk deploy
|
||||
|
||||
**Deploy**'a basın ve **Logs**'ta şu satırları bekleyin:
|
||||
|
||||
```
|
||||
• MongoDB rs0 PRIMARY
|
||||
✓ Migration: 001-initial-indexes — … (ilk kurulumda 15 migration)
|
||||
✓ Admin oluşturuldu: <e-posta> (kullanıcı adı: admin)
|
||||
✓ Ready in …
|
||||
```
|
||||
|
||||
"MongoDB replica set olarak çalışmıyor" hatası görürseniz MONGODB-KURULUM.md › 3'ü uygulayın.
|
||||
Konteynerin Docker sağlık durumu birkaç saniye içinde **healthy** olur (`/api/health`).
|
||||
|
||||
### 2.6 İlk giriş
|
||||
|
||||
`https://butcem.ta1sph.website` › kullanıcı adı **`admin`** ve `SEED_ADMIN_PASSWORD`.
|
||||
|
||||
1. **Yönetim › Kullanıcılar**'dan aile üyelerini oluşturun.
|
||||
2. **Yönetim › Haneler**'den haneyi açıp üyeleri ve rollerini ekleyin.
|
||||
3. (Önerilir) Admin parolasını **Yönetim › Kullanıcılar › kendi satırınız › Şifre sıfırla** ile
|
||||
değiştirip Dokploy'da `SEED_ADMIN_PASSWORD`'u silin ve yeniden deploy edin; başlatma artık
|
||||
yalnızca migration'ları çalıştırır. (Admin hesabı varken bu değişken parolayı değiştirmez.)
|
||||
|
||||
## 3. Duman testi (smoke test)
|
||||
|
||||
Kendi bilgisayarınızda, repo klasöründe:
|
||||
|
||||
```bash
|
||||
scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||
```
|
||||
|
||||
Sağlık (veritabanı, replica set, sürüm), güvenlik başlıkları, HTTP→HTTPS yönlendirmesi ve
|
||||
oturumsuz erişimin engellendiğini kontrol eder; **veri değiştirmez**. Tümü ✓ olmalı.
|
||||
|
||||
```bash
|
||||
SMOKE_USER=admin SMOKE_PASSWORD='<parola>' scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||
```
|
||||
|
||||
Ayrıca giriş yapıp ana sayfanın açıldığını dener (parola komut geçmişine düşer; sonra
|
||||
`history -d` ile silebilir ya da satırı boşlukla başlatabilirsiniz).
|
||||
|
||||
Elle kontrol listesi (bir kez, tarayıcıdan):
|
||||
|
||||
- [ ] Giriş / çıkış, telefondan PWA olarak ana ekrana ekleme.
|
||||
- [ ] Bir hesap ve bir gider işlemi oluşturma (transaction'lı yazma).
|
||||
- [ ] İşleme fiş fotoğrafı ekleyip görüntüleme (uploads volume).
|
||||
- [ ] Bir transfer (iki hesap birden; transaction).
|
||||
- [ ] Dokploy'da uygulamayı **Restart** edip fişin hâlâ açıldığını görme (volume kalıcılığı).
|
||||
|
||||
## 4. Otomatik deploy (Gitea webhook)
|
||||
|
||||
Dokploy › `butcem` › **Deployments** › **Webhook URL**'yi kopyalayın (Auto Deploy açık olmalı).
|
||||
Gitea › repo › **Settings › Webhooks › Add Webhook › Gitea**:
|
||||
|
||||
- **Target URL:** kopyaladığınız Dokploy URL'si · **HTTP Method:** POST · **Content type:** JSON
|
||||
- **Trigger On:** Push Events · **Branch filter:** `main`
|
||||
- Kaydedip **Test Delivery** ile deneyin; Dokploy'da yeni bir deployment başlamalı.
|
||||
|
||||
Bundan sonra `git push origin main` → otomatik build ve yayın. Her deploy'da bekleyen
|
||||
migration'lar uygulama açılmadan önce çalışır.
|
||||
|
||||
## 5. Güncelleme ve geri alma
|
||||
|
||||
- **Normal güncelleme:** `main`'e push (webhook) ya da Dokploy'da **Deploy**.
|
||||
- **Büyük güncellemeden önce** elle yedek alın: `sudo /opt/butcem/butcem-backup.sh` (bkz. 6).
|
||||
- **Geri alma:** Dokploy › Deployments › önceki başarılı deployment › **Rollback** (ya da
|
||||
Gitea'da önceki commit'e dönüp push). Migration'lar yalnızca ileri yönlüdür; yeni sürümün
|
||||
eklediği index/alanlar eski sürümü bozmaz. Veri bozulduysa 7'deki geri yüklemeyi kullanın.
|
||||
- **Acil durum:** başlatma adımı (migration) hata verip uygulama açılmıyorsa geçici olarak
|
||||
`SKIP_BOOTSTRAP=1` ortam değişkeniyle açabilirsiniz; sorunu giderince kaldırın.
|
||||
|
||||
## 6. Yedekleme
|
||||
|
||||
Günlük: `butcem` veritabanı (`mongodump`) + uploads volume arşivi + SHA-256 özetleri,
|
||||
**7 gün saklama**, `/var/backups/butcem` altında. Betik: `scripts/backup/butcem-backup.sh`.
|
||||
|
||||
> ⚠️ Şimdilik yedekler **yalnızca sunucuda**. Disk arızasında yedekler de gider; bir sonraki adım
|
||||
> olarak sunucu dışına kopya (NAS'a `rsync` ya da S3 uyumlu depolama) eklenmeli.
|
||||
|
||||
### 6.1 Kurulum (bir kez)
|
||||
|
||||
```bash
|
||||
sudo install -d -m 755 /opt/butcem
|
||||
```
|
||||
|
||||
Betikler için `/opt/butcem` klasörünü oluşturur.
|
||||
|
||||
```bash
|
||||
sudo install -m 750 scripts/backup/butcem-backup.sh scripts/backup/butcem-restore.sh /opt/butcem/
|
||||
```
|
||||
|
||||
Yedek ve geri yükleme betiklerini (repo klasöründen; sunucuya `scp` ile de kopyalayabilirsiniz)
|
||||
`/opt/butcem`'e yalnızca root'un çalıştırabileceği izinle kopyalar.
|
||||
|
||||
```bash
|
||||
sudo install -m 600 scripts/backup/butcem-backup.env.example /etc/butcem-backup.env
|
||||
sudo nano /etc/butcem-backup.env
|
||||
```
|
||||
|
||||
Ayar dosyasını yalnızca root'un okuyabileceği izinle oluşturur ve düzenlemek için açar. Doldurun:
|
||||
|
||||
- `MONGO_CONTAINER`: MongoDB konteyner adının ayırt edici kısmı (`docker ps --format '{{.Names}}'`).
|
||||
- `MONGO_ROOT_USER` / `MONGO_ROOT_PASSWORD`: `butcem_root` bilgileri.
|
||||
- `UPLOADS_VOLUME`: uygulamanın volume adı.
|
||||
```bash
|
||||
docker volume ls | grep -i uploads
|
||||
```
|
||||
Volume adlarını listeler (Dokploy'da verdiğiniz `butcem-uploads` adını doğrulayın).
|
||||
|
||||
### 6.2 İlk çalıştırma
|
||||
|
||||
```bash
|
||||
sudo /opt/butcem/butcem-backup.sh
|
||||
```
|
||||
|
||||
Yedeği hemen alır; sonunda `Tamam: … veritabanı, … dosya` yazmalı.
|
||||
|
||||
```bash
|
||||
sudo ls -lh /var/backups/butcem && sudo sh -c 'cd /var/backups/butcem && sha256sum -c SHA256SUMS-*'
|
||||
```
|
||||
|
||||
Yedek dosyalarını listeler ve bütünlüklerini doğrular (`OK`).
|
||||
|
||||
### 6.3 Günlük zamanlama
|
||||
|
||||
```bash
|
||||
sudo crontab -e
|
||||
```
|
||||
|
||||
root'un zamanlanmış görevlerini açar. Şu satırı ekleyin (her gece 03:30):
|
||||
|
||||
```
|
||||
30 3 * * * /opt/butcem/butcem-backup.sh >> /var/log/butcem-backup.log 2>&1
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo tail -n 20 /var/log/butcem-backup.log
|
||||
```
|
||||
|
||||
Ertesi gün son yedeğin günlüğünü gösterir.
|
||||
|
||||
## 7. Geri yükleme
|
||||
|
||||
```bash
|
||||
sudo ls -1 /var/backups/butcem
|
||||
```
|
||||
|
||||
Mevcut yedekleri listeler; dönülecek tarih damgasını seçin.
|
||||
|
||||
1. Dokploy › `butcem` › **Stop** (yazma olmasın).
|
||||
2. Geri yükleyin:
|
||||
```bash
|
||||
sudo /opt/butcem/butcem-restore.sh /var/backups/butcem/mongo-<damga>.archive.gz /var/backups/butcem/uploads-<damga>.tar.gz
|
||||
```
|
||||
Bütünlüğü kontrol eder, onay ister (`evet`), `butcem` veritabanını yedektekiyle **değiştirir**
|
||||
ve uploads volume'unu arşivden yeniden yazar. Yalnızca veritabanı için ikinci dosyayı vermeyin.
|
||||
3. Dokploy › **Start**, ardından duman testi (3).
|
||||
|
||||
**Tatbikat:** Ayda bir, en son yedeği bu adımlarla deneme amaçlı bir Dokploy kopyasına ya da
|
||||
lokale (`docker compose` mongo'su) yükleyip açılıp açılmadığını kontrol edin. Yedeği hiç denenmemiş
|
||||
bir yedek, yedek değildir.
|
||||
|
||||
## 8. Sorun giderme
|
||||
|
||||
| Belirti | Neden / çözüm |
|
||||
| --------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Logs: `MongoDB replica set olarak çalışmıyor` | MONGODB-KURULUM.md › 3 |
|
||||
| Logs: `Authentication failed` | `MONGODB_URI` kullanıcı/parola/`authSource`; parola URL kodlu mu? |
|
||||
| Logs: `Geçersiz ortam değişkenleri` | Listelenen değişkeni düzeltin (ör. `BETTER_AUTH_SECRET` < 32 karakter) |
|
||||
| Konteyner `unhealthy` | `curl -s https://butcem.ta1sph.website/api/health` → `db` alanı; MongoDB servisi çalışıyor mu? |
|
||||
| Giriş sonrası tekrar giriş sayfası | `BETTER_AUTH_URL` alan adıyla birebir aynı mı (https, sonda `/` yok)? |
|
||||
| Yüklenen dosya açılmıyor / yükleme hatası | Volume `/app/uploads`'a bağlı mı? `docker exec <app> ls -ln /app/uploads` sahibi 1001 olmalı |
|
||||
| Sertifika alınamadı | DNS A kaydı ve 80 portu Traefik'e ulaşıyor mu? |
|
||||
|
||||
```bash
|
||||
docker logs --tail 100 $(docker ps -q --filter name=<butcem-uygulama-adı> | head -n1)
|
||||
```
|
||||
|
||||
Uygulama konteynerinin son 100 log satırını gösterir (Dokploy › Logs ile aynı).
|
||||
@@ -0,0 +1,144 @@
|
||||
# MongoDB kurulumu (canlı, Dokploy)
|
||||
|
||||
BÜTÇEM çok adımlı yazmaları (transfer, "Alışverişi tamamla", kart ödemesi…) **MongoDB transaction**
|
||||
ile yapar. Transaction yalnızca **replica set** üzerinde çalışır; tek sunucu için **tek düğümlü
|
||||
replica set** (`rs0`) yeterlidir. Uygulama başlarken bunu kontrol eder; replica set yoksa açık bir
|
||||
hata mesajıyla durur.
|
||||
|
||||
Bu belge mevcut kurulumu esas alır: Dokploy'da **`mongo:7`** veritabanı servisi (mongo:8 sunucu
|
||||
çekirdeğiyle uyumsuz), kimlik doğrulama açık, `butcem_root` (yönetici) ve `butcem_app` (uygulama)
|
||||
kullanıcıları oluşturulmuş. Uygulama mongo:7.0 ile test edilmiştir (tüm entegrasyon testleri).
|
||||
|
||||
> Komutlar Atlas sunucusunda çalıştırılır. `<…>` ile gösterilen yerleri kendi değerlerinizle
|
||||
> değiştirin. Parolaları komut geçmişine yazmamak için `mongosh` parola sorduğunda girin.
|
||||
|
||||
---
|
||||
|
||||
## 1. MongoDB konteynerini bul
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Names}}' | grep -i mongo
|
||||
```
|
||||
|
||||
Çalışan konteynerlerin adlarından MongoDB'ninkini listeler. Dokploy ada rastgele bir son ek ekler
|
||||
(ör. `butcem-mongo-a1b2c3.1.xyz`). Aşağıda bu adı `<MONGO>` olarak kullanın; adın ayırt edici
|
||||
başlangıcı (ör. `butcem-mongo-a1b2c3`) da yeterlidir.
|
||||
|
||||
```bash
|
||||
MONGO=$(docker ps -q --filter name=<mongo-servis-adı> | head -n1); echo "$MONGO"
|
||||
```
|
||||
|
||||
Konteyner kimliğini bir değişkene alır; sonraki komutlar `$MONGO` ile kısalır.
|
||||
|
||||
## 2. Replica set açık mı?
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||
--eval 'try { const s = rs.status(); print(s.set, s.members[0].stateStr) } catch (e) { print("REPLICA SET YOK: " + e.codeName) }'
|
||||
```
|
||||
|
||||
Yönetici olarak bağlanıp replica set durumunu yazdırır (parolayı sorar).
|
||||
|
||||
- Çıktı **`rs0 PRIMARY`** ise replica set hazır; **4. adıma** geçin.
|
||||
- Çıktı **`REPLICA SET YOK: NoReplicationEnabled`** ise **3. adımı** uygulayın.
|
||||
|
||||
## 3. Tek düğümlü replica set'i aç
|
||||
|
||||
Dokploy'un MongoDB servisinde bir **"Replica Set"** seçeneği varsa (sürüme göre değişir) onu açıp
|
||||
servisi yeniden deploy etmeniz ve 3.4'e geçmeniz yeterlidir. Yoksa aşağıdaki elle yöntemi uygulayın.
|
||||
Kimlik doğrulama açıkken replica set bir **anahtar dosyası (keyfile)** ister.
|
||||
|
||||
### 3.1 Yedek alın
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongodump -u butcem_root --authenticationDatabase admin --gzip --archive=/data/db/oncesi.archive.gz
|
||||
docker cp "$MONGO":/data/db/oncesi.archive.gz ~/mongo-oncesi.archive.gz && docker exec "$MONGO" rm /data/db/oncesi.archive.gz
|
||||
```
|
||||
|
||||
Değişiklikten önce tüm veritabanlarının yedeğini konteyner içinde bir dosyaya alır (parolayı sorar),
|
||||
sonra ev dizinine kopyalayıp konteynerdeki kopyayı siler. Yeni kurulumda veri yoksa atlanabilir.
|
||||
|
||||
### 3.2 Anahtar dosyasını oluştur
|
||||
|
||||
```bash
|
||||
docker exec "$MONGO" sh -c 'head -c 756 /dev/urandom | base64 > /data/db/rs-keyfile && chmod 400 /data/db/rs-keyfile && chown 999:999 /data/db/rs-keyfile && ls -l /data/db/rs-keyfile'
|
||||
```
|
||||
|
||||
Kalıcı veri volume'u (`/data/db`) içinde rastgele bir anahtar dosyası üretir; yalnızca `mongodb`
|
||||
kullanıcısı (uid 999) okuyabilir. Çıktıda `-r-------- … mongodb mongodb` görmelisiniz. Dosya
|
||||
volume'da olduğu için yeniden başlatmalarda korunur.
|
||||
|
||||
### 3.3 Başlatma komutunu değiştir ve yeniden deploy et
|
||||
|
||||
Dokploy › MongoDB servisi › **Advanced** sekmesi › **Command** alanına:
|
||||
|
||||
```
|
||||
mongod --replSet rs0 --bind_ip_all --keyFile /data/db/rs-keyfile
|
||||
```
|
||||
|
||||
yazıp kaydedin ve servisi **Redeploy** edin. (`MONGO_INITDB_ROOT_*` ortam değişkenleri yerinde
|
||||
kaldığı için kimlik doğrulama açık kalır.) Konteyner yeniden oluşacağından 1. adımdaki `MONGO`
|
||||
değişkenini yeniden alın.
|
||||
|
||||
### 3.4 Replica set'i başlat (tek seferlik)
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||
--eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })'
|
||||
```
|
||||
|
||||
`rs0` adlı tek üyeli replica set'i başlatır; çıktıda `{ ok: 1 }` görmelisiniz. Uygulama
|
||||
`directConnection=true` ile bağlandığı için üye adresinin `localhost` olması sorun değildir.
|
||||
|
||||
Birkaç saniye sonra 2. adımdaki kontrolü tekrarlayın: **`rs0 PRIMARY`**.
|
||||
|
||||
## 4. Uygulama kullanıcısını doğrula
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin --eval '
|
||||
for (const d of ["admin", "butcem"]) {
|
||||
const u = db.getSiblingDB(d).getUser("butcem_app");
|
||||
if (u) print("tanımlı veritabanı:", d, "| roller:", JSON.stringify(u.roles));
|
||||
}'
|
||||
```
|
||||
|
||||
`butcem_app` kullanıcısının hangi veritabanında tanımlı olduğunu (bağlantı dizesindeki
|
||||
`authSource`) ve rollerini yazdırır. Beklenen rol: `[{"role":"readWrite","db":"butcem"}]`.
|
||||
|
||||
Rol farklıysa düzeltin:
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||
--eval 'db.getSiblingDB("<authSource>").updateUser("butcem_app", { roles: [{ role: "readWrite", db: "butcem" }] })'
|
||||
```
|
||||
|
||||
Uygulama kullanıcısına yalnızca `butcem` veritabanında okuma-yazma yetkisi verir (migration'lar
|
||||
index oluşturmak için bu yetkiyle yetinir).
|
||||
|
||||
## 5. Bağlantı dizesi (Dokploy uygulama ortam değişkeni)
|
||||
|
||||
```
|
||||
MONGODB_URI=mongodb://butcem_app:<parola-url-kodlu>@<iç-host>:27017/butcem?authSource=<authSource>&replicaSet=rs0&directConnection=true
|
||||
```
|
||||
|
||||
- `<iç-host>`: Dokploy › MongoDB servisi › **Internal Host** (ör. `butcem-mongo-a1b2c3`). Uygulama
|
||||
ve veritabanı aynı Dokploy ağında olduğundan **dışa port açmaya gerek yoktur**; açmayın.
|
||||
- `<authSource>`: 4. adımın çıktısındaki veritabanı (`admin` veya `butcem`).
|
||||
- Parolada `@ : / ? # %` gibi karakterler varsa URL kodlayın:
|
||||
```bash
|
||||
python3 -c 'import urllib.parse,getpass; print(urllib.parse.quote(getpass.getpass(), safe=""))'
|
||||
```
|
||||
Parolayı sorar ve bağlantı dizesine yazılacak kodlu hâlini basar (geçmişe düşmez).
|
||||
|
||||
## 6. Transaction denemesi (isteğe bağlı)
|
||||
|
||||
```bash
|
||||
docker exec -it "$MONGO" mongosh "mongodb://butcem_app@localhost:27017/butcem?authSource=<authSource>&replicaSet=rs0&directConnection=true" --eval '
|
||||
const s = db.getMongo().startSession(); const c = s.getDatabase("butcem").getCollection("tx_probe");
|
||||
s.startTransaction(); c.insertOne({ a: 1 }); s.commitTransaction();
|
||||
print("transaction tamam:", db.getSiblingDB("butcem").getCollection("tx_probe").countDocuments({}));
|
||||
db.getSiblingDB("butcem").getCollection("tx_probe").drop();'
|
||||
```
|
||||
|
||||
Uygulama kullanıcısıyla bağlanıp küçük bir transaction yazar, sonucu basar ve deneme
|
||||
koleksiyonunu siler. `transaction tamam: 1` görmelisiniz.
|
||||
+11
-2
@@ -295,8 +295,13 @@ Skill fazları (0–4) uygulanır, ardından alan fazları gelir. Her faz kendi
|
||||
| 13 | Alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu, **alışveriş sonrası giriş ve karşılaştırma**, "alışverişi tamamla" transaction'ı | 0.13.0 |
|
||||
| 14 | Dashboard (çizgi grafik + periyotlar) ve raporlar, CSV dışa aktarma | 0.14.0 |
|
||||
| 15 | Canlıya alma: `docs/DEPLOY.md`, Dokploy + Gitea, domain, volume, yedekleme, smoke test | **1.0.0** |
|
||||
| 16 | `/api/v1` mobil API ve mobil kimlik doğrulama | 1.1.0 |
|
||||
| 17 | Otomatik fiyat sağlayıcıları (market ve altın/döviz) araştırması ve izinliyse entegrasyonu | 1.2.0 |
|
||||
| 16 | **Kakeibo K1:** dört grup (Zorunlu/İstek/Kültür/Beklenmeyen), banka maliyeti ayrımı, kayıt bazında grup, Kakeibo raporu | 1.1.0 |
|
||||
| 17 | **Kakeibo K2:** ekstre içe aktarma (Yapı Kredi kart/hesap, Kakeibo çalışma kitabı; önizleme, tekrar engeli, ekstre toplamı kontrolü, geri alma) | 1.2.0 |
|
||||
| 18 | **Kakeibo K3:** ay planı ve dört soru (ay başı sihirbazı, harcanabilir, grup bütçeleri, haftalık nakit limiti), birikim hedefleri | 1.3.0 |
|
||||
| 19 | **Kakeibo K4:** haftalık kontrol, ay sonu değerlendirmesi (pişmanlık, iyileştirme hedefleri), Kakeibo panosu (KMH faizi/gün, taksit yükü, kayıtsız nakit) | 1.4.0 |
|
||||
| 20 | **Kakeibo K5:** satın almadan önce kontrol ve 24 saat bekleme listesi | 1.5.0 |
|
||||
| 21 | `/api/v1` mobil API ve mobil kimlik doğrulama | 1.6.0 |
|
||||
| 22 | Otomatik fiyat sağlayıcıları (market ve altın/döviz) araştırması ve izinliyse entegrasyonu | 1.7.0 |
|
||||
|
||||
---
|
||||
|
||||
@@ -325,6 +330,10 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
||||
- **Faz 11 kararları (2026-11-06):** 11a stok kategorileri, ürün tipleri, hareketler, SKT ve %20 eşik; 11b periyodik sayım, fire ayarlı öneri ve tüketim analizi; `v0.11.0` 11b sonunda. Eşiğin altındaki ürünler Faz 11'de "Alınması önerilenler" listesinde gösterilir (koleksiyon yok); Faz 13'te aktif alışveriş listesine onayla/reddet önerisi olarak bağlanır. SKT alım hareketinde (isteğe bağlı) girilir; tüketim ve fire FIFO ile en eski alımdan düşülür.
|
||||
- **Faz 12 kararları (2026-11-06):** 12a mağazalar ve ürünler (marka/paket, görseller), 12b fiyat kayıtları, birim fiyat ve fiyat geçmişi; `v0.12.0` 12b sonunda. Mağazalar, ürünler ve fiyatlar hane bazlıdır (hane içinde ortak, başka hane görmez). Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa ürün kaydında dönüşüm zorunludur ("5 kg = 33 yıkama"); her fiyat ürün tipinin biriminde karşılaştırılır.
|
||||
- **Faz 13 kararları (2026-11-06):** 13a alışveriş listeleri, stok eşiği önerileri (onayla/reddet), akıllı mağaza önerisi ve alışveriş modu; 13b alışveriş sonrası giriş, karşılaştırma raporu ve "Alışverişi tamamla"; `v0.13.0` 13b sonunda. Birden çok mağazadan alışverişte mağaza başına ayrı bölünmüş gider (her mağaza için ayrı hesap ve fiş). Tamamlanınca liste kapanır ve rapor sabitlenir; alınmayanlar başka aktif listeye taşınır, yeni listeye alınır ya da bırakılır. Reddedilen öneri yeni alım girilene kadar gösterilmez. Markası belirtilmeyen ürün tipi kalemi (ör. pazardan domates) tipin biriminde stoğa işlenir, fiyat kaydı oluşmaz.
|
||||
- **Faz 14 kararları (2026-11-07):** 14a dashboard ana grafiği (Haftalık/Aylık/Altı aylık/Yıllık, kümülatif/dönemsel) ve finans raporları (kategori dağılımı, aylık trend, kişi ve hesap bazlı, bütçe vs. gerçekleşen); 14b cari ve aynî borçlar, birikim kâr/zarar, alışveriş (liste vs. gerçekleşen, mağaza bazlı harcama), birim fiyat geçmişi, fire/zarar; `v0.14.0` 14b sonunda. Raporlar `/raporlar` altında ayrı sayfalar; dönem ve filtreler URL'de, her raporda CSV. "Ay" bütçe ayıdır; gider kuralları bütçeyle aynı (taksit modu, gizli hesap ayarı). Kişi bazlı harcama gideri kaydeden üyeye göre; mağaza bazlı harcama gider alıcısının mağaza adıyla başlamasına göre (şubeler birlikte).
|
||||
- **Faz 15 kararları (2026-11-07):** Canlı MongoDB Dokploy servisi **mongo:7** (mongo:8 sunucu çekirdeğiyle uyumsuz); uygulama mongo:7.0'a karşı tüm entegrasyon testleriyle doğrulandı. Replica set kimlik doğrulamalı tek düğüm (`rs0`, keyfile), uygulama `directConnection=true` ile bağlanır (`docs/MONGODB-KURULUM.md`). Migration'lar ve ilk admin konteyner başlarken otomatik (`scripts/bootstrap.mjs`; replica set yoksa uygulama açılmaz). Gitea `main` push → Dokploy webhook ile otomatik deploy. Yedek: günlük `mongodump` + uploads arşivi, 7 gün, şimdilik yalnızca sunucuda (sunucu dışı kopya sonraki adım).
|
||||
- **Kakeibo kararları (2026-11-07):** Uygulama `.claude/skills/kakeibo` becerisine göre Kakeibo yöntemine uyarlanır (Faz 16–20); mobil API ve fiyat sağlayıcıları öncelik değil, 21–22'ye kaydı. KMH faizi, KKDF/BSMV ve banka ücretleri "banka maliyeti"dir: dört gruba ve Kakeibo harcama toplamına girmez, ayrı gösterilir. Günlük plan korunur, Kakeibo haftalık kontrolü yanına eklenir. Kişilere giden havale/EFT gider sayılır; kampanya iadeleri "İade" geliri. Ekstre dosyaları `import/` (git ve Docker dışı); gerçek veri önce dev veritabanında denenir, canlıya aktarmadan önce sorulur.
|
||||
- **Faz 16 kararları (2026-11-07):** Kakeibo grubu gider kategorisinde tutulur ve alt kategoriye devredilir; işlem ya da bölünmüş kalem bazında değiştirilebilir ("kategoriyi ürün değil amaç belirler"). Banka maliyeti işareti kategori düzeyinde (varsayılan "Banka ve kart giderleri" ağacı); grup değiştirilse bile banka maliyeti kalır. Varsayılan kategoriler beceri eşlemesine göre doldurulur, kullanıcı kategorileri grupsuz kalır ve uyarı gösterilir. Migration'lar artık geri alınabilir (`npm run migrate -- --down <ad>`).
|
||||
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
||||
|
||||
---
|
||||
|
||||
@@ -20,6 +20,8 @@ const eslintConfig = defineConfig([
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
// Derlenmiş başlatma betiği (esbuild paketi).
|
||||
"dist/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.13.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "butcem",
|
||||
"version": "0.13.0",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^7.0.2",
|
||||
"@tanstack/react-table": "^9.3.1",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.13.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||
"engines": {
|
||||
@@ -23,6 +23,8 @@
|
||||
"icons": "tsx scripts/generate-icons.ts",
|
||||
"test:integration": "vitest run --config vitest.integration.config.mts",
|
||||
"migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts",
|
||||
"bootstrap": "tsx --conditions=react-server --env-file=.env.local scripts/bootstrap.ts",
|
||||
"build:scripts": "esbuild scripts/bootstrap.ts --bundle --platform=node --format=esm --target=node22 --conditions=react-server --outfile=dist/bootstrap.mjs --external:sharp --external:kerberos --external:snappy --external:socks --external:aws4 --external:@mongodb-js/zstd --external:mongodb-client-encryption --external:gcp-metadata --external:@aws-sdk/credential-providers --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --log-level=warning",
|
||||
"cleanup": "tsx --conditions=react-server --env-file=.env.local scripts/cleanup.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# /etc/butcem-backup.env — butcem-backup.sh ayarları. İzin: sudo chmod 600 /etc/butcem-backup.env
|
||||
# Değerler için docs/DEPLOY.md › Yedekleme.
|
||||
|
||||
# MongoDB konteyner adı veya adının ayırt edici bir parçası (docker ps --format '{{.Names}}')
|
||||
MONGO_CONTAINER="butcem-mongo"
|
||||
MONGO_ROOT_USER="butcem_root"
|
||||
MONGO_ROOT_PASSWORD=""
|
||||
|
||||
# Uygulamanın /app/uploads volume adı (docker volume ls)
|
||||
UPLOADS_VOLUME="butcem-uploads"
|
||||
|
||||
BACKUP_DIR="/var/backups/butcem"
|
||||
RETENTION_DAYS="7"
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# BÜTÇEM günlük yedek — sunucuda (Atlas) root olarak, cron ile çalışır. Ayrıntı: docs/DEPLOY.md › Yedekleme
|
||||
#
|
||||
# Üretir (BACKUP_DIR altında, tarih damgalı):
|
||||
# mongo-YYYYmmdd-HHMMSS.archive.gz → butcem veritabanı (mongodump --archive --gzip)
|
||||
# uploads-YYYYmmdd-HHMMSS.tar.gz → yüklenen dosyalar (uploads volume)
|
||||
# SHA256SUMS-YYYYmmdd-HHMMSS → bütünlük özetleri
|
||||
# RETENTION_DAYS günden eski yedekleri siler.
|
||||
#
|
||||
# Ayarlar /etc/butcem-backup.env dosyasından okunur (yalnızca root okuyabilmeli: chmod 600).
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG="${BUTCEM_BACKUP_CONFIG:-/etc/butcem-backup.env}"
|
||||
# shellcheck source=/dev/null
|
||||
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||
|
||||
: "${MONGO_CONTAINER:?MONGO_CONTAINER tanımlı değil (MongoDB konteyner adı veya adının bir parçası)}"
|
||||
: "${MONGO_ROOT_USER:?MONGO_ROOT_USER tanımlı değil}"
|
||||
: "${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD tanımlı değil}"
|
||||
: "${UPLOADS_VOLUME:?UPLOADS_VOLUME tanımlı değil (uygulamanın /app/uploads volume adı)}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/var/backups/butcem}"
|
||||
RETENTION_DAYS="${RETENTION_DAYS:-7}"
|
||||
DB_NAME="${DB_NAME:-butcem}"
|
||||
HELPER_IMAGE="${HELPER_IMAGE:-alpine:3.20}"
|
||||
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
log() { echo "[$(date '+%F %T')] $*"; }
|
||||
|
||||
# Dokploy konteyner adlarına rastgele son ek ekler; adın bir parçasıyla eşleşen çalışan konteyner.
|
||||
CID="$(docker ps -q --filter "name=${MONGO_CONTAINER}" | head -n1)"
|
||||
[ -n "$CID" ] || { log "HATA: '${MONGO_CONTAINER}' adlı çalışan MongoDB konteyneri yok."; exit 1; }
|
||||
docker volume inspect "$UPLOADS_VOLUME" >/dev/null 2>&1 || { log "HATA: '${UPLOADS_VOLUME}' volume'u yok."; exit 1; }
|
||||
|
||||
install -d -m 700 "$BACKUP_DIR"
|
||||
MONGO_FILE="$BACKUP_DIR/mongo-$TS.archive.gz"
|
||||
UPLOADS_FILE="$BACKUP_DIR/uploads-$TS.tar.gz"
|
||||
|
||||
# 1) MongoDB: parola komut satırında görünmesin diye geçici yapılandırma dosyasıyla.
|
||||
log "MongoDB yedeği alınıyor ($DB_NAME)…"
|
||||
CONF="/tmp/butcem-dump-$TS.yaml"
|
||||
printf 'password: "%s"\n' "$MONGO_ROOT_PASSWORD" | docker exec -i "$CID" sh -c "umask 077 && cat > $CONF"
|
||||
if ! docker exec "$CID" mongodump --quiet --config "$CONF" --username "$MONGO_ROOT_USER" \
|
||||
--authenticationDatabase admin --db "$DB_NAME" --archive --gzip >"$MONGO_FILE"; then
|
||||
docker exec "$CID" rm -f "$CONF"
|
||||
rm -f "$MONGO_FILE"
|
||||
log "HATA: mongodump başarısız."
|
||||
exit 1
|
||||
fi
|
||||
docker exec "$CID" rm -f "$CONF"
|
||||
[ -s "$MONGO_FILE" ] || { log "HATA: MongoDB yedeği boş."; exit 1; }
|
||||
|
||||
# 2) Yüklenen dosyalar: volume salt okunur bağlanır.
|
||||
log "Uploads yedeği alınıyor ($UPLOADS_VOLUME)…"
|
||||
docker run --rm -v "$UPLOADS_VOLUME":/data:ro -v "$BACKUP_DIR":/backup "$HELPER_IMAGE" \
|
||||
tar czf "/backup/$(basename "$UPLOADS_FILE")" -C /data .
|
||||
[ -s "$UPLOADS_FILE" ] || { log "HATA: uploads yedeği boş."; exit 1; }
|
||||
|
||||
# 3) Bütünlük özeti ve izinler
|
||||
( cd "$BACKUP_DIR" && sha256sum "$(basename "$MONGO_FILE")" "$(basename "$UPLOADS_FILE")" >"SHA256SUMS-$TS" )
|
||||
chmod 600 "$MONGO_FILE" "$UPLOADS_FILE" "$BACKUP_DIR/SHA256SUMS-$TS"
|
||||
|
||||
# 4) Saklama süresi
|
||||
find "$BACKUP_DIR" -maxdepth 1 -type f \( -name 'mongo-*.archive.gz' -o -name 'uploads-*.tar.gz' -o -name 'SHA256SUMS-*' \) \
|
||||
-mtime +"$RETENTION_DAYS" -print -delete | sed 's/^/ silindi: /'
|
||||
|
||||
log "Tamam: $(du -h "$MONGO_FILE" | cut -f1) veritabanı, $(du -h "$UPLOADS_FILE" | cut -f1) dosya → $BACKUP_DIR"
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# BÜTÇEM geri yükleme — sunucuda root olarak, ELLE çalıştırılır. Ayrıntı: docs/DEPLOY.md › Geri yükleme
|
||||
#
|
||||
# sudo scripts/backup/butcem-restore.sh /var/backups/butcem/mongo-….archive.gz [/var/backups/butcem/uploads-….tar.gz]
|
||||
#
|
||||
# DİKKAT: butcem veritabanındaki koleksiyonlar yedektekilerle DEĞİŞTİRİLİR (--drop); uploads arşivi
|
||||
# verilirse volume içeriği silinip arşivden yazılır. Önce uygulamayı durdurun (Dokploy › Stop).
|
||||
# Ayarlar butcem-backup.sh ile aynı dosyadan: /etc/butcem-backup.env
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG="${BUTCEM_BACKUP_CONFIG:-/etc/butcem-backup.env}"
|
||||
# shellcheck source=/dev/null
|
||||
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||
: "${MONGO_CONTAINER:?}" "${MONGO_ROOT_USER:?}" "${MONGO_ROOT_PASSWORD:?}" "${UPLOADS_VOLUME:?}"
|
||||
DB_NAME="${DB_NAME:-butcem}"
|
||||
HELPER_IMAGE="${HELPER_IMAGE:-alpine:3.20}"
|
||||
|
||||
MONGO_FILE="${1:?Kullanım: butcem-restore.sh <mongo-arşivi> [uploads-arşivi]}"
|
||||
UPLOADS_FILE="${2:-}"
|
||||
[ -s "$MONGO_FILE" ] || { echo "Arşiv yok veya boş: $MONGO_FILE"; exit 1; }
|
||||
[ -z "$UPLOADS_FILE" ] || [ -s "$UPLOADS_FILE" ] || { echo "Arşiv yok veya boş: $UPLOADS_FILE"; exit 1; }
|
||||
|
||||
# Bütünlük kontrolü (aynı klasörde SHA256SUMS varsa)
|
||||
DIR="$(cd "$(dirname "$MONGO_FILE")" && pwd)"
|
||||
STAMP="$(basename "$MONGO_FILE" | sed -E 's/^mongo-(.*)\.archive\.gz$/\1/')"
|
||||
if [ -f "$DIR/SHA256SUMS-$STAMP" ]; then
|
||||
(cd "$DIR" && sha256sum -c "SHA256SUMS-$STAMP") || { echo "Bütünlük kontrolü başarısız."; exit 1; }
|
||||
fi
|
||||
|
||||
CID="$(docker ps -q --filter "name=${MONGO_CONTAINER}" | head -n1)"
|
||||
[ -n "$CID" ] || { echo "Çalışan MongoDB konteyneri yok: $MONGO_CONTAINER"; exit 1; }
|
||||
|
||||
echo "Geri yüklenecek: $MONGO_FILE${UPLOADS_FILE:+ ve $UPLOADS_FILE}"
|
||||
echo "'$DB_NAME' veritabanındaki mevcut veri yedektekiyle DEĞİŞTİRİLECEK."
|
||||
if [ "${ASSUME_YES:-0}" != "1" ]; then
|
||||
read -r -p "Devam etmek için 'evet' yazın: " ANSWER
|
||||
[ "$ANSWER" = "evet" ] || { echo "Vazgeçildi."; exit 1; }
|
||||
fi
|
||||
|
||||
CONF="/tmp/butcem-restore-$$.yaml"
|
||||
printf 'password: "%s"\n' "$MONGO_ROOT_PASSWORD" | docker exec -i "$CID" sh -c "umask 077 && cat > $CONF"
|
||||
trap 'docker exec "$CID" rm -f "$CONF" >/dev/null 2>&1 || true' EXIT
|
||||
docker exec -i "$CID" mongorestore --quiet --config "$CONF" --username "$MONGO_ROOT_USER" \
|
||||
--authenticationDatabase admin --archive --gzip --drop --nsInclude "${DB_NAME}.*" <"$MONGO_FILE"
|
||||
echo "✓ Veritabanı geri yüklendi."
|
||||
|
||||
if [ -n "$UPLOADS_FILE" ]; then
|
||||
docker run --rm -v "$UPLOADS_VOLUME":/data -v "$(cd "$(dirname "$UPLOADS_FILE")" && pwd)":/backup:ro "$HELPER_IMAGE" \
|
||||
sh -c "find /data -mindepth 1 -delete && tar xzf /backup/$(basename "$UPLOADS_FILE") -C /data && chown -R 1001:1001 /data"
|
||||
echo "✓ Yüklenen dosyalar geri yüklendi."
|
||||
fi
|
||||
echo "Uygulamayı yeniden başlatın (Dokploy › Start) ve duman testini çalıştırın."
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Canlı başlatma (Docker entrypoint, uygulamadan önce): replica set kontrolü, bekleyen migration'lar
|
||||
* ve SEED_ADMIN_* tanımlıysa ilk admin hesabı (varsa dokunulmaz). İdempotent; her deploy'da çalışır.
|
||||
* İmajda esbuild ile tek dosyaya derlenir (scripts/bootstrap.mjs). Lokal: npm run bootstrap
|
||||
*/
|
||||
import mongoose from "mongoose";
|
||||
import { getDb, getMongoClient } from "@/lib/db/mongo";
|
||||
import { env } from "@/lib/env";
|
||||
import { runMigrations } from "./migrations";
|
||||
import { closeSeedConnections, runSeed } from "./seed/run";
|
||||
|
||||
async function main() {
|
||||
const e = env();
|
||||
const hello = await getDb().admin().command({ hello: 1 });
|
||||
if (!hello.setName) {
|
||||
throw new Error(
|
||||
"MongoDB replica set olarak çalışmıyor; transaction'lı yazmalar başarısız olur. " +
|
||||
"docs/DEPLOY.md › 'MongoDB replica set' adımlarını uygulayın.",
|
||||
);
|
||||
}
|
||||
if (!hello.isWritablePrimary) throw new Error("MongoDB düğümü PRIMARY değil (yazılamaz).");
|
||||
console.log(`• MongoDB ${hello.setName} PRIMARY`);
|
||||
|
||||
if (e.NODE_ENV === "production" && e.SEED_ADMIN_EMAIL && e.SEED_ADMIN_PASSWORD) {
|
||||
// Production'da seed yalnızca migration + admin yapar (örnek veri oluşturulmaz).
|
||||
await runSeed();
|
||||
} else {
|
||||
await runMigrations();
|
||||
if (e.NODE_ENV === "production")
|
||||
console.log("• SEED_ADMIN_* tanımlı değil; admin kontrolü atlandı.");
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err: unknown) => {
|
||||
console.error(`✗ Başlatma hatası: ${err instanceof Error ? err.message : err}`);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await closeSeedConnections();
|
||||
await mongoose.disconnect();
|
||||
await getMongoClient().close();
|
||||
});
|
||||
+10
-2
@@ -4,9 +4,17 @@
|
||||
*/
|
||||
import mongoose from "mongoose";
|
||||
import { getMongoClient } from "@/lib/db/mongo";
|
||||
import { runMigrations } from "./migrations";
|
||||
import { revertMigration, runMigrations } from "./migrations";
|
||||
|
||||
runMigrations()
|
||||
// npm run migrate → bekleyen migration'lar
|
||||
// npm run migrate -- --down <ad> → bir migration'ı geri al
|
||||
const downIndex = process.argv.indexOf("--down");
|
||||
const task =
|
||||
downIndex >= 0
|
||||
? revertMigration(process.argv[downIndex + 1] ?? "")
|
||||
: runMigrations().then(() => {});
|
||||
|
||||
task
|
||||
.catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defaultKakeibo } from "@/features/categories/service";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/**
|
||||
* Kakeibo dört grup (Faz 16): gider kategorilerine `kakeiboGroup` ve `bankCost`. Varsayılan
|
||||
* kategoriler beceri eşlemesine göre doldurulur; kullanıcının kategorileri grupsuz kalır (arayüz
|
||||
* "grup seç" uyarısı gösterir). Alanı zaten olan kategoriye dokunulmaz. Hiçbir kategori silinmez.
|
||||
* Geri alma: alanlar kaldırılır (npm run migrate -- --down 016-kakeibo-groups).
|
||||
*/
|
||||
export const migration: Migration = {
|
||||
name: "016-kakeibo-groups",
|
||||
description: "gider kategorilerine Kakeibo grubu ve banka maliyeti; varsayılanlar dolduruldu",
|
||||
async up() {
|
||||
const defaults = await CategoryModel.find({
|
||||
kind: "expense",
|
||||
defaultKey: { $type: "string" },
|
||||
kakeiboGroup: { $exists: false },
|
||||
})
|
||||
.select({ defaultKey: 1 })
|
||||
.lean<{ _id: unknown; defaultKey: string }[]>();
|
||||
const ops = defaults.map((c) => ({
|
||||
updateOne: { filter: { _id: c._id }, update: { $set: defaultKakeibo(c.defaultKey) } },
|
||||
}));
|
||||
if (ops.length) await CategoryModel.bulkWrite(ops);
|
||||
await CategoryModel.updateMany(
|
||||
{ kakeiboGroup: { $exists: false } },
|
||||
{ $set: { kakeiboGroup: null, bankCost: false } },
|
||||
);
|
||||
},
|
||||
async down() {
|
||||
await CategoryModel.updateMany({}, { $unset: { kakeiboGroup: "", bankCost: "" } });
|
||||
await TransactionModel.updateMany(
|
||||
{ kakeiboGroup: { $exists: true } },
|
||||
{ $unset: { kakeiboGroup: "" } },
|
||||
);
|
||||
await TransactionModel.updateMany(
|
||||
{ "splits.kakeiboGroup": { $exists: true } },
|
||||
{ $unset: { "splits.$[].kakeiboGroup": "" } },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import { migration as m012 } from "./012-stock-counts";
|
||||
import { migration as m013 } from "./013-stores-products";
|
||||
import { migration as m014 } from "./014-product-prices";
|
||||
import { migration as m015 } from "./015-shopping-lists";
|
||||
import { migration as m016 } from "./016-kakeibo-groups";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||
@@ -34,6 +35,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
m013,
|
||||
m014,
|
||||
m015,
|
||||
m016,
|
||||
];
|
||||
|
||||
const COLLECTION = "migrations";
|
||||
@@ -63,3 +65,23 @@ export async function runMigrations(log: (line: string) => void = console.log):
|
||||
if (!ran.length) log("• Bekleyen migration yok.");
|
||||
return ran;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bir migration'ı geri alır (yalnızca `down` tanımlıysa) ve kaydını siler; bir sonraki
|
||||
* `runMigrations` onu yeniden uygular. Kullanım: npm run migrate -- --down 016-kakeibo-groups
|
||||
*/
|
||||
export async function revertMigration(
|
||||
name: string,
|
||||
log: (line: string) => void = console.log,
|
||||
): Promise<void> {
|
||||
const m = MIGRATIONS.find((x) => x.name === name);
|
||||
if (!m) throw new Error(`Migration bulunamadı: ${name}`);
|
||||
if (!m.down) throw new Error(`${name} geri alınamaz (down tanımlı değil).`);
|
||||
const db = getDb();
|
||||
await connectMongoose();
|
||||
const col = db.collection<{ name: string }>(COLLECTION);
|
||||
if (!(await col.findOne({ name }))) throw new Error(`${name} uygulanmamış.`);
|
||||
await m.down(db);
|
||||
await col.deleteOne({ name });
|
||||
log(`↩ Geri alındı: ${name}`);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Db } from "mongodb";
|
||||
|
||||
/** Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). Geri alma yok; ileri yönlü. */
|
||||
/**
|
||||
* Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). İleri yönlü; Faz 16'dan
|
||||
* itibaren veri değiştirenler `down` ile geri alınabilir (npm run migrate -- --down <ad>).
|
||||
*/
|
||||
export type Migration = {
|
||||
name: string;
|
||||
description: string;
|
||||
up: (db: Db) => Promise<void>;
|
||||
down?: (db: Db) => Promise<void>;
|
||||
};
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# BÜTÇEM duman testi (smoke test) — canlıya alma sonrası, yalnızca OKUMA yapar; veri değiştirmez.
|
||||
#
|
||||
# Kullanım:
|
||||
# scripts/smoke-test.sh https://butcem.ta1sph.website [beklenen-sürüm]
|
||||
# SMOKE_USER=admin SMOKE_PASSWORD='…' scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||
#
|
||||
# Gerekenler: bash, curl. Çıkış kodu: 0 = hepsi geçti, 1 = en az bir kontrol başarısız.
|
||||
set -uo pipefail
|
||||
|
||||
BASE="${1:?Kullanım: scripts/smoke-test.sh <adres> [beklenen-sürüm]}"
|
||||
BASE="${BASE%/}"
|
||||
EXPECT_VERSION="${2:-}"
|
||||
FAIL=0
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
|
||||
bad() { printf ' \033[31m✗\033[0m %s\n' "$1"; FAIL=1; }
|
||||
check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; }
|
||||
|
||||
echo "BÜTÇEM duman testi: $BASE"
|
||||
|
||||
echo "1) Sağlık kontrolü"
|
||||
HEALTH_CODE=$(curl -s -o "$TMP/health" -w '%{http_code}' "$BASE/api/health")
|
||||
HEALTH=$(cat "$TMP/health")
|
||||
echo " $HEALTH"
|
||||
check "GET /api/health 200" '[ "$HEALTH_CODE" = 200 ]'
|
||||
check "veritabanı erişilebilir" 'grep -q "\"db\":\"ok\"" <<<"$HEALTH"'
|
||||
check "MongoDB replica set (transaction için)" '! grep -q "\"replicaSet\":null" <<<"$HEALTH" && grep -q "\"replicaSet\":\"" <<<"$HEALTH"'
|
||||
if [ -n "$EXPECT_VERSION" ]; then
|
||||
check "sürüm $EXPECT_VERSION" 'grep -q "\"version\":\"$EXPECT_VERSION\"" <<<"$HEALTH"'
|
||||
fi
|
||||
|
||||
echo "2) Sayfalar ve güvenlik başlıkları"
|
||||
LOGIN_CODE=$(curl -s -D "$TMP/login-headers" -o /dev/null -w '%{http_code}' "$BASE/login")
|
||||
H=$(tr -d '\r' <"$TMP/login-headers" | tr 'A-Z' 'a-z')
|
||||
check "GET /login 200" '[ "$LOGIN_CODE" = 200 ]'
|
||||
check "Content-Security-Policy var" 'grep -q "^content-security-policy:" <<<"$H"'
|
||||
check "X-Frame-Options: DENY" 'grep -q "^x-frame-options: deny" <<<"$H"'
|
||||
check "X-Content-Type-Options: nosniff" 'grep -q "^x-content-type-options: nosniff" <<<"$H"'
|
||||
if [[ "$BASE" == https://* ]]; then
|
||||
check "Strict-Transport-Security var" 'grep -q "^strict-transport-security:" <<<"$H"'
|
||||
HTTP_URL="http://${BASE#https://}/login"
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$HTTP_URL")
|
||||
check "HTTP → HTTPS yönlendirmesi ($HTTP_CODE)" '[[ "$HTTP_CODE" =~ ^30[1278]$ ]]'
|
||||
fi
|
||||
|
||||
echo "3) Oturumsuz erişim engelleniyor"
|
||||
DASH=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$BASE/dashboard")
|
||||
check "/dashboard → giriş sayfasına yönlendirme ($DASH)" '[[ "$DASH" =~ ^30[27]\ .*/login ]]'
|
||||
FILE_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/files/000000000000000000000000")
|
||||
check "/api/files oturumsuz 401 ($FILE_CODE)" '[ "$FILE_CODE" = 401 ]'
|
||||
CSV=$(curl -s -o "$TMP/csv" -w '%{http_code}' "$BASE/raporlar/kategoriler/csv")
|
||||
check "rapor CSV'si oturumsuz veri vermiyor ($CSV)" '! grep -q "Toplam gidere oran" "$TMP/csv"'
|
||||
|
||||
if [ -n "${SMOKE_USER:-}" ] && [ -n "${SMOKE_PASSWORD:-}" ]; then
|
||||
echo "4) Giriş ($SMOKE_USER)"
|
||||
SIGN=$(curl -s -c "$TMP/jar" -o "$TMP/signin" -w '%{http_code}' -X POST "$BASE/api/auth/sign-in/username" \
|
||||
-H 'Content-Type: application/json' -H "Origin: $BASE" \
|
||||
--data "$(printf '{"username":"%s","password":"%s"}' "$SMOKE_USER" "$SMOKE_PASSWORD")")
|
||||
check "giriş 200 ($SIGN)" '[ "$SIGN" = 200 ]'
|
||||
PAGE=$(curl -s -b "$TMP/jar" -L -o /dev/null -w '%{http_code} %{url_effective}' "$BASE/dashboard")
|
||||
check "oturumla ana sayfa açılıyor ($PAGE)" '[[ "$PAGE" =~ ^200\ ]] && [[ ! "$PAGE" =~ /login ]]'
|
||||
curl -s -b "$TMP/jar" -X POST "$BASE/api/auth/sign-out" -H "Origin: $BASE" -o /dev/null
|
||||
else
|
||||
echo "4) Giriş testi atlandı (SMOKE_USER ve SMOKE_PASSWORD tanımlı değil)"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAIL" = 0 ]; then echo "Sonuç: tüm kontroller geçti."; else echo "Sonuç: başarısız kontrol var."; fi
|
||||
exit "$FAIL"
|
||||
@@ -5,6 +5,7 @@ import { BudgetView } from "@/features/budgets/components/budget-view";
|
||||
import { IncomePlanSection } from "@/features/budgets/components/income-plan";
|
||||
import { getIncomePlan } from "@/features/budgets/plan";
|
||||
import { getBudget } from "@/features/budgets/service";
|
||||
import { KakeiboBudgetCard } from "@/features/reports/components/kakeibo-card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
@@ -27,7 +28,13 @@ export default async function BudgetPage({ searchParams }: PageProps<"/butce">)
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.budget.title} description={tr.budget.description} />
|
||||
<BudgetView key={data.month} data={data} />
|
||||
<BudgetView
|
||||
key={data.month}
|
||||
data={data}
|
||||
summary={
|
||||
<KakeiboBudgetCard userId={user.id} householdId={household.id} month={data.month} />
|
||||
}
|
||||
/>
|
||||
<IncomePlanSection
|
||||
key={`income-${data.month}`}
|
||||
month={data.month}
|
||||
|
||||
@@ -3,6 +3,8 @@ import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { OverviewCard } from "@/features/dashboard/components/overview-card";
|
||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||
import { DailyPlanCard } from "@/features/budgets/components/daily-plan-card";
|
||||
import { BudgetWarningBanner } from "@/features/budgets/components/warning-banner";
|
||||
@@ -84,7 +86,7 @@ async function SummaryCards({ userId, householdId }: { userId: string; household
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
export default async function DashboardPage({ searchParams }: PageProps<"/dashboard">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
return (
|
||||
<>
|
||||
@@ -104,6 +106,9 @@ export default async function DashboardPage() {
|
||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||
<SummaryCards userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<Skeleton className="h-96 rounded-xl" />}>
|
||||
<OverviewCard userId={user.id} householdId={household.id} searchParams={searchParams} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<DailyPlanCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getShoppingReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { parsePeriod } from "@/shared/reports";
|
||||
|
||||
/** GET /raporlar/alisveris/csv?donem=3|6|12&tablo=listeler|magazalar */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getShoppingReport(c.userId, c.householdId, parsePeriod(c.params.get("donem")));
|
||||
if (!r) return forbidden();
|
||||
const h = tr.reports.csvHeaders;
|
||||
const today = todayIstanbul();
|
||||
if (c.params.get("tablo") === "magazalar")
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.stores,
|
||||
r.stores.map((s) => [
|
||||
s.name,
|
||||
csvMoney(s.amountMinor),
|
||||
s.count,
|
||||
csvPct(s.sharePct),
|
||||
csvMoney(s.fromListsMinor),
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.stores(today),
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.lists,
|
||||
r.lists.map((l) => [
|
||||
l.date,
|
||||
l.name,
|
||||
csvMoney(l.summary.plannedMinor),
|
||||
csvMoney(l.summary.actualMinor),
|
||||
csvMoney(l.summary.diffMinor),
|
||||
l.summary.counts.same,
|
||||
l.summary.counts.substitute,
|
||||
l.summary.counts.skipped,
|
||||
l.summary.counts.extra,
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.lists(today),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
CsvButton,
|
||||
RangeNav,
|
||||
ReportFrame,
|
||||
ToolsEnd,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { ShoppingReportView } from "@/features/reports/components/shopping-waste-view";
|
||||
import { getShoppingReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { parsePeriod, REPORT_PERIODS } from "@/shared/reports";
|
||||
|
||||
const t = tr.reports.shopping;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/alisveris?donem=3|6|12 — liste vs. gerçekleşen, mağaza bazlı harcama. */
|
||||
export default async function ShoppingReportPage({
|
||||
searchParams,
|
||||
}: PageProps<"/raporlar/alisveris">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const period = parsePeriod((await searchParams).donem);
|
||||
const data = await getShoppingReport(user.id, household.id, period);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<>
|
||||
<RangeNav
|
||||
path="/raporlar/alisveris"
|
||||
label={tr.reports.periodLabel}
|
||||
value={String(period)}
|
||||
options={REPORT_PERIODS.map((p) => ({
|
||||
value: String(p),
|
||||
label: tr.reports.periods[p]!,
|
||||
}))}
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton
|
||||
href={`/raporlar/alisveris/csv?donem=${period}&tablo=listeler`}
|
||||
label={t.csvLists}
|
||||
/>
|
||||
<CsvButton
|
||||
href={`/raporlar/alisveris/csv?donem=${period}&tablo=magazalar`}
|
||||
label={t.csvStores}
|
||||
/>
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ShoppingReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { REPORT_RANGES, type ReportRange } from "@/features/assets/service";
|
||||
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getAssetsReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { formatAssetQuantity } from "@/shared/assets";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
|
||||
/** GET /raporlar/birikim/csv — varlık başına maliyet, değer ve kâr/zarar. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const q = c.params.get("donem");
|
||||
const range: ReportRange = REPORT_RANGES.includes(q as ReportRange) ? (q as ReportRange) : "6";
|
||||
const r = await getAssetsReport(c.userId, c.householdId, range);
|
||||
if (!r) return forbidden();
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
tr.reports.csvHeaders.assets,
|
||||
r.items.map((a) => [
|
||||
a.name || a.label,
|
||||
formatAssetQuantity(a.assetKey, a.summary.quantity),
|
||||
csvMoney(a.summary.costMinor),
|
||||
a.summary.currentValueMinor === null ? "" : csvMoney(a.summary.currentValueMinor),
|
||||
a.summary.unrealizedMinor === null ? "" : csvMoney(a.summary.unrealizedMinor),
|
||||
csvMoney(a.summary.realizedMinor),
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.assets(todayIstanbul()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AssetsReportView } from "@/features/reports/components/contacts-assets-view";
|
||||
import {
|
||||
CsvButton,
|
||||
RangeNav,
|
||||
ReportFrame,
|
||||
ToolsEnd,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getAssetsReport } from "@/features/reports/extra";
|
||||
import { REPORT_RANGES, type ReportRange } from "@/features/assets/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
const t = tr.reports.assets;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/birikim?donem=3|6|12|tum — varlık kâr/zarar ve dönem özeti. */
|
||||
export default async function AssetsReportPage({ searchParams }: PageProps<"/raporlar/birikim">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const q = (await searchParams).donem;
|
||||
const range: ReportRange = REPORT_RANGES.includes(q as ReportRange) ? (q as ReportRange) : "6";
|
||||
const data = await getAssetsReport(user.id, household.id, range);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<>
|
||||
<RangeNav
|
||||
path="/raporlar/birikim"
|
||||
label={tr.reports.periodLabel}
|
||||
value={range}
|
||||
options={REPORT_RANGES.map((r) => ({ value: r, label: t.ranges[r]! }))}
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton href="/raporlar/birikim/csv" />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AssetsReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getUnitPriceReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { unitPriceBase } from "@/shared/prices";
|
||||
|
||||
/** GET /raporlar/birim-fiyat/csv?tip=<ürün tipi> — ay başına en düşük ve ortalama (gösterim biriminde). */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getUnitPriceReport(c.userId, c.householdId, c.params.get("tip") ?? undefined);
|
||||
if (!r?.selected) return forbidden();
|
||||
const sel = r.selected;
|
||||
const base = unitPriceBase(sel.unit);
|
||||
const money = (x: number | null) => (x === null ? "" : csvMoney(Math.round(x * base.factor)));
|
||||
const header = tr.reports.csvHeaders.unitPrice.map((h, i) =>
|
||||
i === 1 || i === 2 ? `${h} (TL/${base.unit})` : h,
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
header,
|
||||
sel.points.map((p) => [p.month, money(p.minMinor), money(p.avgMinor), p.count]),
|
||||
),
|
||||
tr.reports.csvFiles.unitPrice(
|
||||
sel.name.toLocaleLowerCase("tr-TR").replace(/\s+/g, "-"),
|
||||
todayIstanbul(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CsvButton, ReportFrame, ToolsEnd } from "@/features/reports/components/report-frame";
|
||||
import { UnitPriceView } from "@/features/reports/components/unit-price-view";
|
||||
import { getUnitPriceReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
const t = tr.reports.unitPrice;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/birim-fiyat?tip=<ürün tipi> — aylık en düşük ve ortalama birim fiyat. */
|
||||
export default async function UnitPriceReportPage({
|
||||
searchParams,
|
||||
}: PageProps<"/raporlar/birim-fiyat">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const tip = (await searchParams).tip;
|
||||
const data = await getUnitPriceReport(
|
||||
user.id,
|
||||
household.id,
|
||||
typeof tip === "string" ? tip : undefined,
|
||||
);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
data.selected ? (
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/birim-fiyat/csv?tip=${data.selected.id}`} />
|
||||
</ToolsEnd>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<UnitPriceView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getBudgetReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
|
||||
/** GET /raporlar/butce/csv?ay=YYYY-MM — kategori bazında limit ve gerçekleşen. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getBudgetReport(c.userId, c.householdId, c.month);
|
||||
if (!r) return forbidden();
|
||||
const csv = toCsv(
|
||||
tr.reports.csvHeaders.budget,
|
||||
r.lines.map((l) => [
|
||||
l.path,
|
||||
l.limitMinor === null ? "" : csvMoney(l.limitMinor),
|
||||
csvMoney(l.actualMinor),
|
||||
l.remainingMinor === null ? "" : csvMoney(l.remainingMinor),
|
||||
csvPct(l.ratioBp === null ? null : Math.round(l.ratioBp / 10) / 10),
|
||||
]),
|
||||
);
|
||||
return csvResponse(csv, tr.reports.csvFiles.budget(r.month));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BudgetReportView } from "@/features/reports/components/budget-report-view";
|
||||
import {
|
||||
CsvButton,
|
||||
ToolsEnd,
|
||||
MonthNav,
|
||||
ReportFrame,
|
||||
monthLabel,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getBudgetReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
const t = tr.reports.budget;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/butce?ay=YYYY-MM — bütçe ve gerçekleşen. */
|
||||
export default async function BudgetReportPage({ searchParams }: PageProps<"/raporlar/butce">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||
const data = await getBudgetReport(user.id, household.id, ay.success ? ay.data : undefined);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description(monthLabel(data.month))}
|
||||
tools={
|
||||
<>
|
||||
<MonthNav month={data.month} currentMonth={data.currentMonth} path="/raporlar/butce" />
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/butce/csv?ay=${data.month}`} />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<BudgetReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getContactsReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
|
||||
/** GET /raporlar/cari/csv?tablo=cari|ayni */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getContactsReport(c.userId, c.householdId);
|
||||
if (!r) return forbidden();
|
||||
const h = tr.reports.csvHeaders;
|
||||
const today = todayIstanbul();
|
||||
if (c.params.get("tablo") === "ayni")
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.inKind,
|
||||
r.inKind.map((d) => [
|
||||
d.contactName,
|
||||
d.direction === "receivable" ? tr.reports.contacts.receivable : tr.reports.contacts.debt,
|
||||
d.remainingLabel,
|
||||
d.valueMinor === null ? "" : csvMoney(d.valueMinor),
|
||||
d.date,
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.inKind(today),
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.contacts,
|
||||
r.contacts.map((x) => [x.name, csvMoney(x.balanceMinor), x.dueDate ?? ""]),
|
||||
),
|
||||
tr.reports.csvFiles.contacts(today),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContactsReportView } from "@/features/reports/components/contacts-assets-view";
|
||||
import { CsvButton, ReportFrame, ToolsEnd } from "@/features/reports/components/report-frame";
|
||||
import { getContactsReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
const t = tr.reports.contacts;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/cari — cari bakiyeler ve açık aynî borç/alacaklar. */
|
||||
export default async function ContactsReportPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await getContactsReport(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<ToolsEnd>
|
||||
<CsvButton href="/raporlar/cari/csv?tablo=cari" label={t.csvContacts} />
|
||||
<CsvButton href="/raporlar/cari/csv?tablo=ayni" label={t.csvInKind} />
|
||||
</ToolsEnd>
|
||||
}
|
||||
>
|
||||
<ContactsReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getWasteReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { quantityInput } from "@/shared/stock";
|
||||
import { parsePeriod } from "@/shared/reports";
|
||||
|
||||
/** GET /raporlar/fire/csv?donem=3|6|12 */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getWasteReport(c.userId, c.householdId, parsePeriod(c.params.get("donem")));
|
||||
if (!r) return forbidden();
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
tr.reports.csvHeaders.waste,
|
||||
r.rows.map((x) => [
|
||||
x.name,
|
||||
x.unit,
|
||||
quantityInput(x.wasted),
|
||||
quantityInput(x.purchased),
|
||||
csvPct(x.wastePct),
|
||||
x.costMinor === null ? "" : csvMoney(x.costMinor),
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.waste(todayIstanbul()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
CsvButton,
|
||||
RangeNav,
|
||||
ReportFrame,
|
||||
ToolsEnd,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { WasteReportView } from "@/features/reports/components/shopping-waste-view";
|
||||
import { getWasteReport } from "@/features/reports/extra";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { parsePeriod, REPORT_PERIODS } from "@/shared/reports";
|
||||
|
||||
const t = tr.reports.waste;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/fire?donem=3|6|12 — fire/zarar raporu. */
|
||||
export default async function WasteReportPage({ searchParams }: PageProps<"/raporlar/fire">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const period = parsePeriod((await searchParams).donem);
|
||||
const data = await getWasteReport(user.id, household.id, period);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<>
|
||||
<RangeNav
|
||||
path="/raporlar/fire"
|
||||
label={tr.reports.periodLabel}
|
||||
value={String(period)}
|
||||
options={REPORT_PERIODS.map((p) => ({
|
||||
value: String(p),
|
||||
label: tr.reports.periods[p]!,
|
||||
}))}
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/fire/csv?donem=${period}`} />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<WasteReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getKakeiboReport } from "@/features/reports/kakeibo";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { KAKEIBO_GROUPS, type SpendingClass } from "@/shared/kakeibo";
|
||||
|
||||
/** GET /raporlar/kakeibo/csv?ay=YYYY-MM — sınıf × kategori tutarları. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getKakeiboReport(c.userId, c.householdId, c.month);
|
||||
if (!r) return forbidden();
|
||||
const k = tr.kakeibo;
|
||||
const label = (cls: SpendingClass) =>
|
||||
cls === "BANK_COST" ? k.bankCost : cls === "UNGROUPED" ? k.ungrouped : k.groups[cls];
|
||||
const order: SpendingClass[] = [...KAKEIBO_GROUPS, "UNGROUPED", "BANK_COST"];
|
||||
const rows = order.flatMap((cls) =>
|
||||
r.now.byClass[cls].map((x) => [label(cls), x.path, csvMoney(x.amountMinor)]),
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(tr.reports.kakeiboReport.csvHeader, rows),
|
||||
tr.reports.kakeiboReport.csvFile(r.month),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { KakeiboReportView } from "@/features/reports/components/kakeibo-view";
|
||||
import {
|
||||
CsvButton,
|
||||
MonthNav,
|
||||
ReportFrame,
|
||||
ToolsEnd,
|
||||
monthLabel,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getKakeiboReport } from "@/features/reports/kakeibo";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
const t = tr.reports.kakeiboReport;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/kakeibo?ay=YYYY-MM — Kakeibo dört grup, banka maliyeti, grupsuzlar. */
|
||||
export default async function KakeiboReportPage({ searchParams }: PageProps<"/raporlar/kakeibo">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||
const data = await getKakeiboReport(user.id, household.id, ay.success ? ay.data : undefined);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description(monthLabel(data.month))}
|
||||
tools={
|
||||
<>
|
||||
<MonthNav month={data.month} currentMonth={data.currentMonth} path="/raporlar/kakeibo" />
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/kakeibo/csv?ay=${data.month}`} />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.includesHidden ? (
|
||||
<p className="text-xs text-muted-foreground">{tr.reports.includesHidden}</p>
|
||||
) : null}
|
||||
<KakeiboReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getCategoryReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
|
||||
/** GET /raporlar/kategoriler/csv?ay=YYYY-MM — üst kategori ve alt kategori satırları. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getCategoryReport(c.userId, c.householdId, c.month);
|
||||
if (!r) return forbidden();
|
||||
const rows = r.breakdown.rows.flatMap((top) => [
|
||||
top,
|
||||
...top.children.map((k) => ({ ...k, parent: top.name })),
|
||||
]);
|
||||
const csv = toCsv(
|
||||
tr.reports.csvHeaders.categories,
|
||||
rows.map((x) => [
|
||||
"parent" in x ? x.parent : x.name,
|
||||
"parent" in x ? x.name : "",
|
||||
csvMoney(x.amountMinor),
|
||||
csvPct(x.sharePct),
|
||||
csvMoney(x.prevMinor),
|
||||
csvMoney(x.changeMinor),
|
||||
csvPct(x.changePct),
|
||||
]),
|
||||
);
|
||||
return csvResponse(csv, tr.reports.csvFiles.categories(r.month));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CategoriesView } from "@/features/reports/components/categories-view";
|
||||
import {
|
||||
CsvButton,
|
||||
ToolsEnd,
|
||||
MonthNav,
|
||||
ReportFrame,
|
||||
monthLabel,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getCategoryReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
const t = tr.reports.categories;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/kategoriler?ay=YYYY-MM — kategori dağılımı. */
|
||||
export default async function CategoryReportPage({
|
||||
searchParams,
|
||||
}: PageProps<"/raporlar/kategoriler">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||
const data = await getCategoryReport(user.id, household.id, ay.success ? ay.data : undefined);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description(monthLabel(data.month))}
|
||||
tools={
|
||||
<>
|
||||
<MonthNav
|
||||
month={data.month}
|
||||
currentMonth={data.currentMonth}
|
||||
path="/raporlar/kategoriler"
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/kategoriler/csv?ay=${data.month}`} />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.current ? <p className="text-xs text-muted-foreground">{t.partialMonth}</p> : null}
|
||||
{data.includesHidden ? (
|
||||
<p className="text-xs text-muted-foreground">{tr.reports.includesHidden}</p>
|
||||
) : null}
|
||||
<CategoriesView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getPeopleAccountsReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
|
||||
/** GET /raporlar/kisi-hesap/csv?ay=YYYY-MM&tablo=kisi|hesap */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getPeopleAccountsReport(c.userId, c.householdId, c.month);
|
||||
if (!r) return forbidden();
|
||||
const h = tr.reports.csvHeaders;
|
||||
if (c.params.get("tablo") === "hesap")
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.accounts,
|
||||
r.accounts.map((a) => [
|
||||
a.name,
|
||||
csvMoney(a.incomeMinor),
|
||||
csvMoney(a.expenseMinor),
|
||||
csvMoney(a.transferInMinor),
|
||||
csvMoney(a.transferOutMinor),
|
||||
csvMoney(a.netMinor),
|
||||
a.count,
|
||||
]),
|
||||
),
|
||||
tr.reports.csvFiles.accounts(r.month),
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(
|
||||
h.people,
|
||||
r.people.map((p) => [p.name, csvMoney(p.expenseMinor), p.count, csvPct(p.sharePct)]),
|
||||
),
|
||||
tr.reports.csvFiles.people(r.month),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PeopleView } from "@/features/reports/components/people-view";
|
||||
import {
|
||||
CsvButton,
|
||||
ToolsEnd,
|
||||
MonthNav,
|
||||
ReportFrame,
|
||||
monthLabel,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getPeopleAccountsReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
const t = tr.reports.people;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/kisi-hesap?ay=YYYY-MM — kişi bazlı harcama ve hesap bazlı hareket. */
|
||||
export default async function PeopleReportPage({
|
||||
searchParams,
|
||||
}: PageProps<"/raporlar/kisi-hesap">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||
const data = await getPeopleAccountsReport(
|
||||
user.id,
|
||||
household.id,
|
||||
ay.success ? ay.data : undefined,
|
||||
);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description(monthLabel(data.month))}
|
||||
tools={
|
||||
<>
|
||||
<MonthNav
|
||||
month={data.month}
|
||||
currentMonth={data.currentMonth}
|
||||
path="/raporlar/kisi-hesap"
|
||||
/>
|
||||
<ToolsEnd>
|
||||
<CsvButton
|
||||
href={`/raporlar/kisi-hesap/csv?ay=${data.month}&tablo=kisi`}
|
||||
label={t.csvPeople}
|
||||
/>
|
||||
<CsvButton
|
||||
href={`/raporlar/kisi-hesap/csv?ay=${data.month}&tablo=hesap`}
|
||||
label={t.csvAccounts}
|
||||
/>
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.includesHidden ? (
|
||||
<p className="text-xs text-muted-foreground">{tr.reports.includesHidden}</p>
|
||||
) : null}
|
||||
<PeopleView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
BookOpenCheck,
|
||||
ChartPie,
|
||||
ChevronRight,
|
||||
Coins,
|
||||
HandCoins,
|
||||
ShoppingCart,
|
||||
Tags,
|
||||
Target,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { monthLabel, formatPct } from "@/features/reports/components/report-frame";
|
||||
import { getCategoryReport, getTrendReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
|
||||
const t = tr.reports;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
const REPORTS = [
|
||||
{ href: "/raporlar/kakeibo", key: "kakeibo", icon: BookOpenCheck },
|
||||
{ href: "/raporlar/kategoriler", key: "categories", icon: ChartPie },
|
||||
{ href: "/raporlar/trend", key: "trend", icon: TrendingUp },
|
||||
{ href: "/raporlar/kisi-hesap", key: "people", icon: Users },
|
||||
{ href: "/raporlar/butce", key: "budget", icon: Target },
|
||||
{ href: "/raporlar/cari", key: "contacts", icon: HandCoins },
|
||||
{ href: "/raporlar/birikim", key: "assets", icon: Coins },
|
||||
{ href: "/raporlar/alisveris", key: "shopping", icon: ShoppingCart },
|
||||
{ href: "/raporlar/birim-fiyat", key: "unitPrice", icon: Tags },
|
||||
{ href: "/raporlar/fire", key: "waste", icon: Trash2 },
|
||||
] as const;
|
||||
|
||||
/** /raporlar — bu bütçe ayının özeti ve raporlar. */
|
||||
export default async function ReportsPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [trend, cats] = await Promise.all([
|
||||
getTrendReport(user.id, household.id, 1),
|
||||
getCategoryReport(user.id, household.id),
|
||||
]);
|
||||
if (!trend || !cats) notFound();
|
||||
const m = trend.months[0]!;
|
||||
const top = cats.breakdown.rows.filter((r) => r.amountMinor > 0).slice(0, 3);
|
||||
const stats = [
|
||||
{ label: t.hub.income, value: formatMoney(m.incomeMinor), tone: "text-income" },
|
||||
{ label: t.hub.expense, value: formatMoney(m.expenseMinor), tone: "text-expense" },
|
||||
{
|
||||
label: t.hub.net,
|
||||
value: `${m.netMinor < 0 ? "−" : ""}${formatMoney(Math.abs(m.netMinor))}`,
|
||||
tone: m.netMinor < 0 ? "text-expense" : undefined,
|
||||
},
|
||||
{ label: t.hub.savings, value: formatPct(m.savingsPct) },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.title} description={t.description} />
|
||||
<section aria-labelledby="hub-month" className="rounded-xl border bg-card p-4">
|
||||
<h2 id="hub-month" className="text-sm font-medium">
|
||||
{t.hub.thisMonth}: <span className="capitalize">{monthLabel(m.month)}</span>{" "}
|
||||
<span className="font-normal text-muted-foreground">({t.trend.ongoing})</span>
|
||||
</h2>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label}>
|
||||
<dt className="text-xs text-muted-foreground">{s.label}</dt>
|
||||
<dd className={cn("text-lg font-semibold tabular-nums", s.tone)}>{s.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{top.length ? (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs text-muted-foreground">{t.hub.topCategories}</h3>
|
||||
<ul className="mt-1 flex flex-col gap-1 text-sm">
|
||||
{top.map((r) => (
|
||||
<li key={r.id} className="flex justify-between gap-3">
|
||||
<span className="truncate">{r.name}</span>
|
||||
<span className="tabular-nums">
|
||||
{formatMoney(r.amountMinor)}{" "}
|
||||
<span className="text-muted-foreground">· {formatPct(r.sharePct)}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
{REPORTS.map((r) => {
|
||||
const c = t.hub.cards[r.key];
|
||||
const Icon = r.icon;
|
||||
return (
|
||||
<li key={r.href}>
|
||||
<Link
|
||||
href={r.href}
|
||||
className="flex h-full items-start gap-3 rounded-xl border bg-card p-4 transition-colors hover:bg-accent/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Icon className="size-4.5 text-muted-foreground" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium">{c.title}</span>
|
||||
<span className="block text-sm text-muted-foreground">{c.description}</span>
|
||||
</span>
|
||||
<ChevronRight className="mt-2 size-4 text-muted-foreground" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getTrendReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
|
||||
/** GET /raporlar/trend/csv — son 12 bütçe ayı. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getTrendReport(c.userId, c.householdId);
|
||||
if (!r) return forbidden();
|
||||
const csv = toCsv(
|
||||
tr.reports.csvHeaders.trend,
|
||||
r.months.map((m) => [
|
||||
m.month,
|
||||
csvMoney(m.incomeMinor),
|
||||
csvMoney(m.expenseMinor),
|
||||
csvMoney(m.netMinor),
|
||||
csvPct(m.savingsPct),
|
||||
]),
|
||||
);
|
||||
return csvResponse(csv, tr.reports.csvFiles.trend(todayIstanbul()));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CsvButton, ToolsEnd, ReportFrame } from "@/features/reports/components/report-frame";
|
||||
import { TrendView } from "@/features/reports/components/trend-view";
|
||||
import { getTrendReport } from "@/features/reports/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
const t = tr.reports.trend;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/trend — son 12 bütçe ayı gelir, gider, net ve tasarruf oranı. */
|
||||
export default async function TrendReportPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await getTrendReport(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description}
|
||||
tools={
|
||||
<ToolsEnd>
|
||||
<CsvButton href="/raporlar/trend/csv" />
|
||||
</ToolsEnd>
|
||||
}
|
||||
>
|
||||
<TrendView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { connection } from "next/server";
|
||||
import { pingDb } from "@/lib/db/mongo";
|
||||
import { getDb, pingDb } from "@/lib/db/mongo";
|
||||
import { APP_COMMIT, APP_VERSION } from "@/lib/version";
|
||||
|
||||
/**
|
||||
@@ -10,10 +10,21 @@ export async function GET() {
|
||||
// Cache Components açıkken GET handler'ı build'de prerender edilmesin.
|
||||
await connection();
|
||||
const dbOk = await pingDb();
|
||||
// Transaction'lar için replica set şart; adı yoksa (standalone) null.
|
||||
let replicaSet: string | null = null;
|
||||
if (dbOk) {
|
||||
try {
|
||||
const hello = await getDb().admin().command({ hello: 1 }, { timeoutMS: 2_000 });
|
||||
replicaSet = typeof hello.setName === "string" ? hello.setName : null;
|
||||
} catch {
|
||||
replicaSet = null;
|
||||
}
|
||||
}
|
||||
return Response.json(
|
||||
{
|
||||
status: dbOk ? "ok" : "degraded",
|
||||
db: dbOk ? "ok" : "unreachable",
|
||||
replicaSet,
|
||||
version: APP_VERSION,
|
||||
commit: APP_COMMIT,
|
||||
time: new Date().toISOString(),
|
||||
|
||||
@@ -28,7 +28,7 @@ export type NavItem = {
|
||||
|
||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||
export const CURRENT_PHASE = 13.2;
|
||||
export const CURRENT_PHASE = 16;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
@@ -44,7 +44,7 @@ export const MAIN_NAV: NavItem[] = [
|
||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
|
||||
{ href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 },
|
||||
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13.1 },
|
||||
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
|
||||
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14.1 },
|
||||
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
|
||||
];
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: str
|
||||
|
||||
type Pending = { kind: "edit" } | { kind: "replace"; source: "copy" | "actuals" } | null;
|
||||
|
||||
export function BudgetView({ data }: { data: Data }) {
|
||||
export function BudgetView({ data, summary }: { data: Data; summary?: React.ReactNode }) {
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const close = () => setPending(null);
|
||||
@@ -306,6 +306,8 @@ export function BudgetView({ data }: { data: Data }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summary}
|
||||
|
||||
{data.lines.length ? (
|
||||
<ul className="flex flex-col divide-y rounded-xl border bg-card">
|
||||
{data.lines.map((l) => (
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
@@ -19,24 +10,13 @@ import type { DailyPlanView as Data } from "../plan";
|
||||
|
||||
const t = tr.budget.daily;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
const dayFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: "Europe/Istanbul",
|
||||
});
|
||||
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Istanbul",
|
||||
});
|
||||
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||
const signed = (minor: number) => `${minor < 0 ? "−" : ""}${formatMoney(Math.abs(minor))}`;
|
||||
|
||||
const config = {
|
||||
planned: { label: t.cumPlanned, color: "var(--budget)" },
|
||||
actual: { label: t.cumActual, color: "var(--expense)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
@@ -57,7 +37,7 @@ function Stat({
|
||||
);
|
||||
}
|
||||
|
||||
/** Dashboard: ayın günlük planı, kümülatif grafik ve gelirle karşılaştırma. */
|
||||
/** Dashboard: ayın günlük planı ve gelirle karşılaştırma (grafik ana grafikte: Aylık). */
|
||||
export function DailyPlanView({ data }: { data: Data }) {
|
||||
const p = data.plan;
|
||||
if (!data.hasBudget) {
|
||||
@@ -75,14 +55,6 @@ export function DailyPlanView({ data }: { data: Data }) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const chart = p.days.map((day, i) => ({
|
||||
date: day.date,
|
||||
label: dayFmt.format(new Date(`${day.date}T12:00:00+03:00`)),
|
||||
planned: day.cumPlannedMinor / 100,
|
||||
// Gerçekleşen yalnızca bugüne kadar çizilir.
|
||||
actual: p.todayIndex === -1 || i <= p.todayIndex ? day.cumActualMinor / 100 : null,
|
||||
}));
|
||||
const income = data.income.expectedMinor / 100;
|
||||
const nextDiff =
|
||||
data.next.budgetMinor === null ? null : data.next.expectedIncomeMinor - data.next.budgetMinor;
|
||||
|
||||
@@ -116,82 +88,6 @@ export function DailyPlanView({ data }: { data: Data }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="aspect-auto h-56 w-full"
|
||||
aria-label={t.chartLabel}
|
||||
role="img"
|
||||
>
|
||||
<LineChart data={chart} margin={{ left: 4, right: 12, top: 8 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickFormatter={(v: number) => compact.format(v)}
|
||||
/>
|
||||
{income > 0 ? (
|
||||
<ReferenceLine
|
||||
y={income}
|
||||
stroke="var(--income)"
|
||||
strokeDasharray="4 4"
|
||||
label={{
|
||||
value: `${t.income} ${compact.format(income)}`,
|
||||
position: "insideTopLeft",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const date = payload?.[0]?.payload?.date as string | undefined;
|
||||
return date ? d(date) : "";
|
||||
}}
|
||||
formatter={(value, name) => (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{config[name as keyof typeof config]?.label}
|
||||
</span>
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{formatMoney(Math.round(Number(value) * 100))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<Line
|
||||
dataKey="planned"
|
||||
type="linear"
|
||||
stroke="var(--color-planned)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="actual"
|
||||
type="linear"
|
||||
stroke="var(--color-actual)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.modes[data.mode]} {p.fixedMinor ? t.fixedNote(formatMoney(p.fixedMinor)) : ""}
|
||||
</p>
|
||||
|
||||
@@ -60,7 +60,7 @@ export type IncomePlan = {
|
||||
manual: { id: string; name: string; amountMinor: number }[];
|
||||
};
|
||||
|
||||
async function incomePlan(ctx: Ctx, s: Settings, month: string): Promise<IncomePlan> {
|
||||
export async function incomePlan(ctx: Ctx, s: Settings, month: string): Promise<IncomePlan> {
|
||||
const { from, to } = budgetPeriod(month, s.startDay);
|
||||
const [docs, budget] = await Promise.all([
|
||||
TransactionModel.find(baseMatch(ctx, s, "income", from, to))
|
||||
@@ -145,7 +145,7 @@ export async function saveIncomeItems(
|
||||
// --- Günlük plan ---
|
||||
|
||||
/** Güne göre gider: sabit (düzenli/taksit; gerçekleşmiş + planlı) ve gerçekleşen (tümü). */
|
||||
async function expensesByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||||
export async function expensesByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||||
const pipeline: PipelineStage[] = [
|
||||
{ $match: baseMatch(ctx, s, "expense", from, to) },
|
||||
{
|
||||
@@ -182,6 +182,20 @@ async function expensesByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||||
return { fixed, actual, variable };
|
||||
}
|
||||
|
||||
/** Güne göre beklenen gelir (gerçekleşmiş + planlı gelir işlemleri). */
|
||||
export async function incomeByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||||
const rows = await TransactionModel.aggregate<{ _id: string; sum: number }>([
|
||||
{ $match: baseMatch(ctx, s, "income", from, to) },
|
||||
{
|
||||
$group: {
|
||||
_id: { $dateToString: { date: "$date", format: "%Y-%m-%d", timezone: "Europe/Istanbul" } },
|
||||
sum: { $sum: "$amountMinor" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
return new Map(rows.map((r) => [r._id, r.sum]));
|
||||
}
|
||||
|
||||
export type DailyPlanView = {
|
||||
month: string;
|
||||
period: { from: string; to: string };
|
||||
@@ -199,6 +213,40 @@ export type DailyPlanView = {
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bir bütçe ayının günlük planı: sabit giderler kendi günlerine, değişken bütçe hane ayarına göre
|
||||
* (eşit ya da önceki ayın örüntüsü) dağıtılır.
|
||||
*/
|
||||
export async function monthDailyPlan(
|
||||
ctx: Ctx,
|
||||
s: Settings,
|
||||
month: string,
|
||||
budgetMinor: number,
|
||||
today = todayIstanbul(),
|
||||
): Promise<DailyPlan> {
|
||||
const period = budgetPeriod(month, s.startDay);
|
||||
const byDay = await expensesByDay(ctx, s, period.from, period.to);
|
||||
let weights: number[] | undefined;
|
||||
if (s.dailyMode === "previous_month_pattern") {
|
||||
const prev = budgetPeriod(shiftMonth(month, -1), s.startDay);
|
||||
const prevDays = await expensesByDay(ctx, s, prev.from, prev.to);
|
||||
const len = daysBetween(prev.from, prev.to);
|
||||
weights = patternWeights(
|
||||
Array.from({ length: len }, (_, i) => prevDays.variable.get(addDays(prev.from, i)) ?? 0),
|
||||
daysBetween(period.from, period.to),
|
||||
);
|
||||
}
|
||||
return dailyPlan({
|
||||
from: period.from,
|
||||
to: period.to,
|
||||
today,
|
||||
budgetMinor,
|
||||
fixedByDay: byDay.fixed,
|
||||
actualByDay: byDay.actual,
|
||||
weights,
|
||||
});
|
||||
}
|
||||
|
||||
/** Dashboard günlük planı: içinde bulunulan bütçe ayı. */
|
||||
export async function getDailyPlan(
|
||||
userId: string,
|
||||
@@ -212,34 +260,14 @@ export async function getDailyPlan(
|
||||
const period = budgetPeriod(month, s.startDay);
|
||||
const nextMonth = shiftMonth(month, 1);
|
||||
const mode = s.dailyMode;
|
||||
const [budget, byDay, income, nextIncome, nextBudget] = await Promise.all([
|
||||
const [budget, income, nextIncome, nextBudget] = await Promise.all([
|
||||
getBudget(userId, householdId, month),
|
||||
expensesByDay(ctx, s, period.from, period.to),
|
||||
incomePlan(ctx, s, month),
|
||||
incomePlan(ctx, s, nextMonth),
|
||||
getBudget(userId, householdId, nextMonth),
|
||||
]);
|
||||
if (!budget) return null;
|
||||
|
||||
let weights: number[] | undefined;
|
||||
if (mode === "previous_month_pattern") {
|
||||
const prev = budgetPeriod(shiftMonth(month, -1), s.startDay);
|
||||
const prevDays = await expensesByDay(ctx, s, prev.from, prev.to);
|
||||
const len = daysBetween(prev.from, prev.to);
|
||||
weights = patternWeights(
|
||||
Array.from({ length: len }, (_, i) => prevDays.variable.get(addDays(prev.from, i)) ?? 0),
|
||||
daysBetween(period.from, period.to),
|
||||
);
|
||||
}
|
||||
const plan = dailyPlan({
|
||||
from: period.from,
|
||||
to: period.to,
|
||||
today,
|
||||
budgetMinor: budget.totals.limitMinor,
|
||||
fixedByDay: byDay.fixed,
|
||||
actualByDay: byDay.actual,
|
||||
weights,
|
||||
});
|
||||
const plan = await monthDailyPlan(ctx, s, month, budget.totals.limitMinor, today);
|
||||
const forecastExpense = budget.totals.forecastMinor;
|
||||
return {
|
||||
month,
|
||||
|
||||
@@ -191,7 +191,11 @@ export async function getBudget(
|
||||
): Promise<BudgetView | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const s = await settingsOf(ctx.m.householdId);
|
||||
return budgetFor(ctx, await settingsOf(ctx.m.householdId), month);
|
||||
}
|
||||
|
||||
/** Bağlam ve ayarlar hazırken bütçe (raporlarda birden çok ay için yeniden yüklemeden). */
|
||||
export async function budgetFor(ctx: Ctx, s: Settings, month?: string): Promise<BudgetView> {
|
||||
const today = todayIstanbul();
|
||||
const currentMonth = periodOfDate(today, s.startDay);
|
||||
const m = month ?? currentMonth;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { IconBadge } from "@/components/appearance/swatch";
|
||||
import { KakeiboBadge } from "./kakeibo-badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
@@ -61,6 +62,9 @@ export function CategoriesManager({
|
||||
[categories, kind, showArchived],
|
||||
);
|
||||
const archivedCount = categories.filter((c) => c.kind === kind && c.archived).length;
|
||||
const ungrouped = categories.filter(
|
||||
(c) => c.kind === "expense" && !c.archived && !c.kakeibo.group && !c.kakeibo.bankCost,
|
||||
).length;
|
||||
|
||||
function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) {
|
||||
startTransition(async () => {
|
||||
@@ -90,6 +94,9 @@ export function CategoriesManager({
|
||||
<span className={cn("min-w-0 flex-1 truncate", node.depth === 0 && "font-medium")}>
|
||||
{node.name}
|
||||
</span>
|
||||
{node.kind === "expense" && !node.archived ? (
|
||||
<KakeiboBadge kakeibo={node.kakeibo} />
|
||||
) : null}
|
||||
{node.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
||||
{canEdit ? (
|
||||
<DropdownMenu modal={false}>
|
||||
@@ -191,6 +198,11 @@ export function CategoriesManager({
|
||||
) : null}
|
||||
</div>
|
||||
{!canEdit ? <p className="text-sm text-muted-foreground">{t.readOnly}</p> : null}
|
||||
{kind === "expense" && ungrouped ? (
|
||||
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||
{tr.kakeibo.ungroupedCount(ungrouped)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tree.length ? (
|
||||
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
||||
import { IconPicker, SwatchPicker } from "@/components/appearance/pickers";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||
import {
|
||||
categoryFormSchema,
|
||||
type CategoryForm,
|
||||
@@ -60,6 +62,8 @@ export function CategoryFormDialog({
|
||||
parentId: c.parentId ?? "",
|
||||
icon: c.icon,
|
||||
color: c.color,
|
||||
kakeiboGroup: c.kakeiboGroup ?? "",
|
||||
bankCost: c.bankCost,
|
||||
});
|
||||
} else {
|
||||
const parent = categories.find((c) => c.id === state.parentId);
|
||||
@@ -69,6 +73,9 @@ export function CategoryFormDialog({
|
||||
parentId: state.parentId ?? "",
|
||||
icon: parent?.icon ?? "tag",
|
||||
color: parent?.color ?? "blue",
|
||||
// Yeni alt kategori grubu üstten devralır.
|
||||
kakeiboGroup: "",
|
||||
bankCost: false,
|
||||
});
|
||||
}
|
||||
}, [state, categories, form]);
|
||||
@@ -105,6 +112,10 @@ export function CategoryFormDialog({
|
||||
}
|
||||
|
||||
const color = useWatch({ control: form.control, name: "color" }) ?? "blue";
|
||||
const k = tr.kakeibo;
|
||||
const parentId = useWatch({ control: form.control, name: "parentId" });
|
||||
const parentGroup = categories.find((c) => c.id === parentId)?.kakeibo.group ?? null;
|
||||
const parentBank = categories.find((c) => c.id === parentId)?.kakeibo.bankCost ?? false;
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
@@ -177,6 +188,60 @@ export function CategoryFormDialog({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{kind === "expense" ? (
|
||||
<>
|
||||
<Controller
|
||||
name="kakeiboGroup"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cat-kakeibo">{k.field}</FieldLabel>
|
||||
<Select
|
||||
value={field.value || ROOT}
|
||||
onValueChange={(v) => field.onChange(v === ROOT ? "" : v)}
|
||||
>
|
||||
<SelectTrigger id="cat-kakeibo" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ROOT}>
|
||||
{parentGroup ? k.inheritWith(k.groups[parentGroup]) : k.inherit}
|
||||
</SelectItem>
|
||||
{KAKEIBO_GROUPS.map((g) => (
|
||||
<SelectItem key={g} value={g}>
|
||||
{k.groups[g]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>
|
||||
{field.value
|
||||
? k.groupHints[field.value as keyof typeof k.groupHints]
|
||||
: k.ungroupedHint}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="bankCost"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="cat-bank"
|
||||
checked={Boolean(field.value) || parentBank}
|
||||
disabled={parentBank}
|
||||
onCheckedChange={(x) => field.onChange(x === true)}
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<FieldLabel htmlFor="cat-bank">{k.bankCost}</FieldLabel>
|
||||
<FieldDescription>{k.bankCostHint}</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Controller
|
||||
name="color"
|
||||
control={form.control}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { KakeiboGroup, ResolvedKakeibo } from "@/shared/kakeibo";
|
||||
|
||||
const k = tr.kakeibo;
|
||||
|
||||
/** Kakeibo grup renkleri: doğrulanmış kategorik palet (series-1…4); etiket her zaman yanında. */
|
||||
export const KAKEIBO_COLOR: Record<KakeiboGroup, string> = {
|
||||
ZORUNLU: "var(--series-1)",
|
||||
ISTEK: "var(--series-2)",
|
||||
KULTUR: "var(--series-3)",
|
||||
BEKLENMEYEN: "var(--series-4)",
|
||||
};
|
||||
|
||||
export function KakeiboDot({
|
||||
group,
|
||||
className,
|
||||
}: {
|
||||
group: KakeiboGroup | null;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-block size-2.5 shrink-0 rounded-full",
|
||||
!group && "border border-dashed border-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
style={group ? { backgroundColor: KAKEIBO_COLOR[group] } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kategorinin etkin Kakeibo grubu; devralınan grup soluk, banka maliyeti ve grupsuz ayrı. */
|
||||
export function KakeiboBadge({ kakeibo }: { kakeibo: ResolvedKakeibo }) {
|
||||
if (kakeibo.bankCost)
|
||||
return (
|
||||
<Badge variant="secondary" title={k.bankCostHint}>
|
||||
{k.bankCost}
|
||||
</Badge>
|
||||
);
|
||||
if (!kakeibo.group)
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1.5 border-dashed text-muted-foreground"
|
||||
title={k.ungroupedHint}
|
||||
>
|
||||
<KakeiboDot group={null} />
|
||||
{k.ungrouped}
|
||||
</Badge>
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("gap-1.5", kakeibo.inherited && "text-muted-foreground")}
|
||||
title={
|
||||
kakeibo.inherited
|
||||
? `${k.groups[kakeibo.group]} (${k.inherited})`
|
||||
: k.groupHints[kakeibo.group]
|
||||
}
|
||||
>
|
||||
<KakeiboDot group={kakeibo.group} />
|
||||
{k.groups[kakeibo.group]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,8 @@ const form = (over: Partial<Parameters<typeof createCategory>[2]> = {}) => ({
|
||||
parentId: null,
|
||||
icon: "tag" as const,
|
||||
color: "blue" as const,
|
||||
kakeiboGroup: null,
|
||||
bankCost: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ import { toObjectId } from "@/lib/object-id";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||
import type { CategoryForm, CategoryKind } from "@/shared/schemas/category";
|
||||
import {
|
||||
DEFAULT_BANK_COST_KEYS,
|
||||
DEFAULT_KAKEIBO_GROUPS,
|
||||
resolveKakeibo,
|
||||
type KakeiboGroup,
|
||||
type ResolvedKakeibo,
|
||||
} from "@/shared/kakeibo";
|
||||
import { foldTr } from "@/shared/search";
|
||||
import { DEFAULT_CATEGORIES } from "./defaults";
|
||||
import {
|
||||
@@ -26,7 +33,16 @@ import {
|
||||
|
||||
const t = tr.categories;
|
||||
|
||||
export type CategoryView = FlatCategory & { icon: IconKey; color: Swatch; isDefault: boolean };
|
||||
export type CategoryView = FlatCategory & {
|
||||
icon: IconKey;
|
||||
color: Swatch;
|
||||
isDefault: boolean;
|
||||
/** Kendi Kakeibo grubu (null = üstten devral). */
|
||||
kakeiboGroup: KakeiboGroup | null;
|
||||
bankCost: boolean;
|
||||
/** Etkin grup ve banka maliyeti (devralma dahil). */
|
||||
kakeibo: ResolvedKakeibo;
|
||||
};
|
||||
|
||||
type Lean = {
|
||||
_id: Types.ObjectId;
|
||||
@@ -39,9 +55,26 @@ type Lean = {
|
||||
color: string;
|
||||
defaultKey: string | null;
|
||||
ancestors: Types.ObjectId[];
|
||||
kakeiboGroup?: KakeiboGroup | null;
|
||||
bankCost?: boolean;
|
||||
};
|
||||
|
||||
const toView = (c: Lean): CategoryView => ({
|
||||
const NONE: ResolvedKakeibo = { group: null, inherited: false, bankCost: false };
|
||||
|
||||
/** Görünümler: etkin Kakeibo grubu tüm kategoriler (arşivliler dahil) üzerinden çözülür. */
|
||||
function toViews(rows: Lean[], all: Lean[] = rows): CategoryView[] {
|
||||
const resolved = resolveKakeibo(
|
||||
all.map((c) => ({
|
||||
id: String(c._id),
|
||||
parentId: c.parentId ? String(c.parentId) : null,
|
||||
kakeiboGroup: c.kakeiboGroup ?? null,
|
||||
bankCost: Boolean(c.bankCost),
|
||||
})),
|
||||
);
|
||||
return rows.map((c) => ({ ...toView(c), kakeibo: resolved.get(String(c._id)) ?? NONE }));
|
||||
}
|
||||
|
||||
const toView = (c: Lean): Omit<CategoryView, "kakeibo"> & { kakeibo?: ResolvedKakeibo } => ({
|
||||
id: String(c._id),
|
||||
kind: c.kind,
|
||||
name: c.name,
|
||||
@@ -51,8 +84,16 @@ const toView = (c: Lean): CategoryView => ({
|
||||
icon: c.icon as IconKey,
|
||||
color: c.color as Swatch,
|
||||
isDefault: Boolean(c.defaultKey),
|
||||
kakeiboGroup: c.kakeiboGroup ?? null,
|
||||
bankCost: Boolean(c.bankCost),
|
||||
});
|
||||
|
||||
/** Kakeibo alanları yalnızca gider kategorisinde; gelirde temizlenir. */
|
||||
const kakeiboFields = (input: CategoryForm) =>
|
||||
input.kind === "expense"
|
||||
? { kakeiboGroup: input.kakeiboGroup, bankCost: input.bankCost }
|
||||
: { kakeiboGroup: null, bankCost: false };
|
||||
|
||||
const violationMessage = (v: MoveViolation) => t.violations[v];
|
||||
|
||||
export async function listCategories(
|
||||
@@ -64,9 +105,9 @@ export async function listCategories(
|
||||
if (!m) return null;
|
||||
const filter: Record<string, unknown> = { householdId: m.householdId };
|
||||
if (opts.kind) filter.kind = opts.kind;
|
||||
if (!opts.includeArchived) filter.archivedAt = null;
|
||||
const rows = await CategoryModel.find(filter).lean<Lean[]>();
|
||||
return rows.map(toView);
|
||||
const all = await CategoryModel.find(filter).lean<Lean[]>();
|
||||
const rows = opts.includeArchived ? all : all.filter((c) => !c.archivedAt);
|
||||
return toViews(rows, all);
|
||||
}
|
||||
|
||||
async function loadKind(householdId: Types.ObjectId, kind: CategoryKind, session?: ClientSession) {
|
||||
@@ -86,6 +127,17 @@ function hasSiblingNamed(all: Lean[], parentId: string | null, name: string, exc
|
||||
);
|
||||
}
|
||||
|
||||
/** Varsayılan kategorinin Kakeibo grubu ve banka maliyeti işareti (Faz 16). */
|
||||
export function defaultKakeibo(key: string): {
|
||||
kakeiboGroup: KakeiboGroup | null;
|
||||
bankCost: boolean;
|
||||
} {
|
||||
return {
|
||||
kakeiboGroup: DEFAULT_KAKEIBO_GROUPS[key] ?? null,
|
||||
bankCost: (DEFAULT_BANK_COST_KEYS as readonly string[]).includes(key),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Varsayılan seti yükler (idempotent: defaultKey'e göre). Yeni hane oluşturulurken aynı
|
||||
* transaction içinde (session) ya da migration/seed'den çağrılır. Eklenen kategori sayısını döner.
|
||||
@@ -117,6 +169,7 @@ export async function installDefaultCategories(
|
||||
color: def.color,
|
||||
order,
|
||||
defaultKey: def.key,
|
||||
...(kind === "expense" ? defaultKakeibo(def.key) : {}),
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
},
|
||||
@@ -164,6 +217,7 @@ export async function createCategory(
|
||||
ancestors: parent ? [...parent.ancestors, parent._id] : [],
|
||||
icon: input.icon,
|
||||
color: input.color,
|
||||
...kakeiboFields(input),
|
||||
order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
@@ -209,6 +263,7 @@ export async function updateCategory(
|
||||
nameKey: foldTr(input.name),
|
||||
icon: input.icon,
|
||||
color: input.color,
|
||||
...kakeiboFields(input),
|
||||
updatedBy: actor,
|
||||
};
|
||||
if (moving) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { OVERVIEW_PERIODS, type OverviewPeriod } from "@/shared/overview";
|
||||
import { getOverview } from "../service";
|
||||
import { OverviewChart } from "./overview-chart";
|
||||
|
||||
type Search = Record<string, string | string[] | undefined>;
|
||||
|
||||
const periodOf = (x: unknown): OverviewPeriod =>
|
||||
OVERVIEW_PERIODS.includes(x as OverviewPeriod) ? (x as OverviewPeriod) : "month";
|
||||
|
||||
/** Dashboard ana grafiği (sunucu): ?periyot=week|month|half|year&gorunum=kumulatif|donemsel. */
|
||||
export async function OverviewCard({
|
||||
userId,
|
||||
householdId,
|
||||
searchParams,
|
||||
}: {
|
||||
userId: string;
|
||||
householdId: string;
|
||||
searchParams: Promise<Search>;
|
||||
}) {
|
||||
const q = await searchParams;
|
||||
const data = await getOverview(userId, householdId, periodOf(q.periyot));
|
||||
if (!data) return null;
|
||||
return <OverviewChart data={data} view={q.gorunum === "donemsel" ? "periodic" : "cumulative"} />;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { cumulative, OVERVIEW_PERIODS, type OverviewPeriod } from "@/shared/overview";
|
||||
import type { OverviewData } from "../service";
|
||||
|
||||
const t = tr.dashboard.overview;
|
||||
export type OverviewView = "cumulative" | "periodic";
|
||||
|
||||
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||
const dayShort = new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" });
|
||||
const weekdayShort = new Intl.DateTimeFormat("tr-TR", { weekday: "short", day: "numeric" });
|
||||
const monthShort = new Intl.DateTimeFormat("tr-TR", { month: "short", year: "2-digit" });
|
||||
const monthLong = new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" });
|
||||
|
||||
const config = {
|
||||
plannedIncome: { label: t.series.plannedIncome, color: "var(--income)" },
|
||||
budget: { label: t.series.budget, color: "var(--budget)" },
|
||||
actual: { label: t.series.actual, color: "var(--expense)" },
|
||||
} satisfies ChartConfig;
|
||||
const SERIES = ["plannedIncome", "budget", "actual"] as const;
|
||||
|
||||
const asDate = (key: string) =>
|
||||
new Date(key.length === 7 ? `${key}-15T12:00:00+03:00` : `${key}T12:00:00+03:00`);
|
||||
|
||||
function bucketLabel(key: string, period: OverviewPeriod) {
|
||||
const d = asDate(key);
|
||||
if (key.length === 7) return monthShort.format(d);
|
||||
return period === "week" ? weekdayShort.format(d) : dayShort.format(d);
|
||||
}
|
||||
|
||||
/** Bölüm 6.13 ana çizgi grafik: periyot seçici ve kümülatif / dönemsel görünüm (URL'de). */
|
||||
export function OverviewChart({ data, view }: { data: OverviewData; view: OverviewView }) {
|
||||
const points = view === "cumulative" ? cumulative(data.points) : data.points;
|
||||
const chart = points.map((p) => ({
|
||||
key: p.key,
|
||||
label: bucketLabel(p.key, data.period),
|
||||
plannedIncome: p.plannedIncome === null ? null : p.plannedIncome / 100,
|
||||
budget: p.budget === null ? null : p.budget / 100,
|
||||
actual: p.actual === null ? null : p.actual / 100,
|
||||
}));
|
||||
const hasGap = data.points.some((p) => p.budget === null);
|
||||
const href = (period: OverviewPeriod, v: OverviewView) =>
|
||||
`?periyot=${period}&gorunum=${v === "cumulative" ? "kumulatif" : "donemsel"}`;
|
||||
const rangeText =
|
||||
data.range.granularity === "month"
|
||||
? `${monthLong.format(asDate(data.range.buckets[0]!))} – ${monthLong.format(asDate(data.range.buckets.at(-1)!))}`
|
||||
: `${formatDate(asDate(data.range.from))} – ${formatDate(asDate(data.range.to))}`;
|
||||
const pill = (active: boolean) =>
|
||||
cn(
|
||||
"rounded-md px-2.5 py-1 text-sm transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
active
|
||||
? "bg-background font-medium shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
);
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader className="flex flex-wrap items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||
<CardDescription>{t.description(rangeText)}</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<nav aria-label={t.periodLabel} className="flex rounded-lg bg-muted p-0.5">
|
||||
{OVERVIEW_PERIODS.map((p) => (
|
||||
<Link
|
||||
key={p}
|
||||
href={href(p, view)}
|
||||
scroll={false}
|
||||
replace
|
||||
aria-current={p === data.period ? "page" : undefined}
|
||||
className={pill(p === data.period)}
|
||||
>
|
||||
{t.periods[p]}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<nav aria-label={t.viewLabel} className="flex rounded-lg bg-muted p-0.5">
|
||||
{(["cumulative", "periodic"] as const).map((v) => (
|
||||
<Link
|
||||
key={v}
|
||||
href={href(data.period, v)}
|
||||
scroll={false}
|
||||
replace
|
||||
aria-current={v === view ? "page" : undefined}
|
||||
className={pill(v === view)}
|
||||
>
|
||||
{t.views[v]}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="aspect-auto h-64 w-full"
|
||||
aria-label={t.chartLabel}
|
||||
role="img"
|
||||
>
|
||||
<LineChart data={chart} margin={{ left: 4, right: 12, top: 8 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={16}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickFormatter={(v: number) => compact.format(v)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const key = payload?.[0]?.payload?.key as string | undefined;
|
||||
if (!key) return "";
|
||||
return key.length === 7
|
||||
? monthLong.format(asDate(key))
|
||||
: formatDate(asDate(key));
|
||||
}}
|
||||
formatter={(value, name) => (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{config[name as keyof typeof config]?.label}
|
||||
</span>
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{formatMoney(Math.round(Number(value) * 100))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
{SERIES.map((k) => (
|
||||
<Line
|
||||
key={k}
|
||||
dataKey={k}
|
||||
type="linear"
|
||||
stroke={`var(--color-${k})`}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={k === "budget" ? "6 3" : undefined}
|
||||
dot={
|
||||
data.range.granularity === "month" || data.period === "week" ? { r: 3 } : false
|
||||
}
|
||||
activeDot={{ r: 5 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
{hasGap ? <p className="text-xs text-muted-foreground">{t.noBudget}</p> : null}
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground">{t.table}</summary>
|
||||
<div className="mt-2 max-h-80 overflow-y-auto">
|
||||
<table className="w-full text-right tabular-nums">
|
||||
<thead className="sticky top-0 bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-1 text-left font-normal">{t.bucket[data.range.granularity]}</th>
|
||||
{SERIES.map((k) => (
|
||||
<th key={k} className="py-1 font-normal">
|
||||
{t.series[k]}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{points.map((p) => (
|
||||
<tr key={p.key}>
|
||||
<td className="py-1 text-left">
|
||||
{p.key.length === 7
|
||||
? monthLong.format(asDate(p.key))
|
||||
: formatDate(asDate(p.key))}
|
||||
</td>
|
||||
{SERIES.map((k) => (
|
||||
<td key={k} className="py-1">
|
||||
{p[k] === null ? "—" : formatMoney(p[k])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import "server-only";
|
||||
import { budgetFor, settingsOf } from "@/features/budgets/service";
|
||||
import { incomeByDay, incomePlan, monthDailyPlan } from "@/features/budgets/plan";
|
||||
import { loadContext } from "@/features/transactions/service";
|
||||
import { budgetPeriod, periodOfDate } from "@/shared/budget";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import {
|
||||
overviewRange,
|
||||
type OverviewPeriod,
|
||||
type OverviewPoint,
|
||||
type OverviewRange,
|
||||
} from "@/shared/overview";
|
||||
|
||||
/**
|
||||
* Dashboard ana grafiği (Bölüm 6.13). Bütçe ve gelir planı motorlarıyla aynı kurallar: bütçe ayı,
|
||||
* taksit modu ve gizli hesap ayarı.
|
||||
*
|
||||
* Aylık noktalar: beklenen gelir (gerçekleşen + planlı + elle), bütçe toplamı (bütçe yoksa boş),
|
||||
* gerçekleşen gider. Günlük noktalar: o günün gelirleri (elle eklenen beklenen gelir dönemin ilk
|
||||
* gününe), günlük plan payı, o günün gerçekleşen gideri; gelecek günlerde gerçekleşen boş.
|
||||
*/
|
||||
export type OverviewData = {
|
||||
period: OverviewPeriod;
|
||||
range: OverviewRange;
|
||||
points: OverviewPoint[];
|
||||
today: string;
|
||||
};
|
||||
|
||||
export async function getOverview(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
period: OverviewPeriod,
|
||||
): Promise<OverviewData | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const s = await settingsOf(ctx.m.householdId);
|
||||
const today = todayIstanbul();
|
||||
const range = overviewRange(period, today, s.startDay);
|
||||
|
||||
if (range.granularity === "month") {
|
||||
const points = await Promise.all(
|
||||
range.buckets.map(async (month) => {
|
||||
const [budget, income] = await Promise.all([
|
||||
budgetFor(ctx, s, month),
|
||||
incomePlan(ctx, s, month),
|
||||
]);
|
||||
return {
|
||||
key: month,
|
||||
plannedIncome: income.expectedMinor,
|
||||
budget: budget.hasBudget ? budget.totals.limitMinor : null,
|
||||
actual: budget.totals.actualMinor,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return { period, range, points, today };
|
||||
}
|
||||
|
||||
// Günlük: aralığın dokunduğu bütçe ayları (hafta iki aya yayılabilir).
|
||||
const months = [...new Set([range.from, range.to].map((d) => periodOfDate(d, s.startDay)))];
|
||||
const planned = new Map<string, number | null>();
|
||||
const actual = new Map<string, number>();
|
||||
const income = new Map<string, number>();
|
||||
for (const month of months) {
|
||||
const [budget, plan] = await Promise.all([budgetFor(ctx, s, month), incomePlan(ctx, s, month)]);
|
||||
const dp = await monthDailyPlan(ctx, s, month, budget.totals.limitMinor, today);
|
||||
for (const d of dp.days) {
|
||||
planned.set(d.date, budget.hasBudget ? d.plannedMinor : null);
|
||||
actual.set(d.date, d.actualMinor);
|
||||
}
|
||||
const first = budgetPeriod(month, s.startDay).from;
|
||||
if (plan.manualMinor) income.set(first, (income.get(first) ?? 0) + plan.manualMinor);
|
||||
}
|
||||
for (const [d, v] of await incomeByDay(ctx, s, range.from, range.to))
|
||||
income.set(d, (income.get(d) ?? 0) + v);
|
||||
|
||||
const points = range.buckets.map((d) => ({
|
||||
key: d,
|
||||
plannedIncome: income.get(d) ?? 0,
|
||||
budget: planned.get(d) ?? null,
|
||||
actual: d <= today ? (actual.get(d) ?? 0) : null,
|
||||
}));
|
||||
return { period, range, points, today };
|
||||
}
|
||||
@@ -381,6 +381,57 @@ export async function loadTypeOffers(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Bir ürün tipinin tüm fiyat kayıtları, ürün tipi biriminde birim fiyatla (dönüşümsüzler hariç). */
|
||||
export async function loadTypePriceHistory(
|
||||
householdId: Types.ObjectId,
|
||||
productTypeId: Types.ObjectId,
|
||||
): Promise<{ date: string; productId: string; storeId: string; unitPriceMinor: number }[]> {
|
||||
const [products, type] = await Promise.all([
|
||||
ProductModel.find({ householdId, productTypeId })
|
||||
.select({ packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
|
||||
.lean<
|
||||
{
|
||||
_id: Types.ObjectId;
|
||||
packageQuantity: number;
|
||||
packageUnit: string;
|
||||
typeQuantity: number | null;
|
||||
}[]
|
||||
>(),
|
||||
ProductTypeModel.findOne({ householdId, _id: productTypeId })
|
||||
.select({ unit: 1 })
|
||||
.lean<{ unit: string }>(),
|
||||
]);
|
||||
if (!type) return [];
|
||||
const inType = new Map(
|
||||
products.map((p) => [
|
||||
String(p._id),
|
||||
packageInTypeUnit({
|
||||
packageQuantity: p.packageQuantity,
|
||||
packageUnit: p.packageUnit,
|
||||
typeUnit: type.unit,
|
||||
typeQuantity: p.typeQuantity,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const prices = await loadPrices(
|
||||
householdId,
|
||||
products.map((p) => p._id),
|
||||
);
|
||||
return prices.flatMap((r) => {
|
||||
const q = inType.get(String(r.productId));
|
||||
return q
|
||||
? [
|
||||
{
|
||||
date: r.date,
|
||||
productId: String(r.productId),
|
||||
storeId: String(r.storeId),
|
||||
unitPriceMinor: unitPrice(r.priceMinor, q),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar).
|
||||
* Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import type { BudgetReport } from "../service";
|
||||
import { monthLabel, monthShort } from "./report-frame";
|
||||
|
||||
const t = tr.reports.budget;
|
||||
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||
const ratio = (bp: number | null) => (bp === null ? "—" : `%${Math.round(bp / 100)}`);
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
ok: "bg-income",
|
||||
warning: "bg-amber-500",
|
||||
over: "bg-expense",
|
||||
none: "bg-muted-foreground",
|
||||
};
|
||||
|
||||
const config = {
|
||||
limit: { label: t.limit, color: "var(--budget)" },
|
||||
actual: { label: t.actual, color: "var(--expense)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function BudgetReportView({ data }: { data: BudgetReport }) {
|
||||
const chart = data.history.map((h) => ({
|
||||
month: h.month,
|
||||
label: monthShort(h.month),
|
||||
limit: h.limitMinor === null ? null : h.limitMinor / 100,
|
||||
actual: h.actualMinor / 100,
|
||||
}));
|
||||
const { totals, hasBudget } = data.budget;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{!hasBudget ? <p className="text-sm text-muted-foreground">{t.noBudget}</p> : null}
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[34rem] text-sm tabular-nums">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-normal">{t.category}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.limit}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.actual}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.remaining}</th>
|
||||
<th className="w-40 px-3 py-2 text-left font-normal">{t.ratio}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.lines.map((l) => (
|
||||
<tr key={l.categoryId} className={cn(l.depth === 0 && "font-medium")}>
|
||||
<td className="py-1.5 pr-3" style={{ paddingLeft: `${0.75 + l.depth * 1.25}rem` }}>
|
||||
{l.name}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{l.limitMinor === null ? (
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{l.covered ? t.covered : "—"}
|
||||
</span>
|
||||
) : (
|
||||
formatMoney(l.limitMinor)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatMoney(l.actualMinor)}</td>
|
||||
<td
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-right",
|
||||
(l.remainingMinor ?? 0) < 0 && "text-expense",
|
||||
)}
|
||||
>
|
||||
{l.remainingMinor === null
|
||||
? "—"
|
||||
: `${l.remainingMinor < 0 ? "−" : ""}${formatMoney(Math.abs(l.remainingMinor))}`}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{l.ratioBp === null ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted"
|
||||
aria-hidden
|
||||
>
|
||||
<span
|
||||
className={cn("block h-full rounded-full", STATUS_TONE[l.status])}
|
||||
style={{ width: `${Math.min(100, l.ratioBp / 100)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-12 text-right text-xs">{ratio(l.ratioBp)}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t font-medium">
|
||||
<tr>
|
||||
<td className="px-3 py-2">{tr.reports.categories.total}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{hasBudget ? formatMoney(totals.limitMinor) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{formatMoney(totals.actualMinor)}</td>
|
||||
<td
|
||||
className={cn("px-3 py-2 text-right", totals.remainingMinor < 0 && "text-expense")}
|
||||
>
|
||||
{hasBudget
|
||||
? `${totals.remainingMinor < 0 ? "−" : ""}${formatMoney(Math.abs(totals.remainingMinor))}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-left text-xs">{ratio(totals.ratioBp)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border bg-card p-4" aria-labelledby="bh-h">
|
||||
<h2 id="bh-h" className="mb-2 text-sm font-medium">
|
||||
{t.historyTitle}
|
||||
</h2>
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="aspect-auto h-56 w-full"
|
||||
role="img"
|
||||
aria-label={t.historyChartLabel}
|
||||
>
|
||||
<BarChart data={chart} margin={{ left: 4, right: 8, top: 8 }} barGap={2}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickFormatter={(v: number) => compact.format(v)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const m = payload?.[0]?.payload?.month as string | undefined;
|
||||
return m ? monthLabel(m) : "";
|
||||
}}
|
||||
formatter={(value, name) => (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{config[name as keyof typeof config]?.label}
|
||||
</span>
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{formatMoney(Math.round(Number(value) * 100))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<Bar
|
||||
dataKey="limit"
|
||||
fill="var(--color-limit)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="actual"
|
||||
fill="var(--color-actual)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
<table className="mt-3 w-full text-sm tabular-nums">
|
||||
<thead className="text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-1 text-left font-normal">{t.month}</th>
|
||||
<th className="py-1 text-right font-normal">{t.limit}</th>
|
||||
<th className="py-1 text-right font-normal">{t.actual}</th>
|
||||
<th className="py-1 text-right font-normal">{t.ratio}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.history.map((h) => (
|
||||
<tr key={h.month}>
|
||||
<td className="py-1 capitalize">{monthLabel(h.month)}</td>
|
||||
<td className="py-1 text-right">
|
||||
{h.limitMinor === null ? t.noLimit : formatMoney(h.limitMinor)}
|
||||
</td>
|
||||
<td className="py-1 text-right">{formatMoney(h.actualMinor)}</td>
|
||||
<td className="py-1 text-right">{ratio(h.ratioBp)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { Cell, Pie, PieChart } from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { IconBadge } from "@/components/appearance/swatch";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import type { CategoryReport } from "../service";
|
||||
import { Change, formatPct } from "./report-frame";
|
||||
|
||||
const t = tr.reports.categories;
|
||||
/** Halkada en fazla bu kadar dilim; kalanlar "Diğer". */
|
||||
const MAX_SLICES = 7;
|
||||
|
||||
export function CategoriesView({ data }: { data: CategoryReport }) {
|
||||
const { breakdown, appearance } = data;
|
||||
const positive = breakdown.rows.filter((r) => r.amountMinor > 0);
|
||||
const head = positive.slice(0, MAX_SLICES);
|
||||
const rest = positive.slice(MAX_SLICES);
|
||||
const slices = [
|
||||
...head.map((r) => ({
|
||||
key: r.id,
|
||||
name: r.name,
|
||||
value: r.amountMinor / 100,
|
||||
share: r.sharePct,
|
||||
color: `var(--swatch-${appearance[r.id]?.color ?? "slate"})`,
|
||||
})),
|
||||
...(rest.length
|
||||
? [
|
||||
{
|
||||
key: "__other__",
|
||||
name: t.other(rest.length),
|
||||
value: rest.reduce((a, r) => a + r.amountMinor, 0) / 100,
|
||||
share: Math.round(rest.reduce((a, r) => a + r.sharePct, 0) * 10) / 10,
|
||||
color: "var(--muted-foreground)",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const config = Object.fromEntries(
|
||||
slices.map((s) => [s.key, { label: s.name, color: s.color }]),
|
||||
) satisfies ChartConfig;
|
||||
|
||||
if (breakdown.totalMinor === 0 && breakdown.rows.length === 0)
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{tr.reports.noData}
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:items-start">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="relative">
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="mx-auto aspect-square w-full max-w-64"
|
||||
role="img"
|
||||
aria-label={t.chartLabel}
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
nameKey="key"
|
||||
formatter={(value, _name, item) => (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{item.payload.name}</span>
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{formatMoney(Math.round(Number(value) * 100))} ·{" "}
|
||||
{formatPct(item.payload.share)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={slices}
|
||||
dataKey="value"
|
||||
nameKey="key"
|
||||
innerRadius="62%"
|
||||
outerRadius="100%"
|
||||
stroke="var(--card)"
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{slices.map((s) => (
|
||||
<Cell key={s.key} fill={s.color} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">{t.total}</span>
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{formatMoney(breakdown.totalMinor)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="mt-4 flex flex-col gap-1.5 text-sm">
|
||||
{slices.map((s) => (
|
||||
<li key={s.key} className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-2.5 shrink-0 rounded-sm"
|
||||
style={{ background: s.color }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{s.name}</span>
|
||||
<span className="text-muted-foreground tabular-nums">{formatPct(s.share)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[34rem] text-sm">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-normal">{t.category}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.amount}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.share}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.previous}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.change}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{breakdown.rows.map((r) => {
|
||||
const a = appearance[r.id];
|
||||
return [
|
||||
<tr key={r.id} className="border-t bg-muted/20 font-medium">
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-2">
|
||||
{a ? <IconBadge icon={a.icon} color={a.color} size="sm" /> : null}
|
||||
{r.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{formatMoney(r.amountMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatPct(r.sharePct)}</td>
|
||||
<td className="px-3 py-2 text-right text-muted-foreground tabular-nums">
|
||||
{formatMoney(r.prevMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Change minor={r.changeMinor} pct={r.changePct} format={formatMoney} />
|
||||
</td>
|
||||
</tr>,
|
||||
...(r.children.length && r.ownMinor
|
||||
? [
|
||||
<tr key={`${r.id}-own`} className="text-muted-foreground">
|
||||
<td className="py-1.5 pr-3 pl-10 italic">{t.own}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums">
|
||||
{formatMoney(r.ownMinor)}
|
||||
</td>
|
||||
<td colSpan={3} />
|
||||
</tr>,
|
||||
]
|
||||
: []),
|
||||
...r.children.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td className={cn("py-1.5 pr-3", c.depth > 1 ? "pl-14" : "pl-10")}>{c.name}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums">
|
||||
{formatMoney(c.amountMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right text-muted-foreground tabular-nums">
|
||||
{formatPct(c.sharePct)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right text-muted-foreground tabular-nums">
|
||||
{formatMoney(c.prevMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
<Change minor={c.changeMinor} pct={c.changePct} format={formatMoney} />
|
||||
</td>
|
||||
</tr>
|
||||
)),
|
||||
];
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot className="border-t font-medium">
|
||||
<tr>
|
||||
<td className="px-3 py-2">{t.total}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{formatMoney(breakdown.totalMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">%100</td>
|
||||
<td className="px-3 py-2 text-right text-muted-foreground tabular-nums">
|
||||
{formatMoney(breakdown.prevTotalMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Change
|
||||
minor={breakdown.totalMinor - breakdown.prevTotalMinor}
|
||||
pct={
|
||||
breakdown.prevTotalMinor
|
||||
? Math.round(
|
||||
((breakdown.totalMinor - breakdown.prevTotalMinor) * 1000) /
|
||||
breakdown.prevTotalMinor,
|
||||
) / 10
|
||||
: null
|
||||
}
|
||||
format={formatMoney}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import { formatAssetQuantity } from "@/shared/assets";
|
||||
import type { AssetsReport, ContactsReport } from "../extra";
|
||||
import { dayLabel } from "./report-frame";
|
||||
|
||||
const signed = (n: number) => `${n > 0 ? "+" : n < 0 ? "−" : ""}${formatMoney(Math.abs(n))}`;
|
||||
const bp = (n: number | null) =>
|
||||
n === null
|
||||
? ""
|
||||
: ` (${n < 0 ? "−" : "+"}%${(Math.abs(n) / 100).toLocaleString("tr-TR", { maximumFractionDigits: 1 })})`;
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={cn("text-lg font-semibold tabular-nums", tone)}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactsReportView({ data }: { data: ContactsReport }) {
|
||||
const t = tr.reports.contacts;
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<Stat label={t.owesMe} value={formatMoney(data.totals.owesMeMinor)} tone="text-income" />
|
||||
<Stat label={t.iOwe} value={formatMoney(data.totals.iOweMinor)} tone="text-expense" />
|
||||
<Stat
|
||||
label={`${t.receivable} (${t.value.toLocaleLowerCase("tr-TR")})`}
|
||||
value={formatMoney(data.inKindTotals.receivableMinor)}
|
||||
/>
|
||||
<Stat
|
||||
label={`${t.debt} (${t.value.toLocaleLowerCase("tr-TR")})`}
|
||||
value={formatMoney(data.inKindTotals.debtMinor)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="cari-h" className="flex flex-col gap-2">
|
||||
<h2 id="cari-h" className="font-medium">
|
||||
{t.contact}
|
||||
</h2>
|
||||
{data.contacts.length ? (
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[28rem] text-sm tabular-nums">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-normal">{t.contact}</th>
|
||||
<th className="px-3 py-2 text-left font-normal">{t.direction}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.balance}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.due}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.contacts.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td className="px-3 py-1.5">
|
||||
<Link href={`/cari/${c.id}`} className="hover:underline">
|
||||
{c.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-1.5">{c.balanceMinor > 0 ? t.owesMe : t.iOwe}</td>
|
||||
<td
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-right font-medium",
|
||||
c.balanceMinor > 0 ? "text-income" : "text-expense",
|
||||
)}
|
||||
>
|
||||
{formatMoney(Math.abs(c.balanceMinor))}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{c.dueDate ? dayLabel(c.dueDate) : "—"}
|
||||
{t.dueStates[c.due] ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"ml-2",
|
||||
c.due === "overdue" && "border-expense/50 text-expense",
|
||||
)}
|
||||
>
|
||||
{t.dueStates[c.due]}
|
||||
</Badge>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.noContacts}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="ayni-h" className="flex flex-col gap-2">
|
||||
<div>
|
||||
<h2 id="ayni-h" className="font-medium">
|
||||
{t.inKindTitle}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t.inKindHint}</p>
|
||||
</div>
|
||||
{data.inKind.length ? (
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[32rem] text-sm tabular-nums">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-normal">{t.contact}</th>
|
||||
<th className="px-3 py-2 text-left font-normal">{t.direction}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.remaining}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.value}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.since}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.inKind.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td className="px-3 py-1.5">
|
||||
<Link href={`/cari/${d.contactId}`} className="hover:underline">
|
||||
{d.contactName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{d.direction === "receivable" ? t.receivable : t.debt}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{d.remainingLabel}</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{d.valueMinor === null ? "—" : formatMoney(d.valueMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{dayLabel(d.date)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.noInKind}
|
||||
</p>
|
||||
)}
|
||||
{data.inKindTotals.unpriced ? (
|
||||
<p className="text-xs text-muted-foreground">{t.unpriced(data.inKindTotals.unpriced)}</p>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssetsReportView({ data }: { data: AssetsReport }) {
|
||||
const t = tr.reports.assets;
|
||||
const p = data.period;
|
||||
const tone = (n: number | null) =>
|
||||
n === null ? "" : n < 0 ? "text-expense" : n > 0 ? "text-income" : "";
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<section aria-labelledby="period-h" className="flex flex-col gap-2">
|
||||
<h2 id="period-h" className="font-medium">
|
||||
{t.periodTitle}: {dayLabel(p.from)} – {dayLabel(p.to)}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||
<Stat
|
||||
label={t.startValue}
|
||||
value={p.startValueMinor === null ? "—" : formatMoney(p.startValueMinor)}
|
||||
/>
|
||||
<Stat label={t.invested} value={formatMoney(p.investedMinor)} />
|
||||
<Stat label={t.proceeds} value={formatMoney(p.proceedsMinor)} />
|
||||
<Stat
|
||||
label={t.endValue}
|
||||
value={p.endValueMinor === null ? "—" : formatMoney(p.endValueMinor)}
|
||||
/>
|
||||
<Stat
|
||||
label={t.profit}
|
||||
value={p.profitMinor === null ? "—" : `${signed(p.profitMinor)}${bp(p.profitBp)}`}
|
||||
tone={tone(p.profitMinor)}
|
||||
/>
|
||||
</div>
|
||||
{p.excluded ? (
|
||||
<p className="text-xs text-muted-foreground">{t.excluded(p.excluded)}</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{data.items.length ? (
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[40rem] text-sm tabular-nums">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-normal">{t.asset}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.quantity}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.cost}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.value}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.unrealized}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.realized}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.items.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td className="px-3 py-1.5">
|
||||
<Link href={`/birikim/${a.id}`} className="hover:underline">
|
||||
{a.name || a.label}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{formatAssetQuantity(a.assetKey, a.summary.quantity)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatMoney(a.summary.costMinor)}</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{a.summary.currentValueMinor === null
|
||||
? t.noPrice
|
||||
: formatMoney(a.summary.currentValueMinor)}
|
||||
</td>
|
||||
<td className={cn("px-3 py-1.5 text-right", tone(a.summary.unrealizedMinor))}>
|
||||
{a.summary.unrealizedMinor === null
|
||||
? "—"
|
||||
: `${signed(a.summary.unrealizedMinor)}${bp(a.summary.unrealizedBp)}`}
|
||||
</td>
|
||||
<td className={cn("px-3 py-1.5 text-right", tone(a.summary.realizedMinor))}>
|
||||
{signed(a.summary.realizedMinor)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t font-medium">
|
||||
<tr>
|
||||
<td className="px-3 py-2">{t.total}</td>
|
||||
<td />
|
||||
<td className="px-3 py-2 text-right">{formatMoney(data.totals.costMinor)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatMoney(data.totals.valueMinor)}</td>
|
||||
<td className={cn("px-3 py-2 text-right", tone(data.totals.unrealizedMinor))}>
|
||||
{signed(data.totals.unrealizedMinor)}
|
||||
</td>
|
||||
<td className={cn("px-3 py-2 text-right", tone(data.totals.realizedMinor))}>
|
||||
{signed(data.totals.realizedMinor)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.empty}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { KakeiboDot } from "@/features/categories/components/kakeibo-badge";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||
import { getKakeiboReport } from "../kakeibo";
|
||||
import { formatPct } from "./report-frame";
|
||||
|
||||
const k = tr.kakeibo;
|
||||
|
||||
/** Bütçe sayfası: seçili bütçe ayının Kakeibo grup özeti (rapora bağlantılı). */
|
||||
export async function KakeiboBudgetCard({
|
||||
userId,
|
||||
householdId,
|
||||
month,
|
||||
}: {
|
||||
userId: string;
|
||||
householdId: string;
|
||||
month: string;
|
||||
}) {
|
||||
const data = await getKakeiboReport(userId, householdId, month);
|
||||
if (!data) return null;
|
||||
const s = data.now.summary;
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="kb-card"
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 id="kb-card" className="font-medium">
|
||||
{tr.reports.kakeiboReport.title}
|
||||
</h2>
|
||||
<Link
|
||||
href={`/raporlar/kakeibo?ay=${month}`}
|
||||
className="ml-auto flex items-center gap-0.5 text-sm text-primary hover:underline"
|
||||
>
|
||||
{tr.reports.hub.open}
|
||||
<ChevronRight className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-3 md:grid-cols-6">
|
||||
{KAKEIBO_GROUPS.map((g) => (
|
||||
<div key={g}>
|
||||
<dt className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<KakeiboDot group={g} />
|
||||
{k.groups[g]}
|
||||
</dt>
|
||||
<dd className="font-semibold tabular-nums">
|
||||
{formatMoney(s.groups[g])}{" "}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{formatPct(s.shares[g])}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{k.ungrouped}</dt>
|
||||
<dd className="font-semibold tabular-nums">{formatMoney(s.ungroupedMinor)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{k.bankCost}</dt>
|
||||
<dd className="font-semibold tabular-nums">{formatMoney(s.bankCostMinor)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { AlertTriangle, Landmark } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { KAKEIBO_COLOR, KakeiboDot } from "@/features/categories/components/kakeibo-badge";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import { KAKEIBO_GROUPS, type KakeiboGroup } from "@/shared/kakeibo";
|
||||
import type { KakeiboReport } from "../kakeibo";
|
||||
import { Change, formatPct } from "./report-frame";
|
||||
|
||||
const k = tr.kakeibo;
|
||||
const t = tr.reports.kakeiboReport;
|
||||
|
||||
/** Dört grubun (ve grupsuzun) harcamadaki payı: tek yığılmış çubuk + lejant + tablo. */
|
||||
function Composition({ data }: { data: KakeiboReport }) {
|
||||
const s = data.now.summary;
|
||||
const parts = [
|
||||
...KAKEIBO_GROUPS.map((g) => ({
|
||||
key: g,
|
||||
label: k.groups[g],
|
||||
share: s.shares[g],
|
||||
color: KAKEIBO_COLOR[g],
|
||||
})),
|
||||
...(s.ungroupedMinor
|
||||
? [
|
||||
{
|
||||
key: "UNGROUPED",
|
||||
label: k.ungrouped,
|
||||
share: s.ungroupedShare,
|
||||
color: "var(--muted-foreground)",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
].filter((p) => p.share > 0);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div
|
||||
className="flex h-4 w-full gap-0.5 overflow-hidden rounded-md bg-muted"
|
||||
role="img"
|
||||
aria-label={t.chartLabel}
|
||||
>
|
||||
{parts.map((p) => (
|
||||
<div
|
||||
key={p.key}
|
||||
className="h-full first:rounded-l-md last:rounded-r-md"
|
||||
style={{ width: `${p.share}%`, backgroundColor: p.color }}
|
||||
title={`${p.label}: ${formatPct(p.share)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{parts.map((p) => (
|
||||
<li key={p.key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-2.5 rounded-sm"
|
||||
style={{ backgroundColor: p.color }}
|
||||
/>
|
||||
{p.label} <span className="tabular-nums">{formatPct(p.share)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupCard({ data, group }: { data: KakeiboReport; group: KakeiboGroup }) {
|
||||
const s = data.now.summary;
|
||||
const rows = data.now.byClass[group];
|
||||
const ch = data.change[group];
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={`kg-${group}`}
|
||||
className="flex flex-col gap-2 rounded-xl border bg-card p-4"
|
||||
>
|
||||
<header className="flex items-start gap-2">
|
||||
<KakeiboDot group={group} className="mt-1.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id={`kg-${group}`} className="font-medium">
|
||||
{k.groups[group]}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{k.groupHints[group]}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold tabular-nums">{formatMoney(s.groups[group])}</p>
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatPct(s.shares[group])} ·{" "}
|
||||
<Change minor={ch.minor} pct={ch.pct} format={formatMoney} />
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
{rows.length ? (
|
||||
<ul className="divide-y text-sm">
|
||||
{rows.map((r) => (
|
||||
<li key={r.id} className="flex justify-between gap-3 py-1.5">
|
||||
<span className="min-w-0 truncate">{r.path}</span>
|
||||
<span className="shrink-0 tabular-nums">{formatMoney(r.amountMinor)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t.noSpending}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** /raporlar/kakeibo — dört grup, banka maliyeti ve grupsuz harcamalar. */
|
||||
export function KakeiboReportView({ data }: { data: KakeiboReport }) {
|
||||
const s = data.now.summary;
|
||||
const bank = data.now.byClass.BANK_COST;
|
||||
const ungrouped = data.now.byClass.UNGROUPED;
|
||||
const bankChange = data.change.BANK_COST;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-sm text-muted-foreground">{t.spending}</span>
|
||||
<span className="text-xl font-semibold tabular-nums">
|
||||
{formatMoney(s.spendingMinor)}
|
||||
</span>
|
||||
</div>
|
||||
<Composition data={data} />
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-1 rounded-xl border bg-card p-4 md:w-64">
|
||||
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Landmark className="size-4" aria-hidden />
|
||||
{k.bankCost}
|
||||
</span>
|
||||
<span className="text-xl font-semibold tabular-nums">{formatMoney(s.bankCostMinor)}</span>
|
||||
<span className="text-xs">
|
||||
<Change minor={bankChange.minor} pct={bankChange.pct} format={formatMoney} />
|
||||
</span>
|
||||
<span className="text-[11px] leading-tight text-muted-foreground">{t.bankCostNote}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ungrouped.length ? (
|
||||
<section
|
||||
aria-labelledby="kg-ungrouped"
|
||||
className="flex flex-col gap-2 rounded-xl border border-dashed border-amber-500/50 bg-amber-500/5 p-4"
|
||||
>
|
||||
<h2 id="kg-ungrouped" className="flex items-center gap-1.5 font-medium">
|
||||
<AlertTriangle className="size-4 text-amber-600" aria-hidden />
|
||||
{k.ungrouped}: {formatMoney(s.ungroupedMinor)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t.ungroupedHelp}{" "}
|
||||
<Link href="/kategoriler" className="text-primary hover:underline">
|
||||
{t.goCategories}
|
||||
</Link>
|
||||
</p>
|
||||
<ul className="divide-y text-sm">
|
||||
{ungrouped.map((r) => (
|
||||
<li key={r.id} className="flex justify-between gap-3 py-1.5">
|
||||
<span className="min-w-0 truncate">{r.path}</span>
|
||||
<span className="tabular-nums">{formatMoney(r.amountMinor)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{KAKEIBO_GROUPS.map((g) => (
|
||||
<GroupCard key={g} data={data} group={g} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{bank.length ? (
|
||||
<section
|
||||
aria-labelledby="kg-bank"
|
||||
className={cn("flex flex-col gap-2 rounded-xl border bg-card p-4")}
|
||||
>
|
||||
<h2 id="kg-bank" className="font-medium">
|
||||
{k.bankCost}
|
||||
</h2>
|
||||
<ul className="divide-y text-sm">
|
||||
{bank.map((r) => (
|
||||
<li key={r.id} className="flex justify-between gap-3 py-1.5">
|
||||
<span className="min-w-0 truncate">{r.path}</span>
|
||||
<span className="tabular-nums">{formatMoney(r.amountMinor)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import type { PeopleAccountsReport } from "../service";
|
||||
import { formatPct } from "./report-frame";
|
||||
|
||||
const t = tr.reports.people;
|
||||
const signed = (n: number) => `${n > 0 ? "+" : n < 0 ? "−" : ""}${formatMoney(Math.abs(n))}`;
|
||||
|
||||
/** Kişi bazlı harcama (pay çubuğu + tablo) ve hesap bazlı hareket tablosu. */
|
||||
export function PeopleView({ data }: { data: PeopleAccountsReport }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<section aria-labelledby="people-h" className="flex flex-col gap-2">
|
||||
<div>
|
||||
<h2 id="people-h" className="font-medium">
|
||||
{t.peopleTitle}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t.peopleHint}</p>
|
||||
</div>
|
||||
{data.people.length ? (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{data.people.map((p) => (
|
||||
<li key={p.userId} className="flex flex-col gap-1.5 p-3 text-sm">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{p.name}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{p.count} {t.count.toLocaleLowerCase("tr-TR")}
|
||||
</span>
|
||||
<span className="w-24 text-right tabular-nums">
|
||||
{formatMoney(p.expenseMinor)}
|
||||
</span>
|
||||
<span className="w-14 text-right text-muted-foreground tabular-nums">
|
||||
{formatPct(p.sharePct)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
|
||||
<div
|
||||
className="h-full rounded-full bg-expense"
|
||||
style={{ width: `${p.sharePct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex justify-between p-3 text-sm font-medium">
|
||||
<span>{tr.reports.categories.total}</span>
|
||||
<span className="tabular-nums">{formatMoney(data.peopleTotalMinor)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{tr.reports.noData}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="acc-h" className="flex flex-col gap-2">
|
||||
<div>
|
||||
<h2 id="acc-h" className="font-medium">
|
||||
{t.accountsTitle}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t.accountsHint}</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[40rem] text-sm tabular-nums">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-normal">{t.account}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.income}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.expense}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.transferIn}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.transferOut}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.net}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.count}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.accounts.map((a) => (
|
||||
<tr key={a.id} className={cn(a.count === 0 && "text-muted-foreground")}>
|
||||
<td className="px-3 py-1.5">
|
||||
<span className="flex items-center gap-2">
|
||||
{a.name}
|
||||
{a.archived ? <Badge variant="secondary">{t.archived}</Badge> : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatMoney(a.incomeMinor)}</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatMoney(a.expenseMinor)}</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatMoney(a.transferInMinor)}</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatMoney(a.transferOutMinor)}</td>
|
||||
<td
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-right font-medium",
|
||||
a.netMinor < 0 ? "text-expense" : a.netMinor > 0 ? "text-income" : "",
|
||||
)}
|
||||
>
|
||||
{signed(a.netMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{a.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Download } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { shiftMonth } from "@/shared/budget";
|
||||
|
||||
const t = tr.reports;
|
||||
|
||||
const monthFmt = new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" });
|
||||
export const monthLabel = (month: string) =>
|
||||
monthFmt.format(new Date(`${month}-15T12:00:00+03:00`));
|
||||
const monthShortFmt = new Intl.DateTimeFormat("tr-TR", { month: "short", year: "2-digit" });
|
||||
export const monthShort = (month: string) =>
|
||||
monthShortFmt.format(new Date(`${month}-15T12:00:00+03:00`));
|
||||
|
||||
/** 14,3 → "%14,3"; −20 → "−%20"; null → "—". */
|
||||
export function formatPct(n: number | null): string {
|
||||
if (n === null) return "—";
|
||||
const abs = Math.abs(n).toLocaleString("tr-TR", { maximumFractionDigits: 1 });
|
||||
return n < 0 ? `−%${abs}` : `%${abs}`;
|
||||
}
|
||||
|
||||
/** Önceki / sonraki bütçe ayı (?ay=). Gelecek aya gidilmez. */
|
||||
export function MonthNav({
|
||||
month,
|
||||
currentMonth,
|
||||
path,
|
||||
}: {
|
||||
month: string;
|
||||
currentMonth: string;
|
||||
path: string;
|
||||
}) {
|
||||
const next = shiftMonth(month, 1);
|
||||
return (
|
||||
<nav aria-label={t.currentMonth} className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon-sm" asChild>
|
||||
<Link href={`${path}?ay=${shiftMonth(month, -1)}`} aria-label={t.prevMonth}>
|
||||
<ChevronLeft />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="min-w-36 text-center text-sm font-medium capitalize">
|
||||
{monthLabel(month)}
|
||||
</span>
|
||||
{next <= currentMonth ? (
|
||||
<Button variant="outline" size="icon-sm" asChild>
|
||||
<Link href={`${path}?ay=${next}`} aria-label={t.nextMonth}>
|
||||
<ChevronRight />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="icon-sm" disabled aria-label={t.nextMonth}>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
)}
|
||||
{month !== currentMonth ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={path}>{t.currentMonth}</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rapor sayfası çerçevesi: geri bağlantısı, başlık, araçlar (ay seçici, CSV). */
|
||||
export function ReportFrame({
|
||||
title,
|
||||
description,
|
||||
tools,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
tools?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/raporlar">
|
||||
<ArrowLeft />
|
||||
{t.back}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader title={title} description={description} />
|
||||
{tools ? <div className="flex flex-wrap items-center gap-2">{tools}</div> : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CsvButton({ href, label }: { href: string; label?: string }) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={href} download>
|
||||
<Download />
|
||||
{label ?? t.csv}
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Araç çubuğunun sağ tarafı (CSV düğmeleri). */
|
||||
export function ToolsEnd({ children }: { children: React.ReactNode }) {
|
||||
return <div className="ml-auto flex flex-wrap gap-2">{children}</div>;
|
||||
}
|
||||
|
||||
/** İşaretli tutar ve yüzde değişim; renk tek başına anlam taşımaz (işaret metinde). */
|
||||
export function Change({
|
||||
minor,
|
||||
pct,
|
||||
format,
|
||||
}: {
|
||||
minor: number;
|
||||
pct: number | null;
|
||||
format: (n: number) => string;
|
||||
}) {
|
||||
if (minor === 0) return <span className="text-muted-foreground tabular-nums">0</span>;
|
||||
const up = minor > 0;
|
||||
return (
|
||||
<span className={`tabular-nums ${up ? "text-expense" : "text-income"}`}>
|
||||
{up ? "+" : "−"}
|
||||
{format(Math.abs(minor))}
|
||||
{pct !== null ? ` (${up ? "+" : "−"}%${Math.abs(pct).toLocaleString("tr-TR")})` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dönem seçici (?donem=…): seçenekler bağlantı, seçili olan vurgulu. */
|
||||
export function RangeNav({
|
||||
path,
|
||||
param = "donem",
|
||||
options,
|
||||
value,
|
||||
label,
|
||||
}: {
|
||||
path: string;
|
||||
param?: string;
|
||||
options: { value: string; label: string }[];
|
||||
value: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<nav aria-label={label} className="flex rounded-lg bg-muted p-0.5">
|
||||
{options.map((o) => (
|
||||
<Link
|
||||
key={o.value}
|
||||
href={`${path}?${param}=${o.value}`}
|
||||
aria-current={o.value === value ? "page" : undefined}
|
||||
className={
|
||||
o.value === value
|
||||
? "rounded-md bg-background px-2.5 py-1 text-sm font-medium shadow-sm"
|
||||
: "rounded-md px-2.5 py-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
}
|
||||
>
|
||||
{o.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const dayFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
export const dayLabel = (ymd: string) => dayFmt.format(new Date(`${ymd}T12:00:00+03:00`));
|
||||
@@ -0,0 +1,234 @@
|
||||
import Link from "next/link";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import { WASTE_REASONS } from "@/shared/stock";
|
||||
import { formatStock } from "@/shared/stock";
|
||||
import type { ShoppingReport, WasteReport } from "../extra";
|
||||
import { dayLabel, formatPct } from "./report-frame";
|
||||
|
||||
const signed = (n: number) => `${n > 0 ? "+" : n < 0 ? "−" : ""}${formatMoney(Math.abs(n))}`;
|
||||
const empty = (text: string) => (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
|
||||
export function ShoppingReportView({ data }: { data: ShoppingReport }) {
|
||||
const t = tr.reports.shopping;
|
||||
const reasons = tr.shopping.report.reasons;
|
||||
const reasonRows = (
|
||||
["price", "quantity", "substitute", "skipped", "extra", "unplanned"] as const
|
||||
).filter((k) => data.listTotals[k] !== 0);
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<section aria-labelledby="lists-h" className="flex flex-col gap-2">
|
||||
<h2 id="lists-h" className="font-medium">
|
||||
{t.listsTitle}
|
||||
</h2>
|
||||
{data.lists.length ? (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[36rem] text-sm tabular-nums">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-normal">{t.date}</th>
|
||||
<th className="px-3 py-2 text-left font-normal">{t.list}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.planned}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.actual}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.diff}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.lists.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="px-3 py-1.5 whitespace-nowrap">{dayLabel(l.date)}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<Link href={`/alisveris/${l.id}`} className="font-medium hover:underline">
|
||||
{l.name}
|
||||
</Link>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t.counts(
|
||||
l.summary.counts.same,
|
||||
l.summary.counts.substitute,
|
||||
l.summary.counts.skipped,
|
||||
l.summary.counts.extra,
|
||||
)}
|
||||
{l.stores.length ? ` · ${l.stores.join(", ")}` : ""}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{formatMoney(l.summary.plannedMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{formatMoney(l.summary.actualMinor)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-right",
|
||||
l.summary.diffMinor > 0
|
||||
? "text-expense"
|
||||
: l.summary.diffMinor < 0
|
||||
? "text-income"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{signed(l.summary.diffMinor)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t font-medium">
|
||||
<tr>
|
||||
<td className="px-3 py-2" colSpan={2}>
|
||||
{t.total}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{formatMoney(data.listTotals.plannedMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{formatMoney(data.listTotals.actualMinor)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{signed(data.listTotals.diffMinor)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{reasonRows.length ? (
|
||||
<ul className="flex flex-col gap-1 rounded-xl border bg-card p-3 text-sm">
|
||||
{reasonRows.map((k) => (
|
||||
<li key={k} className="flex justify-between gap-3">
|
||||
<span>{reasons[k]}</span>
|
||||
<span className="tabular-nums">{signed(data.listTotals[k])}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
empty(t.listsEmpty)
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="stores-h" className="flex flex-col gap-2">
|
||||
<div>
|
||||
<h2 id="stores-h" className="font-medium">
|
||||
{t.storesTitle}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t.storesHint}</p>
|
||||
</div>
|
||||
{data.stores.length ? (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{data.stores.map((s) => (
|
||||
<li key={s.name} className="flex flex-col gap-1.5 p-3 text-sm">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{s.name}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{s.count} {t.count.toLocaleLowerCase("tr-TR")}
|
||||
{s.fromListsMinor ? ` · ${t.fromLists} ${formatMoney(s.fromListsMinor)}` : ""}
|
||||
</span>
|
||||
<span className="w-28 text-right tabular-nums">{formatMoney(s.amountMinor)}</span>
|
||||
<span className="w-14 text-right text-muted-foreground tabular-nums">
|
||||
{formatPct(s.sharePct)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
|
||||
<div
|
||||
className="h-full rounded-full bg-budget"
|
||||
style={{ width: `${s.sharePct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex justify-between p-3 text-sm font-medium">
|
||||
<span>{t.total}</span>
|
||||
<span className="tabular-nums">{formatMoney(data.storesTotalMinor)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
empty(t.storesEmpty)
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WasteReportView({ data }: { data: WasteReport }) {
|
||||
const t = tr.reports.waste;
|
||||
const reasonText = tr.stock.wasteReasons;
|
||||
if (!data.rows.length) return empty(t.empty);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
|
||||
<div className="rounded-xl border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">{t.total}</p>
|
||||
<p className="text-lg font-semibold text-expense tabular-nums">
|
||||
{formatMoney(data.totalCostMinor)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">{t.costHint}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card p-3">
|
||||
<h2 className="mb-1 text-xs text-muted-foreground">{t.reasonsTitle}</h2>
|
||||
<ul className="flex flex-col gap-1 text-sm">
|
||||
{WASTE_REASONS.filter((r) => data.reasons[r].count).map((r) => (
|
||||
<li key={r} className="flex justify-between gap-3">
|
||||
<span>
|
||||
{reasonText[r]}{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t.count(data.reasons[r].count)})
|
||||
</span>
|
||||
</span>
|
||||
<span className="tabular-nums">{formatMoney(data.reasons[r].costMinor)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[36rem] text-sm tabular-nums">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-normal">{t.product}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.wasted}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.purchased}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.ratio}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.cost}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.rows.map((r) => (
|
||||
<tr key={r.productTypeId}>
|
||||
<td className="px-3 py-1.5">
|
||||
<Link href={`/stok/${r.productTypeId}`} className="hover:underline">
|
||||
{r.name}
|
||||
</Link>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{WASTE_REASONS.filter((x) => r.byReason[x])
|
||||
.map((x) => `${reasonText[x]}: ${formatStock(r.byReason[x], r.unit)}`)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatStock(r.wasted, r.unit)}</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatStock(r.purchased, r.unit)}</td>
|
||||
<td
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-right",
|
||||
(r.wastePct ?? 0) >= 15 && "font-medium text-expense",
|
||||
)}
|
||||
>
|
||||
{formatPct(r.wastePct)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{r.costMinor === null ? "—" : formatMoney(r.costMinor)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{data.unpriced ? (
|
||||
<p className="text-xs text-muted-foreground">{t.unpriced(data.unpriced)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import type { TrendReport } from "../service";
|
||||
import { monthLabel, monthShort, formatPct } from "./report-frame";
|
||||
|
||||
const t = tr.reports.trend;
|
||||
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||
|
||||
const barConfig = {
|
||||
income: { label: t.income, color: "var(--income)" },
|
||||
expense: { label: t.expense, color: "var(--expense)" },
|
||||
} satisfies ChartConfig;
|
||||
const rateConfig = { rate: { label: t.savings, color: "var(--budget)" } } satisfies ChartConfig;
|
||||
|
||||
export function TrendView({ data }: { data: TrendReport }) {
|
||||
const chart = data.months.map((m) => ({
|
||||
month: m.month,
|
||||
label: monthShort(m.month),
|
||||
income: m.incomeMinor / 100,
|
||||
expense: m.expenseMinor / 100,
|
||||
// Devam eden ayın oranı (maaş henüz yatmamış olabilir) ölçeği bozmasın: grafikte yok.
|
||||
rate: m.month === data.currentMonth ? null : m.savingsPct,
|
||||
}));
|
||||
const moneyTip = (
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const m = payload?.[0]?.payload?.month as string | undefined;
|
||||
return m ? monthLabel(m) : "";
|
||||
}}
|
||||
formatter={(value, name) => (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{barConfig[name as keyof typeof barConfig]?.label}
|
||||
</span>
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{formatMoney(Math.round(Number(value) * 100))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<ChartContainer
|
||||
config={barConfig}
|
||||
className="aspect-auto h-64 w-full"
|
||||
role="img"
|
||||
aria-label={t.chartLabel}
|
||||
>
|
||||
<BarChart data={chart} margin={{ left: 4, right: 8, top: 8 }} barGap={2}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickFormatter={(v: number) => compact.format(v)}
|
||||
/>
|
||||
<ChartTooltip content={moneyTip} />
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<Bar
|
||||
dataKey="income"
|
||||
fill="var(--color-income)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="expense"
|
||||
fill="var(--color-expense)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<h2 className="text-sm font-medium">{t.rateTitle}</h2>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{t.rateHint} {t.partialHint}
|
||||
</p>
|
||||
<ChartContainer
|
||||
config={rateConfig}
|
||||
className="aspect-auto h-40 w-full"
|
||||
role="img"
|
||||
aria-label={t.rateChartLabel}
|
||||
>
|
||||
<LineChart data={chart} margin={{ left: 4, right: 8, top: 8 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={40}
|
||||
tickFormatter={(v: number) => `%${v}`}
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="var(--border)" />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const m = payload?.[0]?.payload?.month as string | undefined;
|
||||
return m ? monthLabel(m) : "";
|
||||
}}
|
||||
formatter={(value) => (
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{formatPct(Number(value))}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
dataKey="rate"
|
||||
stroke="var(--color-rate)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[32rem] text-sm tabular-nums">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-normal">{t.month}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.income}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.expense}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.net}</th>
|
||||
<th className="px-3 py-2 text-right font-normal">{t.savings}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{[...data.months].reverse().map((m) => (
|
||||
<tr key={m.month} className={cn(m.month === data.currentMonth && "bg-muted/40")}>
|
||||
<td className="px-3 py-1.5">
|
||||
<span className="capitalize">{monthLabel(m.month)}</span>
|
||||
{m.month === data.currentMonth ? (
|
||||
<span className="ml-1 text-xs text-muted-foreground">({t.ongoing})</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatMoney(m.incomeMinor)}</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatMoney(m.expenseMinor)}</td>
|
||||
<td className={cn("px-3 py-1.5 text-right", m.netMinor < 0 && "text-expense")}>
|
||||
{m.netMinor < 0 ? "−" : ""}
|
||||
{formatMoney(Math.abs(m.netMinor))}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{formatPct(m.savingsPct)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t font-medium">
|
||||
<tr>
|
||||
<td className="px-3 py-2">{t.total}</td>
|
||||
<td className="px-3 py-2 text-right">{formatMoney(data.totals.incomeMinor)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatMoney(data.totals.expenseMinor)}</td>
|
||||
<td
|
||||
className={cn("px-3 py-2 text-right", data.totals.netMinor < 0 && "text-expense")}
|
||||
>
|
||||
{data.totals.netMinor < 0 ? "−" : ""}
|
||||
{formatMoney(Math.abs(data.totals.netMinor))}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{formatPct(data.totals.savingsPct)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { formatUnitPrice, unitPriceBase } from "@/shared/prices";
|
||||
import type { UnitPriceReport } from "../extra";
|
||||
import { dayLabel, monthLabel, monthShort } from "./report-frame";
|
||||
|
||||
const t = tr.reports.unitPrice;
|
||||
const config = {
|
||||
min: { label: t.min, color: "var(--income)" },
|
||||
avg: { label: t.avg, color: "var(--budget)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
/** Ürün tipi seçimi (URL'de) ve aylık en düşük / ortalama birim fiyat. */
|
||||
export function UnitPriceView({ data }: { data: UnitPriceReport }) {
|
||||
const router = useRouter();
|
||||
const sel = data.selected;
|
||||
if (!sel)
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.noTypes}
|
||||
</p>
|
||||
);
|
||||
const base = unitPriceBase(sel.unit);
|
||||
// Gösterim biriminde TL (g → kg, ml → lt).
|
||||
const tl = (minor: number | null) => (minor === null ? null : (minor * base.factor) / 100);
|
||||
const chart = sel.points.map((p) => ({
|
||||
month: p.month,
|
||||
label: monthShort(p.month),
|
||||
min: tl(p.minMinor),
|
||||
avg: tl(p.avgMinor),
|
||||
}));
|
||||
const fmt = (minor: number | null) => (minor === null ? "—" : formatUnitPrice(minor, sel.unit));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="up-type" className="text-sm text-muted-foreground">
|
||||
{t.type}
|
||||
</label>
|
||||
<Select
|
||||
value={sel.id}
|
||||
onValueChange={(id) => router.replace(`/raporlar/birim-fiyat?tip=${id}`)}
|
||||
>
|
||||
<SelectTrigger id="up-type" className="w-64">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{data.types.map((x) => (
|
||||
<SelectItem key={x.id} value={x.id}>
|
||||
{x.name}{" "}
|
||||
<span className="text-xs text-muted-foreground">({t.records(x.records)})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="aspect-auto h-60 w-full"
|
||||
role="img"
|
||||
aria-label={t.chartLabel(sel.name)}
|
||||
>
|
||||
<LineChart data={chart} margin={{ left: 4, right: 8, top: 8 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={56}
|
||||
tickFormatter={(v: number) =>
|
||||
`₺${v.toLocaleString("tr-TR", { maximumFractionDigits: 2 })}`
|
||||
}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const m = payload?.[0]?.payload?.month as string | undefined;
|
||||
return m ? monthLabel(m) : "";
|
||||
}}
|
||||
formatter={(value, name) => (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{config[name as keyof typeof config]?.label}
|
||||
</span>
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{formatUnitPrice((Number(value) * 100) / base.factor, sel.unit)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
{(["min", "avg"] as const).map((k) => (
|
||||
<Line
|
||||
key={k}
|
||||
dataKey={k}
|
||||
stroke={`var(--color-${k})`}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={k === "avg" ? "6 3" : undefined}
|
||||
dot={{ r: 3 }}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
<table className="mt-3 w-full text-sm tabular-nums">
|
||||
<thead className="text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-1 text-left font-normal">{t.month}</th>
|
||||
<th className="py-1 text-right font-normal">{t.min}</th>
|
||||
<th className="py-1 text-right font-normal">{t.avg}</th>
|
||||
<th className="py-1 text-right font-normal">{t.count}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{[...sel.points].reverse().map((p) => (
|
||||
<tr key={p.month}>
|
||||
<td className="py-1 capitalize">{monthLabel(p.month)}</td>
|
||||
<td className="py-1 text-right">{fmt(p.minMinor)}</td>
|
||||
<td className="py-1 text-right">{fmt(p.avgMinor)}</td>
|
||||
<td className="py-1 text-right">{p.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{sel.stores.length ? (
|
||||
<section className="rounded-xl border bg-card p-4" aria-labelledby="up-stores">
|
||||
<h2 id="up-stores" className="mb-2 text-sm font-medium">
|
||||
{t.storesTitle}
|
||||
</h2>
|
||||
<table className="w-full text-sm tabular-nums">
|
||||
<thead className="text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-1 text-left font-normal">{t.store}</th>
|
||||
<th className="py-1 text-right font-normal">{t.min}</th>
|
||||
<th className="py-1 text-right font-normal">{t.last}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{sel.stores.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="py-1">{s.label}</td>
|
||||
<td className="py-1 text-right">{fmt(s.minMinor)}</td>
|
||||
<td className="py-1 text-right">{dayLabel(s.lastDate)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import "server-only";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { getHouseholdContext } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
/** CSV rotaları için ortak: oturum + aktif hane, ?ay= doğrulaması ve yanıt üretimi. */
|
||||
export async function csvContext(request: Request) {
|
||||
const ctx = await getHouseholdContext();
|
||||
if (!ctx?.household) return null;
|
||||
const params = new URL(request.url).searchParams;
|
||||
const ay = budgetMonthSchema.safeParse(params.get("ay"));
|
||||
return {
|
||||
userId: ctx.user.id,
|
||||
householdId: ctx.household.id,
|
||||
month: ay.success ? ay.data : undefined,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export const unauthorized = () => new Response(tr.common.unauthorized, { status: 401 });
|
||||
export const forbidden = () => new Response(tr.common.unauthorized, { status: 403 });
|
||||
|
||||
export function csvResponse(csv: string, filename: string) {
|
||||
return new Response(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 14,3 → "14,3" (CSV ondalık virgül). */
|
||||
export const csvPct = (n: number | null) => (n === null ? "" : String(n).replace(".", ","));
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { completeShopping, getCompletionSheet } from "@/features/shopping/completion";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { ShoppingListModel } from "@/models/shopping-list";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { shoppingCompletionSchema } from "@/shared/schemas/shopping";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
getAssetsReport,
|
||||
getContactsReport,
|
||||
getShoppingReport,
|
||||
getUnitPriceReport,
|
||||
getWasteReport,
|
||||
} from "./extra";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
describe("cari ve aynî", () => {
|
||||
it("bakiyeli cariler büyükten küçüğe; toplamlar; açık aynî borçlar; başka hane göremez", async () => {
|
||||
const r = (await getContactsReport(ayse, cakir))!;
|
||||
expect(r.contacts.length).toBeGreaterThan(0);
|
||||
expect(r.contacts.every((c) => c.balanceMinor !== 0 && !c.archived)).toBe(true);
|
||||
const abs = r.contacts.map((c) => Math.abs(c.balanceMinor));
|
||||
expect(abs).toEqual([...abs].sort((a, b) => b - a));
|
||||
expect(r.inKind.length).toBeGreaterThan(0);
|
||||
expect(r.inKind.every((d) => d.remainingQuantity > 0)).toBe(true);
|
||||
expect(await getContactsReport(fatma, cakir)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("birikim", () => {
|
||||
it("varlık kâr/zarar ve dönem raporu", async () => {
|
||||
const r = (await getAssetsReport(ayse, cakir, "6"))!;
|
||||
expect(r.items.length).toBeGreaterThan(0);
|
||||
expect(r.period.from < r.period.to).toBe(true);
|
||||
expect(r.range).toBe("6");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alışveriş", () => {
|
||||
it("mağaza bazlı harcama alıcı adıyla eşleşir; tamamlanan liste plan/gerçek satırı verir", async () => {
|
||||
const before = (await getShoppingReport(ayse, cakir, 6))!;
|
||||
expect(before.lists).toEqual([]);
|
||||
expect(before.stores.map((s) => s.name)).toEqual(expect.arrayContaining(["Migros", "BİM"]));
|
||||
expect(before.stores.reduce((a, s) => a + s.amountMinor, 0)).toBe(before.storesTotalMinor);
|
||||
|
||||
// Haftalık marketten yalnızca süt alınır, kalanlar bırakılır.
|
||||
const weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id);
|
||||
const sheet = (await getCompletionSheet(ayse, cakir, weekly))!;
|
||||
const milk = sheet.items.find((i) => i.label === "Süt")!;
|
||||
const plan = sheet.planned[milk.id]!;
|
||||
const account = String((await AccountModel.findOne({ name: "Ziraat Maaş" }).lean())!._id);
|
||||
const done = await completeShopping(
|
||||
ayse,
|
||||
cakir,
|
||||
weekly,
|
||||
shoppingCompletionSchema.parse({
|
||||
date: todayIstanbul(),
|
||||
lines: [
|
||||
{
|
||||
itemId: milk.id,
|
||||
productId: plan.productId,
|
||||
storeId: plan.storeId,
|
||||
packages: String(plan.packages),
|
||||
amount: formatMoneyInput(plan.priceMinor * plan.packages),
|
||||
},
|
||||
],
|
||||
stores: [{ storeId: plan.storeId, accountId: account, attachmentIds: [] }],
|
||||
leftover: { action: "drop" },
|
||||
}),
|
||||
);
|
||||
expect(done.ok).toBe(true);
|
||||
|
||||
const after = (await getShoppingReport(ayse, cakir, 3))!;
|
||||
expect(after.lists).toHaveLength(1);
|
||||
const row = after.lists[0]!;
|
||||
expect(row.summary.counts.same).toBe(1);
|
||||
expect(row.summary.actualMinor).toBe(plan.priceMinor * plan.packages);
|
||||
expect(after.listTotals.diffMinor).toBe(row.summary.diffMinor);
|
||||
const store = after.stores.find((s) => s.fromListsMinor > 0)!;
|
||||
expect(store.fromListsMinor).toBe(plan.priceMinor * plan.packages);
|
||||
});
|
||||
});
|
||||
|
||||
describe("birim fiyat geçmişi", () => {
|
||||
it("fiyatı olan tipler; seçilen tipte 12 ay noktası ve mağaza en düşükleri", async () => {
|
||||
const r = (await getUnitPriceReport(ayse, cakir))!;
|
||||
expect(r.types.length).toBeGreaterThan(5);
|
||||
expect(r.months).toHaveLength(12);
|
||||
const toz = String((await ProductTypeModel.findOne({ seedRef: "toz-deterjan" }).lean())!._id);
|
||||
const d = (await getUnitPriceReport(ayse, cakir, toz))!;
|
||||
expect(d.selected!.id).toBe(toz);
|
||||
expect(d.selected!.points).toHaveLength(12);
|
||||
const priced = d.selected!.points.filter((p) => p.count > 0);
|
||||
expect(priced.length).toBeGreaterThan(3);
|
||||
for (const p of priced) expect(p.minMinor!).toBeLessThanOrEqual(p.avgMinor!);
|
||||
const mins = d.selected!.stores.map((s) => s.minMinor);
|
||||
expect(mins).toEqual([...mins].sort((a, b) => a - b));
|
||||
});
|
||||
});
|
||||
|
||||
describe("fire / zarar", () => {
|
||||
it("fire satırları, oran ve nedenler; izleyici de okuyabilir", async () => {
|
||||
const r = (await getWasteReport(zeynep, cakir, 12))!;
|
||||
expect(r.rows.length).toBeGreaterThan(0);
|
||||
for (const row of r.rows) {
|
||||
const sum = Object.values(row.byReason).reduce((a, b) => a + b, 0);
|
||||
expect(sum).toBe(row.wasted);
|
||||
if (row.purchased > 0) expect(row.wastePct).not.toBeNull();
|
||||
}
|
||||
const priced = r.rows.reduce((a, x) => a + (x.costMinor ?? 0), 0);
|
||||
expect(priced).toBe(r.totalCostMinor);
|
||||
expect(await getWasteReport(fatma, cakir, 12)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,482 @@
|
||||
import "server-only";
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
getPeriodReport,
|
||||
listAssets,
|
||||
type AssetView,
|
||||
type ReportRange,
|
||||
} from "@/features/assets/service";
|
||||
import { listContacts, type ContactView } from "@/features/contacts/service";
|
||||
import { listInKind } from "@/features/in-kind/service";
|
||||
import { latestTypeUnitPrices, loadTypePriceHistory } from "@/features/prices/service";
|
||||
import { storeLabel } from "@/features/stores/service";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { ProductPriceModel } from "@/models/product-price";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { ShoppingListModel } from "@/models/shopping-list";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { StoreModel } from "@/models/store";
|
||||
import { formatAssetQuantity, type AssetKey, type InKindDirection } from "@/shared/assets";
|
||||
import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget";
|
||||
import { istanbulDayStart } from "@/shared/dates";
|
||||
import type { PeriodReport } from "@/shared/portfolio";
|
||||
import {
|
||||
matchStoreName,
|
||||
monthlyUnitPrices,
|
||||
sharePct,
|
||||
type ReportPeriod,
|
||||
type UnitPricePoint,
|
||||
} from "@/shared/reports";
|
||||
import { foldTr, compareTr } from "@/shared/search";
|
||||
import type { ShoppingCompletion } from "@/shared/shopping";
|
||||
import { compareShopping, type ComparisonSummary } from "@/shared/shopping-compare";
|
||||
import { WASTE_REASONS, type WasteReason } from "@/shared/stock";
|
||||
import { base, type Base } from "./service";
|
||||
|
||||
/**
|
||||
* Faz 14b raporları: cari ve aynî borçlar, birikim kâr/zarar, alışveriş (liste vs. gerçekleşen,
|
||||
* mağaza bazlı harcama), birim fiyat geçmişi, fire/zarar. Okuma: viewer; gizlilik kuralları
|
||||
* kaynak servislerle aynı (gizli cari/varlık/hesap görünmez).
|
||||
*/
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** İçinde bulunulan dahil son N bütçe ayı ve tarih aralığı. */
|
||||
function periodRange(b: Base, months: number) {
|
||||
const list = Array.from({ length: months }, (_, i) => shiftMonth(b.currentMonth, i - months + 1));
|
||||
return {
|
||||
months: list,
|
||||
from: budgetPeriod(list[0]!, b.s.startDay).from,
|
||||
to: budgetPeriod(list.at(-1)!, b.s.startDay).to,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Cari özet ve aynî borçlar ---
|
||||
|
||||
export type InKindRow = {
|
||||
id: string;
|
||||
contactId: string;
|
||||
contactName: string;
|
||||
direction: InKindDirection;
|
||||
assetKey: AssetKey;
|
||||
/** Kalan miktar ("12,5 gr 22 ayar"). */
|
||||
remainingLabel: string;
|
||||
remainingQuantity: number;
|
||||
valueMinor: number | null;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type ContactsReport = {
|
||||
contacts: ContactView[];
|
||||
totals: { owesMeMinor: number; iOweMinor: number };
|
||||
inKind: InKindRow[];
|
||||
inKindTotals: { receivableMinor: number; debtMinor: number; unpriced: number };
|
||||
};
|
||||
|
||||
export async function getContactsReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<ContactsReport | null> {
|
||||
const [list, inKind] = await Promise.all([
|
||||
listContacts(userId, householdId),
|
||||
listInKind(userId, householdId),
|
||||
]);
|
||||
if (!list || !inKind) return null;
|
||||
const open = inKind.filter((d) => !d.closedAt && d.progress.remainingQuantity > 0);
|
||||
const totals = { receivableMinor: 0, debtMinor: 0, unpriced: 0 };
|
||||
for (const d of open) {
|
||||
const v = d.progress.currentValueMinor;
|
||||
if (v === null) totals.unpriced++;
|
||||
else if (d.direction === "receivable") totals.receivableMinor += v;
|
||||
else totals.debtMinor += v;
|
||||
}
|
||||
return {
|
||||
contacts: list.contacts
|
||||
.filter((c) => !c.archived && c.balanceMinor !== 0)
|
||||
.sort((a, b) => Math.abs(b.balanceMinor) - Math.abs(a.balanceMinor)),
|
||||
totals: list.totals,
|
||||
inKind: open.map((d) => ({
|
||||
id: d.id,
|
||||
contactId: d.contactId,
|
||||
contactName: d.contactName,
|
||||
direction: d.direction,
|
||||
assetKey: d.assetKey,
|
||||
remainingLabel: formatAssetQuantity(d.assetKey, d.progress.remainingQuantity),
|
||||
remainingQuantity: d.progress.remainingQuantity,
|
||||
valueMinor: d.progress.currentValueMinor,
|
||||
date: d.date,
|
||||
})),
|
||||
inKindTotals: totals,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Birikim kâr/zarar ---
|
||||
|
||||
export type AssetsReport = {
|
||||
items: AssetView[];
|
||||
totals: {
|
||||
valueMinor: number;
|
||||
costMinor: number;
|
||||
unrealizedMinor: number;
|
||||
realizedMinor: number;
|
||||
missingPrice: number;
|
||||
};
|
||||
period: PeriodReport & { excluded: number };
|
||||
range: ReportRange;
|
||||
};
|
||||
|
||||
export async function getAssetsReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
range: ReportRange,
|
||||
): Promise<AssetsReport | null> {
|
||||
const [assets, period] = await Promise.all([
|
||||
listAssets(userId, householdId),
|
||||
getPeriodReport(userId, householdId, range),
|
||||
]);
|
||||
if (!assets || !period) return null;
|
||||
return {
|
||||
items: assets.items.filter((a) => a.summary.quantity > 0 || a.summary.realizedMinor !== 0),
|
||||
totals: assets.totals,
|
||||
period,
|
||||
range,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Alışveriş: liste vs. gerçekleşen, mağaza bazlı harcama ---
|
||||
|
||||
export type CompletedListRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
date: string;
|
||||
summary: ComparisonSummary;
|
||||
stores: string[];
|
||||
};
|
||||
|
||||
export type StoreSpendRow = {
|
||||
name: string;
|
||||
amountMinor: number;
|
||||
count: number;
|
||||
/** Alışverişi tamamla ile gelen kısım. */
|
||||
fromListsMinor: number;
|
||||
sharePct: number;
|
||||
};
|
||||
|
||||
export type ShoppingReport = {
|
||||
period: ReportPeriod;
|
||||
from: string;
|
||||
to: string;
|
||||
lists: CompletedListRow[];
|
||||
listTotals: ComparisonSummary["reasons"] & {
|
||||
plannedMinor: number;
|
||||
actualMinor: number;
|
||||
diffMinor: number;
|
||||
};
|
||||
stores: StoreSpendRow[];
|
||||
storesTotalMinor: number;
|
||||
};
|
||||
|
||||
export async function getShoppingReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
period: ReportPeriod,
|
||||
): Promise<ShoppingReport | null> {
|
||||
const b = await base(userId, householdId);
|
||||
if (!b) return null;
|
||||
const range = periodRange(b, period);
|
||||
const hid = b.ctx.m.householdId;
|
||||
const [lists, storeDocs, txs] = await Promise.all([
|
||||
ShoppingListModel.find({
|
||||
householdId: hid,
|
||||
deletedAt: null,
|
||||
status: "completed",
|
||||
"completion.date": { $gte: range.from, $lte: range.to },
|
||||
})
|
||||
.select({ name: 1, completion: 1 })
|
||||
.lean<{ _id: Types.ObjectId; name: string; completion: ShoppingCompletion }[]>(),
|
||||
StoreModel.find({ householdId: hid })
|
||||
.select({ name: 1, branch: 1 })
|
||||
.lean<{ name: string; branch: string }[]>(),
|
||||
TransactionModel.find({
|
||||
householdId: hid,
|
||||
type: "expense",
|
||||
status: "posted",
|
||||
deletedAt: null,
|
||||
date: {
|
||||
$gte: istanbulDayStart(range.from)!,
|
||||
$lt: new Date(istanbulDayStart(range.to)!.getTime() + DAY_MS),
|
||||
},
|
||||
...(!b.s.includePrivate && b.ctx.hiddenAccountIds.length
|
||||
? { accountId: { $nin: b.ctx.hiddenAccountIds } }
|
||||
: {}),
|
||||
})
|
||||
.select({ payeeName: 1, amountMinor: 1, shoppingListId: 1 })
|
||||
.lean<{ payeeName: string; amountMinor: number; shoppingListId: Types.ObjectId | null }[]>(),
|
||||
]);
|
||||
|
||||
const rows: CompletedListRow[] = lists
|
||||
.map((l) => ({
|
||||
id: String(l._id),
|
||||
name: l.name,
|
||||
date: l.completion.date,
|
||||
stores: l.completion.stores.map((s) => s.label),
|
||||
summary: compareShopping(
|
||||
l.completion.rows.map((r) => ({
|
||||
match: r.match,
|
||||
plan: r.plan,
|
||||
actual:
|
||||
r.amountMinor === null
|
||||
? null
|
||||
: { productId: r.productId, packages: r.packages, amountMinor: r.amountMinor },
|
||||
})),
|
||||
),
|
||||
}))
|
||||
.sort((a, c) => (a.date < c.date ? 1 : -1));
|
||||
const listTotals = {
|
||||
plannedMinor: 0,
|
||||
actualMinor: 0,
|
||||
diffMinor: 0,
|
||||
price: 0,
|
||||
quantity: 0,
|
||||
substitute: 0,
|
||||
skipped: 0,
|
||||
extra: 0,
|
||||
unplanned: 0,
|
||||
};
|
||||
for (const r of rows) {
|
||||
listTotals.plannedMinor += r.summary.plannedMinor;
|
||||
listTotals.actualMinor += r.summary.actualMinor;
|
||||
listTotals.diffMinor += r.summary.diffMinor;
|
||||
for (const k of ["price", "quantity", "substitute", "skipped", "extra", "unplanned"] as const)
|
||||
listTotals[k] += r.summary.reasons[k];
|
||||
}
|
||||
|
||||
// Mağaza adı bazında (şubeler birlikte): gider alıcısı mağaza adıyla başlıyorsa.
|
||||
const names = [...new Set(storeDocs.map((s) => s.name))];
|
||||
const byStore = new Map<string, StoreSpendRow>();
|
||||
for (const t of txs) {
|
||||
const name = matchStoreName(t.payeeName, names, foldTr);
|
||||
if (!name) continue;
|
||||
const row = byStore.get(name) ?? {
|
||||
name,
|
||||
amountMinor: 0,
|
||||
count: 0,
|
||||
fromListsMinor: 0,
|
||||
sharePct: 0,
|
||||
};
|
||||
row.amountMinor += t.amountMinor;
|
||||
row.count++;
|
||||
if (t.shoppingListId) row.fromListsMinor += t.amountMinor;
|
||||
byStore.set(name, row);
|
||||
}
|
||||
const storesTotal = [...byStore.values()].reduce((a, r) => a + r.amountMinor, 0);
|
||||
const stores = [...byStore.values()]
|
||||
.map((r) => ({ ...r, sharePct: sharePct(r.amountMinor, storesTotal) }))
|
||||
.sort((a, c) => c.amountMinor - a.amountMinor);
|
||||
return {
|
||||
period,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
lists: rows,
|
||||
listTotals,
|
||||
stores,
|
||||
storesTotalMinor: storesTotal,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Birim fiyat geçmişi ---
|
||||
|
||||
export type UnitPriceReport = {
|
||||
types: { id: string; name: string; unit: string; records: number }[];
|
||||
selected: {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
points: UnitPricePoint[];
|
||||
/** Mağaza başına dönemdeki en düşük birim fiyat. */
|
||||
stores: { id: string; label: string; minMinor: number; lastDate: string }[];
|
||||
} | null;
|
||||
months: string[];
|
||||
};
|
||||
|
||||
export async function getUnitPriceReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
typeId?: string,
|
||||
): Promise<UnitPriceReport | null> {
|
||||
const b = await base(userId, householdId);
|
||||
if (!b) return null;
|
||||
const hid = b.ctx.m.householdId;
|
||||
const range = periodRange(b, 12);
|
||||
const [types, counts] = await Promise.all([
|
||||
ProductTypeModel.find({ householdId: hid, archivedAt: null })
|
||||
.select({ name: 1, unit: 1 })
|
||||
.lean<{ _id: Types.ObjectId; name: string; unit: string }[]>(),
|
||||
ProductPriceModel.aggregate<{ _id: Types.ObjectId; n: number }>([
|
||||
{ $match: { householdId: hid, deletedAt: null } },
|
||||
{ $lookup: { from: "products", localField: "productId", foreignField: "_id", as: "p" } },
|
||||
{ $unwind: "$p" },
|
||||
{ $group: { _id: "$p.productTypeId", n: { $sum: 1 } } },
|
||||
]),
|
||||
]);
|
||||
const count = new Map(counts.map((c) => [String(c._id), c.n]));
|
||||
const options = types
|
||||
.map((x) => ({
|
||||
id: String(x._id),
|
||||
name: x.name,
|
||||
unit: x.unit,
|
||||
records: count.get(String(x._id)) ?? 0,
|
||||
}))
|
||||
.filter((x) => x.records > 0)
|
||||
.sort((a, c) => compareTr(a.name, c.name));
|
||||
const chosen = options.find((x) => x.id === typeId) ?? options[0];
|
||||
if (!chosen) return { types: options, selected: null, months: range.months };
|
||||
|
||||
const [history, storeDocs] = await Promise.all([
|
||||
loadTypePriceHistory(hid, new Types.ObjectId(chosen.id)),
|
||||
StoreModel.find({ householdId: hid })
|
||||
.select({ name: 1, branch: 1 })
|
||||
.lean<{ _id: Types.ObjectId; name: string; branch: string }[]>(),
|
||||
]);
|
||||
const inRange = history.filter((h) => h.date >= range.from && h.date <= range.to);
|
||||
const points = monthlyUnitPrices(
|
||||
inRange.map((h) => ({
|
||||
month: periodOfDate(h.date, b.s.startDay),
|
||||
unitPriceMinor: h.unitPriceMinor,
|
||||
})),
|
||||
range.months,
|
||||
);
|
||||
const labelOf = new Map(storeDocs.map((s) => [String(s._id), storeLabel(s)]));
|
||||
const perStore = new Map<string, { minMinor: number; lastDate: string }>();
|
||||
for (const h of inRange) {
|
||||
const cur = perStore.get(h.storeId);
|
||||
perStore.set(h.storeId, {
|
||||
minMinor: cur ? Math.min(cur.minMinor, h.unitPriceMinor) : h.unitPriceMinor,
|
||||
lastDate: cur && cur.lastDate > h.date ? cur.lastDate : h.date,
|
||||
});
|
||||
}
|
||||
return {
|
||||
types: options,
|
||||
months: range.months,
|
||||
selected: {
|
||||
id: chosen.id,
|
||||
name: chosen.name,
|
||||
unit: chosen.unit,
|
||||
points,
|
||||
stores: [...perStore.entries()]
|
||||
.map(([id, v]) => ({ id, label: labelOf.get(id) ?? "—", ...v }))
|
||||
.sort((a, c) => a.minMinor - c.minMinor),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Fire / zarar ---
|
||||
|
||||
export type WasteRow = {
|
||||
productTypeId: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
wasted: number;
|
||||
purchased: number;
|
||||
/** Fire / alım (yüzde); alım yoksa null. */
|
||||
wastePct: number | null;
|
||||
byReason: Record<WasteReason, number>;
|
||||
/** Tahmini maliyet: fire × son bilinen birim fiyat (fiyat yoksa null). */
|
||||
costMinor: number | null;
|
||||
};
|
||||
|
||||
export type WasteReport = {
|
||||
period: ReportPeriod;
|
||||
from: string;
|
||||
to: string;
|
||||
rows: WasteRow[];
|
||||
/** Neden başına fire satırı sayısı ve tahmini maliyet. */
|
||||
reasons: Record<WasteReason, { count: number; costMinor: number }>;
|
||||
totalCostMinor: number;
|
||||
unpriced: number;
|
||||
};
|
||||
|
||||
export async function getWasteReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
period: ReportPeriod,
|
||||
): Promise<WasteReport | null> {
|
||||
const b = await base(userId, householdId);
|
||||
if (!b) return null;
|
||||
const range = periodRange(b, period);
|
||||
const hid = b.ctx.m.householdId;
|
||||
const moves = await StockMovementModel.find({
|
||||
householdId: hid,
|
||||
deletedAt: null,
|
||||
kind: { $in: ["waste", "purchase"] },
|
||||
date: { $gte: range.from, $lte: range.to },
|
||||
})
|
||||
.select({ productTypeId: 1, kind: 1, quantity: 1, wasteReason: 1 })
|
||||
.lean<
|
||||
{
|
||||
productTypeId: Types.ObjectId;
|
||||
kind: "waste" | "purchase";
|
||||
quantity: number;
|
||||
wasteReason: WasteReason | null;
|
||||
}[]
|
||||
>();
|
||||
const wasteTypeIds = [
|
||||
...new Set(moves.filter((m) => m.kind === "waste").map((m) => String(m.productTypeId))),
|
||||
].map((id) => new Types.ObjectId(id));
|
||||
const [types, prices] = await Promise.all([
|
||||
ProductTypeModel.find({ householdId: hid, _id: { $in: wasteTypeIds } })
|
||||
.select({ name: 1, unit: 1 })
|
||||
.lean<{ _id: Types.ObjectId; name: string; unit: string }[]>(),
|
||||
latestTypeUnitPrices(hid, wasteTypeIds),
|
||||
]);
|
||||
const emptyReasons = () =>
|
||||
Object.fromEntries(WASTE_REASONS.map((r) => [r, 0])) as Record<WasteReason, number>;
|
||||
const rows = new Map<string, WasteRow>();
|
||||
for (const t of types)
|
||||
rows.set(String(t._id), {
|
||||
productTypeId: String(t._id),
|
||||
name: t.name,
|
||||
unit: t.unit,
|
||||
wasted: 0,
|
||||
purchased: 0,
|
||||
wastePct: null,
|
||||
byReason: emptyReasons(),
|
||||
costMinor: null,
|
||||
});
|
||||
const reasons = Object.fromEntries(
|
||||
WASTE_REASONS.map((r) => [r, { count: 0, costMinor: 0 }]),
|
||||
) as WasteReport["reasons"];
|
||||
for (const m of moves) {
|
||||
const row = rows.get(String(m.productTypeId));
|
||||
if (!row) continue;
|
||||
if (m.kind === "purchase") {
|
||||
row.purchased += m.quantity;
|
||||
continue;
|
||||
}
|
||||
const reason = m.wasteReason ?? "other";
|
||||
row.wasted += m.quantity;
|
||||
row.byReason[reason] += m.quantity;
|
||||
reasons[reason].count++;
|
||||
const price = prices.get(row.productTypeId);
|
||||
if (price) reasons[reason].costMinor += Math.round((m.quantity * price.unitPriceMinor) / 1000);
|
||||
}
|
||||
let total = 0;
|
||||
let unpriced = 0;
|
||||
const list = [...rows.values()].map((r) => {
|
||||
const price = prices.get(r.productTypeId);
|
||||
const costMinor = price ? Math.round((r.wasted * price.unitPriceMinor) / 1000) : null;
|
||||
if (costMinor === null) unpriced++;
|
||||
else total += costMinor;
|
||||
return { ...r, costMinor, wastePct: r.purchased > 0 ? sharePct(r.wasted, r.purchased) : null };
|
||||
});
|
||||
list.sort((a, c) => (c.costMinor ?? -1) - (a.costMinor ?? -1) || compareTr(a.name, c.name));
|
||||
return {
|
||||
period,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
rows: list,
|
||||
reasons,
|
||||
totalCostMinor: total,
|
||||
unpriced,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getBudget } from "@/features/budgets/service";
|
||||
import { listCategories, updateCategory } from "@/features/categories/service";
|
||||
import {
|
||||
createTransaction,
|
||||
getTransactionFormOptions,
|
||||
setTransactionDeleted,
|
||||
} from "@/features/transactions/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { transactionFormSchema } from "@/shared/schemas/transaction";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import { getKakeiboReport } from "./kakeibo";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let fatma = "";
|
||||
|
||||
const catId = (defaultKey: string) =>
|
||||
CategoryModel.findOne({ householdId: cakir, defaultKey })
|
||||
.lean()
|
||||
.then((c) => String(c!._id));
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
fatma = ids.user("fatma");
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
describe("varsayılan gruplar", () => {
|
||||
it("varsayılan kategoriler gruplu; alt kategoriler devralır; banka maliyeti işaretli", async () => {
|
||||
const cats = (await listCategories(ayse, cakir, { kind: "expense" }))!;
|
||||
const by = (name: string) => cats.find((c) => c.name === name)!;
|
||||
expect(by("Market").kakeibo).toEqual({ group: "ZORUNLU", inherited: false, bankCost: false });
|
||||
expect(by("Gıda").kakeibo).toEqual({ group: "ZORUNLU", inherited: true, bankCost: false });
|
||||
expect(by("Kişisel bakım").kakeibo.group).toBe("ISTEK");
|
||||
expect(by("Dışarıda yemek").kakeibo.group).toBe("ISTEK");
|
||||
expect(by("Eğitim").kakeibo.group).toBe("KULTUR");
|
||||
expect(by("Faiz").kakeibo.bankCost).toBe(true);
|
||||
expect(by("Diğer").kakeibo.group).toBeNull();
|
||||
const opts = (await getTransactionFormOptions(ayse, cakir))!;
|
||||
expect(opts.categoryKakeibo[by("Gıda").id]).toEqual({ group: "ZORUNLU", bankCost: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kakeibo raporu", () => {
|
||||
it("harcama + banka maliyeti = bütçenin gerçekleşeni; başka hane göremez", async () => {
|
||||
const r = (await getKakeiboReport(ayse, cakir))!;
|
||||
const budget = (await getBudget(ayse, cakir))!;
|
||||
const s = r.now.summary;
|
||||
expect(s.spendingMinor + s.bankCostMinor).toBe(budget.totals.actualMinor);
|
||||
const classSum = Object.values(r.now.byClass)
|
||||
.flat()
|
||||
.reduce((a, x) => a + x.amountMinor, 0);
|
||||
expect(classSum).toBe(budget.totals.actualMinor);
|
||||
expect(await getKakeiboReport(fatma, cakir)).toBeNull();
|
||||
});
|
||||
|
||||
it("işlem bazında grup değiştirme tutarı taşır; banka maliyeti harcamaya girmez", async () => {
|
||||
const before = (await getKakeiboReport(ayse, cakir))!.now.summary;
|
||||
const account = String((await AccountModel.findOne({ name: "Ziraat Maaş" }).lean())!._id);
|
||||
const form = (over: Record<string, unknown>) =>
|
||||
transactionFormSchema.parse({
|
||||
type: "expense",
|
||||
amount: "100",
|
||||
date: todayIstanbul(),
|
||||
accountId: account,
|
||||
installments: 1,
|
||||
...over,
|
||||
});
|
||||
const gida = await catId("market.gida");
|
||||
const faiz = await catId("banka.faiz");
|
||||
// Market alışverişi ama amaç "istek" (ör. doğum günü pastası).
|
||||
const a = await createTransaction(
|
||||
ayse,
|
||||
cakir,
|
||||
form({ categoryId: gida, kakeiboGroup: "ISTEK" }),
|
||||
);
|
||||
// Bölünmüş: 60 ZORUNLU (kategoriden) + 40 KÜLTÜR (değiştirilmiş).
|
||||
const b = await createTransaction(
|
||||
ayse,
|
||||
cakir,
|
||||
form({
|
||||
categoryId: "",
|
||||
splits: [
|
||||
{ categoryId: gida, amount: "60", note: "" },
|
||||
{ categoryId: gida, amount: "40", note: "", kakeiboGroup: "KULTUR" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const c = await createTransaction(ayse, cakir, form({ categoryId: faiz, amount: "25" }));
|
||||
expect([a.ok, b.ok, c.ok]).toEqual([true, true, true]);
|
||||
|
||||
const after = (await getKakeiboReport(ayse, cakir))!.now.summary;
|
||||
expect(after.groups.ISTEK - before.groups.ISTEK).toBe(100_00);
|
||||
expect(after.groups.ZORUNLU - before.groups.ZORUNLU).toBe(60_00);
|
||||
expect(after.groups.KULTUR - before.groups.KULTUR).toBe(40_00);
|
||||
expect(after.bankCostMinor - before.bankCostMinor).toBe(25_00);
|
||||
expect(after.spendingMinor - before.spendingMinor).toBe(200_00);
|
||||
|
||||
for (const r of [a, b, c]) if (r.ok) await setTransactionDeleted(ayse, cakir, r.data.id, true);
|
||||
});
|
||||
|
||||
it("kategoriye grup atanınca grupsuzdan çıkar", async () => {
|
||||
const diger = await catId("diger-gider");
|
||||
const cats = (await listCategories(ayse, cakir, { kind: "expense" }))!;
|
||||
const d = cats.find((c) => c.id === diger)!;
|
||||
const r = await updateCategory(ayse, cakir, diger, {
|
||||
kind: "expense",
|
||||
name: d.name,
|
||||
parentId: null,
|
||||
icon: d.icon,
|
||||
color: d.color,
|
||||
kakeiboGroup: "BEKLENMEYEN",
|
||||
bankCost: false,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
const after = (await listCategories(ayse, cakir, { kind: "expense" }))!.find(
|
||||
(c) => c.id === diger,
|
||||
)!;
|
||||
expect(after.kakeibo.group).toBe("BEKLENMEYEN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("migration 016", () => {
|
||||
it("geri alınır (alanlar kalkar) ve yeniden uygulanınca varsayılanlar dolar", async () => {
|
||||
const { revertMigration, runMigrations } = await import("../../../scripts/migrations");
|
||||
const log = () => {};
|
||||
await revertMigration("016-kakeibo-groups", log);
|
||||
expect(await CategoryModel.countDocuments({ kakeiboGroup: { $exists: true } })).toBe(0);
|
||||
expect(await runMigrations(log)).toEqual(["016-kakeibo-groups"]);
|
||||
const gida = await CategoryModel.findOne({ householdId: cakir, defaultKey: "market" }).lean();
|
||||
expect(gida!.kakeiboGroup).toBe("ZORUNLU");
|
||||
const banka = await CategoryModel.findOne({ householdId: cakir, defaultKey: "banka" }).lean();
|
||||
expect(banka!.bankCost).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import "server-only";
|
||||
import type { Types } from "mongoose";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { budgetPeriod, shiftMonth } from "@/shared/budget";
|
||||
import { istanbulDayStart } from "@/shared/dates";
|
||||
import {
|
||||
classifyAmounts,
|
||||
isKakeiboGroup,
|
||||
KAKEIBO_GROUPS,
|
||||
summarizeGroups,
|
||||
type GroupSummary,
|
||||
type KakeiboGroup,
|
||||
type SpendingClass,
|
||||
} from "@/shared/kakeibo";
|
||||
import { changePct } from "@/shared/reports";
|
||||
import { actualByCategory, base, monthInfo, ORPHAN, type Base, type MonthInfo } from "./service";
|
||||
|
||||
/**
|
||||
* Kakeibo grup raporu (Faz 16). Kategori tutarları bütçeyle aynı kaynaktan (gerçekleşmiş
|
||||
* giderler, bölünmüş kalemler, taksit modu, gizli hesap ayarı); işlem/kalem bazında grubu
|
||||
* değiştirilen tutarlar seçilen gruba taşınır. Banka maliyeti ayrı, grupsuzlar listelenir.
|
||||
*/
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export type KakeiboCategoryRow = { id: string; path: string; amountMinor: number };
|
||||
|
||||
export type KakeiboMonth = {
|
||||
summary: GroupSummary;
|
||||
/** Sınıf başına kategori kırılımı (tutara göre büyükten küçüğe). */
|
||||
byClass: Record<SpendingClass, KakeiboCategoryRow[]>;
|
||||
};
|
||||
|
||||
export type KakeiboReport = MonthInfo & {
|
||||
current: boolean;
|
||||
previous: string;
|
||||
now: KakeiboMonth;
|
||||
prev: KakeiboMonth;
|
||||
/** Grup başına önceki aya göre değişim (tutar ve yüzde). */
|
||||
change: Record<KakeiboGroup | "BANK_COST", { minor: number; pct: number | null }>;
|
||||
includesHidden: boolean;
|
||||
};
|
||||
|
||||
/** Dönemde grubu işlem/kalem bazında değiştirilmiş gider tutarları (kategori × grup). */
|
||||
async function overrides(b: Base, month: string) {
|
||||
const { from, to } = budgetPeriod(month, b.s.startDay);
|
||||
const match: Record<string, unknown> = {
|
||||
householdId: b.ctx.m.householdId,
|
||||
type: "expense",
|
||||
status: "posted",
|
||||
deletedAt: null,
|
||||
date: {
|
||||
$gte: istanbulDayStart(from)!,
|
||||
$lt: new Date(istanbulDayStart(to)!.getTime() + DAY_MS),
|
||||
},
|
||||
$or: [{ kakeiboGroup: { $type: "string" } }, { "splits.kakeiboGroup": { $type: "string" } }],
|
||||
};
|
||||
if (!b.s.includePrivate && b.ctx.hiddenAccountIds.length)
|
||||
match.accountId = { $nin: b.ctx.hiddenAccountIds };
|
||||
// Tamamı alış ayına modunda taksitler bütçede alış ayına toplanır; değiştirme yalnızca
|
||||
// taksitsiz işlemlere uygulanır (tutarlar kategori toplamıyla tutarlı kalsın).
|
||||
if (b.s.installmentMode === "purchase_month") match["installment.planId"] = { $in: [null] };
|
||||
const docs = await TransactionModel.find(match)
|
||||
.select({ categoryId: 1, amountMinor: 1, kakeiboGroup: 1, splits: 1 })
|
||||
.lean<
|
||||
{
|
||||
categoryId: Types.ObjectId | null;
|
||||
amountMinor: number;
|
||||
kakeiboGroup: string | null;
|
||||
splits: { categoryId: Types.ObjectId; amountMinor: number; kakeiboGroup?: string | null }[];
|
||||
}[]
|
||||
>();
|
||||
const sums = new Map<string, { categoryId: string; group: KakeiboGroup; amountMinor: number }>();
|
||||
const add = (categoryId: string, group: unknown, amount: number) => {
|
||||
if (!isKakeiboGroup(group)) return;
|
||||
const key = `${categoryId}:${group}`;
|
||||
const cur = sums.get(key) ?? { categoryId, group, amountMinor: 0 };
|
||||
cur.amountMinor += amount;
|
||||
sums.set(key, cur);
|
||||
};
|
||||
for (const d of docs) {
|
||||
if (d.splits?.length)
|
||||
for (const s of d.splits) add(String(s.categoryId), s.kakeiboGroup, s.amountMinor);
|
||||
else if (d.categoryId) add(String(d.categoryId), d.kakeiboGroup, d.amountMinor);
|
||||
}
|
||||
return [...sums.values()];
|
||||
}
|
||||
|
||||
async function kakeiboMonth(b: Base, month: string): Promise<KakeiboMonth> {
|
||||
const [amounts, over] = await Promise.all([actualByCategory(b, month), overrides(b, month)]);
|
||||
const items = classifyAmounts(amounts, over, b.ctx.kakeibo);
|
||||
const byClass = Object.fromEntries(
|
||||
[...KAKEIBO_GROUPS, "BANK_COST", "UNGROUPED"].map((c) => [c, [] as KakeiboCategoryRow[]]),
|
||||
) as Record<SpendingClass, KakeiboCategoryRow[]>;
|
||||
const merged = new Map<string, KakeiboCategoryRow & { cls: SpendingClass }>();
|
||||
for (const it of items) {
|
||||
const key = `${it.cls}:${it.categoryId}`;
|
||||
const path =
|
||||
it.categoryId === ORPHAN
|
||||
? tr.reports.uncategorized
|
||||
: (b.ctx.categoryMap.get(it.categoryId)?.path ?? "—");
|
||||
const cur = merged.get(key) ?? { id: it.categoryId, path, amountMinor: 0, cls: it.cls };
|
||||
cur.amountMinor += it.amountMinor;
|
||||
merged.set(key, cur);
|
||||
}
|
||||
for (const r of merged.values())
|
||||
byClass[r.cls].push({ id: r.id, path: r.path, amountMinor: r.amountMinor });
|
||||
for (const list of Object.values(byClass)) list.sort((a, c) => c.amountMinor - a.amountMinor);
|
||||
return { summary: summarizeGroups(items), byClass };
|
||||
}
|
||||
|
||||
export async function getKakeiboReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
month?: string,
|
||||
): Promise<KakeiboReport | null> {
|
||||
const b = await base(userId, householdId);
|
||||
if (!b) return null;
|
||||
const m = month ?? b.currentMonth;
|
||||
const previous = shiftMonth(m, -1);
|
||||
const [now, prev] = await Promise.all([kakeiboMonth(b, m), kakeiboMonth(b, previous)]);
|
||||
const change = Object.fromEntries(
|
||||
[...KAKEIBO_GROUPS, "BANK_COST" as const].map((g) => {
|
||||
const cur = g === "BANK_COST" ? now.summary.bankCostMinor : now.summary.groups[g];
|
||||
const old = g === "BANK_COST" ? prev.summary.bankCostMinor : prev.summary.groups[g];
|
||||
return [g, { minor: cur - old, pct: changePct(cur, old) }];
|
||||
}),
|
||||
) as KakeiboReport["change"];
|
||||
return {
|
||||
...monthInfo(b, m),
|
||||
previous,
|
||||
now,
|
||||
prev,
|
||||
change,
|
||||
includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getBudget } from "@/features/budgets/service";
|
||||
import { getOverview } from "@/features/dashboard/service";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
getBudgetReport,
|
||||
getCategoryReport,
|
||||
getPeopleAccountsReport,
|
||||
getTrendReport,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
describe("kategori dağılımı", () => {
|
||||
it("toplam bütçenin gerçekleşeniyle aynı; oranlar ~%100; başka hane göremez", async () => {
|
||||
const r = (await getCategoryReport(ayse, cakir))!;
|
||||
const budget = (await getBudget(ayse, cakir))!;
|
||||
expect(r.breakdown.totalMinor).toBe(budget.totals.actualMinor);
|
||||
expect(r.breakdown.totalMinor).toBeGreaterThan(0);
|
||||
const sum = r.breakdown.rows.reduce((a, x) => a + x.amountMinor, 0);
|
||||
expect(sum).toBe(r.breakdown.totalMinor);
|
||||
const pct = r.breakdown.rows.reduce((a, x) => a + x.sharePct, 0);
|
||||
expect(Math.abs(pct - 100)).toBeLessThan(1);
|
||||
// Alt kategori kırılımı: çocukların ve doğrudan tutarın toplamı üst satıra eşit.
|
||||
for (const row of r.breakdown.rows) {
|
||||
const kids = row.children.reduce((a, c) => a + c.amountMinor, 0);
|
||||
expect(kids + row.ownMinor).toBe(row.amountMinor);
|
||||
}
|
||||
expect(r.appearance[r.breakdown.rows[0]!.id]).toBeDefined();
|
||||
expect(await getCategoryReport(fatma, cakir)).toBeNull();
|
||||
});
|
||||
|
||||
it("önceki ay seçilebilir", async () => {
|
||||
const cur = (await getCategoryReport(ayse, cakir))!;
|
||||
const prev = (await getCategoryReport(ayse, cakir, cur.previous))!;
|
||||
expect(prev.current).toBe(false);
|
||||
expect(prev.breakdown.totalMinor).toBe(cur.breakdown.prevTotalMinor);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aylık trend", () => {
|
||||
it("12 ay; içinde bulunulan ayın gideri bütçeyle aynı; tasarruf oranı tutarlı", async () => {
|
||||
const r = (await getTrendReport(ayse, cakir))!;
|
||||
expect(r.months).toHaveLength(12);
|
||||
const last = r.months.at(-1)!;
|
||||
expect(last.month).toBe(r.currentMonth);
|
||||
expect(last.expenseMinor).toBe((await getBudget(ayse, cakir))!.totals.actualMinor);
|
||||
for (const m of r.months) {
|
||||
expect(m.netMinor).toBe(m.incomeMinor - m.expenseMinor);
|
||||
if (m.incomeMinor === 0) expect(m.savingsPct).toBeNull();
|
||||
}
|
||||
expect(r.months.filter((m) => m.incomeMinor > 0).length).toBeGreaterThan(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("kişi ve hesap bazlı", () => {
|
||||
it("kişi toplamları ve oranları; gizli hesap yalnızca sahibine görünür", async () => {
|
||||
const own = (await getPeopleAccountsReport(ayse, cakir))!;
|
||||
expect(own.people.reduce((a, p) => a + p.expenseMinor, 0)).toBe(own.peopleTotalMinor);
|
||||
expect(own.people.every((p) => p.name !== "—")).toBe(true);
|
||||
expect(own.accounts.map((a) => a.name)).toContain("Ayşe — Pluxee");
|
||||
for (const a of own.accounts)
|
||||
expect(a.netMinor).toBe(
|
||||
a.incomeMinor + a.transferInMinor - a.expenseMinor - a.transferOutMinor,
|
||||
);
|
||||
const other = (await getPeopleAccountsReport(zeynep, cakir))!;
|
||||
expect(other.accounts.map((a) => a.name)).not.toContain("Ayşe — Pluxee");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bütçe ve gerçekleşen", () => {
|
||||
it("son 6 ay; son satır seçilen ay", async () => {
|
||||
const r = (await getBudgetReport(ayse, cakir))!;
|
||||
expect(r.history).toHaveLength(6);
|
||||
expect(r.history.at(-1)!.month).toBe(r.month);
|
||||
expect(r.history.at(-1)!.actualMinor).toBe(r.budget.totals.actualMinor);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard ana grafik", () => {
|
||||
it("periyotlar: hafta 7 gün, ay günleri, 6 ve 12 ay; gelecek günlerde gerçekleşen boş", async () => {
|
||||
const week = (await getOverview(ayse, cakir, "week"))!;
|
||||
expect(week.points).toHaveLength(7);
|
||||
expect(week.points.filter((p) => p.key > week.today).every((p) => p.actual === null)).toBe(
|
||||
true,
|
||||
);
|
||||
const month = (await getOverview(ayse, cakir, "month"))!;
|
||||
const budget = (await getBudget(ayse, cakir))!;
|
||||
const actual = month.points.reduce((a, p) => a + (p.actual ?? 0), 0);
|
||||
expect(actual).toBe(budget.totals.actualMinor);
|
||||
if (budget.hasBudget) {
|
||||
const planned = month.points.reduce((a, p) => a + (p.budget ?? 0), 0);
|
||||
expect(planned).toBe(budget.totals.limitMinor);
|
||||
}
|
||||
const half = (await getOverview(ayse, cakir, "half"))!;
|
||||
expect(half.points).toHaveLength(6);
|
||||
expect(half.points.at(-1)!.actual).toBe(budget.totals.actualMinor);
|
||||
expect((await getOverview(ayse, cakir, "year"))!.points).toHaveLength(12);
|
||||
expect(await getOverview(fatma, cakir, "month")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
import "server-only";
|
||||
import { Types } from "mongoose";
|
||||
import { incomePlan } from "@/features/budgets/plan";
|
||||
import {
|
||||
budgetFor,
|
||||
expenseAmounts,
|
||||
settingsOf,
|
||||
type BudgetLineView,
|
||||
type BudgetView,
|
||||
type Settings,
|
||||
} from "@/features/budgets/service";
|
||||
import { loadContext, userNames, type Ctx } from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { AccountType } from "@/shared/accounts";
|
||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||
import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget";
|
||||
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||
import { categoryBreakdown, savingsRate, sharePct, type CategoryBreakdown } from "@/shared/reports";
|
||||
|
||||
/**
|
||||
* Raporlar (Bölüm 6.14). Okuma: viewer. "Ay" bütçe ayıdır (hane ayarı); gider kuralları bütçeyle
|
||||
* aynı: gerçekleşmiş işlemler, bölünmüş işlemler kalem bazında, taksit modu ve başkasının
|
||||
* "yalnızca ben" hesabındaki giderler hane ayarına göre (yalnızca tutar). Başkasının gizli
|
||||
* hesabı hesap raporunda hiç görünmez.
|
||||
*/
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
export const ORPHAN = "__none__";
|
||||
|
||||
export type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string };
|
||||
|
||||
export async function base(userId: string, householdId: string): Promise<Base | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const s = await settingsOf(ctx.m.householdId);
|
||||
const today = todayIstanbul();
|
||||
return { ctx, s, today, currentMonth: periodOfDate(today, s.startDay) };
|
||||
}
|
||||
|
||||
/** Kategori bazında gerçekleşen gider (kendi tutarı); kategorisiz/bilinmeyen tek satırda. */
|
||||
export async function actualByCategory(b: Base, month: string) {
|
||||
const { from, to } = budgetPeriod(month, b.s.startDay);
|
||||
const amounts = await expenseAmounts(b.ctx, b.s, from, to);
|
||||
const out = new Map<string, number>();
|
||||
for (const [id, a] of amounts) {
|
||||
const v = a.fixedMinor + a.variableMinor;
|
||||
if (!v) continue;
|
||||
const known = id && b.ctx.categoryMap.get(id)?.kind === "expense";
|
||||
const key = known ? id : ORPHAN;
|
||||
out.set(key, (out.get(key) ?? 0) + v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type MonthInfo = {
|
||||
month: string;
|
||||
from: string;
|
||||
to: string;
|
||||
current: boolean;
|
||||
currentMonth: string;
|
||||
};
|
||||
|
||||
export const monthInfo = (b: Base, month: string): MonthInfo => ({
|
||||
month,
|
||||
...budgetPeriod(month, b.s.startDay),
|
||||
current: month === b.currentMonth,
|
||||
currentMonth: b.currentMonth,
|
||||
});
|
||||
|
||||
// --- Kategori dağılımı ---
|
||||
|
||||
export type CategoryReport = MonthInfo & {
|
||||
previous: string;
|
||||
breakdown: CategoryBreakdown;
|
||||
appearance: Record<string, { icon: IconKey; color: Swatch; path: string }>;
|
||||
includesHidden: boolean;
|
||||
};
|
||||
|
||||
export async function getCategoryReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
month?: string,
|
||||
): Promise<CategoryReport | null> {
|
||||
const b = await base(userId, householdId);
|
||||
if (!b) return null;
|
||||
const m = month ?? b.currentMonth;
|
||||
const prev = shiftMonth(m, -1);
|
||||
const [current, previous] = await Promise.all([
|
||||
actualByCategory(b, m),
|
||||
actualByCategory(b, prev),
|
||||
]);
|
||||
const cats = b.ctx.flatCategories
|
||||
.filter((c) => c.kind === "expense")
|
||||
.map((c) => ({ id: c.id, parentId: c.parentId, name: c.name, order: c.order }));
|
||||
cats.push({ id: ORPHAN, parentId: null, name: tr.reports.uncategorized, order: 1e9 });
|
||||
const appearance: CategoryReport["appearance"] = {
|
||||
[ORPHAN]: { icon: "tag", color: "slate", path: tr.reports.uncategorized },
|
||||
};
|
||||
for (const c of b.ctx.categoryMap.values())
|
||||
if (c.kind === "expense")
|
||||
appearance[String(c.id)] = { icon: c.icon, color: c.color, path: c.path };
|
||||
return {
|
||||
...monthInfo(b, m),
|
||||
previous: prev,
|
||||
breakdown: categoryBreakdown(cats, current, previous),
|
||||
appearance,
|
||||
includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Aylık trend ---
|
||||
|
||||
export type TrendRow = {
|
||||
month: string;
|
||||
incomeMinor: number;
|
||||
expenseMinor: number;
|
||||
netMinor: number;
|
||||
savingsPct: number | null;
|
||||
};
|
||||
|
||||
export type TrendReport = {
|
||||
months: TrendRow[];
|
||||
totals: {
|
||||
incomeMinor: number;
|
||||
expenseMinor: number;
|
||||
netMinor: number;
|
||||
savingsPct: number | null;
|
||||
};
|
||||
currentMonth: string;
|
||||
};
|
||||
|
||||
/** Son `count` bütçe ayı (içinde bulunulan dahil): gerçekleşen gelir, gider, net, tasarruf oranı. */
|
||||
export async function getTrendReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
count = 12,
|
||||
): Promise<TrendReport | null> {
|
||||
const b = await base(userId, householdId);
|
||||
if (!b) return null;
|
||||
const months = Array.from({ length: count }, (_, i) => shiftMonth(b.currentMonth, i - count + 1));
|
||||
const rows = await Promise.all(
|
||||
months.map(async (month) => {
|
||||
const [income, expense] = await Promise.all([
|
||||
incomePlan(b.ctx, b.s, month),
|
||||
actualByCategory(b, month),
|
||||
]);
|
||||
const incomeMinor = income.actualMinor;
|
||||
const expenseMinor = [...expense.values()].reduce((a, v) => a + v, 0);
|
||||
return {
|
||||
month,
|
||||
incomeMinor,
|
||||
expenseMinor,
|
||||
netMinor: incomeMinor - expenseMinor,
|
||||
savingsPct: savingsRate(incomeMinor, expenseMinor),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const incomeMinor = rows.reduce((a, r) => a + r.incomeMinor, 0);
|
||||
const expenseMinor = rows.reduce((a, r) => a + r.expenseMinor, 0);
|
||||
return {
|
||||
months: rows,
|
||||
totals: {
|
||||
incomeMinor,
|
||||
expenseMinor,
|
||||
netMinor: incomeMinor - expenseMinor,
|
||||
savingsPct: savingsRate(incomeMinor, expenseMinor),
|
||||
},
|
||||
currentMonth: b.currentMonth,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Kişi ve hesap bazlı ---
|
||||
|
||||
export type PersonRow = {
|
||||
userId: string;
|
||||
name: string;
|
||||
expenseMinor: number;
|
||||
count: number;
|
||||
sharePct: number;
|
||||
};
|
||||
|
||||
export type AccountRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: AccountType;
|
||||
archived: boolean;
|
||||
incomeMinor: number;
|
||||
expenseMinor: number;
|
||||
transferInMinor: number;
|
||||
transferOutMinor: number;
|
||||
netMinor: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type PeopleAccountsReport = MonthInfo & {
|
||||
people: PersonRow[];
|
||||
peopleTotalMinor: number;
|
||||
accounts: AccountRow[];
|
||||
includesHidden: boolean;
|
||||
};
|
||||
|
||||
type TxLean = {
|
||||
type: "income" | "expense" | "transfer";
|
||||
amountMinor: number;
|
||||
accountId: Types.ObjectId | null;
|
||||
toAccountId: Types.ObjectId | null;
|
||||
createdBy: Types.ObjectId;
|
||||
};
|
||||
|
||||
export async function getPeopleAccountsReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
month?: string,
|
||||
): Promise<PeopleAccountsReport | null> {
|
||||
const b = await base(userId, householdId);
|
||||
if (!b) return null;
|
||||
const m = month ?? b.currentMonth;
|
||||
const info = monthInfo(b, m);
|
||||
const docs = await TransactionModel.find({
|
||||
householdId: b.ctx.m.householdId,
|
||||
deletedAt: null,
|
||||
status: "posted",
|
||||
date: {
|
||||
$gte: istanbulDayStart(info.from)!,
|
||||
$lt: new Date(istanbulDayStart(info.to)!.getTime() + DAY_MS),
|
||||
},
|
||||
})
|
||||
.select({ type: 1, amountMinor: 1, accountId: 1, toAccountId: 1, createdBy: 1 })
|
||||
.lean<TxLean[]>();
|
||||
const hidden = new Set(b.ctx.hiddenAccountIds.map(String));
|
||||
|
||||
// Kişi: kaydı giren üye; gizli hesap giderleri hane ayarına göre (yalnızca tutar).
|
||||
const people = new Map<string, { expense: number; count: number }>();
|
||||
for (const d of docs) {
|
||||
if (d.type !== "expense") continue;
|
||||
if (d.accountId && hidden.has(String(d.accountId)) && !b.s.includePrivate) continue;
|
||||
const key = String(d.createdBy);
|
||||
const p = people.get(key) ?? { expense: 0, count: 0 };
|
||||
p.expense += d.amountMinor;
|
||||
p.count++;
|
||||
people.set(key, p);
|
||||
}
|
||||
const names = await userNames([...people.keys()].map((id) => new Types.ObjectId(id)));
|
||||
const peopleTotal = [...people.values()].reduce((a, p) => a + p.expense, 0);
|
||||
|
||||
// Hesap: başkasının gizli hesabı hiç görünmez.
|
||||
const acc = new Map<string, Omit<AccountRow, "id" | "name" | "type" | "archived" | "netMinor">>();
|
||||
const bump = (
|
||||
id: Types.ObjectId | null,
|
||||
k: "incomeMinor" | "expenseMinor" | "transferInMinor" | "transferOutMinor",
|
||||
v: number,
|
||||
) => {
|
||||
if (!id || hidden.has(String(id))) return;
|
||||
const key = String(id);
|
||||
const row = acc.get(key) ?? {
|
||||
incomeMinor: 0,
|
||||
expenseMinor: 0,
|
||||
transferInMinor: 0,
|
||||
transferOutMinor: 0,
|
||||
count: 0,
|
||||
};
|
||||
row[k] += v;
|
||||
row.count++;
|
||||
acc.set(key, row);
|
||||
};
|
||||
for (const d of docs) {
|
||||
if (d.type === "income") bump(d.accountId, "incomeMinor", d.amountMinor);
|
||||
else if (d.type === "expense") bump(d.accountId, "expenseMinor", d.amountMinor);
|
||||
else {
|
||||
bump(d.accountId, "transferOutMinor", d.amountMinor);
|
||||
bump(d.toAccountId, "transferInMinor", d.amountMinor);
|
||||
}
|
||||
}
|
||||
const accounts: AccountRow[] = [];
|
||||
for (const a of b.ctx.accountMap.values()) {
|
||||
if (a.hidden) continue;
|
||||
const row = acc.get(String(a.id));
|
||||
if (!row && a.archived) continue;
|
||||
const r = row ?? {
|
||||
incomeMinor: 0,
|
||||
expenseMinor: 0,
|
||||
transferInMinor: 0,
|
||||
transferOutMinor: 0,
|
||||
count: 0,
|
||||
};
|
||||
accounts.push({
|
||||
id: String(a.id),
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
archived: a.archived,
|
||||
...r,
|
||||
netMinor: r.incomeMinor + r.transferInMinor - r.expenseMinor - r.transferOutMinor,
|
||||
});
|
||||
}
|
||||
accounts.sort((x, y) => y.count - x.count || x.name.localeCompare(y.name, "tr"));
|
||||
|
||||
return {
|
||||
...info,
|
||||
people: [...people.entries()]
|
||||
.map(([id, p]) => ({
|
||||
userId: id,
|
||||
name: names.get(id) ?? "—",
|
||||
expenseMinor: p.expense,
|
||||
count: p.count,
|
||||
sharePct: sharePct(p.expense, peopleTotal),
|
||||
}))
|
||||
.sort((x, y) => y.expenseMinor - x.expenseMinor),
|
||||
peopleTotalMinor: peopleTotal,
|
||||
accounts,
|
||||
includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Bütçe vs. gerçekleşen ---
|
||||
|
||||
export type BudgetHistoryRow = {
|
||||
month: string;
|
||||
limitMinor: number | null;
|
||||
actualMinor: number;
|
||||
ratioBp: number | null;
|
||||
};
|
||||
|
||||
export type BudgetReport = MonthInfo & {
|
||||
budget: BudgetView;
|
||||
lines: BudgetLineView[];
|
||||
history: BudgetHistoryRow[];
|
||||
};
|
||||
|
||||
/** Seçilen ayın kategori bazında bütçe/gerçekleşen tablosu ve son 6 ayın toplamları. */
|
||||
export async function getBudgetReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
month?: string,
|
||||
): Promise<BudgetReport | null> {
|
||||
const b = await base(userId, householdId);
|
||||
if (!b) return null;
|
||||
const m = month ?? b.currentMonth;
|
||||
const months = Array.from({ length: 6 }, (_, i) => shiftMonth(m, i - 5));
|
||||
const budgets = await Promise.all(months.map((x) => budgetFor(b.ctx, b.s, x)));
|
||||
const budget = budgets.at(-1)!;
|
||||
return {
|
||||
...monthInfo(b, m),
|
||||
budget,
|
||||
lines: budget.lines,
|
||||
history: budgets.map((x) => ({
|
||||
month: x.month,
|
||||
limitMinor: x.hasBudget ? x.totals.limitMinor : null,
|
||||
actualMinor: x.totals.actualMinor,
|
||||
ratioBp: x.hasBudget ? x.totals.ratioBp : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { KakeiboDot } from "@/features/categories/components/kakeibo-badge";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { KAKEIBO_GROUPS, type KakeiboGroup } from "@/shared/kakeibo";
|
||||
|
||||
const k = tr.kakeibo;
|
||||
const FROM_CATEGORY = "__category__";
|
||||
|
||||
/**
|
||||
* Kakeibo grubu seçimi: varsayılan "kategoriden" (önerilen grup gösterilir); kullanıcı bu kayıt
|
||||
* için değiştirebilir. Banka maliyeti kategorisinde seçim kapalıdır (harcama değildir).
|
||||
*/
|
||||
export function KakeiboSelect({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
suggested,
|
||||
bankCost,
|
||||
compact = false,
|
||||
ariaLabel,
|
||||
}: {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Kategorinin etkin grubu (öneri). */
|
||||
suggested: KakeiboGroup | null;
|
||||
bankCost: boolean;
|
||||
compact?: boolean;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
if (bankCost)
|
||||
return (
|
||||
<p
|
||||
className={cn("text-xs text-muted-foreground", compact ? "self-center" : "")}
|
||||
title={k.bankCostHint}
|
||||
>
|
||||
{k.bankCost}
|
||||
</p>
|
||||
);
|
||||
const effective = (value || suggested) as KakeiboGroup | null;
|
||||
return (
|
||||
<Select
|
||||
value={value || FROM_CATEGORY}
|
||||
onValueChange={(v) => onChange(v === FROM_CATEGORY ? "" : v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
aria-label={ariaLabel ?? k.field}
|
||||
className={cn("w-full", compact && "h-8 text-sm")}
|
||||
>
|
||||
<SelectValue>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<KakeiboDot group={effective} />
|
||||
<span className="truncate">
|
||||
{value
|
||||
? k.groups[value as KakeiboGroup]
|
||||
: suggested
|
||||
? compact
|
||||
? k.groups[suggested]
|
||||
: k.fromCategory(k.groups[suggested])
|
||||
: k.fromCategoryNone}
|
||||
</span>
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FROM_CATEGORY}>
|
||||
{suggested ? k.fromCategory(k.groups[suggested]) : k.fromCategoryNone}
|
||||
</SelectItem>
|
||||
{KAKEIBO_GROUPS.map((g) => (
|
||||
<SelectItem key={g} value={g}>
|
||||
<span className="flex items-center gap-2">
|
||||
<KakeiboDot group={g} />
|
||||
{k.groups[g]}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { KakeiboSelect } from "./kakeibo-select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
@@ -61,7 +62,7 @@ export type TransactionFormMode =
|
||||
| { kind: "edit"; transaction: TransactionDetail }
|
||||
| { kind: "copy"; transaction: TransactionDetail };
|
||||
|
||||
type SplitValue = { categoryId: string; amount: string; note: string };
|
||||
type SplitValue = { categoryId: string; amount: string; note: string; kakeiboGroup: string };
|
||||
type FormValues = {
|
||||
type: TransactionType;
|
||||
amount: string;
|
||||
@@ -70,6 +71,8 @@ type FormValues = {
|
||||
toAccountId: string;
|
||||
categoryId: string;
|
||||
splits: SplitValue[];
|
||||
/** Kakeibo grubu bu işlem için değiştirildiyse ("" = kategoriden). */
|
||||
kakeiboGroup: string;
|
||||
payee: string;
|
||||
description: string;
|
||||
tags: string;
|
||||
@@ -90,6 +93,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
||||
toAccountId: p.toAccountId ?? "",
|
||||
categoryId: p.categoryId ?? "",
|
||||
splits: [],
|
||||
kakeiboGroup: "",
|
||||
payee: "",
|
||||
description: p.description ?? "",
|
||||
tags: "",
|
||||
@@ -109,7 +113,9 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
||||
categoryId: s.categoryId,
|
||||
amount: formatMoneyInput(s.amountMinor),
|
||||
note: s.note,
|
||||
kakeiboGroup: s.kakeiboGroup ?? "",
|
||||
})),
|
||||
kakeiboGroup: x.kakeiboGroup ?? "",
|
||||
payee: x.payeeName,
|
||||
description: x.description,
|
||||
tags: x.tags.join(", "),
|
||||
@@ -359,6 +365,7 @@ export function TransactionFormDialog({
|
||||
const amount = useWatch({ control: form.control, name: "amount" });
|
||||
const accountId = useWatch({ control: form.control, name: "accountId" });
|
||||
const splitValues = useWatch({ control: form.control, name: "splits" });
|
||||
const categoryIdValue = useWatch({ control: form.control, name: "categoryId" });
|
||||
const attachmentCount = useWatch({ control: form.control, name: "attachments" })?.length ?? 0;
|
||||
const splitMode = splits.fields.length > 0;
|
||||
|
||||
@@ -426,9 +433,15 @@ export function TransactionFormDialog({
|
||||
}
|
||||
const first = form.getValues("categoryId");
|
||||
splits.replace([
|
||||
{ categoryId: first, amount: form.getValues("amount"), note: "" },
|
||||
{ categoryId: "", amount: "", note: "" },
|
||||
{
|
||||
categoryId: first,
|
||||
amount: form.getValues("amount"),
|
||||
note: "",
|
||||
kakeiboGroup: form.getValues("kakeiboGroup"),
|
||||
},
|
||||
{ categoryId: "", amount: "", note: "", kakeiboGroup: "" },
|
||||
]);
|
||||
form.setValue("kakeiboGroup", "");
|
||||
form.setValue("categoryId", "");
|
||||
setAdvancedOpen(true);
|
||||
}
|
||||
@@ -711,6 +724,30 @@ export function TransactionFormDialog({
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{type === "expense" && !splitMode && categoryIdValue ? (
|
||||
<Controller
|
||||
name="kakeiboGroup"
|
||||
control={form.control}
|
||||
render={({ field }) => {
|
||||
const kk = options?.categoryKakeibo[categoryIdValue];
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tx-kakeibo">{tr.kakeibo.field}</FieldLabel>
|
||||
<KakeiboSelect
|
||||
id="tx-kakeibo"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
suggested={kk?.group ?? null}
|
||||
bankCost={kk?.bankCost ?? false}
|
||||
/>
|
||||
{field.value ? (
|
||||
<FieldDescription>{tr.kakeibo.overrideHint}</FieldDescription>
|
||||
) : null}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Controller
|
||||
name="payee"
|
||||
@@ -862,10 +899,30 @@ export function TransactionFormDialog({
|
||||
{...field}
|
||||
placeholder={f.splitNote}
|
||||
aria-label={`${f.splitNote} ${i + 1}`}
|
||||
className="col-span-2 h-8 text-sm"
|
||||
className={cn("h-8 text-sm", type !== "expense" && "col-span-2")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{type === "expense" ? (
|
||||
<Controller
|
||||
name={`splits.${i}.kakeiboGroup`}
|
||||
control={form.control}
|
||||
render={({ field }) => {
|
||||
const kk =
|
||||
options?.categoryKakeibo[splitValues?.[i]?.categoryId ?? ""];
|
||||
return (
|
||||
<KakeiboSelect
|
||||
compact
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
suggested={kk?.group ?? null}
|
||||
bankCost={kk?.bankCost ?? false}
|
||||
ariaLabel={`${tr.kakeibo.field} ${i + 1}`}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{splitMode ? (
|
||||
@@ -874,7 +931,14 @@ export function TransactionFormDialog({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => splits.append({ categoryId: "", amount: "", note: "" })}
|
||||
onClick={() =>
|
||||
splits.append({
|
||||
categoryId: "",
|
||||
amount: "",
|
||||
note: "",
|
||||
kakeiboGroup: "",
|
||||
})
|
||||
}
|
||||
disabled={splits.fields.length >= MAX_SPLITS}
|
||||
>
|
||||
<Plus />
|
||||
|
||||
@@ -24,6 +24,7 @@ import { allowedAttachmentKinds } from "@/shared/attachments";
|
||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||
import { installmentSchedule } from "@/shared/card-periods";
|
||||
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||
import { classify, resolveKakeibo, type KakeiboGroup, type SpendingClass } from "@/shared/kakeibo";
|
||||
import type { CategoryKind } from "@/shared/schemas/category";
|
||||
import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction";
|
||||
import { compareTr, foldTr } from "@/shared/search";
|
||||
@@ -85,6 +86,8 @@ export async function loadContext(
|
||||
icon: 1,
|
||||
color: 1,
|
||||
ancestors: 1,
|
||||
kakeiboGroup: 1,
|
||||
bankCost: 1,
|
||||
})
|
||||
.lean(),
|
||||
ContactModel.find({ householdId: m.householdId })
|
||||
@@ -134,6 +137,15 @@ export async function loadContext(
|
||||
},
|
||||
]),
|
||||
);
|
||||
// Kakeibo: kategori başına etkin grup ve banka maliyeti (devralma dahil; Faz 16).
|
||||
const kakeibo = resolveKakeibo(
|
||||
categories.map((c) => ({
|
||||
id: String(c._id),
|
||||
parentId: c.parentId ? String(c.parentId) : null,
|
||||
kakeiboGroup: (c.kakeiboGroup ?? null) as KakeiboGroup | null,
|
||||
bankCost: Boolean(c.bankCost),
|
||||
})),
|
||||
);
|
||||
const hiddenAccountIds = [...accountMap.values()].filter((a) => a.hidden).map((a) => a.id);
|
||||
const contactMap = new Map(
|
||||
contacts.map((c) => [
|
||||
@@ -170,6 +182,7 @@ export async function loadContext(
|
||||
assetMap,
|
||||
hiddenAssetIds,
|
||||
flatCategories: flat,
|
||||
kakeibo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -291,7 +304,12 @@ export function buildDoc(
|
||||
|
||||
let toAccountId: Types.ObjectId | null = null;
|
||||
let categoryId: Types.ObjectId | null = null;
|
||||
let splits: { categoryId: Types.ObjectId; amountMinor: number; note: string }[] = [];
|
||||
let splits: {
|
||||
categoryId: Types.ObjectId;
|
||||
amountMinor: number;
|
||||
note: string;
|
||||
kakeiboGroup: string | null;
|
||||
}[] = [];
|
||||
|
||||
if (input.type === "transfer") {
|
||||
toAccountId = checkAccount(
|
||||
@@ -316,7 +334,13 @@ export function buildDoc(
|
||||
return fail(t.validation.categoryInvalid, {
|
||||
[`splits.${i}.categoryId`]: t.validation.categoryInvalid,
|
||||
});
|
||||
splits.push({ categoryId: cid, amountMinor: s.amount, note: s.note });
|
||||
splits.push({
|
||||
categoryId: cid,
|
||||
amountMinor: s.amount,
|
||||
note: s.note,
|
||||
// Kakeibo grubu yalnızca giderde anlamlı.
|
||||
kakeiboGroup: input.type === "expense" ? (s.kakeiboGroup ?? null) : null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
categoryId = input.categoryId ? checkCategory(ctx, input.categoryId, kind, keep) : null;
|
||||
@@ -338,6 +362,8 @@ export function buildDoc(
|
||||
amountMinor: input.amount,
|
||||
categoryId,
|
||||
splits,
|
||||
kakeiboGroup:
|
||||
input.type === "expense" && splits.length === 0 ? (input.kakeiboGroup ?? null) : null,
|
||||
effectiveCategoryIds: effective,
|
||||
payeeName,
|
||||
description: input.description,
|
||||
@@ -650,7 +676,14 @@ export type TransactionDetail = TransactionListItem & {
|
||||
color: Swatch;
|
||||
amountMinor: number;
|
||||
note: string;
|
||||
/** Bu kalem için değiştirilmiş Kakeibo grubu (null = kategoriden). */
|
||||
kakeiboGroup: KakeiboGroup | null;
|
||||
/** Etkin sınıf (grup, banka maliyeti ya da grupsuz). */
|
||||
kakeiboClass: SpendingClass;
|
||||
}[];
|
||||
/** Bölünmemiş giderde değiştirilmiş Kakeibo grubu ve etkin sınıf (gider değilse null). */
|
||||
kakeiboGroup: KakeiboGroup | null;
|
||||
kakeiboClass: SpendingClass | null;
|
||||
updatedBy: { id: string; name: string };
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -830,8 +863,17 @@ export async function getTransaction(
|
||||
attachments,
|
||||
...toItem(ctx, tx, names),
|
||||
categoryId: tx.categoryId ? String(tx.categoryId) : null,
|
||||
kakeiboGroup: (tx.kakeiboGroup ?? null) as KakeiboGroup | null,
|
||||
kakeiboClass:
|
||||
tx.type === "expense" && tx.categoryId
|
||||
? classify(
|
||||
ctx.kakeibo.get(String(tx.categoryId)),
|
||||
(tx.kakeiboGroup ?? null) as KakeiboGroup | null,
|
||||
)
|
||||
: null,
|
||||
splits: (tx.splits ?? []).map((s) => {
|
||||
const c = ctx.categoryMap.get(String(s.categoryId));
|
||||
const override = (s.kakeiboGroup ?? null) as KakeiboGroup | null;
|
||||
return {
|
||||
categoryId: String(s.categoryId),
|
||||
path: c?.path ?? "—",
|
||||
@@ -839,6 +881,8 @@ export async function getTransaction(
|
||||
color: c?.color ?? "slate",
|
||||
amountMinor: s.amountMinor,
|
||||
note: s.note ?? "",
|
||||
kakeiboGroup: override,
|
||||
kakeiboClass: classify(ctx.kakeibo.get(String(s.categoryId)), override),
|
||||
};
|
||||
}),
|
||||
updatedBy: { id: String(tx.updatedBy), name: names.get(String(tx.updatedBy)) ?? "—" },
|
||||
@@ -924,6 +968,8 @@ export type TransactionFormOptions = {
|
||||
accounts: { id: string; name: string; type: AccountType; icon: IconKey; color: Swatch }[];
|
||||
categories: CategoryOption[];
|
||||
categoryAppearance: Record<string, { icon: IconKey; color: Swatch }>;
|
||||
/** Gider kategorisi başına etkin Kakeibo grubu (formda öneri) ve banka maliyeti. */
|
||||
categoryKakeibo: Record<string, { group: KakeiboGroup | null; bankCost: boolean }>;
|
||||
};
|
||||
|
||||
export async function getTransactionFormOptions(
|
||||
@@ -950,7 +996,10 @@ export async function getTransactionFormOptions(
|
||||
const categoryAppearance = Object.fromEntries(
|
||||
[...ctx.categoryMap.values()].map((c) => [String(c.id), { icon: c.icon, color: c.color }]),
|
||||
);
|
||||
return { accounts, categories, categoryAppearance };
|
||||
const categoryKakeibo = Object.fromEntries(
|
||||
[...ctx.kakeibo.entries()].map(([id, k]) => [id, { group: k.group, bankCost: k.bankCost }]),
|
||||
);
|
||||
return { accounts, categories, categoryAppearance, categoryKakeibo };
|
||||
}
|
||||
|
||||
export type PayeeSuggestion = {
|
||||
|
||||
+361
@@ -1862,6 +1862,350 @@ export const tr = {
|
||||
identifierRequired: "Kullanıcı adı veya e-posta girin",
|
||||
passwordRequired: "Şifre girin",
|
||||
},
|
||||
kakeibo: {
|
||||
groups: {
|
||||
ZORUNLU: "Zorunlu",
|
||||
ISTEK: "İstek",
|
||||
KULTUR: "Kültür",
|
||||
BEKLENMEYEN: "Beklenmeyen",
|
||||
},
|
||||
groupHints: {
|
||||
ZORUNLU: "İhtiyaç: market, faturalar, ulaşım, sağlık, sigorta, resmi ödemeler.",
|
||||
ISTEK:
|
||||
"Opsiyonel: dışarıda yemek, kahve-börek, giyim, kozmetik, hobi, online alışveriş, abonelik, tatil.",
|
||||
KULTUR: "Kitap, kurs, eğitim, çocuk etkinlikleri, müze/konser, bağış.",
|
||||
BEKLENMEYEN: "Plansız tamir/tadilat, büyük sağlık gideri, arıza, hediye/düğün.",
|
||||
},
|
||||
bankCost: "Banka maliyeti",
|
||||
bankCostHint:
|
||||
"KMH faizi, KKDF/BSMV, kart ve hesap ücretleri. Harcama değildir; dört gruba ve harcama toplamına girmez.",
|
||||
ungrouped: "Grupsuz",
|
||||
ungroupedHint: "Kakeibo grubu seçilmemiş. Kategoriye grup atayın.",
|
||||
field: "Kakeibo grubu",
|
||||
inherit: "Üst kategoriden devral",
|
||||
inheritWith: (group: string) => `Üst kategoriden: ${group}`,
|
||||
fromCategory: (group: string) => `Kategoriden: ${group}`,
|
||||
fromCategoryNone: "Kategoriden (grupsuz)",
|
||||
overrideHint: "Kategoriyi ürün değil amaç belirler; bu kayıt için grubu değiştirebilirsiniz.",
|
||||
inherited: "devralınan",
|
||||
ungroupedCount: (n: number) =>
|
||||
`${n} gider kategorisinin Kakeibo grubu yok; kategoriyi düzenleyip grup seçin.`,
|
||||
},
|
||||
reports: {
|
||||
title: "Raporlar",
|
||||
description:
|
||||
"Bütçe ayına göre. Gizli hesap giderleri hane ayarına göre (yalnızca tutar) dahil edilir.",
|
||||
uncategorized: "Kategorisiz",
|
||||
back: "Raporlar",
|
||||
csv: "CSV indir",
|
||||
prevMonth: "Önceki ay",
|
||||
nextMonth: "Sonraki ay",
|
||||
currentMonth: "Bu ay",
|
||||
includesHidden: 'Başka üyelerin "yalnızca ben" hesaplarındaki giderler tutar olarak dahil.',
|
||||
noData: "Bu dönemde kayıt yok.",
|
||||
hub: {
|
||||
thisMonth: "Bu bütçe ayı",
|
||||
income: "Gelir",
|
||||
expense: "Gider",
|
||||
net: "Net",
|
||||
savings: "Tasarruf oranı",
|
||||
topCategories: "En çok harcanan kategoriler",
|
||||
open: "Raporu aç",
|
||||
cards: {
|
||||
categories: {
|
||||
title: "Kategori dağılımı",
|
||||
description:
|
||||
"Kategorilerin toplam gidere oranı, alt kategori kırılımı, önceki aya göre değişim.",
|
||||
},
|
||||
trend: {
|
||||
title: "Aylık trend",
|
||||
description: "Son 12 ay gelir, gider, net ve tasarruf oranı.",
|
||||
},
|
||||
people: {
|
||||
title: "Kişi ve hesap bazlı",
|
||||
description: "Üyelerin harcaması ve hesapların aylık hareketi.",
|
||||
},
|
||||
budget: {
|
||||
title: "Bütçe ve gerçekleşen",
|
||||
description: "Kategori bazında limit ve harcama, son 6 ayın karşılaştırması.",
|
||||
},
|
||||
contacts: {
|
||||
title: "Cari ve aynî borçlar",
|
||||
description: "Kim kime ne kadar borçlu; açık altın/döviz borç ve alacakları.",
|
||||
},
|
||||
kakeibo: {
|
||||
title: "Kakeibo grupları",
|
||||
description: "Zorunlu, İstek, Kültür, Beklenmeyen; banka maliyeti ve grupsuz harcamalar.",
|
||||
},
|
||||
assets: {
|
||||
title: "Birikim kâr/zarar",
|
||||
description: "Varlık başına maliyet, değer ve kâr/zarar; dönem özeti.",
|
||||
},
|
||||
shopping: {
|
||||
title: "Alışveriş",
|
||||
description: "Liste ve gerçekleşen karşılaştırması, mağaza bazlı harcama.",
|
||||
},
|
||||
unitPrice: {
|
||||
title: "Birim fiyat geçmişi",
|
||||
description: "Ürün tipi başına aylık en düşük ve ortalama birim fiyat.",
|
||||
},
|
||||
waste: {
|
||||
title: "Fire / zarar",
|
||||
description: "Fire miktarı, alıma oranı, nedenleri ve tahmini maliyet.",
|
||||
},
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
title: "Kategori dağılımı",
|
||||
description: (month: string) =>
|
||||
`${month} bütçe ayı; toplam gidere oran ve önceki aya göre değişim.`,
|
||||
chartLabel: "Kategorilerin toplam gider içindeki payı, halka grafik",
|
||||
total: "Toplam gider",
|
||||
category: "Kategori",
|
||||
amount: "Tutar",
|
||||
share: "Oran",
|
||||
previous: "Önceki ay",
|
||||
change: "Değişim",
|
||||
own: "Doğrudan üst kategoriye",
|
||||
partialMonth: "Ay devam ediyor; değişim önceki ayın tamamıyla karşılaştırılır.",
|
||||
newCategory: "yeni",
|
||||
other: (n: number) => `Diğer (${n} kategori)`,
|
||||
},
|
||||
trend: {
|
||||
title: "Aylık trend",
|
||||
description: "Son 12 bütçe ayı: gerçekleşen gelir ve gider, net ve tasarruf oranı.",
|
||||
chartLabel: "Son 12 ay gelir ve gider sütun grafiği",
|
||||
rateChartLabel: "Son 12 ay tasarruf oranı",
|
||||
month: "Ay",
|
||||
income: "Gelir",
|
||||
expense: "Gider",
|
||||
net: "Net",
|
||||
savings: "Tasarruf oranı",
|
||||
total: "12 ay toplam",
|
||||
rateTitle: "Tasarruf oranı",
|
||||
rateHint: "(gelir − gider) / gelir. Gelir yoksa hesaplanmaz.",
|
||||
partialHint: "Devam eden ay grafikte gösterilmez.",
|
||||
ongoing: "devam ediyor",
|
||||
},
|
||||
people: {
|
||||
title: "Kişi ve hesap bazlı",
|
||||
description: (month: string) => `${month} bütçe ayı; yalnızca gerçekleşmiş işlemler.`,
|
||||
peopleTitle: "Kişi bazlı harcama",
|
||||
peopleHint: "Gideri kaydeden üyeye göre.",
|
||||
person: "Kişi",
|
||||
expense: "Gider",
|
||||
count: "İşlem",
|
||||
share: "Oran",
|
||||
csvPeople: "Kişiler CSV",
|
||||
csvAccounts: "Hesaplar CSV",
|
||||
accountsTitle: "Hesap bazlı hareket",
|
||||
accountsHint: 'Transferler dahil; başka üyelerin "yalnızca ben" hesapları gösterilmez.',
|
||||
account: "Hesap",
|
||||
income: "Gelir",
|
||||
transferIn: "Gelen transfer",
|
||||
transferOut: "Giden transfer",
|
||||
net: "Net değişim",
|
||||
archived: "arşivli",
|
||||
},
|
||||
budget: {
|
||||
title: "Bütçe ve gerçekleşen",
|
||||
description: (month: string) => `${month} bütçe ayı; kategori bazında limit ve gerçekleşen.`,
|
||||
noBudget: "Bu ay için bütçe yok.",
|
||||
category: "Kategori",
|
||||
limit: "Limit",
|
||||
actual: "Gerçekleşen",
|
||||
remaining: "Kalan",
|
||||
ratio: "Kullanım",
|
||||
historyTitle: "Son 6 ay",
|
||||
historyChartLabel: "Son 6 ay bütçe ve gerçekleşen sütun grafiği",
|
||||
month: "Ay",
|
||||
noLimit: "Bütçe yok",
|
||||
covered: "üst kategoride",
|
||||
},
|
||||
periods: { 3: "Son 3 ay", 6: "Son 6 ay", 12: "Son 12 ay" } as Record<number, string>,
|
||||
periodLabel: "Dönem",
|
||||
rangeText: (from: string, to: string) => `${from} – ${to}`,
|
||||
kakeiboReport: {
|
||||
title: "Kakeibo grupları",
|
||||
description: (month: string) =>
|
||||
`${month} bütçe ayı; harcamanın Zorunlu, İstek, Kültür ve Beklenmeyen gruplarına dağılımı.`,
|
||||
spending: "Kakeibo harcaması",
|
||||
chartLabel: "Harcamanın dört Kakeibo grubuna dağılımı, yığılmış çubuk",
|
||||
bankCostNote: "Harcamaya ve gruplara dahil değil.",
|
||||
noSpending: "Bu ay harcama yok.",
|
||||
ungroupedHelp: "Bu kategorilerin Kakeibo grubu seçilmemiş.",
|
||||
goCategories: "Kategorilere grup ata",
|
||||
csvHeader: ["Grup", "Kategori", "Tutar"],
|
||||
csvFile: (month: string) => `butcem-kakeibo-${month}.csv`,
|
||||
},
|
||||
contacts: {
|
||||
title: "Cari ve aynî borçlar",
|
||||
description:
|
||||
"Kim kime ne kadar borçlu; açık altın/döviz borç ve alacakları güncel değeriyle.",
|
||||
owesMe: "Bana borçlu",
|
||||
iOwe: "Ben borçluyum",
|
||||
contact: "Cari",
|
||||
balance: "Bakiye",
|
||||
direction: "Yön",
|
||||
due: "Vade",
|
||||
noContacts: "Bakiyesi olan cari yok.",
|
||||
inKindTitle: "Açık aynî borç ve alacaklar",
|
||||
csvContacts: "Cariler CSV",
|
||||
csvInKind: "Aynî CSV",
|
||||
inKindHint: "Kalan miktarın güncel fiyatla TL karşılığı (alacak alış, borç satış fiyatıyla).",
|
||||
remaining: "Kalan",
|
||||
value: "Güncel değer",
|
||||
since: "Tarih",
|
||||
noInKind: "Açık aynî kayıt yok.",
|
||||
receivable: "Alacak",
|
||||
debt: "Borç",
|
||||
unpriced: (n: number) => `${n} kaydın fiyatı yok; toplama katılmadı.`,
|
||||
dueStates: { overdue: "Vadesi geçti", upcoming: "Vadesi yaklaşıyor", none: "" } as Record<
|
||||
string,
|
||||
string
|
||||
>,
|
||||
},
|
||||
assets: {
|
||||
title: "Birikim kâr/zarar",
|
||||
description:
|
||||
"Varlık başına maliyet, güncel değer (alış fiyatı) ve kâr/zarar; seçilen dönemin özeti.",
|
||||
ranges: { "3": "Son 3 ay", "6": "Son 6 ay", "12": "Son 12 ay", tum: "Tümü" } as Record<
|
||||
string,
|
||||
string
|
||||
>,
|
||||
asset: "Varlık",
|
||||
quantity: "Miktar",
|
||||
cost: "Maliyet",
|
||||
value: "Güncel değer",
|
||||
unrealized: "Gerçekleşmemiş K/Z",
|
||||
realized: "Gerçekleşen K/Z",
|
||||
noPrice: "Fiyat yok",
|
||||
periodTitle: "Dönem özeti",
|
||||
startValue: "Dönem başı değer",
|
||||
invested: "Yatırılan",
|
||||
proceeds: "Satıştan alınan",
|
||||
endValue: "Dönem sonu değer",
|
||||
profit: "Dönem kârı",
|
||||
excluded: (n: number) => `${n} varlığın fiyatı olmadığı için dönem toplamına katılmadı.`,
|
||||
empty: "Varlık yok.",
|
||||
total: "Toplam",
|
||||
},
|
||||
shopping: {
|
||||
title: "Alışveriş raporu",
|
||||
description: "Tamamlanan listelerde plan ve gerçekleşen; mağaza bazlı harcama.",
|
||||
listsTitle: "Liste ve gerçekleşen",
|
||||
listsEmpty: "Bu dönemde tamamlanan alışveriş yok.",
|
||||
list: "Liste",
|
||||
date: "Tarih",
|
||||
planned: "Planlanan",
|
||||
actual: "Gerçekleşen",
|
||||
diff: "Fark",
|
||||
counts: (same: number, sub: number, skipped: number, extra: number) =>
|
||||
`${same} aynı · ${sub} muadil · ${skipped} alınmadı · ${extra} ekstra`,
|
||||
storesTitle: "Mağaza bazlı harcama",
|
||||
storesHint: "Alıcısı mağaza adıyla başlayan gerçekleşmiş giderler (şubeler birlikte).",
|
||||
store: "Mağaza",
|
||||
amount: "Tutar",
|
||||
count: "İşlem",
|
||||
share: "Oran",
|
||||
fromLists: "Listelerden",
|
||||
storesEmpty: "Bu dönemde mağaza adıyla eşleşen gider yok.",
|
||||
total: "Toplam",
|
||||
csvLists: "Listeler CSV",
|
||||
csvStores: "Mağazalar CSV",
|
||||
},
|
||||
unitPrice: {
|
||||
title: "Birim fiyat geçmişi",
|
||||
description: "Ürün tipi başına son 12 bütçe ayında en düşük ve ortalama birim fiyat.",
|
||||
type: "Ürün tipi",
|
||||
noTypes: "Henüz fiyat kaydı yok.",
|
||||
min: "En düşük",
|
||||
avg: "Ortalama",
|
||||
month: "Ay",
|
||||
count: "Kayıt",
|
||||
chartLabel: (name: string) => `${name}: aylık en düşük ve ortalama birim fiyat`,
|
||||
storesTitle: "Mağazalarda dönemin en düşük birim fiyatı",
|
||||
store: "Mağaza",
|
||||
last: "Son kayıt",
|
||||
records: (n: number) => `${n} kayıt`,
|
||||
},
|
||||
waste: {
|
||||
title: "Fire / zarar",
|
||||
description: "Ürün tipi başına fire miktarı, alıma oranı, nedenleri ve tahmini maliyeti.",
|
||||
total: "Tahmini toplam maliyet",
|
||||
product: "Ürün tipi",
|
||||
wasted: "Fire",
|
||||
purchased: "Alım",
|
||||
ratio: "Fire / alım",
|
||||
cost: "Tahmini maliyet",
|
||||
reasonsTitle: "Nedenler",
|
||||
count: (n: number) => `${n} kayıt`,
|
||||
empty: "Bu dönemde fire kaydı yok.",
|
||||
unpriced: (n: number) => `${n} ürün tipinin fiyatı yok; maliyete katılmadı.`,
|
||||
costHint: "Fire miktarı × ürün tipinin son bilinen birim fiyatı.",
|
||||
},
|
||||
csvFiles: {
|
||||
categories: (month: string) => `butcem-kategori-dagilimi-${month}.csv`,
|
||||
trend: (date: string) => `butcem-aylik-trend-${date}.csv`,
|
||||
people: (month: string) => `butcem-kisi-bazli-${month}.csv`,
|
||||
accounts: (month: string) => `butcem-hesap-bazli-${month}.csv`,
|
||||
budget: (month: string) => `butcem-butce-gerceklesen-${month}.csv`,
|
||||
contacts: (date: string) => `butcem-cari-${date}.csv`,
|
||||
inKind: (date: string) => `butcem-ayni-${date}.csv`,
|
||||
assets: (date: string) => `butcem-birikim-${date}.csv`,
|
||||
lists: (date: string) => `butcem-alisveris-listeleri-${date}.csv`,
|
||||
stores: (date: string) => `butcem-magaza-harcama-${date}.csv`,
|
||||
unitPrice: (name: string, date: string) => `butcem-birim-fiyat-${name}-${date}.csv`,
|
||||
waste: (date: string) => `butcem-fire-${date}.csv`,
|
||||
},
|
||||
csvHeaders: {
|
||||
categories: [
|
||||
"Üst kategori",
|
||||
"Kategori",
|
||||
"Tutar",
|
||||
"Toplam gidere oran (%)",
|
||||
"Önceki ay",
|
||||
"Değişim",
|
||||
"Değişim (%)",
|
||||
],
|
||||
trend: ["Ay", "Gelir", "Gider", "Net", "Tasarruf oranı (%)"],
|
||||
people: ["Kişi", "Gider", "İşlem sayısı", "Oran (%)"],
|
||||
accounts: [
|
||||
"Hesap",
|
||||
"Gelir",
|
||||
"Gider",
|
||||
"Gelen transfer",
|
||||
"Giden transfer",
|
||||
"Net değişim",
|
||||
"İşlem sayısı",
|
||||
],
|
||||
budget: ["Kategori", "Limit", "Gerçekleşen", "Kalan", "Kullanım (%)"],
|
||||
contacts: ["Cari", "Bakiye (+ bana borçlu, − ben borçluyum)", "Vade"],
|
||||
inKind: ["Cari", "Yön", "Kalan", "Güncel değer", "Tarih"],
|
||||
assets: [
|
||||
"Varlık",
|
||||
"Miktar",
|
||||
"Maliyet",
|
||||
"Güncel değer",
|
||||
"Gerçekleşmemiş K/Z",
|
||||
"Gerçekleşen K/Z",
|
||||
],
|
||||
lists: [
|
||||
"Tarih",
|
||||
"Liste",
|
||||
"Planlanan",
|
||||
"Gerçekleşen",
|
||||
"Fark",
|
||||
"Aynı",
|
||||
"Muadil",
|
||||
"Alınmadı",
|
||||
"Ekstra",
|
||||
],
|
||||
stores: ["Mağaza", "Tutar", "İşlem", "Oran (%)", "Listelerden"],
|
||||
unitPrice: ["Ay", "En düşük birim fiyat", "Ortalama birim fiyat", "Kayıt"],
|
||||
waste: ["Ürün tipi", "Birim", "Fire", "Alım", "Fire / alım (%)", "Tahmini maliyet"],
|
||||
},
|
||||
},
|
||||
dashboard: {
|
||||
title: "Ana sayfa",
|
||||
welcome: (name: string) => `Hoş geldin, ${name}`,
|
||||
@@ -1875,6 +2219,23 @@ export const tr = {
|
||||
netWorth: "Net varlık",
|
||||
},
|
||||
noData: "Henüz veri yok",
|
||||
overview: {
|
||||
title: "Gelir, bütçe ve gider",
|
||||
description: (range: string) => `${range}. Bütçe ayına göre.`,
|
||||
periods: { week: "Haftalık", month: "Aylık", half: "Altı aylık", year: "Yıllık" },
|
||||
views: { cumulative: "Kümülatif", periodic: "Dönemsel" },
|
||||
periodLabel: "Periyot",
|
||||
viewLabel: "Görünüm",
|
||||
series: {
|
||||
plannedIncome: "Planlanan gelir",
|
||||
budget: "Planlanan gider (bütçe)",
|
||||
actual: "Gerçekleşen gider",
|
||||
},
|
||||
chartLabel: "Planlanan gelir, planlanan gider ve gerçekleşen gider çizgi grafiği",
|
||||
noBudget: "Bütçesi olmayan dönemde planlanan gider çizgisi boş kalır.",
|
||||
table: "Tablo olarak göster",
|
||||
bucket: { day: "Gün", month: "Ay" },
|
||||
},
|
||||
},
|
||||
adminUsers: {
|
||||
title: "Kullanıcılar",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { ICON_KEYS, SWATCHES } from "@/shared/appearance";
|
||||
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||
import { CATEGORY_KINDS } from "@/shared/schemas/category";
|
||||
|
||||
/** Gelir/gider kategori ağacı (Bölüm 6.2). Silme yok; arşivleme (alt ağaçla birlikte). */
|
||||
@@ -16,6 +17,13 @@ const categorySchema = new Schema(
|
||||
icon: { type: String, enum: ICON_KEYS, default: "tag" },
|
||||
color: { type: String, enum: SWATCHES, default: "slate" },
|
||||
order: { type: Number, default: 0 },
|
||||
/**
|
||||
* Kakeibo grubu (yalnızca gider; Faz 16). null ise üst kategoriden devralınır; zincirde yoksa
|
||||
* grupsuz.
|
||||
*/
|
||||
kakeiboGroup: { type: String, enum: [...KAKEIBO_GROUPS, null], default: null },
|
||||
/** Banka maliyeti (KMH faizi, KKDF/BSMV, ücret): Kakeibo harcamasına girmez; alt ağaç devralır. */
|
||||
bankCost: { type: Boolean, default: false },
|
||||
archivedAt: { type: Date, default: null },
|
||||
/** Varsayılan setten geldiyse sabit anahtar (tekrar yüklemede kopya olmaz). */
|
||||
defaultKey: { type: String, default: null },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { CONTACT_ENTRY_KINDS } from "@/shared/contacts";
|
||||
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||
import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions";
|
||||
|
||||
/**
|
||||
@@ -11,6 +12,8 @@ const splitSchema = new Schema(
|
||||
categoryId: { type: Schema.Types.ObjectId, required: true },
|
||||
amountMinor: { type: Number, required: true, min: 1 },
|
||||
note: { type: String, default: "", maxlength: 120 },
|
||||
/** Kakeibo grubu bu kalem için değiştirildiyse; null = kategoriden (Faz 16). */
|
||||
kakeiboGroup: { type: String, enum: [...KAKEIBO_GROUPS, null], default: null },
|
||||
},
|
||||
{ _id: true },
|
||||
);
|
||||
@@ -29,6 +32,8 @@ const transactionSchema = new Schema(
|
||||
/** Bölünmemiş gelir/giderde kategori; bölünmüşte null ve kalemler `splits`te. */
|
||||
categoryId: { type: Schema.Types.ObjectId, default: null },
|
||||
splits: { type: [splitSchema], default: [] },
|
||||
/** Bölünmemiş giderde Kakeibo grubu değiştirildiyse; null = kategoriden (Faz 16). */
|
||||
kakeiboGroup: { type: String, enum: [...KAKEIBO_GROUPS, null], default: null },
|
||||
/** Kategori filtresi/raporlar için: categoryId veya kalem kategorileri (tekilleştirilmiş). */
|
||||
effectiveCategoryIds: { type: [Schema.Types.ObjectId], default: [] },
|
||||
payeeId: { type: Schema.Types.ObjectId, default: null },
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classify,
|
||||
classifyAmounts,
|
||||
resolveKakeibo,
|
||||
summarizeGroups,
|
||||
type KakeiboCategory,
|
||||
} from "./kakeibo";
|
||||
|
||||
const cat = (
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
kakeiboGroup: KakeiboCategory["kakeiboGroup"] = null,
|
||||
bankCost = false,
|
||||
): KakeiboCategory => ({ id, parentId, kakeiboGroup, bankCost });
|
||||
|
||||
describe("resolveKakeibo", () => {
|
||||
const map = resolveKakeibo([
|
||||
cat("market", null, "ZORUNLU"),
|
||||
cat("gida", "market"),
|
||||
cat("bakim", "market", "ISTEK"),
|
||||
cat("banka", null, null, true),
|
||||
cat("faiz", "banka"),
|
||||
cat("diger", null),
|
||||
// Döngü: kendine üst (bozuk veri) sonsuz döngüye girmemeli.
|
||||
cat("dongu", "dongu"),
|
||||
]);
|
||||
it("alt kategori grubu üstten devralır; kendi değeri önceliklidir", () => {
|
||||
expect(map.get("gida")).toEqual({ group: "ZORUNLU", inherited: true, bankCost: false });
|
||||
expect(map.get("bakim")).toEqual({ group: "ISTEK", inherited: false, bankCost: false });
|
||||
expect(map.get("market")!.inherited).toBe(false);
|
||||
});
|
||||
it("banka maliyeti alt kategorilere geçer; grupsuz ve döngülü kategori güvenli", () => {
|
||||
expect(map.get("faiz")!.bankCost).toBe(true);
|
||||
expect(map.get("diger")).toEqual({ group: null, inherited: false, bankCost: false });
|
||||
expect(map.get("dongu")!.group).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("classify", () => {
|
||||
const map = resolveKakeibo([cat("market", null, "ZORUNLU"), cat("faiz", null, null, true)]);
|
||||
it("işlem bazında grup kategoriyi ezer; banka maliyeti her zaman banka maliyeti", () => {
|
||||
expect(classify(map.get("market"), null)).toBe("ZORUNLU");
|
||||
expect(classify(map.get("market"), "ISTEK")).toBe("ISTEK");
|
||||
expect(classify(map.get("faiz"), "ISTEK")).toBe("BANK_COST");
|
||||
expect(classify(undefined, null)).toBe("UNGROUPED");
|
||||
expect(classify(undefined, "KULTUR")).toBe("KULTUR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeGroups", () => {
|
||||
it("banka maliyeti harcamaya ve paylara girmez; grupsuz harcamaya girer", () => {
|
||||
const s = summarizeGroups([
|
||||
{ cls: "ZORUNLU", amountMinor: 4_600 },
|
||||
{ cls: "ISTEK", amountMinor: 3_900 },
|
||||
{ cls: "KULTUR", amountMinor: 150 },
|
||||
{ cls: "BEKLENMEYEN", amountMinor: 1_250 },
|
||||
{ cls: "UNGROUPED", amountMinor: 100 },
|
||||
{ cls: "BANK_COST", amountMinor: 999 },
|
||||
]);
|
||||
expect(s.spendingMinor).toBe(10_000);
|
||||
expect(s.bankCostMinor).toBe(999);
|
||||
expect(s.shares).toEqual({ ZORUNLU: 46, ISTEK: 39, KULTUR: 1.5, BEKLENMEYEN: 12.5 });
|
||||
expect(s.ungroupedShare).toBe(1);
|
||||
});
|
||||
it("harcama yoksa paylar 0 (sıfıra bölme yok)", () => {
|
||||
const s = summarizeGroups([{ cls: "BANK_COST", amountMinor: 50 }]);
|
||||
expect(s.spendingMinor).toBe(0);
|
||||
expect(s.shares.ZORUNLU).toBe(0);
|
||||
expect(s.ungroupedShare).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyAmounts", () => {
|
||||
const resolved = resolveKakeibo([
|
||||
cat("market", null, "ZORUNLU"),
|
||||
cat("faiz", null, null, true),
|
||||
cat("diger", null),
|
||||
]);
|
||||
it("değiştirilen tutar seçilen gruba taşınır, kategori toplamı korunur", () => {
|
||||
const out = classifyAmounts(
|
||||
new Map([
|
||||
["market", 10_000],
|
||||
["faiz", 500],
|
||||
["diger", 300],
|
||||
]),
|
||||
[
|
||||
{ categoryId: "market", group: "ISTEK", amountMinor: 2_500 },
|
||||
// Banka maliyetinde grup değiştirme etkisiz.
|
||||
{ categoryId: "faiz", group: "ISTEK", amountMinor: 500 },
|
||||
// Kategori toplamını aşan değiştirme kırpılır.
|
||||
{ categoryId: "diger", group: "KULTUR", amountMinor: 900 },
|
||||
],
|
||||
resolved,
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{ categoryId: "market", cls: "ISTEK", amountMinor: 2_500 },
|
||||
{ categoryId: "market", cls: "ZORUNLU", amountMinor: 7_500 },
|
||||
{ categoryId: "faiz", cls: "BANK_COST", amountMinor: 500 },
|
||||
{ categoryId: "diger", cls: "KULTUR", amountMinor: 300 },
|
||||
]);
|
||||
const s = summarizeGroups(out);
|
||||
expect(s.spendingMinor + s.bankCostMinor).toBe(10_800);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Kakeibo (家計簿) dört harcama grubu. Saf fonksiyonlar.
|
||||
*
|
||||
* Her gider tam olarak bir gruba girer: ZORUNLU (ihtiyaç), ISTEK (opsiyonel), KULTUR,
|
||||
* BEKLENMEYEN. Grup kategoriden gelir (kategori → yoksa üst kategoriden devralınır); kayıt
|
||||
* sırasında işlem ya da bölünmüş işlem kalemi bazında değiştirilebilir ("kategoriyi ürün değil
|
||||
* amaç belirler").
|
||||
*
|
||||
* Banka maliyeti (KMH faizi, KKDF/BSMV, kart/hesap ücretleri) harcama değildir: dört gruba ve
|
||||
* Kakeibo harcama toplamına girmez, ayrı gösterilir. Kategori ya da üst kategorisi "banka
|
||||
* maliyeti" işaretliyse kalem grup değiştirilse bile banka maliyetidir.
|
||||
*
|
||||
* Grubu belirlenemeyen gider "grupsuz"dur: harcama toplamına girer, kullanıcıdan grup seçmesi
|
||||
* istenir.
|
||||
*/
|
||||
|
||||
export const KAKEIBO_GROUPS = ["ZORUNLU", "ISTEK", "KULTUR", "BEKLENMEYEN"] as const;
|
||||
export type KakeiboGroup = (typeof KAKEIBO_GROUPS)[number];
|
||||
|
||||
export const isKakeiboGroup = (x: unknown): x is KakeiboGroup =>
|
||||
KAKEIBO_GROUPS.includes(x as KakeiboGroup);
|
||||
|
||||
/** Harcama sınıfı: dört grup, banka maliyeti ya da grupsuz. */
|
||||
export type SpendingClass = KakeiboGroup | "BANK_COST" | "UNGROUPED";
|
||||
|
||||
export type KakeiboCategory = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
kakeiboGroup: KakeiboGroup | null;
|
||||
bankCost: boolean;
|
||||
};
|
||||
|
||||
export type ResolvedKakeibo = {
|
||||
/** Etkin grup (kendisi ya da en yakın üst kategori); yoksa null. */
|
||||
group: KakeiboGroup | null;
|
||||
/** Grup üst kategoriden mi geliyor? */
|
||||
inherited: boolean;
|
||||
/** Kendisi ya da bir üst kategorisi banka maliyeti. */
|
||||
bankCost: boolean;
|
||||
};
|
||||
|
||||
/** Kategori başına etkin grup ve banka maliyeti (döngüye karşı korumalı). */
|
||||
export function resolveKakeibo(
|
||||
categories: readonly KakeiboCategory[],
|
||||
): Map<string, ResolvedKakeibo> {
|
||||
const byId = new Map(categories.map((c) => [c.id, c]));
|
||||
const out = new Map<string, ResolvedKakeibo>();
|
||||
for (const c of categories) {
|
||||
let group: KakeiboGroup | null = null;
|
||||
let inherited = false;
|
||||
let bankCost = false;
|
||||
const seen = new Set<string>();
|
||||
let cur: KakeiboCategory | undefined = c;
|
||||
while (cur && !seen.has(cur.id)) {
|
||||
seen.add(cur.id);
|
||||
if (cur.bankCost) bankCost = true;
|
||||
if (!group && cur.kakeiboGroup) {
|
||||
group = cur.kakeiboGroup;
|
||||
inherited = cur !== c;
|
||||
}
|
||||
cur = cur.parentId ? byId.get(cur.parentId) : undefined;
|
||||
}
|
||||
out.set(c.id, { group, inherited, bankCost });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Bir gider kaleminin sınıfı: banka maliyeti > işlem bazında grup > kategorinin grubu. */
|
||||
export function classify(
|
||||
category: ResolvedKakeibo | undefined,
|
||||
override: KakeiboGroup | null,
|
||||
): SpendingClass {
|
||||
if (category?.bankCost) return "BANK_COST";
|
||||
return override ?? category?.group ?? "UNGROUPED";
|
||||
}
|
||||
|
||||
export type GroupSummary = {
|
||||
/** Grup başına tutar. */
|
||||
groups: Record<KakeiboGroup, number>;
|
||||
ungroupedMinor: number;
|
||||
bankCostMinor: number;
|
||||
/** Kakeibo harcaması: dört grup + grupsuz (banka maliyeti hariç). */
|
||||
spendingMinor: number;
|
||||
/** Grubun harcamadaki payı (bir ondalık yüzde; harcama 0 ise 0). */
|
||||
shares: Record<KakeiboGroup, number>;
|
||||
ungroupedShare: number;
|
||||
};
|
||||
|
||||
const pct = (part: number, total: number) =>
|
||||
total > 0 ? Math.round((part * 1000) / total) / 10 : 0;
|
||||
|
||||
export function summarizeGroups(
|
||||
items: readonly { cls: SpendingClass; amountMinor: number }[],
|
||||
): GroupSummary {
|
||||
const groups = Object.fromEntries(KAKEIBO_GROUPS.map((g) => [g, 0])) as Record<
|
||||
KakeiboGroup,
|
||||
number
|
||||
>;
|
||||
let ungrouped = 0;
|
||||
let bank = 0;
|
||||
for (const it of items) {
|
||||
if (it.cls === "BANK_COST") bank += it.amountMinor;
|
||||
else if (it.cls === "UNGROUPED") ungrouped += it.amountMinor;
|
||||
else groups[it.cls] += it.amountMinor;
|
||||
}
|
||||
const spending = KAKEIBO_GROUPS.reduce((a, g) => a + groups[g], 0) + ungrouped;
|
||||
return {
|
||||
groups,
|
||||
ungroupedMinor: ungrouped,
|
||||
bankCostMinor: bank,
|
||||
spendingMinor: spending,
|
||||
shares: Object.fromEntries(KAKEIBO_GROUPS.map((g) => [g, pct(groups[g], spending)])) as Record<
|
||||
KakeiboGroup,
|
||||
number
|
||||
>,
|
||||
ungroupedShare: pct(ungrouped, spending),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Varsayılan kategori setinin Kakeibo eşlemesi (anahtar → grup). Alt kategori kendi değeri
|
||||
* yoksa üstünden devralır. Listede olmayan varsayılanlar (ör. "Diğer") grupsuz kalır.
|
||||
*/
|
||||
export const DEFAULT_KAKEIBO_GROUPS: Record<string, KakeiboGroup> = {
|
||||
market: "ZORUNLU",
|
||||
"market.kisisel-bakim": "ISTEK",
|
||||
faturalar: "ZORUNLU",
|
||||
kira: "ZORUNLU",
|
||||
aidat: "ZORUNLU",
|
||||
ulasim: "ZORUNLU",
|
||||
saglik: "ZORUNLU",
|
||||
egitim: "KULTUR",
|
||||
giyim: "ISTEK",
|
||||
eglence: "ISTEK",
|
||||
abonelikler: "ISTEK",
|
||||
ev: "BEKLENMEYEN",
|
||||
cocuk: "KULTUR",
|
||||
"evcil-hayvan": "ZORUNLU",
|
||||
hediye: "BEKLENMEYEN",
|
||||
vergi: "ZORUNLU",
|
||||
};
|
||||
|
||||
/** Banka maliyeti işaretli varsayılan kategoriler (alt kategoriler devralır). */
|
||||
export const DEFAULT_BANK_COST_KEYS = ["banka"] as const;
|
||||
|
||||
export type ClassifiedAmount = { categoryId: string; cls: SpendingClass; amountMinor: number };
|
||||
|
||||
/**
|
||||
* Kategori tutarlarını sınıflara dağıtır. `overrides`: işlem/kalem bazında grubu değiştirilmiş
|
||||
* tutarlar (kategori × grup); bunlar kategorinin varsayılan payından düşülüp seçilen gruba
|
||||
* yazılır. Kategori toplamı her zaman korunur (taşma olursa varsayılan pay 0'a çekilir).
|
||||
*/
|
||||
export function classifyAmounts(
|
||||
categoryAmounts: ReadonlyMap<string, number>,
|
||||
overrides: readonly { categoryId: string; group: KakeiboGroup; amountMinor: number }[],
|
||||
resolved: ReadonlyMap<string, ResolvedKakeibo>,
|
||||
): ClassifiedAmount[] {
|
||||
const out: ClassifiedAmount[] = [];
|
||||
const byCategory = new Map<string, { group: KakeiboGroup; amountMinor: number }[]>();
|
||||
for (const o of overrides)
|
||||
byCategory.set(o.categoryId, [...(byCategory.get(o.categoryId) ?? []), o]);
|
||||
for (const [categoryId, total] of categoryAmounts) {
|
||||
if (!total) continue;
|
||||
const info = resolved.get(categoryId);
|
||||
let rest = total;
|
||||
for (const o of byCategory.get(categoryId) ?? []) {
|
||||
const part = Math.min(o.amountMinor, Math.max(0, rest));
|
||||
if (!part) continue;
|
||||
rest -= part;
|
||||
out.push({ categoryId, cls: classify(info, o.group), amountMinor: part });
|
||||
}
|
||||
if (rest) out.push({ categoryId, cls: classify(info, null), amountMinor: rest });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cumulative, overviewRange, weekday } from "./overview";
|
||||
|
||||
describe("overviewRange", () => {
|
||||
it("haftalık: Pazartesi–Pazar, bugünü içerir", () => {
|
||||
// 2026-11-07 Cumartesi.
|
||||
expect(weekday("2026-11-07")).toBe(5);
|
||||
const r = overviewRange("week", "2026-11-07");
|
||||
expect(r).toMatchObject({ granularity: "day", from: "2026-11-02", to: "2026-11-08" });
|
||||
expect(r.buckets).toHaveLength(7);
|
||||
// Ay sınırını aşan hafta.
|
||||
expect(overviewRange("week", "2026-12-01").from).toBe("2026-11-30");
|
||||
});
|
||||
|
||||
it("aylık: bütçe ayının günleri (başlangıç günü 15)", () => {
|
||||
const r = overviewRange("month", "2026-11-07", 15);
|
||||
expect(r.from).toBe("2026-10-15");
|
||||
expect(r.to).toBe("2026-11-14");
|
||||
expect(r.buckets).toHaveLength(31);
|
||||
expect(overviewRange("month", "2026-11-07").buckets).toHaveLength(30);
|
||||
});
|
||||
|
||||
it("altı aylık ve yıllık: bu ay dahil son 6 / 12 bütçe ayı", () => {
|
||||
const half = overviewRange("half", "2026-11-07");
|
||||
expect(half).toMatchObject({ granularity: "month", from: "2026-06-01", to: "2026-11-30" });
|
||||
expect(half.buckets).toEqual([
|
||||
"2026-06",
|
||||
"2026-07",
|
||||
"2026-08",
|
||||
"2026-09",
|
||||
"2026-10",
|
||||
"2026-11",
|
||||
]);
|
||||
const year = overviewRange("year", "2026-01-20", 15);
|
||||
expect(year.buckets[0]).toBe("2025-02");
|
||||
expect(year.buckets.at(-1)).toBe("2026-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cumulative", () => {
|
||||
it("seriler ayrı birikir; boş nokta boş kalır, toplamı bozmaz", () => {
|
||||
const out = cumulative([
|
||||
{ key: "a", plannedIncome: 100, budget: 50, actual: 10 },
|
||||
{ key: "b", plannedIncome: 0, budget: null, actual: 20 },
|
||||
{ key: "c", plannedIncome: 50, budget: 30, actual: null },
|
||||
]);
|
||||
expect(out.map((p) => [p.plannedIncome, p.budget, p.actual])).toEqual([
|
||||
[100, 50, 10],
|
||||
[100, null, 30],
|
||||
[150, 80, null],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { budgetPeriod, daysBetween, periodOfDate, shiftMonth } from "./budget";
|
||||
import { addDays } from "./recurrence";
|
||||
|
||||
/**
|
||||
* Dashboard ana grafiği (Bölüm 6.13): Planlanan Gelir, Planlanan Gider (Bütçe), Gerçekleşen Gider.
|
||||
* Saf fonksiyonlar: periyot aralığı ve kümülatif dönüşüm.
|
||||
*
|
||||
* Periyotlar: Haftalık (bu hafta, Pazartesi–Pazar, günlük), Aylık (içinde bulunulan bütçe ayı,
|
||||
* günlük), Altı Aylık ve Yıllık (bu bütçe ayı dahil son 6 / 12 bütçe ayı, aylık).
|
||||
*/
|
||||
|
||||
export const OVERVIEW_PERIODS = ["week", "month", "half", "year"] as const;
|
||||
export type OverviewPeriod = (typeof OVERVIEW_PERIODS)[number];
|
||||
|
||||
export type OverviewRange = {
|
||||
granularity: "day" | "month";
|
||||
/** Günlükte "YYYY-MM-DD", aylıkta bütçe ayı "YYYY-MM". */
|
||||
buckets: string[];
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
/** "YYYY-MM-DD" → haftanın günü (0 = Pazartesi … 6 = Pazar). */
|
||||
export function weekday(ymd: string): number {
|
||||
const d = new Date(`${ymd}T12:00:00Z`).getUTCDay();
|
||||
return (d + 6) % 7;
|
||||
}
|
||||
|
||||
export function overviewRange(period: OverviewPeriod, today: string, startDay = 1): OverviewRange {
|
||||
if (period === "week") {
|
||||
const from = addDays(today, -weekday(today));
|
||||
const buckets = Array.from({ length: 7 }, (_, i) => addDays(from, i));
|
||||
return { granularity: "day", buckets, from, to: buckets[6]! };
|
||||
}
|
||||
const current = periodOfDate(today, startDay);
|
||||
if (period === "month") {
|
||||
const { from, to } = budgetPeriod(current, startDay);
|
||||
const buckets = Array.from({ length: daysBetween(from, to) }, (_, i) => addDays(from, i));
|
||||
return { granularity: "day", buckets, from, to };
|
||||
}
|
||||
const n = period === "half" ? 6 : 12;
|
||||
const buckets = Array.from({ length: n }, (_, i) => shiftMonth(current, i - n + 1));
|
||||
return {
|
||||
granularity: "month",
|
||||
buckets,
|
||||
from: budgetPeriod(buckets[0]!, startDay).from,
|
||||
to: budgetPeriod(buckets.at(-1)!, startDay).to,
|
||||
};
|
||||
}
|
||||
|
||||
export type OverviewPoint = {
|
||||
key: string;
|
||||
plannedIncome: number | null;
|
||||
budget: number | null;
|
||||
actual: number | null;
|
||||
};
|
||||
|
||||
const SERIES = ["plannedIncome", "budget", "actual"] as const;
|
||||
|
||||
/**
|
||||
* Kümülatif görünüm: her seri kendi içinde birikir. Boş (null) nokta boş kalır ama birikime 0
|
||||
* katar; böylece bütçesi olmayan ay ya da gelecek günler çizgiyi koparır, toplamı bozmaz.
|
||||
*/
|
||||
export function cumulative(points: readonly OverviewPoint[]): OverviewPoint[] {
|
||||
const run = { plannedIncome: 0, budget: 0, actual: 0 };
|
||||
return points.map((p) => {
|
||||
const out: OverviewPoint = { ...p };
|
||||
for (const k of SERIES) {
|
||||
const v = p[k];
|
||||
if (v === null) continue;
|
||||
run[k] += v;
|
||||
out[k] = run[k];
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
categoryBreakdown,
|
||||
changePct,
|
||||
matchStoreName,
|
||||
monthlyUnitPrices,
|
||||
savingsRate,
|
||||
sharePct,
|
||||
} from "./reports";
|
||||
|
||||
describe("oranlar", () => {
|
||||
it("toplam gidere oran bir ondalıklı (35.000 içinde 5.000 → %14,3)", () => {
|
||||
expect(sharePct(5_000_00, 35_000_00)).toBe(14.3);
|
||||
expect(sharePct(1, 0)).toBe(0);
|
||||
});
|
||||
it("önceki aya göre değişim; önceki 0 ise null", () => {
|
||||
expect(changePct(120, 100)).toBe(20);
|
||||
expect(changePct(90, 120)).toBe(-25);
|
||||
expect(changePct(50, 0)).toBeNull();
|
||||
});
|
||||
it("tasarruf oranı; gelir yoksa null, gider gelirden fazlaysa negatif", () => {
|
||||
expect(savingsRate(50_000_00, 35_000_00)).toBe(30);
|
||||
expect(savingsRate(10_000_00, 12_000_00)).toBe(-20);
|
||||
expect(savingsRate(0, 100)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("categoryBreakdown", () => {
|
||||
const cats = [
|
||||
{ id: "fatura", parentId: null, name: "Faturalar", order: 1 },
|
||||
{ id: "elektrik", parentId: "fatura", name: "Elektrik", order: 1 },
|
||||
{ id: "su", parentId: "fatura", name: "Su", order: 2 },
|
||||
{ id: "market", parentId: null, name: "Market", order: 2 },
|
||||
{ id: "gida", parentId: "market", name: "Gıda", order: 1 },
|
||||
{ id: "ulasim", parentId: null, name: "Ulaşım", order: 3 },
|
||||
];
|
||||
|
||||
it("üst kategori alt ağacı toplar; oranlar toplam gidere göre; boşlar gizli", () => {
|
||||
const current = new Map([
|
||||
["elektrik", 3_000_00],
|
||||
["su", 2_000_00],
|
||||
["gida", 25_000_00],
|
||||
["market", 5_000_00],
|
||||
]);
|
||||
const previous = new Map([
|
||||
["elektrik", 2_500_00],
|
||||
["gida", 20_000_00],
|
||||
["ulasim", 1_000_00],
|
||||
]);
|
||||
const b = categoryBreakdown(cats, current, previous);
|
||||
expect(b.totalMinor).toBe(35_000_00);
|
||||
expect(b.prevTotalMinor).toBe(23_500_00);
|
||||
expect(b.rows.map((r) => [r.id, r.amountMinor, r.sharePct])).toEqual([
|
||||
["market", 30_000_00, 85.7],
|
||||
["fatura", 5_000_00, 14.3],
|
||||
// Bu ay harcama yok ama geçen ay vardı: değişimi göstermek için kalır.
|
||||
["ulasim", 0, 0],
|
||||
]);
|
||||
const fatura = b.rows.find((r) => r.id === "fatura")!;
|
||||
expect(fatura.children.map((c) => [c.id, c.amountMinor, c.changePct])).toEqual([
|
||||
["elektrik", 3_000_00, 20],
|
||||
["su", 2_000_00, null],
|
||||
]);
|
||||
const market = b.rows.find((r) => r.id === "market")!;
|
||||
expect(market.ownMinor).toBe(5_000_00);
|
||||
expect(market.changePct).toBe(50);
|
||||
expect(b.rows.find((r) => r.id === "ulasim")!.changeMinor).toBe(-1_000_00);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchStoreName", () => {
|
||||
const fold = (s: string) => s.toLocaleLowerCase("tr-TR");
|
||||
const names = ["Şok", "Migros", "Migros Jet", "A101"];
|
||||
it("katlanmış başlangıç eşleşmesi, en uzun ad, kelime sınırı", () => {
|
||||
expect(matchStoreName("Şok · Rasimpaşa", names, fold)).toBe("Şok");
|
||||
expect(matchStoreName("ŞOK MARKET", names, fold)).toBe("Şok");
|
||||
expect(matchStoreName("Migros Jet Moda", names, fold)).toBe("Migros Jet");
|
||||
expect(matchStoreName("A101x", names, fold)).toBeNull();
|
||||
expect(matchStoreName("Ev sahibi", names, fold)).toBeNull();
|
||||
expect(matchStoreName("", names, fold)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("monthlyUnitPrices", () => {
|
||||
it("ay başına en düşük ve ortalama; kayıt yoksa boş", () => {
|
||||
const out = monthlyUnitPrices(
|
||||
[
|
||||
{ month: "2026-10", unitPriceMinor: 100 },
|
||||
{ month: "2026-10", unitPriceMinor: 140 },
|
||||
{ month: "2026-11", unitPriceMinor: 90 },
|
||||
],
|
||||
["2026-09", "2026-10", "2026-11"],
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{ month: "2026-09", minMinor: null, avgMinor: null, count: 0 },
|
||||
{ month: "2026-10", minMinor: 100, avgMinor: 120, count: 2 },
|
||||
{ month: "2026-11", minMinor: 90, avgMinor: 90, count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Raporlar (Bölüm 6.14). Saf hesaplar: kategori dağılımı (üst kategori toplamları ve alt kategori
|
||||
* kırılımı), toplam gidere oran, önceki aya göre değişim, tasarruf oranı. Tutarlar kuruş; oranlar
|
||||
* bir ondalıklı yüzde (14,3).
|
||||
*/
|
||||
|
||||
/** Payın toplamdaki yüzdesi, bir ondalık (toplam 0 ise 0). */
|
||||
export function sharePct(part: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((part * 1000) / total) / 10;
|
||||
}
|
||||
|
||||
/** Önceki döneme göre yüzde değişim; önceki 0 ise hesaplanamaz (null). */
|
||||
export function changePct(current: number, previous: number): number | null {
|
||||
if (previous === 0) return null;
|
||||
return Math.round(((current - previous) * 1000) / previous) / 10;
|
||||
}
|
||||
|
||||
/** Tasarruf oranı = (gelir − gider) / gelir; gelir yoksa null. */
|
||||
export function savingsRate(incomeMinor: number, expenseMinor: number): number | null {
|
||||
if (incomeMinor <= 0) return null;
|
||||
return sharePct(incomeMinor - expenseMinor, incomeMinor);
|
||||
}
|
||||
|
||||
export type ReportCategory = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type CategoryShare = {
|
||||
id: string;
|
||||
name: string;
|
||||
depth: number;
|
||||
amountMinor: number;
|
||||
sharePct: number;
|
||||
prevMinor: number;
|
||||
changeMinor: number;
|
||||
changePct: number | null;
|
||||
};
|
||||
|
||||
export type TopCategoryShare = CategoryShare & {
|
||||
/** Alt kategoriler (ağaç sırasıyla, derinlik 1+); tutarları kendi tutarlarıdır. */
|
||||
children: CategoryShare[];
|
||||
/** Üst kategoriye doğrudan (alt kategori seçilmeden) yazılan tutar. */
|
||||
ownMinor: number;
|
||||
};
|
||||
|
||||
export type CategoryBreakdown = {
|
||||
totalMinor: number;
|
||||
prevTotalMinor: number;
|
||||
rows: TopCategoryShare[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Üst kategori toplamları (alt ağaç dahil) ve alt kategori kırılımı. Tutarı ve önceki tutarı
|
||||
* sıfır olan kategoriler gösterilmez. Satırlar tutara göre büyükten küçüğe.
|
||||
*/
|
||||
export function categoryBreakdown(
|
||||
categories: readonly ReportCategory[],
|
||||
current: ReadonlyMap<string, number>,
|
||||
previous: ReadonlyMap<string, number>,
|
||||
): CategoryBreakdown {
|
||||
const byId = new Map(categories.map((c) => [c.id, c]));
|
||||
const children = new Map<string, ReportCategory[]>();
|
||||
const roots: ReportCategory[] = [];
|
||||
for (const c of categories) {
|
||||
if (c.parentId && byId.has(c.parentId))
|
||||
children.set(c.parentId, [...(children.get(c.parentId) ?? []), c]);
|
||||
else roots.push(c);
|
||||
}
|
||||
const sorted = (list: ReportCategory[]) =>
|
||||
[...list].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr"));
|
||||
const totalMinor = [...current.values()].reduce((a, b) => a + b, 0);
|
||||
const prevTotalMinor = [...previous.values()].reduce((a, b) => a + b, 0);
|
||||
|
||||
const share = (id: string, name: string, depth: number, cur: number, prev: number) => ({
|
||||
id,
|
||||
name,
|
||||
depth,
|
||||
amountMinor: cur,
|
||||
sharePct: sharePct(cur, totalMinor),
|
||||
prevMinor: prev,
|
||||
changeMinor: cur - prev,
|
||||
changePct: changePct(cur, prev),
|
||||
});
|
||||
|
||||
const rows: TopCategoryShare[] = [];
|
||||
for (const root of roots) {
|
||||
const kids: CategoryShare[] = [];
|
||||
let cur = current.get(root.id) ?? 0;
|
||||
let prev = previous.get(root.id) ?? 0;
|
||||
const walk = (list: ReportCategory[], depth: number, seen: Set<string>) => {
|
||||
for (const c of sorted(list)) {
|
||||
if (seen.has(c.id)) continue;
|
||||
seen.add(c.id);
|
||||
const a = current.get(c.id) ?? 0;
|
||||
const p = previous.get(c.id) ?? 0;
|
||||
cur += a;
|
||||
prev += p;
|
||||
const sub = children.get(c.id) ?? [];
|
||||
const before = kids.length;
|
||||
kids.push(share(c.id, c.name, depth, a, p));
|
||||
walk(sub, depth + 1, seen);
|
||||
// Kendisi ve altı boşsa satırı çıkar.
|
||||
if (a === 0 && p === 0 && kids.length === before + 1) kids.pop();
|
||||
}
|
||||
};
|
||||
walk(children.get(root.id) ?? [], 1, new Set([root.id]));
|
||||
if (cur === 0 && prev === 0) continue;
|
||||
rows.push({
|
||||
...share(root.id, root.name, 0, cur, prev),
|
||||
ownMinor: current.get(root.id) ?? 0,
|
||||
children: kids,
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => b.amountMinor - a.amountMinor || a.name.localeCompare(b.name, "tr"));
|
||||
return { totalMinor, prevTotalMinor, rows };
|
||||
}
|
||||
|
||||
// --- Alışveriş raporları ---
|
||||
|
||||
/** Rapor dönemi: içinde bulunulan dahil son N bütçe ayı. */
|
||||
export const REPORT_PERIODS = [3, 6, 12] as const;
|
||||
export type ReportPeriod = (typeof REPORT_PERIODS)[number];
|
||||
|
||||
export const parsePeriod = (x: unknown): ReportPeriod =>
|
||||
REPORT_PERIODS.includes(Number(x) as ReportPeriod) ? (Number(x) as ReportPeriod) : 6;
|
||||
|
||||
/**
|
||||
* Gider alıcısını mağaza adına eşler: Türkçe katlanmış alıcı adı mağaza adıyla başlıyorsa
|
||||
* (en uzun eşleşme). "Şok · Rasimpaşa" ve "ŞOK MARKET" → "Şok". Eşleşme yoksa null.
|
||||
*/
|
||||
export function matchStoreName(
|
||||
payee: string,
|
||||
storeNames: readonly string[],
|
||||
fold: (s: string) => string,
|
||||
): string | null {
|
||||
const p = fold(payee).trim();
|
||||
if (!p) return null;
|
||||
let best: string | null = null;
|
||||
for (const name of storeNames) {
|
||||
const n = fold(name).trim();
|
||||
if (!n || !p.startsWith(n)) continue;
|
||||
// Kelime sınırı: "a101x" "a101" ile eşleşmesin.
|
||||
const next = p.charAt(n.length);
|
||||
if (next && /[\p{L}\p{N}]/u.test(next)) continue;
|
||||
if (!best || n.length > fold(best).length) best = name;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export type UnitPricePoint = {
|
||||
month: string;
|
||||
minMinor: number | null;
|
||||
avgMinor: number | null;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/** Ay başına en düşük ve ortalama birim fiyat (kayıt yoksa boş). Birim fiyat kesirli kuruş. */
|
||||
export function monthlyUnitPrices(
|
||||
records: readonly { month: string; unitPriceMinor: number }[],
|
||||
months: readonly string[],
|
||||
): UnitPricePoint[] {
|
||||
return months.map((month) => {
|
||||
const xs = records.filter((r) => r.month === month).map((r) => r.unitPriceMinor);
|
||||
return {
|
||||
month,
|
||||
minMinor: xs.length ? Math.min(...xs) : null,
|
||||
avgMinor: xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null,
|
||||
count: xs.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { tr } from "../../i18n/tr";
|
||||
import { ICON_KEYS, SWATCHES } from "../appearance";
|
||||
import { KAKEIBO_GROUPS } from "../kakeibo";
|
||||
|
||||
export const CATEGORY_KINDS = ["expense", "income"] as const;
|
||||
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
|
||||
@@ -14,6 +15,13 @@ export const categoryFormSchema = z.object({
|
||||
.transform((s) => (s === "" ? null : s)),
|
||||
icon: z.enum(ICON_KEYS),
|
||||
color: z.enum(SWATCHES),
|
||||
/** Kakeibo grubu ("" = üst kategoriden devral); yalnızca gider. */
|
||||
kakeiboGroup: z
|
||||
.union([z.enum(KAKEIBO_GROUPS), z.literal("")])
|
||||
.default("")
|
||||
.transform((x) => (x === "" ? null : x)),
|
||||
/** Banka maliyeti (KMH faizi, ücret): Kakeibo harcamasına girmez. */
|
||||
bankCost: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type CategoryFormInput = z.input<typeof categoryFormSchema>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tr } from "../../i18n/tr";
|
||||
import { parseMoney } from "../money";
|
||||
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
|
||||
import { MAX_INSTALLMENTS } from "../card-periods";
|
||||
import { KAKEIBO_GROUPS } from "../kakeibo";
|
||||
import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions";
|
||||
|
||||
const v = tr.validation;
|
||||
@@ -28,10 +29,17 @@ const positiveMoney = z
|
||||
return minor;
|
||||
});
|
||||
|
||||
/** Kakeibo grubu bu kayıt için değiştirildiyse; "" / yok = kategoriden (Faz 16). */
|
||||
const kakeiboOverride = z
|
||||
.union([z.enum(KAKEIBO_GROUPS), z.literal(""), z.null()])
|
||||
.transform((x) => (x ? x : null))
|
||||
.optional();
|
||||
|
||||
const splitSchema = z.object({
|
||||
categoryId: objectId.refine(Boolean, t.selectCategory),
|
||||
amount: positiveMoney,
|
||||
note: z.string().trim().max(120).default(""),
|
||||
kakeiboGroup: kakeiboOverride,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -48,6 +56,7 @@ export const transactionFormSchema = z
|
||||
toAccountId: optionalId,
|
||||
categoryId: optionalId,
|
||||
splits: z.array(splitSchema).max(MAX_SPLITS).default([]),
|
||||
kakeiboGroup: kakeiboOverride,
|
||||
payee: z.string().trim().max(80).default(""),
|
||||
description: z.string().trim().max(500).default(""),
|
||||
tags: z
|
||||
|
||||
Reference in New Issue
Block a user