29 Commits

Author SHA1 Message Date
suphicakir 99fc9dfea6 feat: Faz 6b - işlemler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:23:30 +03:00
suphicakir 00f8809823 chore: typecheck route tipleri, CHANGELOG (Faz 6b)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:23:30 +03:00
suphicakir 3acd71fd59 feat(seed): son 6 ayın örnek işlemleri ve 003 migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:23:30 +03:00
suphicakir dcfa2f8c8d feat(transactions): işlem formu, bölme, liste, filtreler, ayrıntı ve CSV
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:23:30 +03:00
suphicakir 1208e2d1eb feat(transactions): işlem servisi, bakiye hesaplama, satıcı kuralları ve izolasyon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:23:30 +03:00
suphicakir d18c8a0d76 feat(shared): işlem şeması, bakiye etkisi, etiket ve CSV yardımcıları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:23:30 +03:00
suphicakir a00c953dc0 feat: Faz 6a - para, hesaplar ve kategoriler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:57:58 +03:00
suphicakir 904a969b18 docs: CHANGELOG (Faz 6a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:57:58 +03:00
suphicakir 0558d92203 feat(seed): varsayılan kategoriler, örnek hesaplar ve 002 migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:57:58 +03:00
suphicakir d2aa9d8ae2 feat(ui): renk paleti, ikonlar, MoneyInput ve navigasyon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:57:58 +03:00
suphicakir 8dd5fd42e3 feat(accounts): hesaplar (KMH, kredi kartı, yemek kartı, yalnızca ben)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:57:58 +03:00
suphicakir 380ddaf303 feat(categories): kategori ağacı, varsayılan Türkçe set ve yönetim ekranı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:57:58 +03:00
suphicakir eaf80c6bb4 feat(shared): para, miktar ve İstanbul günü yardımcıları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:57:58 +03:00
suphicakir 7fda139199 feat: Faz 5 - haneler, roller ve veri izolasyonu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
suphicakir be9f2a5c3a docs: CHANGELOG, README ve PROMPT (0.5.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
suphicakir b79951601c feat(db): migration mekanizması ve ilk index migration'ı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
suphicakir 3644e081b1 feat(households): hane seçici, ayarlar, admin hane yönetimi ve hane-yok sayfası
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
suphicakir de91f2505a feat(households): hane rolleri, servis, DAL hane kapsamı ve denetim kaydı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
suphicakir 291a117b4f feat: Faz 4 - örnek test verisi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:14:10 +03:00
suphicakir cd98694f0d docs: CHANGELOG ve README (0.4.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:14:10 +03:00
suphicakir 2b648975ae feat(seed): örnek aile kullanıcıları, haneler ve roller (idempotent)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:14:10 +03:00
suphicakir 38e2a7a26d feat(models): households ve householdMembers Mongoose modelleri
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:14:10 +03:00
suphicakir 3e11a34235 feat: Faz 3 - backend, kimlik doğrulama ve güvenlik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:02:08 +03:00
suphicakir cdbd7a0446 docs: CHANGELOG ve README (0.3.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:02:00 +03:00
suphicakir fc3e0ae21b test: admin seed ve kimlik doğrulama entegrasyon testleri
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:02:00 +03:00
suphicakir 97e7ebe9aa feat(security): CSP ve güvenlik başlıkları, health DB ping
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:02:00 +03:00
suphicakir 2035f73e7d feat(admin): Server Action'larla kullanıcı yönetimi ve gerçek oturumlu sayfalar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:02:00 +03:00
suphicakir 5191a5cce1 feat(auth): Better Auth, kullanıcı adı ile giriş, DAL ve proxy
- MongoDB/Mongoose bağlantıları, kayıt kapalı, admin + username plugin, rate limit
- DAL: getCurrentUser, requireSession, requireRole, getActionUser
- Kullanıcı adı normalizasyonu: İ/I/ı → i (tr-TR 'ADMIN' → 'admın' hatası)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:02:00 +03:00
suphicakir 381214c2cb feat(brand): BÜTÇEM logosu ve ikon seti
- public/brand: logo.svg, logo-dark.svg, icon.svg, icon-maskable.svg (yazı Noto Sans Bold yolları, OFL)
- npm run icons artık icon.svg / icon-maskable.svg'den üretir; maskable ve Apple ikonları köşesiz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:44:59 +03:00
144 changed files with 12394 additions and 407 deletions
+2
View File
@@ -12,6 +12,8 @@ BETTER_AUTH_URL="http://localhost:3000"
# Faz 4 seed'i için ilk admin hesabı # Faz 4 seed'i için ilk admin hesabı
SEED_ADMIN_EMAIL="admin@butcem.local" SEED_ADMIN_EMAIL="admin@butcem.local"
SEED_ADMIN_PASSWORD="" SEED_ADMIN_PASSWORD=""
# Örnek aile kullanıcılarının ortak şifresi (yalnızca geliştirme; production'da örnek kullanıcı oluşturulmaz)
SEED_USER_PASSWORD=""
# Ek dosyalar (canlıda Docker volume: /app/uploads) # Ek dosyalar (canlıda Docker volume: /app/uploads)
UPLOAD_DIR="./uploads" UPLOAD_DIR="./uploads"
+108 -1
View File
@@ -6,6 +6,110 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
## [Yayınlanmamış] ## [Yayınlanmamış]
Faz 6 (alt fazlar halinde; `v0.6.0` Faz 6c sonunda).
### Eklendi — Faz 6b: işlemler
- İşlemler (`/islemler`): gelir, gider ve transfer (hesap ↔ hesap). Tutar her zaman pozitif; işaret türden gelir. Hesap bakiyeleri artık açılış bakiyesi + gerçekleşmiş işlemlerden hesaplanır (tek doğruluk kaynağı: işlemler).
- Bölünmüş işlem: en az 2 kalem, kalemler toplamı = tutar (istemci ve sunucuda aynı zod şeması); kalan tutar göstergesi, "kalanı son kaleme ekle"; listede tek satır, ayrıntıda kalemler.
- Akıllı giriş: satıcı önerileri (Türkçe normalize arama, kullanım sıklığı) ve satıcının son kullanılan kategori/hesabı; işlemi kopyalama.
- Liste: güne göre gruplama ve günlük net, gelir/gider/net özetleri, sayfalama; filtreler (tarih aralığı İstanbul gününe göre, tür, durum, hesap — transfer hedefi dahil, kategori — alt kategoriler ve bölünmüş kalemler dahil, ekleyen kişi, tutar aralığı, etiket, metin arama); filtreler URL'de.
- İşlem ayrıntısı, düzenleme ve yumuşak silme; silme bildiriminde "Geri al".
- CSV dışa aktarma (`/islemler/disa-aktar`): listedeki filtrelerle, bölünmüş işlem kalem başına satır; Türkçe Excel uyumlu (noktalı virgül, ondalık virgül, UTF-8 BOM), formül enjeksiyonu koruması.
- Hızlı ekle her ekrandan doğrudan işlem formunu açar (gider/gelir/transfer sekmeleri).
- Gizlilik: başkasının "yalnızca ben" hesabına dokunan işlemler listede, ayrıntıda ve CSV'de görünmez; o hesaba yalnızca sahibi işlem girer.
- Seed: son 6 ayın 221 gerçekçi işlemi (faker.seed(42); maaş, kira, faturalar — kışın doğalgaz yüksek, market — bir kısmı bölünmüş, yakıt, kafe, kart ödemesi, birikim, nakit çekme), satıcı kuralları; idempotent (`seedRef`).
- `003` migration'ı: `transactions` ve `payees` index'leri. Denetim kaydı: işlem silme/geri alma.
- Testler: bakiye tutarlılığı (bağımsız doğrulama dahil), izolasyon, gizli hesap, arşivli kategori, filtreler, satıcı kuralları, CSV.
### Değişti
- `npm run typecheck` önce route tiplerini üretir (`next typegen`).
### Eklendi — Faz 6a: para, hesaplar ve kategoriler
- `shared/money.ts`: Türkçe tutar ayrıştırma (`"1.250,50"` → 125050 kuruş; binlik/ondalık kuralları belgeli), `formatMoneyInput`, `sumMoney`, kuruş kaybı olmadan oransal bölme (`allocate`, `splitEvenly`).
- `shared/quantity.ts`: miktarlar birimin binde biri tamsayı (`1,5 kg` → 1500); ayrıştırma, biçimlendirme, kg↔g / lt↔ml dönüşümü.
- `shared/dates.ts`: İstanbul günü yardımcıları (gün başlangıcı, UTC → yerel gün).
- Hesaplar (`/hesaplar`): nakit, vadesiz banka (KMH limiti, aylık faiz, kesim günü), birikim, kredi kartı (limit, kesim günü, son ödeme kuralı, ödeme hesabı), yemek kartı (sağlayıcı); hesap sahibi kişi ve "yalnızca ben" (bakiyeyi yalnızca hesap sahibi görür, toplamlar hane ayarına uyar); renk/ikon, sıralama, arşiv. Varlık / kart borcu / net özet.
- Kategoriler (`/kategoriler`): gelir ve gider ağaçları (5 seviyeye kadar), ekleme, düzenleme, taşıma (alt ağaçla, transaction), sıralama, arşivleme (alt ağaçla); kardeşler arasında ad tekrarı engeli (Türkçe normalize).
- Varsayılan Türkçe kategori seti (44 kategori): yeni hanede aynı transaction'da, mevcut hanelere `002` migration'ıyla, idempotent (`defaultKey`).
- Kategori ağacı saf fonksiyonları (`buildTree`, `toOptions`, `checkMove`, `rollupTotals` …) ve birim testleri.
- Renk paleti (13 renk, açık/koyu tema değişkenleri) ve ikon kayıt tablosu; ikon/renk seçiciler, `MoneyInput`.
- Seed: varsayılan kategoriler ve 7 örnek hesap (KMH'li banka, kredi kartı, birikim, "yalnızca ben" yemek kartı).
- Denetim kaydı: hesap oluşturma/düzenleme/arşiv, kategori taşıma/arşiv.
- Entegrasyon testleri: kategori ve hesap izolasyonu, roller, gizli bakiye, kart kuralları, ağaç kuralları.
## [0.5.0] - 2026-11-05
Faz 5: Haneler, hane içi roller ve veri izolasyonu.
### Eklendi
- Hane içi roller (`viewer < member < owner`) ve kuralları: son sahip düşürülemez veya çıkarılamaz.
- DAL: `getHouseholdContext` (istek başına tek okuma), `requireActiveHousehold(minRole)`, `getHouseholdActionContext(minRole)`, `requireHouseholdMember(householdId, minRole)`. Sistem admini için hane verisinde ayrıcalık yok.
- Hane servisi (`features/households/service.ts`, framework'ten bağımsız): her hane kapsamlı fonksiyon üyeliği kendisi de doğrular; hane oluşturma + ilk sahip + denetim kaydı tek MongoDB transaction'ında.
- Aktif hane seçici (üst bar): seçim sunucuda doğrulanır, `butcem.hh` cookie'sine yazılır; geçersiz/uydurma cookie yok sayılır. Tercih yoksa en yüksek rolün olduğu hane açılır.
- `/ayarlar`: hane adı, bütçe ayı başlangıç günü, taksit modu, günlük plan dağıtımı, "yalnızca ben" hesaplarının toplamlara dahil edilmesi, ek mağaza tasarruf eşiği; üye rolleri ve üye çıkarma (yalnızca sahip; diğer roller salt okunur).
- `/admin/haneler`: hane oluşturma (ilk sahip seçerek), kullanıcıyı haneye ekleme, hane içi rol değiştirme, üye çıkarma.
- `/hane-yok`: hiçbir haneye üye olmayan kullanıcılar için yönlendirme sayfası.
- Denetim kaydı (`auditLogs`): hane ayarları, üye ekleme/rol/çıkarma, hane oluşturma ve tüm admin kullanıcı işlemleri (oluşturma, düzenleme, rol, şifre sıfırlama, askı, silme).
- Migration mekanizması: `npm run migrate`, `scripts/migrations/NNN-*.ts`, uygulananlar `migrations` koleksiyonunda; `001-initial-indexes`. Seed önce migration'ları çalıştırır.
- Veri izolasyonu entegrasyon testleri (başka hanenin üyeliği/ayrıntısı/ayarları, rol yetersizliği, uydurma ve enjeksiyon denemesi kimlikler, hanesi olmayan admin, çapraz hane üyelik değişikliği, son sahip, transaction).
### Değişti
- İzleyici (viewer) rolünde Hızlı ekle gizlenir/pasifleşir.
- Kullanıcı silindiğinde tüm hane üyelikleri de temizlenir.
### Kaldırıldı
- Hane mock verisi.
## [0.4.0] - 2026-11-05
Faz 4: Örnek test verisi.
### Eklendi
- Seed (`npm run seed`): admin + 7 örnek kullanıcı (biri askıda, biri hanesi olmayan ikinci admin, ikisi e-postasız ve kullanıcı adıyla giriş yapan), 2 hane ("Çakır Ailesi", "Annemler") ve hane içi roller (owner / member / viewer; bir kullanıcı iki hanede farklı rollerle).
- Seed idempotenttir (kullanıcı adı, hane adı ve üyelik çiftine göre); kullanıcılar Better Auth API'siyle oluşturulur. Geliştirme hesapları konsolda tablo olarak gösterilir.
- `npm run seed:reset`: kimlik doğrulama ve hane koleksiyonlarını temizleyip yeniden doldurur; production'da reddedilir. Production'da seed yalnızca admini oluşturur.
- Mongoose modelleri: `households` (ayarlar: bütçe ayı başlangıcı, taksit ve günlük plan tercihleri, özel hesaplar, ek mağaza eşiği) ve `householdMembers` (benzersiz `{householdId, userId}` index'i).
- `SEED_USER_PASSWORD` ortam değişkeni (örnek kullanıcıların ortak geliştirme şifresi).
- Seed entegrasyon testleri (idempotentlik, roller, askı, e-postasız giriş, reset).
## [0.3.0] - 2026-11-05
Faz 3: Backend — kimlik doğrulama, yetki katmanı ve güvenlik.
### Eklendi
- Better Auth (MongoDB adaptörü, replica set transaction): e-posta veya **kullanıcı adı** ile giriş, kayıt (sign-up) kapalı, admin plugin, 30 günlük oturum, `HttpOnly`/`Secure`/`SameSite=Lax` cookie.
- Rate limit (veritabanında): genel 100 istek/dk, giriş denemeleri 5/dk.
- Data Access Layer (`lib/dal.ts`): `getCurrentUser` (istek başına tek okuma), `requireSession`, `requireRole`, `getActionUser`. Askıdaki kullanıcının oturumu reddedilir.
- `proxy.ts`: iyimser oturum cookie kontrolü (oturumsuz → `/login`); asıl kontrol DAL'da.
- Admin kullanıcı yönetimi Server Action'ları: oluşturma, düzenleme, rol, şifre sıfırlama (diğer oturumları kapatır), askıya alma/kaldırma, silme. Kendini silememe/askıya alamama ve son admini düşürememe sunucuda da uygulanır; Better Auth hataları Türkçe alan hatalarına çevrilir.
- E-postası olmayan kullanıcılar için `.invalid` yer tutucu adres (arayüzde gizlenir).
- MongoDB (`lib/db/mongo.ts`) ve Mongoose (`lib/db/mongoose.ts`) bağlantıları (HMR güvenli tekil örnek, tembel oluşturma).
- Güvenlik başlıkları: CSP (nonce'suz, Cache Components uyumlu), `X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`; production'da HSTS.
- `GET /api/health` veritabanı ping'i; erişilemezse 503.
- `npm run seed`: ilk admini (`SEED_ADMIN_*`, kullanıcı adı `admin`) idempotent olarak oluşturur.
- `npm run test:integration`: gerçek MongoDB'de (`butcem_test`) kimlik doğrulama sözleşmesi testleri (kayıt kapalı, giriş, askı, admin yetkisi, rate limit).
- BÜTÇEM logosu ve ikon seti (`logo.svg`, `logo-dark.svg`, `icon.svg`, `icon-maskable.svg`); `npm run icons` SVG kaynaktan üretir.
### Değişti
- Giriş, çıkış, dashboard ve admin sayfaları mock yerine gerçek oturum ve verilerle çalışır; oturuma bağlı kabuk Suspense içinde akış olarak gelir.
### Düzeltildi
- Kullanıcı adı normalizasyonu: `tr-TR` küçük harf dönüşümü "ADMIN"i "admın" yapıyordu; artık İ/I/ı → i ve ASCII küçük harf.
### Kaldırıldı
- Oturum ve admin kullanıcı listesi mock verileri (hane mock'u Faz 5'e kadar duruyor).
## [0.2.0] - 2026-11-05 ## [0.2.0] - 2026-11-05
Faz 2: Frontend iskeleti (veriler Faz 3'e kadar mock). Faz 2: Frontend iskeleti (veriler Faz 3'e kadar mock).
@@ -50,6 +154,9 @@ Faz 1: Framework, klasör yapısı ve altyapı.
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri. - `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`. - `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...HEAD [Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.5.0...HEAD
[0.5.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...v0.5.0
[0.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0
[0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0
[0.2.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.1.0...v0.2.0 [0.2.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.1.0...v0.2.0
[0.1.0]: https://gitea.ta1sph.info/suphicakir/butcem/releases/tag/v0.1.0 [0.1.0]: https://gitea.ta1sph.info/suphicakir/butcem/releases/tag/v0.1.0
+29 -3
View File
@@ -26,10 +26,30 @@ cp .env.example .env.local
# 3) MongoDB (tek düğümlü replica set rs0, host portu 27019) — sağlıklı olana kadar bekler # 3) MongoDB (tek düğümlü replica set rs0, host portu 27019) — sağlıklı olana kadar bekler
npm run db:up npm run db:up
# 4) Uygulama # 4) Örnek veri: admin + aile kullanıcıları + 2 hane (tekrar çalıştırmak güvenli)
npm run dev # http://localhost:3000 npm run seed
# 5) Uygulama
npm run dev # http://localhost:3000 → /login
``` ```
### Geliştirme hesapları (`npm run seed`)
| Kullanıcı adı | Rol | Hane(ler) | Not |
| ------------- | ----- | --------------------------------------- | ------------------------------- |
| `admin` | admin | Çakır Ailesi (owner) | şifre `SEED_ADMIN_PASSWORD` |
| `ayse` | user | Çakır Ailesi (owner), Annemler (viewer) | |
| `emre` | user | Çakır Ailesi (member) | e-postasız |
| `zeynep` | user | Çakır Ailesi (viewer) | e-postasız |
| `fatma` | user | Annemler (owner) | |
| `mehmet` | user | Annemler (member) | |
| `burak` | admin | — | hanesi yok → hane verisi görmez |
| `ozan` | user | Çakır Ailesi (member) | askıda, giriş yapamaz |
Admin dışındaki hesapların şifresi `SEED_USER_PASSWORD` (`.env.local`). Production'da yalnızca admin oluşturulur.
Giriş kullanıcı adı **veya** e-posta ile yapılır. Kayıt kapalıdır; kullanıcıları admin `/admin/kullanicilar` sayfasından oluşturur.
## Scriptler ## Scriptler
| Komut | Açıklama | | Komut | Açıklama |
@@ -43,9 +63,15 @@ npm run dev # http://localhost:3000
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur | | `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) | | `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
## Haneler ve yetki
- Tüm alan verisi bir **haneye** (`householdId`) aittir; kullanıcı yalnızca üyesi olduğu hanelerin verisini görür. Sistem admini de bu kurala tabidir.
- Hane içi roller: **sahip** (ayarlar, üyeler), **üye** (veri girer), **izleyici** (yalnızca görüntüler).
- Her sayfa, Server Action ve API, hane yetkisini `src/lib/dal.ts` üzerinden sunucuda doğrular; servisler de üyeliği ayrıca kontrol eder.
## Marka dosyaları ## Marka dosyaları
`public/brand/` altındaki `logo.svg`, `logo-dark.svg` ve `icon.png` (512×512) şu an **yer tutucudur**. Gerçek logoları aynı adlarla koyup `npm run icons` çalıştırın. `public/brand/`: `logo.svg` (açık tema), `logo-dark.svg` (koyu tema), `icon.svg` (uygulama ikonu), `icon-maskable.svg` (Android/iOS, köşesiz). Yazı Noto Sans Bold (OFL) harf yollarıdır. İkonları değiştirdikten sonra `npm run icons` çalıştırın; favicon ve PWA ikonları bu kaynaklardan üretilir.
## Docker imajı ## Docker imajı
+1
View File
@@ -317,6 +317,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
- **Değerleme:** varlıklar ve aynî alacaklar **alış** fiyatıyla, aynî borçlar **satış** fiyatıyla değerlenir. Tek fiyat girilirse ikisine de yazılır. - **Değerleme:** varlıklar ve aynî alacaklar **alış** fiyatıyla, aynî borçlar **satış** fiyatıyla değerlenir. Tek fiyat girilirse ikisine de yazılır.
- **URL'ler:** ekran yolları Türkçe ASCII (`/islemler`, `/butce`); kod, koleksiyon adları ve `/api/v1` İngilizce. - **URL'ler:** ekran yolları Türkçe ASCII (`/islemler`, `/butce`); kod, koleksiyon adları ve `/api/v1` İngilizce.
- **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı). - **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı).
- **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır.
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir. - **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
--- ---
+45
View File
@@ -1,5 +1,47 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const isDev = process.env.NODE_ENV === "development";
/**
* Content-Security-Policy (nonce'suz): Cache Components'in statik kabuğu ile uyumlu.
* 'unsafe-inline' script: Next.js'in satır içi hydration verisi ve next-themes'in tema betiği için.
* Grafikler (Recharts, SVG) ve carousel (Embla) ek kaynak gerektirmez; ek dosyalar
* kendi origin'imizden (/api/files/...) ve blob: önizlemelerle servis edilir.
*/
const csp = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' blob: data:",
"font-src 'self'",
"connect-src 'self'",
"media-src 'self' blob:",
"worker-src 'self' blob:",
"manifest-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
...(isDev ? [] : ["upgrade-insecure-requests"]),
].join("; ");
const securityHeaders = [
{ key: "Content-Security-Policy", value: csp },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
// Kamera: Faz 6'da fiş fotoğrafı için yalnızca kendi origin'imiz.
{
key: "Permissions-Policy",
value: "camera=(self), microphone=(), geolocation=(), payment=(), usb=()",
},
...(isDev
? []
: // includeSubDomains/preload bilinçli olarak yok: ta1sph.website'in diğer alt alanlarını bağlamasın.
[{ key: "Strict-Transport-Security", value: "max-age=31536000" }]),
];
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Docker imajı için yalnızca gerekli dosyaları .next/standalone altına kopyala. // Docker imajı için yalnızca gerekli dosyaları .next/standalone altına kopyala.
output: "standalone", output: "standalone",
@@ -17,6 +59,9 @@ const nextConfig: NextConfig = {
}, },
}, },
}, },
async headers() {
return [{ source: "/:path*", headers: securityHeaders }];
},
}; };
export default nextConfig; export default nextConfig;
+7 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "butcem", "name": "butcem",
"version": "0.2.0", "version": "0.5.0",
"private": true, "private": true,
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
"engines": { "engines": {
@@ -11,16 +11,18 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"typecheck": "tsc --noEmit", "typecheck": "next typegen && tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"db:up": "docker compose up -d --wait", "db:up": "docker compose up -d --wait",
"db:down": "docker compose down", "db:down": "docker compose down",
"seed": "tsx --env-file=.env.local scripts/seed.ts", "seed": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts",
"seed:reset": "tsx --env-file=.env.local scripts/seed.ts --reset", "seed:reset": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts --reset",
"icons": "tsx scripts/generate-icons.ts" "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"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^7.0.2", "@hookform/resolvers": "^7.0.2",
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="BÜTÇEM">
<!-- BÜTÇEM maskable/Apple ikonu (köşesiz, tam zemin). Kaynak: npm run icons -->
<defs><linearGradient id="mg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
<rect width="512" height="512" fill="url(#mg)"/>
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
</svg>

After

Width:  |  Height:  |  Size: 785 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="BÜTÇEM">
<!-- BÜTÇEM uygulama ikonu: hane (çatı) + yükselen bütçe/birikim sütunları. Kaynak: npm run icons -->
<defs><linearGradient id="ig" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
<rect width="512" height="512" rx="112" fill="url(#ig)"/>
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
</svg>

After

Width:  |  Height:  |  Size: 820 B

+13 -4
View File
@@ -1,5 +1,14 @@
<!-- YER TUTUCU: gerçek BÜTÇEM logosu ile değiştirin (koyu tema). --> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 227 64" width="227" height="64" role="img" aria-label="BÜTÇEM">
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="64" viewBox="0 0 240 64" role="img" aria-label="BÜTÇEM"> <!-- BÜTÇEM logosu (koyu tema). Yazı Noto Sans Bold (OFL) yollarıdır; font gerektirmez. -->
<rect x="1" y="1" width="238" height="62" rx="12" fill="none" stroke="#6bb9f8" stroke-width="2" stroke-dasharray="6 4"/> <g transform="scale(0.125)">
<text x="120" y="40" text-anchor="middle" font-family="sans-serif" font-size="26" font-weight="700" fill="#6bb9f8">BÜTÇEM</text> <defs><linearGradient id="dg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
<rect width="512" height="512" rx="112" fill="url(#dg)"/>
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
</g>
<path fill="#edf2f8" d="M88.744 19.863999999999997Q93.742 19.863999999999997 96.02000000000001 21.342999999999996Q98.298 22.822 98.298 25.916Q98.298 28.194 97.09100000000001 29.537Q95.884 30.88 94.184 31.253999999999998V31.424Q95.374 31.662 96.46199999999999 32.274Q97.55 32.885999999999996 98.22999999999999 34.042Q98.91 35.198 98.91 37.102000000000004Q98.91 40.434 96.513 42.287Q94.116 44.14 89.968 44.14H80.89V19.863999999999997ZM89.016 29.588Q91.226 29.588 92.093 28.772Q92.96000000000001 27.956 92.96000000000001 26.63Q92.96000000000001 25.236 91.94 24.573Q90.92 23.91 88.778 23.91H86.092V29.588ZM86.092 33.498V40.026H89.39Q91.702 40.026 92.60300000000001 39.091Q93.504 38.156 93.504 36.694Q93.504 35.368 92.58600000000001 34.433Q91.668 33.498 89.22 33.498Z M124.10400000000001 35.572Q124.10400000000001 38.088 122.99900000000001 40.111000000000004Q121.894 42.134 119.667 43.307Q117.44000000000001 44.480000000000004 114.04 44.480000000000004Q109.212 44.480000000000004 106.696 42.015Q104.18 39.55 104.18 35.504V19.863999999999997H109.31400000000001V34.722Q109.31400000000001 37.714 110.53800000000001 38.938Q111.762 40.162 114.176 40.162Q116.69200000000001 40.162 117.831 38.836Q118.97 37.51 118.97 34.688V19.863999999999997H124.10400000000001ZM108.05600000000001 15.103999999999996Q108.05600000000001 13.913999999999994 108.71900000000001 13.369999999999996Q109.382 12.825999999999997 110.40200000000002 12.825999999999997Q111.32000000000001 12.825999999999997 112.03400000000002 13.369999999999996Q112.74800000000002 13.913999999999994 112.74800000000002 15.103999999999996Q112.74800000000002 16.225999999999996 112.03400000000002 16.769999999999996Q111.32000000000001 17.313999999999997 110.40200000000002 17.313999999999997Q109.382 17.313999999999997 108.71900000000001 16.769999999999996Q108.05600000000001 16.225999999999996 108.05600000000001 15.103999999999996ZM115.4 15.103999999999996Q115.4 13.913999999999994 116.08000000000001 13.369999999999996Q116.76000000000002 12.825999999999997 117.74600000000001 12.825999999999997Q118.69800000000001 12.825999999999997 119.39500000000001 13.369999999999996Q120.09200000000001 13.913999999999994 120.09200000000001 15.103999999999996Q120.09200000000001 16.225999999999996 119.39500000000001 16.769999999999996Q118.69800000000001 17.313999999999997 117.74600000000001 17.313999999999997Q116.76000000000002 17.313999999999997 116.08000000000001 16.769999999999996Q115.4 16.225999999999996 115.4 15.103999999999996Z M140.084 44.14H134.882V24.182H128.32V19.863999999999997H146.64600000000002V24.182H140.084Z M161.50400000000002 23.842Q158.58 23.842 156.897 26.035Q155.214 28.228 155.214 32.07Q155.214 35.912 156.829 38.037Q158.44400000000002 40.162 161.708 40.162Q163.306 40.162 164.78500000000003 39.788Q166.264 39.414 167.76000000000002 38.836V43.256Q164.87 44.480000000000004 161.13 44.480000000000004Q157.25400000000002 44.480000000000004 154.755 42.93300000000001Q152.256 41.386 151.049 38.581Q149.842 35.775999999999996 149.842 32.036Q149.842 28.363999999999997 151.202 25.541999999999998Q152.562 22.72 155.18 21.122Q157.798 19.523999999999997 161.572 19.523999999999997Q163.37400000000002 19.523999999999997 165.22700000000003 19.915Q167.08 20.305999999999997 168.746 21.122L167.04600000000002 25.27Q165.822 24.692 164.445 24.267Q163.068 23.842 161.50400000000002 23.842ZM164.496 48.526Q164.496 50.26 163.238 51.28Q161.98000000000002 52.3 159.73600000000002 52.3Q158.818 52.3 158.07000000000002 52.181Q157.32200000000003 52.062 156.846 51.892V49.206Q157.83200000000002 49.512 158.88600000000002 49.512Q160.68800000000002 49.512 160.68800000000002 48.322Q160.68800000000002 47.642 160.00800000000004 47.268Q159.32800000000003 46.894 158.03600000000003 46.69L159.22600000000003 44.14H161.94600000000003L161.436 45.466Q162.15 45.636 162.86400000000003 45.976Q163.57800000000003 46.316 164.03700000000003 46.928Q164.496 47.54 164.496 48.526Z"/>
<path fill="#6bb9f8" d="M187.514 44.14H173.54000000000002V19.863999999999997H187.514V24.08H178.674V29.418H186.90200000000002V33.634H178.674V39.89H187.514Z M203.52800000000002 44.14 197.68 25.099999999999998H197.544Q197.57800000000003 25.779999999999998 197.64600000000002 27.14Q197.71400000000003 28.5 197.78200000000004 30.047Q197.85000000000002 31.594 197.85000000000002 32.852000000000004V44.14H193.26000000000002V19.863999999999997H200.264L206.01000000000002 38.428H206.11200000000002L212.198 19.863999999999997H219.20200000000003V44.14H214.40800000000002V32.647999999999996Q214.40800000000002 31.491999999999997 214.459 29.996Q214.51000000000002 28.5 214.56100000000004 27.157Q214.61200000000002 25.814 214.64600000000002 25.134H214.51000000000002L208.25400000000002 44.14Z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 457 B

After

Width:  |  Height:  |  Size: 5.4 KiB

+13 -4
View File
@@ -1,5 +1,14 @@
<!-- YER TUTUCU: gerçek BÜTÇEM logosu ile değiştirin (açık tema). --> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 227 64" width="227" height="64" role="img" aria-label="BÜTÇEM">
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="64" viewBox="0 0 240 64" role="img" aria-label="BÜTÇEM"> <!-- BÜTÇEM logosu (açık tema). Yazı Noto Sans Bold (OFL) yollarıdır; font gerektirmez. -->
<rect x="1" y="1" width="238" height="62" rx="12" fill="none" stroke="#105bbe" stroke-width="2" stroke-dasharray="6 4"/> <g transform="scale(0.125)">
<text x="120" y="40" text-anchor="middle" font-family="sans-serif" font-size="26" font-weight="700" fill="#105bbe">BÜTÇEM</text> <defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
<rect width="512" height="512" rx="112" fill="url(#lg)"/>
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
</g>
<path fill="#101a2d" d="M88.744 19.863999999999997Q93.742 19.863999999999997 96.02000000000001 21.342999999999996Q98.298 22.822 98.298 25.916Q98.298 28.194 97.09100000000001 29.537Q95.884 30.88 94.184 31.253999999999998V31.424Q95.374 31.662 96.46199999999999 32.274Q97.55 32.885999999999996 98.22999999999999 34.042Q98.91 35.198 98.91 37.102000000000004Q98.91 40.434 96.513 42.287Q94.116 44.14 89.968 44.14H80.89V19.863999999999997ZM89.016 29.588Q91.226 29.588 92.093 28.772Q92.96000000000001 27.956 92.96000000000001 26.63Q92.96000000000001 25.236 91.94 24.573Q90.92 23.91 88.778 23.91H86.092V29.588ZM86.092 33.498V40.026H89.39Q91.702 40.026 92.60300000000001 39.091Q93.504 38.156 93.504 36.694Q93.504 35.368 92.58600000000001 34.433Q91.668 33.498 89.22 33.498Z M124.10400000000001 35.572Q124.10400000000001 38.088 122.99900000000001 40.111000000000004Q121.894 42.134 119.667 43.307Q117.44000000000001 44.480000000000004 114.04 44.480000000000004Q109.212 44.480000000000004 106.696 42.015Q104.18 39.55 104.18 35.504V19.863999999999997H109.31400000000001V34.722Q109.31400000000001 37.714 110.53800000000001 38.938Q111.762 40.162 114.176 40.162Q116.69200000000001 40.162 117.831 38.836Q118.97 37.51 118.97 34.688V19.863999999999997H124.10400000000001ZM108.05600000000001 15.103999999999996Q108.05600000000001 13.913999999999994 108.71900000000001 13.369999999999996Q109.382 12.825999999999997 110.40200000000002 12.825999999999997Q111.32000000000001 12.825999999999997 112.03400000000002 13.369999999999996Q112.74800000000002 13.913999999999994 112.74800000000002 15.103999999999996Q112.74800000000002 16.225999999999996 112.03400000000002 16.769999999999996Q111.32000000000001 17.313999999999997 110.40200000000002 17.313999999999997Q109.382 17.313999999999997 108.71900000000001 16.769999999999996Q108.05600000000001 16.225999999999996 108.05600000000001 15.103999999999996ZM115.4 15.103999999999996Q115.4 13.913999999999994 116.08000000000001 13.369999999999996Q116.76000000000002 12.825999999999997 117.74600000000001 12.825999999999997Q118.69800000000001 12.825999999999997 119.39500000000001 13.369999999999996Q120.09200000000001 13.913999999999994 120.09200000000001 15.103999999999996Q120.09200000000001 16.225999999999996 119.39500000000001 16.769999999999996Q118.69800000000001 17.313999999999997 117.74600000000001 17.313999999999997Q116.76000000000002 17.313999999999997 116.08000000000001 16.769999999999996Q115.4 16.225999999999996 115.4 15.103999999999996Z M140.084 44.14H134.882V24.182H128.32V19.863999999999997H146.64600000000002V24.182H140.084Z M161.50400000000002 23.842Q158.58 23.842 156.897 26.035Q155.214 28.228 155.214 32.07Q155.214 35.912 156.829 38.037Q158.44400000000002 40.162 161.708 40.162Q163.306 40.162 164.78500000000003 39.788Q166.264 39.414 167.76000000000002 38.836V43.256Q164.87 44.480000000000004 161.13 44.480000000000004Q157.25400000000002 44.480000000000004 154.755 42.93300000000001Q152.256 41.386 151.049 38.581Q149.842 35.775999999999996 149.842 32.036Q149.842 28.363999999999997 151.202 25.541999999999998Q152.562 22.72 155.18 21.122Q157.798 19.523999999999997 161.572 19.523999999999997Q163.37400000000002 19.523999999999997 165.22700000000003 19.915Q167.08 20.305999999999997 168.746 21.122L167.04600000000002 25.27Q165.822 24.692 164.445 24.267Q163.068 23.842 161.50400000000002 23.842ZM164.496 48.526Q164.496 50.26 163.238 51.28Q161.98000000000002 52.3 159.73600000000002 52.3Q158.818 52.3 158.07000000000002 52.181Q157.32200000000003 52.062 156.846 51.892V49.206Q157.83200000000002 49.512 158.88600000000002 49.512Q160.68800000000002 49.512 160.68800000000002 48.322Q160.68800000000002 47.642 160.00800000000004 47.268Q159.32800000000003 46.894 158.03600000000003 46.69L159.22600000000003 44.14H161.94600000000003L161.436 45.466Q162.15 45.636 162.86400000000003 45.976Q163.57800000000003 46.316 164.03700000000003 46.928Q164.496 47.54 164.496 48.526Z"/>
<path fill="#105bbe" d="M187.514 44.14H173.54000000000002V19.863999999999997H187.514V24.08H178.674V29.418H186.90200000000002V33.634H178.674V39.89H187.514Z M203.52800000000002 44.14 197.68 25.099999999999998H197.544Q197.57800000000003 25.779999999999998 197.64600000000002 27.14Q197.71400000000003 28.5 197.78200000000004 30.047Q197.85000000000002 31.594 197.85000000000002 32.852000000000004V44.14H193.26000000000002V19.863999999999997H200.264L206.01000000000002 38.428H206.11200000000002L212.198 19.863999999999997H219.20200000000003V44.14H214.40800000000002V32.647999999999996Q214.40800000000002 31.491999999999997 214.459 29.996Q214.51000000000002 28.5 214.56100000000004 27.157Q214.61200000000002 25.814 214.64600000000002 25.134H214.51000000000002L208.25400000000002 44.14Z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 459 B

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+40 -5
View File
@@ -1,23 +1,30 @@
/** /**
* Uygulama ikonlarını logodan üretir: npm run icons * Uygulama ikonlarını logodan üretir: npm run icons
* *
* Kaynak: public/brand/icon.png (kare, en az 512×512). * Kaynak: public/brand/icon.svg (varsa; önce 512×512 icon.png olarak render edilir)
* veya doğrudan public/brand/icon.png (kare, en az 512×512).
* Çıktılar (commit edilir): * Çıktılar (commit edilir):
* public/brand/icon.png 512 px (icon.svg varsa ondan üretilir)
* src/app/favicon.ico 16/32/48 px (PNG gömülü ICO) * src/app/favicon.ico 16/32/48 px (PNG gömülü ICO)
* src/app/icon.png 192 px → <link rel="icon"> * src/app/icon.png 192 px → <link rel="icon">
* src/app/apple-icon.png 180 px, saydamsız → iOS ana ekran * src/app/apple-icon.png 180 px, saydamsız → iOS ana ekran
* public/icons/icon-192.png PWA manifest * public/icons/icon-192.png PWA manifest
* public/icons/icon-512.png PWA manifest * public/icons/icon-512.png PWA manifest
* public/icons/maskable-512.png PWA manifest (purpose: maskable, %80 güvenli alan) * public/icons/maskable-512.png PWA manifest (purpose: maskable)
*
* public/brand/icon-maskable.svg (köşesiz, tam zemin) varsa maskable ve Apple ikonları ondan
* üretilir; yoksa icon.png marka mavisi zemine %80 güvenli alanla yerleştirilir.
* *
* Logoyu değiştirdikten sonra bu betiği yeniden çalıştırın. * Logoyu değiştirdikten sonra bu betiği yeniden çalıştırın.
*/ */
import { mkdir, writeFile } from "node:fs/promises"; import { access, mkdir, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import sharp from "sharp"; import sharp from "sharp";
const ROOT = process.cwd(); const ROOT = process.cwd();
const SOURCE = path.join(ROOT, "public/brand/icon.png"); const SOURCE = path.join(ROOT, "public/brand/icon.png");
const SOURCE_SVG = path.join(ROOT, "public/brand/icon.svg");
const MASKABLE_SVG = path.join(ROOT, "public/brand/icon-maskable.svg");
/** Saydamlık kabul etmeyen ikonlarda arka plan: marka mavisi (--primary, açık tema). */ /** Saydamlık kabul etmeyen ikonlarda arka plan: marka mavisi (--primary, açık tema). */
const BRAND_BG = "#105bbe"; const BRAND_BG = "#105bbe";
@@ -60,19 +67,47 @@ function toIco(images: { size: number; data: Buffer }[]): Buffer {
return Buffer.concat([header, ...entries, ...images.map((i) => i.data)]); return Buffer.concat([header, ...entries, ...images.map((i) => i.data)]);
} }
async function exists(file: string) {
return access(file).then(
() => true,
() => false,
);
}
async function main() { async function main() {
if (await exists(SOURCE_SVG)) {
// Vektörden yüksek yoğunlukta render: küçük boyutlarda da keskin kenarlar.
const png512 = await sharp(SOURCE_SVG, { density: 288 }).resize(512, 512).png().toBuffer();
await writeFile(SOURCE, png512);
console.log(`✓ public/brand/icon.png ← icon.svg (${png512.length} bayt)`);
}
const meta = await sharp(SOURCE).metadata(); const meta = await sharp(SOURCE).metadata();
if (!meta.width || !meta.height || meta.width !== meta.height || meta.width < 512) { if (!meta.width || !meta.height || meta.width !== meta.height || meta.width < 512) {
throw new Error(`${SOURCE} kare ve en az 512×512 olmalı (şu an ${meta.width}×${meta.height}).`); throw new Error(`${SOURCE} kare ve en az 512×512 olmalı (şu an ${meta.width}×${meta.height}).`);
} }
await mkdir(path.join(ROOT, "public/icons"), { recursive: true }); await mkdir(path.join(ROOT, "public/icons"), { recursive: true });
const fullBleed = (await exists(MASKABLE_SVG))
? (size: number) =>
sharp(MASKABLE_SVG, { density: 288 })
.resize(size, size)
.flatten({ background: BRAND_BG })
.png()
.toBuffer()
: null;
const out: [string, Buffer][] = [ const out: [string, Buffer][] = [
["src/app/icon.png", await png(192)], ["src/app/icon.png", await png(192)],
["src/app/apple-icon.png", await png(180, { flatten: true })], [
"src/app/apple-icon.png",
fullBleed ? await fullBleed(180) : await png(180, { flatten: true }),
],
["public/icons/icon-192.png", await png(192)], ["public/icons/icon-192.png", await png(192)],
["public/icons/icon-512.png", await png(512)], ["public/icons/icon-512.png", await png(512)],
["public/icons/maskable-512.png", await png(512, { padding: 0.1 })], [
"public/icons/maskable-512.png",
fullBleed ? await fullBleed(512) : await png(512, { padding: 0.1 }),
],
[ [
"src/app/favicon.ico", "src/app/favicon.ico",
toIco(await Promise.all([16, 32, 48].map(async (size) => ({ size, data: await png(size) })))), toIco(await Promise.all([16, 32, 48].map(async (size) => ({ size, data: await png(size) })))),
+17
View File
@@ -0,0 +1,17 @@
/**
* Migration'ları uygular: npm run migrate (canlıda deploy sonrası; bkz. docs/DEPLOY.md, Faz 15).
* `server-only` modülleri Next dışında yükleyebilmek için --conditions=react-server ile çalışır.
*/
import mongoose from "mongoose";
import { getMongoClient } from "@/lib/db/mongo";
import { runMigrations } from "./migrations";
runMigrations()
.catch((err: unknown) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
})
.finally(async () => {
await mongoose.disconnect();
await getMongoClient().close();
});
View File
+15
View File
@@ -0,0 +1,15 @@
import { AuditLogModel } from "@/models/audit-log";
import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import type { Migration } from "./types";
/** Hane, üyelik ve denetim kaydı index'leri (Mongoose şemalarındaki tanımlarla). */
export const migration: Migration = {
name: "001-initial-indexes",
description: "households, householdMembers ve auditLogs index'leri",
async up() {
await HouseholdModel.createIndexes();
await HouseholdMemberModel.createIndexes();
await AuditLogModel.createIndexes();
},
};
@@ -0,0 +1,19 @@
import { Types } from "mongoose";
import { installDefaultCategories } from "@/features/categories/service";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { HouseholdModel } from "@/models/household";
import type { Migration } from "./types";
/** Hesap ve kategori index'leri + mevcut hanelere varsayılan kategori seti (idempotent). */
export const migration: Migration = {
name: "002-accounts-categories",
description: "accounts/categories index'leri ve mevcut hanelere varsayılan kategoriler",
async up() {
await AccountModel.createIndexes();
await CategoryModel.createIndexes();
for (const h of await HouseholdModel.find().select({ _id: 1, createdBy: 1 }).lean()) {
await installDefaultCategories(h._id as Types.ObjectId, h.createdBy as Types.ObjectId);
}
},
};
+12
View File
@@ -0,0 +1,12 @@
import { PayeeModel } from "@/models/payee";
import { TransactionModel } from "@/models/transaction";
import type { Migration } from "./types";
export const migration: Migration = {
name: "003-transactions",
description: "transactions ve payees index'leri",
async up() {
await TransactionModel.createIndexes();
await PayeeModel.createIndexes();
},
};
+37
View File
@@ -0,0 +1,37 @@
import { getDb } from "@/lib/db/mongo";
import { connectMongoose } from "@/lib/db/mongoose";
import { migration as m001 } from "./001-initial-indexes";
import { migration as m002 } from "./002-accounts-categories";
import { migration as m003 } from "./003-transactions";
import type { Migration } from "./types";
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
export const MIGRATIONS: Migration[] = [m001, m002, m003];
const COLLECTION = "migrations";
/**
* Uygulanmamış migration'ları sırayla çalıştırır; her biri bir kez (`migrations` koleksiyonu).
* Aynı anda iki çalıştırma olursa benzersiz `name` index'i ikinci kaydı reddeder.
*/
export async function runMigrations(log: (line: string) => void = console.log): Promise<string[]> {
const db = getDb();
await connectMongoose();
const col = db.collection<{ name: string; appliedAt: Date; durationMs: number }>(COLLECTION);
await col.createIndex({ name: 1 }, { unique: true });
const applied = new Set(
(await col.find({}, { projection: { name: 1 } }).toArray()).map((d) => d.name),
);
const ran: string[] = [];
for (const m of MIGRATIONS) {
if (applied.has(m.name)) continue;
const started = Date.now();
await m.up(db);
await col.insertOne({ name: m.name, appliedAt: new Date(), durationMs: Date.now() - started });
ran.push(m.name);
log(`✓ Migration: ${m.name} — ${m.description}`);
}
if (!ran.length) log("• Bekleyen migration yok.");
return ran;
}
+8
View File
@@ -0,0 +1,8 @@
import type { Db } from "mongodb";
/** Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). Geri alma yok; ileri yönlü. */
export type Migration = {
name: string;
description: string;
up: (db: Db) => Promise<void>;
};
+32 -6
View File
@@ -1,10 +1,36 @@
/** /**
* Seed betiği — Faz 4'te doldurulacak (admin, örnek hane, aile üyeleri ve alan verisi). * Seed: npm run seed | Sıfırlayıp doldurma: npm run seed:reset (production'da reddedilir)
* Çalıştırma: npm run seed | Sıfırlayıp doldurma: npm run seed:reset *
* Oluşturulanlar: admin (SEED_ADMIN_*), 7 örnek kullanıcı (biri askıda, biri ikinci admin),
* 2 hane ve hane içi rolleri. Ayrıntı: scripts/seed/fixtures.ts
* `server-only` modülleri Next dışında yükleyebilmek için --conditions=react-server ile çalışır.
*/ */
if (process.env.NODE_ENV === "production" && process.argv.includes("--reset")) { import { getMongoClient } from "@/lib/db/mongo";
console.error("seed:reset production ortamında çalıştırılamaz."); import { env } from "@/lib/env";
process.exit(1); import { closeSeedConnections, runSeed } from "./seed/run";
async function main() {
const summary = await runSeed({ reset: process.argv.includes("--reset") });
const { users, households, memberships, categories, accounts, transactions } = summary.created;
console.log(
`\nOluşturulan: ${users} kullanıcı, ${households} hane, ${memberships} üyelik, ${categories} kategori, ${accounts} hesap, ${transactions} işlem.`,
);
// Hesap tablosu yalnızca geliştirme ortamında gösterilir.
if (env().NODE_ENV !== "production" && summary.accounts.length) {
console.log(
"\nGeliştirme hesapları (şifreler: admin → SEED_ADMIN_PASSWORD, diğerleri → SEED_USER_PASSWORD):",
);
console.table(summary.accounts);
}
} }
console.log("Seed henüz uygulanmadı (Faz 4)."); main()
.catch((err: unknown) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
})
.finally(async () => {
await closeSeedConnections();
await getMongoClient().close();
});
+154
View File
@@ -0,0 +1,154 @@
import type { HouseholdRole } from "@/features/households/types";
import type { AccountType, MealCardProvider } from "@/shared/accounts";
import type { IconKey, Swatch } from "@/shared/appearance";
import type { SystemRole } from "@/shared/schemas/common";
/** Örnek (geliştirme) kullanıcıları. E-postası olmayanlar kullanıcı adıyla giriş yapar. */
export type SeedUser = {
username: string;
name: string;
email?: string;
role: SystemRole;
banReason?: string;
};
export const SEED_USERS: SeedUser[] = [
{ username: "ayse", name: "Ayşe Çakır", email: "ayse@butcem.local", role: "user" },
{ username: "emre", name: "Emre Çakır", role: "user" },
{ username: "zeynep", name: "Zeynep Çakır", role: "user" },
{ username: "fatma", name: "Fatma Demir", email: "fatma@butcem.local", role: "user" },
{ username: "mehmet", name: "Mehmet Demir", email: "mehmet@butcem.local", role: "user" },
// İkinci admin: hiçbir haneye üye değil → hane verisini göremez (Faz 0 kararı).
{ username: "burak", name: "Burak Öztürk", email: "burak@butcem.local", role: "admin" },
{
username: "ozan",
name: "Ozan Kaya",
email: "ozan@butcem.local",
role: "user",
banReason: "Ev arkadaşlığı sona erdi; erişim kaldırıldı.",
},
];
/** Seed admin'in kullanıcı adı (SEED_ADMIN_EMAIL ile oluşturulur). */
export const ADMIN_USERNAME = "admin";
export type SeedHousehold = {
name: string;
members: { username: string; role: HouseholdRole }[];
};
export const SEED_HOUSEHOLDS: SeedHousehold[] = [
{
name: "Çakır Ailesi",
members: [
{ username: ADMIN_USERNAME, role: "owner" },
{ username: "ayse", role: "owner" },
{ username: "emre", role: "member" },
{ username: "zeynep", role: "viewer" },
{ username: "ozan", role: "member" },
],
},
{
name: "Annemler",
members: [
{ username: "fatma", role: "owner" },
{ username: "mehmet", role: "member" },
// Bir kullanıcı birden çok haneye üye olabilir.
{ username: "ayse", role: "viewer" },
],
},
];
/** Örnek hesaplar (tutarlar kuruş; kart borcu negatif). Açılış: 6 ay önce. */
export type SeedAccount = {
household: string;
name: string;
type: AccountType;
institution?: string;
openingBalanceMinor: number;
color: Swatch;
icon: IconKey;
owner?: string;
isPrivate?: boolean;
mealCardProvider?: MealCardProvider;
overdraft?: { limitMinor: number; monthlyRateBp: number; statementDay: number };
creditCard?: {
limitMinor: number;
statementDay: number;
dueDays: number;
paymentAccount: string;
};
};
export const SEED_ACCOUNTS: SeedAccount[] = [
{
household: "Çakır Ailesi",
name: "Cüzdan",
type: "cash",
openingBalanceMinor: 2_350_00,
color: "green",
icon: "wallet",
},
{
household: "Çakır Ailesi",
name: "Ziraat Maaş",
type: "bank",
institution: "Ziraat Bankası",
openingBalanceMinor: 18_420_50,
color: "red",
icon: "landmark",
overdraft: { limitMinor: 10_000_00, monthlyRateBp: 425, statementDay: 1 },
},
{
household: "Çakır Ailesi",
name: "Garanti Bonus",
type: "credit_card",
institution: "Garanti BBVA",
openingBalanceMinor: -6_480_75,
color: "teal",
icon: "credit-card",
creditCard: {
limitMinor: 60_000_00,
statementDay: 29,
dueDays: 10,
paymentAccount: "Ziraat Maaş",
},
},
{
household: "Çakır Ailesi",
name: "Birikim (Vadeli)",
type: "savings",
institution: "Ziraat Bankası",
openingBalanceMinor: 45_000_00,
color: "indigo",
icon: "piggy-bank",
},
{
household: "Çakır Ailesi",
name: "Ayşe — Pluxee",
type: "meal_card",
mealCardProvider: "pluxee",
openingBalanceMinor: 3_120_00,
color: "lime",
icon: "utensils",
owner: "ayse",
isPrivate: true,
},
{
household: "Annemler",
name: "Nakit",
type: "cash",
openingBalanceMinor: 1_500_00,
color: "green",
icon: "wallet",
},
{
household: "Annemler",
name: "Vakıfbank Emekli",
type: "bank",
institution: "VakıfBank",
openingBalanceMinor: 27_800_00,
color: "amber",
icon: "landmark",
},
];
+94
View File
@@ -0,0 +1,94 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getAuth } from "@/lib/auth";
import { authErrorCode } from "@/lib/auth-errors";
import { getDb, getMongoClient } from "@/lib/db/mongo";
import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import { closeSeedConnections, runSeed } from "./run";
import { SEED_HOUSEHOLDS, SEED_USERS } from "./fixtures";
const silent = () => {};
beforeAll(async () => {
expect(getDb().databaseName).toBe("butcem_test");
await getDb().dropDatabase();
});
afterAll(async () => {
await getDb().dropDatabase();
await closeSeedConnections();
await getMongoClient().close();
});
describe("seed", () => {
it("ilk çalıştırmada admin, örnek kullanıcılar, haneler ve üyelikleri oluşturur", async () => {
const summary = await runSeed({ log: silent });
const memberCount = SEED_HOUSEHOLDS.reduce((n, h) => n + h.members.length, 0);
expect(summary.created).toMatchObject({
users: SEED_USERS.length + 1,
households: SEED_HOUSEHOLDS.length,
memberships: memberCount,
});
expect(summary.created.categories).toBeGreaterThan(40);
expect(summary.created.accounts).toBe(7);
expect(summary.created.transactions).toBeGreaterThan(150);
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
});
it("idempotenttir: ikinci çalıştırma kopya oluşturmaz", async () => {
const summary = await runSeed({ log: silent });
expect(summary.created).toEqual({
users: 0,
households: 0,
memberships: 0,
categories: 0,
accounts: 0,
transactions: 0,
});
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
});
it("roller ve askı durumu doğru", async () => {
const users = await getDb()
.collection("user")
.find({}, { projection: { username: 1, role: 1, banned: 1 } })
.toArray();
const by = new Map(users.map((u) => [u.username as string, u]));
expect(by.get("admin")?.role).toBe("admin");
expect(by.get("burak")?.role).toBe("admin");
expect(by.get("ozan")?.banned).toBe(true);
expect(users.filter((u) => u.banned)).toHaveLength(1);
// Bir kullanıcı birden çok hanede, farklı rollerle.
const ayse = by.get("ayse")!;
const roles = await HouseholdMemberModel.find({ userId: ayse._id }).lean();
expect(roles.map((m) => m.role).sort()).toEqual(["owner", "viewer"]);
// Hanesi olmayan admin.
expect(await HouseholdMemberModel.countDocuments({ userId: by.get("burak")!._id })).toBe(0);
});
it("e-postası olmayan kullanıcı kullanıcı adıyla girer; askıdaki kullanıcı giremez", async () => {
const api = getAuth().api;
const emre = await api.signInUsername({
body: { username: "emre", password: "Test-User-12345" },
});
expect(emre.user.name).toBe("Emre Çakır");
const error = await api
.signInUsername({ body: { username: "ozan", password: "Test-User-12345" } })
.then(
() => null,
(e: unknown) => e,
);
expect(authErrorCode(error)).toBe("BANNED_USER");
});
it("reset koleksiyonları temizleyip yeniden doldurur", async () => {
const summary = await runSeed({ reset: true, log: silent });
expect(summary.created.users).toBe(SEED_USERS.length + 1);
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
});
});
+281
View File
@@ -0,0 +1,281 @@
import mongoose, { Types } from "mongoose";
import { getAuth } from "@/lib/auth";
import { getDb } from "@/lib/db/mongo";
import { connectMongoose } from "@/lib/db/mongoose";
import { env } from "@/lib/env";
import { installDefaultCategories } from "@/features/categories/service";
import { AccountModel } from "@/models/account";
import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import { storedEmail } from "@/shared/account-email";
import { runMigrations } from "../migrations";
import { seedTransactions } from "./transactions";
import {
ADMIN_USERNAME,
SEED_ACCOUNTS,
SEED_HOUSEHOLDS,
SEED_USERS,
type SeedUser,
} from "./fixtures";
/**
* İdempotent seed. Kullanıcılar Better Auth API'siyle oluşturulur (şifreler doğru hashlenir);
* tekrar çalıştırınca kopya oluşturmaz (kullanıcı adı / hane adı / üyelik çiftine göre).
* Production'da yalnızca admin oluşturulur; örnek kullanıcı ve hane oluşturulmaz.
*/
export type SeedOptions = { reset?: boolean; log?: (line: string) => void };
export type SeedAccountRow = {
rol: string;
kullaniciAdi: string;
eposta: string;
durum: string;
haneler: string;
};
export type SeedSummary = {
created: {
users: number;
households: number;
memberships: number;
categories: number;
accounts: number;
transactions: number;
};
accounts: SeedAccountRow[];
};
const AUTH_COLLECTIONS = ["user", "account", "session", "verification", "rateLimit"];
const DOMAIN_COLLECTIONS = [
"households",
"householdMembers",
"auditLogs",
"categories",
"accounts",
];
type AuthApi = ReturnType<typeof getAuth>["api"];
async function findUserIdByUsername(username: string): Promise<string | null> {
const doc = await getDb()
.collection("user")
.findOne({ username }, { projection: { _id: 1 } });
return doc ? String(doc._id) : null;
}
/** Admin olarak giriş yapıp oturum başlıklarını döner (ban gibi oturum isteyen admin API'leri için). */
async function adminHeaders(api: AuthApi, password: string): Promise<Headers> {
const { headers } = await api.signInUsername({
body: { username: ADMIN_USERNAME, password },
returnHeaders: true,
});
const cookie = headers
.getSetCookie()
.map((c) => c.split(";")[0])
.join("; ");
return new Headers({ cookie });
}
export async function runSeed({
reset = false,
log = console.log,
}: SeedOptions = {}): Promise<SeedSummary> {
const e = env();
const isProd = e.NODE_ENV === "production";
if (reset && isProd) throw new Error("seed:reset production ortamında çalıştırılamaz.");
if (!e.SEED_ADMIN_EMAIL || !e.SEED_ADMIN_PASSWORD) {
throw new Error("SEED_ADMIN_EMAIL ve SEED_ADMIN_PASSWORD tanımlı olmalı.");
}
if (!isProd && !e.SEED_USER_PASSWORD) {
throw new Error("SEED_USER_PASSWORD tanımlı olmalı (örnek kullanıcıların ortak şifresi).");
}
const api = getAuth().api;
const db = getDb();
await connectMongoose();
const summary: SeedSummary = {
created: {
users: 0,
households: 0,
memberships: 0,
categories: 0,
accounts: 0,
transactions: 0,
},
accounts: [],
};
if (reset) {
for (const name of [...AUTH_COLLECTIONS, ...DOMAIN_COLLECTIONS]) {
await db.collection(name).deleteMany({});
}
log("• Kimlik doğrulama ve hane koleksiyonları temizlendi.");
}
// Index'ler ve şema değişiklikleri (production'da Mongoose autoIndex kapalı).
await runMigrations(log);
// 1) Admin
const adminEmail = e.SEED_ADMIN_EMAIL.toLowerCase();
let adminId = await findUserIdByUsername(ADMIN_USERNAME);
if (!adminId) {
const res = await api.createUser({
body: {
email: adminEmail,
password: e.SEED_ADMIN_PASSWORD,
name: "Yönetici",
role: "admin",
data: { username: ADMIN_USERNAME },
},
});
adminId = res.user.id;
summary.created.users++;
log(`✓ Admin oluşturuldu: ${adminEmail} (kullanıcı adı: ${ADMIN_USERNAME})`);
} else {
log(`• Admin zaten var: ${ADMIN_USERNAME}`);
}
if (isProd) {
log("• Production: örnek kullanıcı ve hane oluşturulmadı.");
return summary;
}
// 2) Örnek kullanıcılar
const ids = new Map<string, string>([[ADMIN_USERNAME, adminId]]);
const toBan: SeedUser[] = [];
for (const u of SEED_USERS) {
let id = await findUserIdByUsername(u.username);
if (!id) {
const res = await api.createUser({
body: {
email: storedEmail(u.username, u.email),
password: e.SEED_USER_PASSWORD!,
name: u.name,
role: u.role,
data: { username: u.username },
},
});
id = res.user.id;
summary.created.users++;
log(`✓ Kullanıcı: ${u.name} (@${u.username})`);
}
ids.set(u.username, id);
if (u.banReason) toBan.push(u);
}
// 3) Askıdaki kullanıcı(lar) — admin API'si oturum ister.
const pendingBans: { user: SeedUser; id: string }[] = [];
for (const u of toBan) {
const id = ids.get(u.username)!;
const doc = await db
.collection("user")
.findOne({ _id: new Types.ObjectId(id) }, { projection: { banned: 1 } });
if (!doc?.banned) pendingBans.push({ user: u, id });
}
if (pendingBans.length) {
const headers = await adminHeaders(api, e.SEED_ADMIN_PASSWORD);
for (const { user, id } of pendingBans) {
await api.banUser({ headers, body: { userId: id, banReason: user.banReason } });
log(`✓ Askıya alındı: @${user.username}`);
}
}
// 4) Haneler ve üyelikler
const memberships = new Map<string, string[]>();
const actor = new Types.ObjectId(adminId);
for (const h of SEED_HOUSEHOLDS) {
let household = await HouseholdModel.findOne({ name: h.name });
if (!household) {
household = await HouseholdModel.create({ name: h.name, createdBy: actor, updatedBy: actor });
summary.created.households++;
log(`✓ Hane: ${h.name}`);
}
for (const m of h.members) {
const userId = new Types.ObjectId(ids.get(m.username)!);
const result = await HouseholdMemberModel.updateOne(
{ householdId: household._id, userId },
{
$setOnInsert: { householdId: household._id, userId, createdBy: actor },
$set: { role: m.role, updatedBy: actor },
},
{ upsert: true },
);
if (result.upsertedCount) summary.created.memberships++;
memberships.set(m.username, [
...(memberships.get(m.username) ?? []),
`${h.name} (${m.role})`,
]);
}
}
// 5) Varsayılan kategoriler ve örnek hesaplar
const householdIds = new Map<string, Types.ObjectId>();
for (const h of SEED_HOUSEHOLDS) {
const doc = await HouseholdModel.findOne({ name: h.name }).select({ _id: 1 }).lean();
householdIds.set(h.name, doc!._id);
summary.created.categories += await installDefaultCategories(doc!._id, actor);
}
const openingDate = new Date(Date.now() - 183 * 24 * 60 * 60 * 1000);
for (const [order, a] of SEED_ACCOUNTS.entries()) {
const householdId = householdIds.get(a.household)!;
if (await AccountModel.exists({ householdId, name: a.name })) continue;
const payer = a.creditCard
? await AccountModel.findOne({ householdId, name: a.creditCard.paymentAccount })
.select({ _id: 1 })
.lean()
: null;
await AccountModel.create({
householdId,
name: a.name,
type: a.type,
institution: a.institution ?? "",
mealCardProvider: a.mealCardProvider ?? null,
openingBalanceMinor: a.openingBalanceMinor,
openingDate,
color: a.color,
icon: a.icon,
order,
ownerUserId: a.owner ? new Types.ObjectId(ids.get(a.owner)!) : null,
isPrivate: a.isPrivate ?? false,
overdraft: a.overdraft ? { enabled: true, ...a.overdraft } : null,
creditCard: a.creditCard
? {
limitMinor: a.creditCard.limitMinor,
statementDay: a.creditCard.statementDay,
dueRule: { kind: "daysAfter", value: a.creditCard.dueDays },
paymentAccountId: payer?._id ?? null,
}
: null,
createdBy: actor,
updatedBy: actor,
});
summary.created.accounts++;
}
if (summary.created.categories) log(`✓ Varsayılan kategoriler: ${summary.created.categories}`);
if (summary.created.accounts) log(`✓ Örnek hesaplar: ${summary.created.accounts}`);
// 6) Son 6 ayın örnek işlemleri (faker.seed(42), idempotent)
const userIdMap = new Map([...ids.entries()]);
summary.created.transactions = await seedTransactions(householdIds, userIdMap);
if (summary.created.transactions) log(`✓ Örnek işlemler: ${summary.created.transactions}`);
// 7) Geliştirme hesapları tablosu
const all = [
{ username: ADMIN_USERNAME, name: "Yönetici", email: adminEmail, role: "admin" as const },
...SEED_USERS,
];
summary.accounts = all.map((u) => ({
rol: u.role,
kullaniciAdi: u.username,
eposta: u.email ?? "—",
durum: "banReason" in u && u.banReason ? "askıda" : "aktif",
haneler: (memberships.get(u.username) ?? ["—"]).join(", "),
}));
return summary;
}
/** Seed sonrası bağlantıları kapatır (CLI ve testler). */
export async function closeSeedConnections() {
await mongoose.disconnect();
}
+496
View File
@@ -0,0 +1,496 @@
import { faker } from "@faker-js/faker";
import { Types } from "mongoose";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { PayeeModel } from "@/models/payee";
import { TransactionModel } from "@/models/transaction";
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
import { allocate } from "@/shared/money";
import { foldTr } from "@/shared/search";
import type { TransactionType } from "@/shared/transactions";
/**
* Son 6 ayın gerçekçi işlemleri (faker.seed(42) ile tekrarlanabilir).
* Her işlemin sabit `seedRef`'i vardır: tekrar çalıştırmada kopya oluşmaz.
*/
type Ctx = {
householdId: Types.ObjectId;
account: (name: string) => Types.ObjectId;
category: (key: string) => Types.ObjectId;
user: (username: string) => Types.ObjectId;
};
type SeedTx = {
ref: string;
type: TransactionType;
ymd: string;
amountMinor: number;
account: string;
toAccount?: string;
category?: string;
splits?: { category: string; weight: number; note?: string }[];
payee?: string;
description?: string;
tags?: string[];
by: string;
};
const tl = (min: number, max: number) => faker.number.int({ min: min * 100, max: max * 100 });
const pick = <T>(xs: readonly T[]) => faker.helpers.arrayElement(xs);
/** Ayın gün sayısına göre güvenli gün (31 → ayın son günü). */
function ymdOf(year: number, month: number, day: number) {
const last = new Date(Date.UTC(year, month, 0)).getUTCDate();
return `${year}-${String(month).padStart(2, "0")}-${String(Math.min(day, last)).padStart(2, "0")}`;
}
function months(count: number): { y: number; m: number; key: string }[] {
const [ty, tm] = todayIstanbul().split("-").map(Number) as [number, number];
return Array.from({ length: count }, (_, i) => {
const d = new Date(Date.UTC(ty, tm - 1 - (count - 1 - i), 1));
const y = d.getUTCFullYear();
const m = d.getUTCMonth() + 1;
return { y, m, key: `${y}-${String(m).padStart(2, "0")}` };
});
}
function cakirAiles(): SeedTx[] {
const out: SeedTx[] = [];
const marketPayees = ["Migros", "BİM", "A101", "Şok", "CarrefourSA"] as const;
for (const { y, m, key } of months(6)) {
const winter = [11, 12, 1, 2, 3].includes(m);
const d = (day: number) => ymdOf(y, m, day);
const add = (x: Omit<SeedTx, "ref">, n = 0) =>
out.push({ ...x, ref: `cakir:${key}:${x.payee ?? x.category ?? x.type}:${n}` });
add({
type: "income",
ymd: d(15),
amountMinor: 52_000_00,
account: "Ziraat Maaş",
category: "maas",
payee: "Anadolu Yazılım A.Ş.",
by: "admin",
});
add({
type: "income",
ymd: d(1),
amountMinor: 4_500_00,
account: "Ayşe — Pluxee",
category: "yemek-karti",
payee: "Pluxee",
by: "ayse",
});
add({
type: "expense",
ymd: d(3),
amountMinor: 17_500_00,
account: "Ziraat Maaş",
category: "kira",
payee: "Ev sahibi",
description: "Kira",
by: "admin",
});
add({
type: "expense",
ymd: d(5),
amountMinor: 1_250_00,
account: "Ziraat Maaş",
category: "aidat",
payee: "Site yönetimi",
by: "admin",
});
add({
type: "expense",
ymd: d(10),
amountMinor: tl(750, 1_600),
account: "Ziraat Maaş",
category: "faturalar.elektrik",
payee: "Enerjisa",
by: "ayse",
});
add({
type: "expense",
ymd: d(12),
amountMinor: tl(300, 620),
account: "Ziraat Maaş",
category: "faturalar.su",
payee: "İSKİ",
by: "ayse",
});
add({
type: "expense",
ymd: d(14),
amountMinor: winter ? tl(1_800, 3_400) : tl(350, 700),
account: "Ziraat Maaş",
category: "faturalar.dogalgaz",
payee: "İGDAŞ",
by: "ayse",
});
add({
type: "expense",
ymd: d(20),
amountMinor: 499_90,
account: "Garanti Bonus",
category: "faturalar.internet",
payee: "Türk Telekom",
by: "admin",
});
add({
type: "expense",
ymd: d(22),
amountMinor: 714_00,
account: "Garanti Bonus",
category: "faturalar.telefon",
payee: "Turkcell",
description: "2 hat",
by: "admin",
});
add({
type: "expense",
ymd: d(8),
amountMinor: 229_99,
account: "Garanti Bonus",
category: "abonelikler",
payee: "Netflix",
by: "admin",
});
add({
type: "expense",
ymd: d(9),
amountMinor: 99_99,
account: "Garanti Bonus",
category: "abonelikler",
payee: "Spotify",
by: "emre",
});
// Transferler: nakit çekme, kart ödemesi, birikim.
add({
type: "transfer",
ymd: d(2),
amountMinor: 2_000_00,
account: "Ziraat Maaş",
toAccount: "Cüzdan",
description: "ATM",
by: "admin",
});
add({
type: "transfer",
ymd: d(9),
amountMinor: tl(11_000, 15_000),
account: "Ziraat Maaş",
toAccount: "Garanti Bonus",
description: "Kart borcu ödemesi",
by: "admin",
});
add({
type: "transfer",
ymd: d(16),
amountMinor: 5_000_00,
account: "Ziraat Maaş",
toAccount: "Birikim (Vadeli)",
description: "Aylık birikim",
by: "ayse",
});
// Market: haftada 1–2; bir kısmı bölünmüş.
const marketCount = faker.number.int({ min: 6, max: 8 });
for (let i = 0; i < marketCount; i++) {
const payee = pick(marketPayees);
const amountMinor = tl(280, 2_600);
const split = amountMinor > 1_200_00 && faker.datatype.boolean({ probability: 0.5 });
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor,
account: pick(["Garanti Bonus", "Garanti Bonus", "Cüzdan"]),
...(split
? {
splits: [
{ category: "market.gida", weight: faker.number.int({ min: 55, max: 75 }) },
{
category: "market.temizlik",
weight: faker.number.int({ min: 10, max: 25 }),
note: "Deterjan",
},
{
category: "market.kisisel-bakim",
weight: faker.number.int({ min: 5, max: 15 }),
},
],
}
: { category: "market.gida" }),
payee,
tags: faker.datatype.boolean({ probability: 0.15 }) ? ["indirim"] : [],
by: pick(["ayse", "ayse", "admin", "emre"]),
},
i,
);
}
for (let i = 0; i < faker.number.int({ min: 2, max: 3 }); i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(1_200, 2_300),
account: "Garanti Bonus",
category: "ulasim.yakit",
payee: pick(["Shell", "Opet", "BP"]),
by: "admin",
},
i,
);
}
for (let i = 0; i < faker.number.int({ min: 4, max: 6 }); i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(110, 340),
account: "Ayşe — Pluxee",
category: "eglence.kafe",
payee: pick(["Starbucks", "Kahve Dünyası", "Simit Sarayı"]),
by: "ayse",
},
i,
);
}
for (let i = 0; i < faker.number.int({ min: 2, max: 4 }); i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(450, 1_600),
account: pick(["Garanti Bonus", "Ayşe — Pluxee"]),
category: "eglence.disarida-yemek",
payee: pick(["Köfteci Yusuf", "Nusr-Et", "Big Chefs", "Yemeksepeti"]),
by: pick(["ayse", "admin"]),
},
i,
);
}
for (let i = 0; i < 4; i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(60, 220),
account: "Cüzdan",
category: "ulasim.toplu-tasima",
payee: "İstanbulkart",
by: "emre",
},
i,
);
}
if (faker.datatype.boolean({ probability: 0.6 })) {
add({
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(140, 650),
account: "Cüzdan",
category: "saglik.ilac",
payee: "Eczane",
by: "ayse",
});
}
if (faker.datatype.boolean({ probability: 0.5 })) {
add({
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(600, 3_200),
account: "Garanti Bonus",
category: "giyim",
payee: pick(["LC Waikiki", "Trendyol", "Koton"]),
by: "ayse",
});
}
}
return out;
}
function annemler(): SeedTx[] {
const out: SeedTx[] = [];
for (const { y, m, key } of months(6)) {
const winter = [11, 12, 1, 2, 3].includes(m);
const d = (day: number) => ymdOf(y, m, day);
const add = (x: Omit<SeedTx, "ref">, n = 0) =>
out.push({ ...x, ref: `annemler:${key}:${x.payee ?? x.category ?? x.type}:${n}` });
add({
type: "income",
ymd: d(17),
amountMinor: 24_350_00,
account: "Vakıfbank Emekli",
category: "maas",
payee: "SGK",
description: "Emekli maaşı",
by: "fatma",
});
add({
type: "expense",
ymd: d(11),
amountMinor: tl(450, 900),
account: "Vakıfbank Emekli",
category: "faturalar.elektrik",
payee: "Enerjisa",
by: "mehmet",
});
add({
type: "expense",
ymd: d(13),
amountMinor: winter ? tl(1_400, 2_600) : tl(250, 500),
account: "Vakıfbank Emekli",
category: "faturalar.dogalgaz",
payee: "İGDAŞ",
by: "mehmet",
});
add({
type: "transfer",
ymd: d(18),
amountMinor: 4_000_00,
account: "Vakıfbank Emekli",
toAccount: "Nakit",
description: "ATM",
by: "fatma",
});
for (let i = 0; i < 5; i++) {
add(
{
type: "expense",
ymd: d(faker.number.int({ min: 1, max: 28 })),
amountMinor: tl(200, 1_100),
account: pick(["Nakit", "Vakıfbank Emekli"]),
category: "market.gida",
payee: pick(["BİM", "Şok", "Pazar"]),
by: pick(["fatma", "mehmet"]),
},
i,
);
}
}
return out;
}
export async function seedTransactions(
householdIds: Map<string, Types.ObjectId>,
userIds: Map<string, string>,
): Promise<number> {
faker.seed(42);
let inserted = 0;
const plan: [string, SeedTx[]][] = [
["Çakır Ailesi", cakirAiles()],
["Annemler", annemler()],
];
const today = todayIstanbul();
for (const [householdName, txs] of plan) {
const householdId = householdIds.get(householdName)!;
const [accounts, categories] = await Promise.all([
AccountModel.find({ householdId }).select({ name: 1 }).lean(),
CategoryModel.find({ householdId, defaultKey: { $ne: null } })
.select({ defaultKey: 1 })
.lean(),
]);
const ctx: Ctx = {
householdId,
account: (name) => accounts.find((a) => a.name === name)!._id,
category: (key) => categories.find((c) => c.defaultKey === key)!._id,
user: (username) => new Types.ObjectId(userIds.get(username)!),
};
const payeeStats = new Map<
string,
{
name: string;
type: TransactionType;
account: Types.ObjectId;
category: Types.ObjectId | null;
count: number;
last: Date;
}
>();
for (const x of txs) {
if (x.ymd > today) continue; // Gelecek tarihli seed işlemi yok.
const date = istanbulDayStart(x.ymd)!;
const splits = x.splits
? allocate(
x.amountMinor,
x.splits.map((s) => s.weight),
).map((amountMinor, i) => ({
categoryId: ctx.category(x.splits![i]!.category),
amountMinor,
note: x.splits![i]!.note ?? "",
}))
: [];
const categoryId = x.category ? ctx.category(x.category) : null;
const accountId = ctx.account(x.account);
const by = ctx.user(x.by);
const doc = {
householdId,
type: x.type,
status: "posted",
date,
accountId,
toAccountId: x.toAccount ? ctx.account(x.toAccount) : null,
amountMinor: x.amountMinor,
categoryId: splits.length ? null : categoryId,
splits,
effectiveCategoryIds: splits.length
? [...new Set(splits.map((s) => String(s.categoryId)))].map(
(id) => new Types.ObjectId(id),
)
: categoryId
? [categoryId]
: [],
payeeName: x.payee ?? "",
description: x.description ?? "",
searchText: foldTr(
[x.payee ?? "", x.description ?? "", ...splits.map((s) => s.note)].join(" "),
),
tags: x.tags ?? [],
seedRef: x.ref,
createdBy: by,
updatedBy: by,
};
const res = await TransactionModel.updateOne(
{ householdId, seedRef: x.ref },
{ $setOnInsert: doc },
{ upsert: true },
);
if (res.upsertedCount) inserted++;
if (x.payee) {
const key = foldTr(x.payee);
const prev = payeeStats.get(key);
const latest = !prev || date >= prev.last;
payeeStats.set(key, {
name: x.payee,
type: latest ? x.type : prev!.type,
account: latest ? accountId : prev!.account,
category: latest ? (splits.length ? null : categoryId) : prev!.category,
count: (prev?.count ?? 0) + 1,
last: latest ? date : prev!.last,
});
}
}
for (const [nameKey, p] of payeeStats) {
await PayeeModel.updateOne(
{ householdId, nameKey },
{
$setOnInsert: { householdId, nameKey, name: p.name },
$set: {
lastType: p.type,
lastAccountId: p.account,
lastCategoryId: p.category,
useCount: p.count,
lastUsedAt: p.last,
},
},
{ upsert: true },
);
}
}
return inserted;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/page-header";
import { HouseholdsAdmin } from "@/features/admin-households/components/households-admin";
import { getAdminHouseholdsData } from "@/features/admin-households/queries";
import { tr } from "@/i18n/tr";
import { requireRole } from "@/lib/dal";
export const metadata: Metadata = { title: tr.adminHouseholds.title };
export default async function AdminHouseholdsPage() {
const [admin, data] = await Promise.all([requireRole("admin"), getAdminHouseholdsData()]);
return (
<>
<PageHeader title={tr.adminHouseholds.title} description={tr.adminHouseholds.description} />
<HouseholdsAdmin households={data.households} users={data.users} currentUserId={admin.id} />
</>
);
}
+5 -5
View File
@@ -1,18 +1,18 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { UsersTable } from "@/features/admin-users/components/users-table"; import { UsersTable } from "@/features/admin-users/components/users-table";
import { MOCK_ADMIN_USERS } from "@/features/admin-users/mock"; import { listAdminUsers } from "@/features/admin-users/queries";
import { MOCK_SESSION_USER } from "@/features/auth/mock";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { requireRole } from "@/lib/dal";
export const metadata: Metadata = { title: tr.adminUsers.title }; export const metadata: Metadata = { title: tr.adminUsers.title };
export default function AdminUsersPage() { export default async function AdminUsersPage() {
// FAZ 3: const users = await listUsers() (Better Auth admin API) const [admin, users] = await Promise.all([requireRole("admin"), listAdminUsers()]);
return ( return (
<> <>
<PageHeader title={tr.adminUsers.title} description={tr.adminUsers.description} /> <PageHeader title={tr.adminUsers.title} description={tr.adminUsers.description} />
<UsersTable initialUsers={MOCK_ADMIN_USERS} currentUserId={MOCK_SESSION_USER.id} /> <UsersTable users={users} currentUserId={admin.id} />
</> </>
); );
} }
+4 -8
View File
@@ -1,11 +1,7 @@
import { notFound } from "next/navigation"; import { requireRole } from "@/lib/dal";
import { MOCK_SESSION_USER } from "@/features/auth/mock";
/** /** Yalnızca sistem admini; diğerlerine 404. Sayfalar ayrıca kendi kontrolünü yapar. */
* Yalnızca sistem admini. Admin olmayana 404 (sayfanın varlığını bile göstermez). export default async function AdminLayout({ children }: { children: React.ReactNode }) {
* FAZ 3: `await requireRole("admin")` (lib/dal.ts) — sunucu tarafı gerçek kontrol. await requireRole("admin");
*/
export default function AdminLayout({ children }: { children: React.ReactNode }) {
if (MOCK_SESSION_USER.role !== "admin") notFound();
return children; return children;
} }
+50
View File
@@ -0,0 +1,50 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { HouseholdSettingsForm } from "@/features/households/components/household-settings-form";
import { SettingsMembers } from "@/features/households/components/settings-members";
import { getHouseholdDetail } from "@/features/households/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.settings.title };
export default async function SettingsPage() {
const { user, household } = await requireActiveHousehold("viewer");
const detail = await getHouseholdDetail(user.id, household.id);
if (!detail) notFound();
const isOwner = household.role === "owner";
const t = tr.settings;
return (
<>
<PageHeader title={t.title} description={t.description} />
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
<Card>
<CardHeader>
<CardTitle>{t.household}</CardTitle>
<CardDescription>{isOwner ? t.householdDescription : t.ownerOnly}</CardDescription>
</CardHeader>
<CardContent>
<HouseholdSettingsForm settings={detail.settings} canEdit={isOwner} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t.members}</CardTitle>
<CardDescription>{t.membersDescription}</CardDescription>
</CardHeader>
<CardContent>
<SettingsMembers
members={detail.members}
householdName={detail.name}
currentUserId={user.id}
canManage={isOwner}
/>
</CardContent>
</Card>
</div>
</>
);
}
+4 -4
View File
@@ -2,9 +2,9 @@ import type { Metadata } from "next";
import { Suspense } from "react"; import { Suspense } from "react";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { MOCK_SESSION_USER } from "@/features/auth/mock";
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format"; import { formatMoney } from "@/shared/format";
@@ -51,13 +51,13 @@ async function SummaryCards() {
); );
} }
export default function DashboardPage() { export default async function DashboardPage() {
const user = MOCK_SESSION_USER; const { user, household } = await requireActiveHousehold("viewer");
return ( return (
<> <>
<PageHeader <PageHeader
title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)} title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)}
description={tr.dashboard.welcomeDescription} description={`${household.name} · ${tr.dashboard.welcomeDescription}`}
/> />
<Suspense fallback={<SummaryCardsSkeleton />}> <Suspense fallback={<SummaryCardsSkeleton />}>
<SummaryCards /> <SummaryCards />
+39
View File
@@ -0,0 +1,39 @@
import { House } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { tr } from "@/i18n/tr";
import { getHouseholdContext } from "@/lib/dal";
export const metadata: Metadata = { title: tr.households.noHousehold.title };
/** Hiçbir haneye üye olmayan kullanıcılar (ör. hanesi olmayan admin) buraya yönlenir. */
export default async function NoHouseholdPage() {
const ctx = await getHouseholdContext();
if (!ctx) redirect("/login");
if (ctx.household) redirect("/dashboard");
const t = tr.households.noHousehold;
return (
<div className="flex flex-1 items-center justify-center">
<Card className="max-w-md">
<CardHeader>
<span className="mb-2 flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<House className="size-6" />
</span>
<CardTitle className="text-xl">{t.title}</CardTitle>
<CardDescription>{t.description}</CardDescription>
</CardHeader>
{ctx.user.role === "admin" ? (
<CardContent className="flex flex-col items-start gap-3">
<p className="text-sm text-muted-foreground">{t.adminHint}</p>
<Button asChild>
<Link href="/admin/haneler">{t.adminLink}</Link>
</Button>
</CardContent>
) : null}
</Card>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { AccountsView } from "@/features/accounts/components/accounts-view";
import { listAccounts, listMemberOptions } from "@/features/accounts/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.accounts.title };
export default async function AccountsPage() {
const { user, household } = await requireActiveHousehold("viewer");
const [data, members] = await Promise.all([
listAccounts(user.id, household.id, { includeArchived: true }),
listMemberOptions(user.id, household.id),
]);
if (!data || !members) notFound();
return (
<>
<PageHeader title={tr.accounts.title} description={tr.accounts.description} />
<AccountsView
accounts={data.accounts}
totals={data.totals}
members={members}
canEdit={household.role === "owner"}
/>
</>
);
}
@@ -0,0 +1,44 @@
import { exportTransactions } from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import { getHouseholdContext } from "@/lib/dal";
import { csvMoney, toCsv } from "@/shared/csv";
import { todayIstanbul } from "@/shared/dates";
import { formatDate } from "@/shared/format";
import { transactionFilterSchema } from "@/shared/schemas/transaction";
/** GET /islemler/disa-aktar?…filtreler → CSV (listedeki filtrelerle, sayfalama yok). */
export async function GET(request: Request) {
const ctx = await getHouseholdContext();
if (!ctx?.household) return new Response(tr.common.unauthorized, { status: 401 });
const params = Object.fromEntries(new URL(request.url).searchParams);
const filters = transactionFilterSchema.parse(params);
const rows = await exportTransactions(ctx.user.id, ctx.household.id, filters);
if (!rows) return new Response(tr.common.unauthorized, { status: 403 });
const t = tr.transactions;
const csv = toCsv(
t.csv.header,
rows.map((r) => [
r.no,
formatDate(`${r.date}T12:00:00+03:00`),
t.types[r.type],
t.statuses[r.status],
r.account,
r.toAccount,
r.category,
r.payee,
r.description,
r.note,
r.tags,
csvMoney(r.amountMinor),
r.createdBy,
]),
);
return new Response(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${t.csv.filename(todayIstanbul())}"`,
"Cache-Control": "no-store",
},
});
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { listMemberOptions } from "@/features/accounts/service";
import { hasHouseholdRole } from "@/features/households/roles";
import { TransactionsView } from "@/features/transactions/components/transactions-view";
import { getTransactionFormOptions, listTransactions } from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { transactionFilterSchema } from "@/shared/schemas/transaction";
export const metadata: Metadata = { title: tr.transactions.title };
export default async function TransactionsPage({ searchParams }: PageProps<"/islemler">) {
const { user, household } = await requireActiveHousehold("viewer");
const params = await searchParams;
// Yalnızca tekil string değerler (dizi parametreleri yok sayılır).
const raw = Object.fromEntries(
Object.entries(params).filter(
(e): e is [string, string] => typeof e[1] === "string" && e[1] !== "",
),
);
const filters = transactionFilterSchema.parse(raw);
const [data, options, members] = await Promise.all([
listTransactions(user.id, household.id, filters),
getTransactionFormOptions(user.id, household.id),
listMemberOptions(user.id, household.id),
]);
if (!data || !options || !members) notFound();
return (
<>
<PageHeader title={tr.transactions.title} description={tr.transactions.description} />
<TransactionsView
// Filtre değişince istemci durumu (açık diyaloglar) sıfırlansın.
key={JSON.stringify(raw)}
data={data}
filters={filters}
raw={raw}
options={{ ...options, members }}
canWrite={hasHouseholdRole(household.role, "member")}
/>
</>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { CategoriesManager } from "@/features/categories/components/categories-manager";
import { listCategories } from "@/features/categories/service";
import { hasHouseholdRole } from "@/features/households/roles";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.categories.title };
export default async function CategoriesPage() {
const { user, household } = await requireActiveHousehold("viewer");
const categories = await listCategories(user.id, household.id, { includeArchived: true });
if (!categories) notFound();
return (
<>
<PageHeader title={tr.categories.title} description={tr.categories.description} />
<CategoriesManager
categories={categories}
canEdit={hasHouseholdRole(household.role, "member")}
/>
</>
);
}
+28 -7
View File
@@ -1,28 +1,49 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { AppHeader } from "@/components/layout/app-header"; import { AppHeader } from "@/components/layout/app-header";
import { AppShellFallback } from "@/components/layout/app-shell-fallback";
import { AppSidebar } from "@/components/layout/app-sidebar"; import { AppSidebar } from "@/components/layout/app-sidebar";
import { BottomNav } from "@/components/layout/bottom-nav"; import { BottomNav } from "@/components/layout/bottom-nav";
import { QuickAddProvider } from "@/components/layout/quick-add"; import { QuickAddProvider } from "@/components/layout/quick-add";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { MOCK_SESSION_USER } from "@/features/auth/mock"; import { hasHouseholdRole } from "@/features/households/roles";
import { MOCK_HOUSEHOLDS } from "@/features/households/mock"; import { getHouseholdContext } from "@/lib/dal";
import { APP_VERSION } from "@/lib/version"; import { APP_VERSION } from "@/lib/version";
/** /**
* Oturum gerektiren alan: yan menü (masaüstü), üst bar, alt menü (mobil), hızlı ekle. * Oturum gerektiren alan. Oturum okuması istek anında yapılır (Cache Components), bu yüzden
* FAZ 3: `const session = await requireSession()` — oturum yoksa /login. * kullanıcıya bağlı kabuk Suspense içinde render edilir. Her sayfa ayrıca kendi yetkisini
* DAL üzerinden kontrol eder (layout istemci gezinmesinde yeniden çalışmaz).
*/ */
export default function AppLayout({ children }: { children: React.ReactNode }) { export default function AppLayout({ children }: { children: React.ReactNode }) {
const user = MOCK_SESSION_USER; return (
<Suspense fallback={<AppShellFallback />}>
<AuthenticatedShell>{children}</AuthenticatedShell>
</Suspense>
);
}
async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
const ctx = await getHouseholdContext();
if (!ctx) redirect("/login");
const { user, household, memberships } = ctx;
const canWrite = household ? hasHouseholdRole(household.role, "member") : false;
return ( return (
<SidebarProvider> <SidebarProvider>
<QuickAddProvider> <QuickAddProvider>
<AppSidebar isAdmin={user.role === "admin"} version={APP_VERSION} /> <AppSidebar isAdmin={user.role === "admin"} version={APP_VERSION} />
<SidebarInset> <SidebarInset>
<AppHeader user={user} households={MOCK_HOUSEHOLDS} version={APP_VERSION} /> <AppHeader
user={user}
households={memberships}
activeHouseholdId={household?.id}
canWrite={canWrite}
version={APP_VERSION}
/>
{/* Mobilde alt menü yüksekliği kadar boşluk */} {/* Mobilde alt menü yüksekliği kadar boşluk */}
<div className="flex flex-1 flex-col gap-4 p-4 pb-24 md:p-6 md:pb-6">{children}</div> <div className="flex flex-1 flex-col gap-4 p-4 pb-24 md:p-6 md:pb-6">{children}</div>
</SidebarInset> </SidebarInset>
<BottomNav /> <BottomNav canWrite={canWrite} />
</QuickAddProvider> </QuickAddProvider>
</SidebarProvider> </SidebarProvider>
); );
+17 -3
View File
@@ -10,6 +10,8 @@ import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { authClient } from "@/lib/auth-client";
import { looksLikeEmail, normalizeUsername } from "@/shared/schemas/common";
import { loginSchema, type LoginInput } from "@/shared/schemas/auth"; import { loginSchema, type LoginInput } from "@/shared/schemas/auth";
export function LoginForm() { export function LoginForm() {
@@ -20,11 +22,23 @@ export function LoginForm() {
defaultValues: { identifier: "", password: "" }, defaultValues: { identifier: "", password: "" },
}); });
async function onSubmit(_values: LoginInput) { async function onSubmit({ identifier, password }: LoginInput) {
setFormError(null); setFormError(null);
// FAZ 3: looksLikeEmail(identifier) ? authClient.signIn.email : authClient.signIn.username const value = identifier.trim();
await new Promise((r) => setTimeout(r, 400)); const { error } = looksLikeEmail(value)
? await authClient.signIn.email({ email: value.toLowerCase(), password })
: await authClient.signIn.username({ username: normalizeUsername(value), password });
if (error) {
// Hangi bilginin yanlış olduğu söylenmez (hesap tahmini zorlaşır).
if (error.status === 429) setFormError(tr.auth.tooManyAttempts);
else if (error.code === "BANNED_USER") setFormError(tr.auth.banned);
else setFormError(tr.auth.invalidCredentials);
form.setValue("password", "");
return;
}
router.replace("/dashboard"); router.replace("/dashboard");
router.refresh();
} }
const submitting = form.formState.isSubmitting; const submitting = form.formState.isSubmitting;
+13
View File
@@ -1,14 +1,27 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { Logo } from "@/components/brand/logo"; import { Logo } from "@/components/brand/logo";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { getCurrentUser } from "@/lib/dal";
import { LoginForm } from "./login-form"; import { LoginForm } from "./login-form";
export const metadata: Metadata = { title: tr.auth.loginTitle }; export const metadata: Metadata = { title: tr.auth.loginTitle };
/** Oturum açıksa dashboard'a yönlendirir (istek anında, Suspense içinde). */
async function RedirectIfSignedIn() {
const user = await getCurrentUser();
if (user) redirect("/dashboard");
return null;
}
export default function LoginPage() { export default function LoginPage() {
return ( return (
<main className="flex w-full max-w-sm flex-col items-center gap-6"> <main className="flex w-full max-w-sm flex-col items-center gap-6">
<Suspense fallback={null}>
<RedirectIfSignedIn />
</Suspense>
<Logo priority className="scale-125" /> <Logo priority className="scale-125" />
<Card className="w-full"> <Card className="w-full">
<CardHeader> <CardHeader>
+11
View File
@@ -0,0 +1,11 @@
import { toNextJsHandler } from "better-auth/next-js";
import { getAuth } from "@/lib/auth";
/** Better Auth uç noktaları (/api/auth/*). Kayıt (sign-up) yapılandırmada kapalıdır. */
export function GET(request: Request) {
return toNextJsHandler(getAuth()).GET(request);
}
export function POST(request: Request) {
return toNextJsHandler(getAuth()).POST(request);
}
+7 -4
View File
@@ -1,20 +1,23 @@
import { connection } from "next/server"; import { connection } from "next/server";
import { pingDb } from "@/lib/db/mongo";
import { APP_COMMIT, APP_VERSION } from "@/lib/version"; import { APP_COMMIT, APP_VERSION } from "@/lib/version";
/** /**
* Sağlık kontrolü (Dokploy / Docker HEALTHCHECK). * Sağlık kontrolü (Dokploy / Docker HEALTHCHECK). Veritabanına erişilemezse 503.
* Faz 3'te veritabanı ping'i eklenecek; DB erişilemezse 503 dönecek. * İç hata detayı döndürülmez.
*/ */
export async function GET() { export async function GET() {
// Cache Components açıkken GET handler'ı build'de prerender edilmesin. // Cache Components açıkken GET handler'ı build'de prerender edilmesin.
await connection(); await connection();
const dbOk = await pingDb();
return Response.json( return Response.json(
{ {
status: "ok", status: dbOk ? "ok" : "degraded",
db: dbOk ? "ok" : "unreachable",
version: APP_VERSION, version: APP_VERSION,
commit: APP_COMMIT, commit: APP_COMMIT,
time: new Date().toISOString(), time: new Date().toISOString(),
}, },
{ headers: { "Cache-Control": "no-store" } }, { status: dbOk ? 200 : 503, headers: { "Cache-Control": "no-store" } },
); );
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

+29
View File
@@ -96,6 +96,21 @@
--chart-4: var(--warning); --chart-4: var(--warning);
--chart-5: oklch(0.65 0.08 230); --chart-5: oklch(0.65 0.08 230);
--radius: 0.625rem; --radius: 0.625rem;
/* Kullanıcı verisi renkleri (kategori/hesap): anahtar → değişken (shared/appearance.ts) */
--swatch-blue: oklch(0.6 0.15 258);
--swatch-sky: oklch(0.6 0.15 230);
--swatch-teal: oklch(0.6 0.15 185);
--swatch-green: oklch(0.6 0.15 150);
--swatch-lime: oklch(0.6 0.15 125);
--swatch-amber: oklch(0.6 0.15 80);
--swatch-orange: oklch(0.6 0.15 55);
--swatch-red: oklch(0.6 0.15 25);
--swatch-rose: oklch(0.6 0.15 10);
--swatch-pink: oklch(0.6 0.15 350);
--swatch-purple: oklch(0.6 0.15 305);
--swatch-indigo: oklch(0.6 0.15 275);
--swatch-slate: oklch(0.55 0.03 255);
--sidebar: oklch(0.975 0.008 250); --sidebar: oklch(0.975 0.008 250);
--sidebar-foreground: oklch(0.22 0.04 262); --sidebar-foreground: oklch(0.22 0.04 262);
--sidebar-primary: oklch(0.49 0.17 258); --sidebar-primary: oklch(0.49 0.17 258);
@@ -140,6 +155,20 @@
--chart-3: var(--expense); --chart-3: var(--expense);
--chart-4: var(--warning); --chart-4: var(--warning);
--chart-5: oklch(0.7 0.07 230); --chart-5: oklch(0.7 0.07 230);
--swatch-blue: oklch(0.74 0.13 258);
--swatch-sky: oklch(0.74 0.13 230);
--swatch-teal: oklch(0.74 0.13 185);
--swatch-green: oklch(0.74 0.13 150);
--swatch-lime: oklch(0.74 0.13 125);
--swatch-amber: oklch(0.74 0.13 80);
--swatch-orange: oklch(0.74 0.13 55);
--swatch-red: oklch(0.74 0.13 25);
--swatch-rose: oklch(0.74 0.13 10);
--swatch-pink: oklch(0.74 0.13 350);
--swatch-purple: oklch(0.74 0.13 305);
--swatch-indigo: oklch(0.74 0.13 275);
--swatch-slate: oklch(0.72 0.03 255);
--sidebar: oklch(0.2 0.045 264); --sidebar: oklch(0.2 0.045 264);
--sidebar-foreground: oklch(0.96 0.01 250); --sidebar-foreground: oklch(0.96 0.01 250);
--sidebar-primary: oklch(0.76 0.12 245); --sidebar-primary: oklch(0.76 0.12 245);
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
/** Uygulama her zaman login ile açılır. FAZ 3: oturum varsa /dashboard'a yönlenecek. */ /** Oturum yoksa proxy zaten /login'e yönlendirir; varsa dashboard. */
export default function Home() { export default function Home() {
redirect("/login"); redirect("/dashboard");
} }
+108
View File
@@ -0,0 +1,108 @@
import {
Baby,
Banknote,
Book,
Briefcase,
Building,
Bus,
Car,
CircleEllipsis,
Coffee,
Coins,
CreditCard,
Droplet,
Dumbbell,
Film,
Flame,
Fuel,
Gamepad2,
Gift,
GraduationCap,
HeartPulse,
House,
Landmark,
Music,
PawPrint,
Percent,
PiggyBank,
Pill,
Plane,
Receipt,
Repeat,
Scissors,
Shirt,
ShoppingBasket,
ShoppingCart,
Smartphone,
Sofa,
Sparkles,
SprayCan,
Tag,
Train,
TrendingUp,
Undo2,
Utensils,
Wallet,
Wifi,
Wrench,
Zap,
type LucideIcon,
type LucideProps,
} from "lucide-react";
import type { IconKey } from "@/shared/appearance";
/** Saklanan ikon anahtarı → lucide bileşeni (shared/appearance.ts ICON_KEYS ile birebir). */
export const ICONS: Record<IconKey, LucideIcon> = {
wallet: Wallet,
banknote: Banknote,
landmark: Landmark,
"piggy-bank": PiggyBank,
"credit-card": CreditCard,
utensils: Utensils,
"shopping-cart": ShoppingCart,
"shopping-basket": ShoppingBasket,
receipt: Receipt,
zap: Zap,
droplet: Droplet,
flame: Flame,
wifi: Wifi,
smartphone: Smartphone,
house: House,
building: Building,
car: Car,
fuel: Fuel,
bus: Bus,
train: Train,
"heart-pulse": HeartPulse,
pill: Pill,
"graduation-cap": GraduationCap,
book: Book,
shirt: Shirt,
gamepad: Gamepad2,
film: Film,
music: Music,
coffee: Coffee,
plane: Plane,
gift: Gift,
baby: Baby,
"paw-print": PawPrint,
wrench: Wrench,
sofa: Sofa,
repeat: Repeat,
briefcase: Briefcase,
"trending-up": TrendingUp,
coins: Coins,
percent: Percent,
undo: Undo2,
sparkles: Sparkles,
"spray-can": SprayCan,
scissors: Scissors,
dumbbell: Dumbbell,
tag: Tag,
"circle-ellipsis": CircleEllipsis,
};
export function AppIcon({ name, ...props }: { name: IconKey } & LucideProps) {
const Icon = ICONS[name] ?? Tag;
return <Icon aria-hidden {...props} />;
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { ICON_KEYS, SWATCHES, type IconKey, type Swatch } from "@/shared/appearance";
import { AppIcon } from "./app-icon";
import { swatchStyle } from "./swatch";
import { tr } from "@/i18n/tr";
/** Renk seçici (radio grubu, klavye ile gezilebilir). */
export function SwatchPicker({
value,
onChange,
labelledBy,
}: {
value: Swatch;
onChange: (v: Swatch) => void;
labelledBy: string;
}) {
return (
<div role="radiogroup" aria-labelledby={labelledBy} className="flex flex-wrap gap-2">
{SWATCHES.map((s) => (
<button
key={s}
type="button"
role="radio"
aria-checked={value === s}
aria-label={tr.swatches[s]}
title={tr.swatches[s]}
onClick={() => onChange(s)}
style={swatchStyle(s)}
className="flex size-8 items-center justify-center rounded-full bg-(--swatch) text-background ring-offset-2 ring-offset-background outline-none focus-visible:ring-3 focus-visible:ring-ring/50 aria-checked:ring-2 aria-checked:ring-(--swatch)"
>
{value === s ? <Check className="size-4" /> : null}
</button>
))}
</div>
);
}
/** İkon seçici; seçili renkle önizler. */
export function IconPicker({
value,
color,
onChange,
labelledBy,
}: {
value: IconKey;
color: Swatch;
onChange: (v: IconKey) => void;
labelledBy: string;
}) {
return (
<div
role="radiogroup"
aria-labelledby={labelledBy}
style={swatchStyle(color)}
className="grid max-h-44 grid-cols-8 gap-1 overflow-y-auto rounded-lg border p-1.5 sm:grid-cols-10"
>
{ICON_KEYS.map((key) => (
<button
key={key}
type="button"
role="radio"
aria-checked={value === key}
aria-label={key}
onClick={() => onChange(key)}
className={cn(
"flex aspect-square items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50",
"aria-checked:bg-(--swatch)/15 aria-checked:text-(--swatch) aria-checked:ring-1 aria-checked:ring-(--swatch)",
)}
>
<AppIcon name={key} className="size-4.5" />
</button>
))}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { cn } from "@/lib/utils";
import type { IconKey, Swatch } from "@/shared/appearance";
import { AppIcon } from "./app-icon";
/** Renk anahtarını CSS değişkenine bağlar: --swatch-blue → --swatch (sınıflar bunu kullanır). */
export const swatchStyle = (color: Swatch) =>
({ "--swatch": `var(--swatch-${color})` }) as React.CSSProperties;
/** Renkli yuvarlak ikon rozeti (kategori/hesap). */
export function IconBadge({
icon,
color,
className,
size = "md",
}: {
icon: IconKey;
color: Swatch;
className?: string;
size?: "sm" | "md";
}) {
return (
<span
style={swatchStyle(color)}
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full bg-(--swatch)/15 text-(--swatch)",
size === "sm" ? "size-7 [&_svg]:size-3.5" : "size-9 [&_svg]:size-4.5",
className,
)}
>
<AppIcon name={icon} />
</span>
);
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { tr } from "@/i18n/tr";
* hydration uyumsuzluğu olmaz). Dosyalar public/brand/ altında. * hydration uyumsuzluğu olmaz). Dosyalar public/brand/ altında.
*/ */
export function Logo({ className, priority }: { className?: string; priority?: boolean }) { export function Logo({ className, priority }: { className?: string; priority?: boolean }) {
const common = { width: 120, height: 32, priority, unoptimized: true } as const; const common = { width: 114, height: 32, priority, unoptimized: true } as const;
return ( return (
<span className={cn("inline-flex items-center", className)}> <span className={cn("inline-flex items-center", className)}>
<Image <Image
+7 -2
View File
@@ -11,18 +11,23 @@ import { UserMenu } from "./user-menu";
export function AppHeader({ export function AppHeader({
user, user,
households, households,
activeHouseholdId,
canWrite,
version, version,
}: { }: {
user: SessionUser; user: SessionUser;
households: HouseholdSummary[]; households: HouseholdSummary[];
activeHouseholdId: string | undefined;
/** Aktif hanede en az "member" mi? (İzleyici veri giremez.) */
canWrite: boolean;
version: string; version: string;
}) { }) {
return ( return (
<header className="sticky top-0 z-30 flex h-14 items-center gap-2 border-b bg-background/95 px-3 backdrop-blur supports-backdrop-filter:bg-background/80 md:px-4"> <header className="sticky top-0 z-30 flex h-14 items-center gap-2 border-b bg-background/95 px-3 backdrop-blur supports-backdrop-filter:bg-background/80 md:px-4">
<SidebarTrigger className="hidden md:inline-flex" aria-label={tr.nav.toggleSidebar} /> <SidebarTrigger className="hidden md:inline-flex" aria-label={tr.nav.toggleSidebar} />
<HouseholdSwitcher households={households} /> <HouseholdSwitcher households={households} activeId={activeHouseholdId} />
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
<QuickAddButton className="hidden md:inline-flex" /> {canWrite ? <QuickAddButton className="hidden md:inline-flex" /> : null}
<ThemeToggle /> <ThemeToggle />
<UserMenu user={user} version={version} /> <UserMenu user={user} version={version} />
</div> </div>
@@ -0,0 +1,26 @@
import { Skeleton } from "@/components/ui/skeleton";
import { tr } from "@/i18n/tr";
/** Oturum okunurken gösterilen kabuk iskeleti (yan menü + üst bar + içerik). */
export function AppShellFallback() {
return (
<div role="status" aria-label={tr.common.loading} className="flex min-h-dvh w-full">
<div className="hidden w-64 shrink-0 flex-col gap-3 border-r bg-sidebar p-3 md:flex">
<Skeleton className="h-8 w-28" />
{Array.from({ length: 8 }, (_, i) => (
<Skeleton key={i} className="h-7 w-full" />
))}
</div>
<div className="flex flex-1 flex-col">
<div className="flex h-14 items-center gap-2 border-b px-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="ml-auto size-8 rounded-full" />
</div>
<div className="flex flex-col gap-4 p-4 md:p-6">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-24 w-full" />
</div>
</div>
</div>
);
}
+5 -3
View File
@@ -40,7 +40,7 @@ function BottomNavLink({ item, pathname }: { item: NavItem; pathname: string })
} }
/** Mobil alt gezinme çubuğu (md altı). */ /** Mobil alt gezinme çubuğu (md altı). */
export function BottomNav() { export function BottomNav({ canWrite }: { canWrite: boolean }) {
const pathname = usePathname(); const pathname = usePathname();
const { openQuickAdd } = useQuickAdd(); const { openQuickAdd } = useQuickAdd();
const { setOpenMobile } = useSidebar(); const { setOpenMobile } = useSidebar();
@@ -56,9 +56,11 @@ export function BottomNav() {
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<button <button
type="button" type="button"
onClick={openQuickAdd} onClick={() => openQuickAdd()}
disabled={!canWrite}
title={canWrite ? undefined : tr.households.viewerReadOnly}
aria-label={tr.quickAdd.button} aria-label={tr.quickAdd.button}
className="-mt-5 flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg outline-none focus-visible:ring-4 focus-visible:ring-ring/50" className="-mt-5 flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg outline-none focus-visible:ring-4 focus-visible:ring-ring/50 disabled:opacity-40"
> >
<Plus className="size-6" /> <Plus className="size-6" />
</button> </button>
+29 -7
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { House } from "lucide-react"; import { House } from "lucide-react";
import { useState } from "react"; import { useTransition } from "react";
import { toast } from "sonner";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -9,17 +10,38 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { switchHouseholdAction } from "@/features/households/actions";
import type { HouseholdSummary } from "@/features/households/types"; import type { HouseholdSummary } from "@/features/households/types";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
/** Üst bar aktif hane seçici. FAZ 5: seçim sunucuda (cookie) saklanacak. */ /** Üst bar aktif hane seçici. Seçim sunucuda doğrulanır ve cookie'ye yazılır. */
export function HouseholdSwitcher({ households }: { households: HouseholdSummary[] }) { export function HouseholdSwitcher({
const [value, setValue] = useState(households[0]?.id); households,
const current = households.find((h) => h.id === value); activeId,
}: {
households: HouseholdSummary[];
activeId: string | undefined;
}) {
const [pending, startTransition] = useTransition();
const current = households.find((h) => h.id === activeId);
if (households.length === 0) return null; if (households.length === 0) return null;
function onChange(id: string) {
if (id === activeId) return;
startTransition(async () => {
const result = await switchHouseholdAction(id);
if (result.ok) toast.success(tr.household.switched(result.data.name));
else toast.error(result.error);
});
}
return ( return (
<Select value={value} onValueChange={setValue}> <Select value={activeId} onValueChange={onChange} disabled={pending || households.length < 2}>
<SelectTrigger aria-label={tr.household.label} className="w-[min(46vw,13rem)]"> <SelectTrigger
aria-label={tr.household.label}
className="w-[min(46vw,13rem)]"
aria-busy={pending}
>
<House className="text-muted-foreground" /> <House className="text-muted-foreground" />
{/* Etiket children ile verilir: Radix aksi halde hydration'a kadar boş gösterir. */} {/* Etiket children ile verilir: Radix aksi halde hydration'a kadar boş gösterir. */}
<SelectValue placeholder={tr.household.select}>{current?.name}</SelectValue> <SelectValue placeholder={tr.household.select}>{current?.name}</SelectValue>
+10 -5
View File
@@ -8,6 +8,7 @@ import {
Package, Package,
Settings, Settings,
ShoppingCart, ShoppingCart,
Tags,
Target, Target,
UserCog, UserCog,
Users, Users,
@@ -25,14 +26,16 @@ export type NavItem = {
}; };
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
export const CURRENT_PHASE = 2; /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
export const CURRENT_PHASE = 6.2;
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
export const MAIN_NAV: NavItem[] = [ export const MAIN_NAV: NavItem[] = [
{ href: "/dashboard", label: tr.nav.dashboard, icon: LayoutDashboard, phase: 2 }, { href: "/dashboard", label: tr.nav.dashboard, icon: LayoutDashboard, phase: 2 },
{ href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6 }, { href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6.2 },
{ href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6 }, { href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 },
{ href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 },
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7 }, { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7 },
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 }, { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 },
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 }, { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 },
@@ -49,9 +52,11 @@ export const ADMIN_NAV: NavItem[] = [
]; ];
/** Mobil alt menü: ortada Hızlı ekle, sağda "Daha fazla" (yan menüyü açar). */ /** Mobil alt menü: ortada Hızlı ekle, sağda "Daha fazla" (yan menüyü açar). */
const navByHref = (href: string) => MAIN_NAV.find((i) => i.href === href)!;
export const BOTTOM_NAV = { export const BOTTOM_NAV = {
left: [MAIN_NAV[0]!, MAIN_NAV[1]!], left: [navByHref("/dashboard"), navByHref("/islemler")],
right: [MAIN_NAV[6]!], right: [navByHref("/butce")],
}; };
export function isActivePath(pathname: string, href: string) { export function isActivePath(pathname: string, href: string) {
+21 -72
View File
@@ -1,16 +1,22 @@
"use client"; "use client";
import { ArrowDownLeft, ArrowLeftRight, ArrowUpRight, Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { createContext, use, useState } from "react"; import { createContext, use, useState } from "react";
import { toast } from "sonner";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
TransactionFormDialog,
type TransactionFormMode,
} from "@/features/transactions/components/transaction-form";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
type QuickAddKind = "expense" | "income" | "transfer"; type QuickAddApi = {
/** Hızlı ekle: yeni işlem formu (varsayılan gider; sekmelerle gelir/transfer). */
openQuickAdd: (type?: "expense" | "income" | "transfer") => void;
/** Düzenle / kopyala (işlem listesi ve ayrıntısı). */
openTransactionForm: (mode: TransactionFormMode) => void;
};
const QuickAddContext = createContext<{ openQuickAdd: () => void } | null>(null); const QuickAddContext = createContext<QuickAddApi | null>(null);
export function useQuickAdd() { export function useQuickAdd() {
const ctx = use(QuickAddContext); const ctx = use(QuickAddContext);
@@ -18,74 +24,17 @@ export function useQuickAdd() {
return ctx; return ctx;
} }
const CHOICES: { /** Uygulama genelinde tek işlem formu; her ekrandan açılabilir (Bölüm 4). */
kind: QuickAddKind;
label: string;
hint: string;
icon: typeof Plus;
tone: string;
}[] = [
{
kind: "expense",
label: tr.quickAdd.expense,
hint: tr.quickAdd.expenseHint,
icon: ArrowUpRight,
tone: "text-expense bg-expense/10",
},
{
kind: "income",
label: tr.quickAdd.income,
hint: tr.quickAdd.incomeHint,
icon: ArrowDownLeft,
tone: "text-income bg-income/10",
},
{
kind: "transfer",
label: tr.quickAdd.transfer,
hint: tr.quickAdd.transferHint,
icon: ArrowLeftRight,
tone: "text-budget bg-budget/10",
},
];
/** Hızlı ekle (gider / gelir / transfer) — her ekrandan açılabilir. */
export function QuickAddProvider({ children }: { children: React.ReactNode }) { export function QuickAddProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false); const [mode, setMode] = useState<TransactionFormMode | null>(null);
const api: QuickAddApi = {
function choose(kind: QuickAddKind) { openQuickAdd: (type = "expense") => setMode({ kind: "create", type }),
setOpen(false); openTransactionForm: setMode,
// FAZ 6: ilgili işlem formu açılacak. };
toast.info(`${CHOICES.find((c) => c.kind === kind)?.label}: ${tr.quickAdd.notYet}`);
}
return ( return (
<QuickAddContext value={{ openQuickAdd: () => setOpen(true) }}> <QuickAddContext value={api}>
{children} {children}
<ResponsiveDialog <TransactionFormDialog mode={mode} onOpenChange={(open) => !open && setMode(null)} />
open={open}
onOpenChange={setOpen}
title={tr.quickAdd.title}
description={tr.quickAdd.description}
>
<div className="grid gap-2 pb-2 sm:grid-cols-3">
{CHOICES.map(({ kind, label, hint, icon: Icon, tone }) => (
<button
key={kind}
type="button"
onClick={() => choose(kind)}
className="flex items-center gap-3 rounded-xl border p-4 text-left transition-colors outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 sm:flex-col sm:text-center"
>
<span className={cn("flex size-10 items-center justify-center rounded-full", tone)}>
<Icon className="size-5" />
</span>
<span className="flex flex-col">
<span className="font-medium">{label}</span>
<span className="text-xs text-muted-foreground">{hint}</span>
</span>
</button>
))}
</div>
</ResponsiveDialog>
</QuickAddContext> </QuickAddContext>
); );
} }
@@ -94,7 +43,7 @@ export function QuickAddProvider({ children }: { children: React.ReactNode }) {
export function QuickAddButton({ className }: { className?: string }) { export function QuickAddButton({ className }: { className?: string }) {
const { openQuickAdd } = useQuickAdd(); const { openQuickAdd } = useQuickAdd();
return ( return (
<Button onClick={openQuickAdd} className={className}> <Button onClick={() => openQuickAdd()} className={className}>
<Plus /> <Plus />
{tr.quickAdd.button} {tr.quickAdd.button}
</Button> </Button>
+9 -3
View File
@@ -15,6 +15,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import type { SessionUser } from "@/features/auth/types"; import type { SessionUser } from "@/features/auth/types";
import { authClient } from "@/lib/auth-client";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
export function initials(name: string) { export function initials(name: string) {
@@ -30,10 +31,15 @@ export function initials(name: string) {
export function UserMenu({ user, version }: { user: SessionUser; version: string }) { export function UserMenu({ user, version }: { user: SessionUser; version: string }) {
const router = useRouter(); const router = useRouter();
function logout() { async function logout() {
// FAZ 3: authClient.signOut() sonrası yönlendirme. const { error } = await authClient.signOut();
if (error) {
toast.error(tr.common.genericError);
return;
}
toast.success(tr.userMenu.loggedOut); toast.success(tr.userMenu.loggedOut);
router.replace("/login"); router.replace("/login");
router.refresh();
} }
return ( return (
@@ -61,7 +67,7 @@ export function UserMenu({ user, version }: { user: SessionUser; version: string
</Badge> </Badge>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={logout}> <DropdownMenuItem variant="destructive" onSelect={() => void logout()}>
<LogOut /> <LogOut />
{tr.userMenu.logout} {tr.userMenu.logout}
</DropdownMenuItem> </DropdownMenuItem>
+49
View File
@@ -0,0 +1,49 @@
"use client";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import { formatMoneyInput, parseMoney } from "@/shared/money";
/**
* Türkçe tutar girişi ("1.250,50"). Değer string olarak tutulur (zod sunucuda/istemcide ayrıştırır);
* alandan çıkınca geçerliyse Türkçe biçime getirilir.
*/
export function MoneyInput({
value,
onChange,
onBlur,
allowNegative = false,
...props
}: Omit<React.ComponentProps<typeof InputGroupInput>, "value" | "onChange" | "type"> & {
value: string;
onChange: (value: string) => void;
allowNegative?: boolean;
}) {
return (
<InputGroup>
<InputGroupInput
{...props}
type="text"
inputMode="decimal"
autoComplete="off"
className="text-right tabular-nums"
value={value}
onChange={(e) =>
onChange(e.target.value.replace(allowNegative ? /[^\d.,\s-−]/g : /[^\d.,\s]/g, ""))
}
onBlur={(e) => {
const minor = parseMoney(value);
if (minor !== null && value.trim() !== "") onChange(formatMoneyInput(minor));
onBlur?.(e);
}}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>₺</InputGroupText>
</InputGroupAddon>
</InputGroup>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { accountFormSchema } from "@/shared/schemas/account";
import { createAccount, reorderAccount, setAccountArchived, updateAccount } from "./service";
const PATH = "/hesaplar";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
/** zod hatalarını alan bazlı mesajlara çevirir (formda gösterilir). */
function fieldErrors(error: z.ZodError) {
const out: Record<string, string> = {};
for (const issue of error.issues) {
const key = String(issue.path[0] ?? "");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
export async function createAccountAction(input: unknown): Promise<ActionResult<{ id: string }>> {
const ctx = await getHouseholdActionContext("owner");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = accountFormSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await createAccount(ctx.user.id, ctx.household.id, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
export async function updateAccountAction(
accountId: unknown,
input: unknown,
): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("owner");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(accountId);
const parsed = accountFormSchema.safeParse(input);
if (!id.success) return fail(tr.common.notFound);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await updateAccount(ctx.user.id, ctx.household.id, id.data, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
export async function setAccountArchivedAction(
accountId: unknown,
archived: unknown,
): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("owner");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(accountId);
const a = z.boolean().safeParse(archived);
if (!id.success || !a.success) return fail(tr.common.genericError);
const result = await setAccountArchived(ctx.user.id, ctx.household.id, id.data, a.data);
if (result.ok) revalidatePath(PATH);
return result;
}
export async function reorderAccountAction(
accountId: unknown,
direction: unknown,
): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("owner");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(accountId);
const dir = z.enum(["up", "down"]).safeParse(direction);
if (!id.success || !dir.success) return fail(tr.common.genericError);
const result = await reorderAccount(ctx.user.id, ctx.household.id, id.data, dir.data);
if (result.ok) revalidatePath(PATH);
return result;
}
@@ -0,0 +1,678 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronDown, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Controller, useForm, useWatch, type Control, type Resolver } from "react-hook-form";
import { toast } from "sonner";
import { IconPicker, SwatchPicker } from "@/components/appearance/pickers";
import { MoneyInput } from "@/components/money-input";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { tr } from "@/i18n/tr";
import {
ACCOUNT_TYPES,
DUE_RULE_KINDS,
MEAL_CARD_PROVIDERS,
type AccountType,
} from "@/shared/accounts";
import type { IconKey } from "@/shared/appearance";
import { todayIstanbul } from "@/shared/dates";
import { formatMoneyInput } from "@/shared/money";
import {
accountFormSchema,
type AccountForm,
type AccountFormInput,
} from "@/shared/schemas/account";
import { createAccountAction, updateAccountAction } from "../actions";
import type { AccountView } from "../service";
const t = tr.accounts;
const f = t.fields;
const NONE = "__none__";
const DAYS = Array.from({ length: 31 }, (_, i) => i + 1);
const DEFAULT_ICON: Record<AccountType, IconKey> = {
cash: "wallet",
bank: "landmark",
savings: "piggy-bank",
credit_card: "credit-card",
meal_card: "utensils",
};
type FormValues = {
name: string;
type: AccountType;
institution: string;
mealCardProvider: AccountFormInput["mealCardProvider"];
openingBalance: string;
openingDate: string;
color: AccountFormInput["color"];
icon: AccountFormInput["icon"];
ownerUserId: string;
isPrivate: boolean;
overdraftEnabled: boolean;
overdraftLimit: string;
overdraftMonthlyRate: string;
overdraftStatementDay: string;
cardLimit: string;
cardStatementDay: string;
cardDueKind: AccountFormInput["cardDueKind"];
cardDueValue: string;
cardPaymentAccountId: string;
};
const money = (minor: number | null | undefined) => (minor ? formatMoneyInput(minor) : "");
function toValues(a?: AccountView): FormValues {
if (!a) {
return {
name: "",
type: "bank",
institution: "",
mealCardProvider: null,
openingBalance: "",
openingDate: todayIstanbul(),
color: "blue",
icon: "landmark",
ownerUserId: "",
isPrivate: false,
overdraftEnabled: false,
overdraftLimit: "",
overdraftMonthlyRate: "",
overdraftStatementDay: "",
cardLimit: "",
cardStatementDay: "1",
cardDueKind: "daysAfter",
cardDueValue: "10",
cardPaymentAccountId: "",
};
}
const isCard = a.type === "credit_card";
return {
name: a.name,
type: a.type,
institution: a.institution,
mealCardProvider: a.mealCardProvider,
openingBalance:
a.openingBalanceMinor === null
? ""
: money(isCard ? Math.abs(a.openingBalanceMinor) : a.openingBalanceMinor),
openingDate: a.openingDate,
color: a.color,
icon: a.icon,
ownerUserId: a.ownerUserId ?? "",
isPrivate: a.isPrivate,
overdraftEnabled: Boolean(a.overdraft),
overdraftLimit: money(a.overdraft?.limitMinor),
overdraftMonthlyRate: a.overdraft?.monthlyRateBp
? formatMoneyInput(a.overdraft.monthlyRateBp)
: "",
overdraftStatementDay: a.overdraft?.statementDay ? String(a.overdraft.statementDay) : "",
cardLimit: money(a.creditCard?.limitMinor),
cardStatementDay: String(a.creditCard?.statementDay ?? 1),
cardDueKind: a.creditCard?.dueRule.kind ?? "daysAfter",
cardDueValue: String(a.creditCard?.dueRule.value ?? 10),
cardPaymentAccountId: a.creditCard?.paymentAccountId ?? "",
};
}
function DaySelect({
id,
value,
onChange,
max = 31,
allowEmpty,
}: {
id: string;
value: string;
onChange: (v: string) => void;
max?: number;
allowEmpty?: boolean;
}) {
return (
<Select value={value || NONE} onValueChange={(v) => onChange(v === NONE ? "" : v)}>
<SelectTrigger id={id} className="w-28">
<SelectValue>{value || "—"}</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-64">
{allowEmpty ? <SelectItem value={NONE}>—</SelectItem> : null}
{DAYS.slice(0, max).map((d) => (
<SelectItem key={d} value={String(d)}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function MoneyField({
control,
name,
label,
hint,
allowNegative,
disabled,
}: {
control: Control<FormValues, unknown, AccountForm>;
name: "openingBalance" | "overdraftLimit" | "cardLimit";
label: string;
hint?: string;
allowNegative?: boolean;
disabled?: boolean;
}) {
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`acc-${name}`}>{label}</FieldLabel>
<MoneyInput
id={`acc-${name}`}
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
allowNegative={allowNegative}
disabled={disabled}
placeholder={disabled ? t.hidden : "0,00"}
aria-invalid={fieldState.invalid}
aria-describedby={hint ? `acc-${name}-hint` : undefined}
/>
{hint ? <FieldDescription id={`acc-${name}-hint`}>{hint}</FieldDescription> : null}
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
);
}
export function AccountFormDialog({
open,
account,
members,
paymentAccounts,
onOpenChange,
}: {
open: boolean;
account?: AccountView;
members: { id: string; name: string }[];
paymentAccounts: { id: string; name: string }[];
onOpenChange: (open: boolean) => void;
}) {
const isEdit = Boolean(account);
const balanceHidden = isEdit && account!.openingBalanceMinor === null;
const form = useForm<FormValues, unknown, AccountForm>({
// FormValues, şemanın giriş şekliyle birebir (sayısal alanlar string olarak girilir, zod çevirir).
resolver: zodResolver(accountFormSchema) as unknown as Resolver<
FormValues,
unknown,
AccountForm
>,
defaultValues: toValues(),
});
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
if (open) form.reset(toValues(account));
}, [open, account, form]);
function handleOpenChange(next: boolean) {
if (!next) setAdvancedOpen(false);
onOpenChange(next);
}
const type = useWatch({ control: form.control, name: "type" });
const color = useWatch({ control: form.control, name: "color" });
const overdraftEnabled = useWatch({ control: form.control, name: "overdraftEnabled" });
const dueKind = useWatch({ control: form.control, name: "cardDueKind" });
const isCard = type === "credit_card";
/** Ham form değerleri gönderilir; sunucu aynı şemayla ayrıştırır. */
async function onSubmit() {
const values = form.getValues();
// Gizli bakiyede sunucu mevcut değeri korur; doğrulamadan geçmesi için 0 gönderilir.
const payload = balanceHidden ? { ...values, openingBalance: "0" } : values;
const result = account
? await updateAccountAction(account.id, payload)
: await createAccountAction(payload);
if (result.ok) {
toast.success(account ? t.updated(values.name) : t.created(values.name));
handleOpenChange(false);
return;
}
let advancedError = false;
for (const [name, message] of Object.entries(result.fieldErrors ?? {})) {
if (!message) continue;
form.setError(name as keyof FormValues, { message });
if (/^(overdraft|ownerUserId|isPrivate)/.test(name)) advancedError = true;
}
if (advancedError) setAdvancedOpen(true);
if (!result.fieldErrors) form.setError("root", { message: result.error });
}
return (
<ResponsiveDialog
open={open}
onOpenChange={handleOpenChange}
title={isEdit ? t.editTitle : t.createTitle}
description={t.formDescription}
footer={
<>
<Button variant="outline" type="button" onClick={() => handleOpenChange(false)}>
{tr.common.cancel}
</Button>
<Button type="submit" form="account-form" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
{isEdit ? tr.common.save : tr.common.create}
</Button>
</>
}
>
<form id="account-form" onSubmit={form.handleSubmit(onSubmit)} noValidate>
<FieldGroup>
<Controller
name="type"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="acc-type">{f.type}</FieldLabel>
<Select
value={field.value}
disabled={isEdit}
onValueChange={(v) => {
field.onChange(v);
if (!isEdit) form.setValue("icon", DEFAULT_ICON[v as AccountType]);
}}
>
<SelectTrigger id="acc-type" className="w-full">
<SelectValue>{t.types[field.value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{ACCOUNT_TYPES.map((x) => (
<SelectItem key={x} value={x}>
{t.types[x]}
</SelectItem>
))}
</SelectContent>
</Select>
{isEdit ? <FieldDescription>{f.typeLockedHint}</FieldDescription> : null}
</Field>
)}
/>
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-name">{f.name}</FieldLabel>
<Input {...field} id="acc-name" aria-invalid={fieldState.invalid} />
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
{type === "meal_card" ? (
<Controller
name="mealCardProvider"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-provider">{f.provider}</FieldLabel>
<Select value={field.value ?? undefined} onValueChange={field.onChange}>
<SelectTrigger
id="acc-provider"
className="w-full"
aria-invalid={fieldState.invalid}
>
<SelectValue placeholder={f.provider}>
{field.value ? t.providers[field.value] : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{MEAL_CARD_PROVIDERS.map((p) => (
<SelectItem key={p} value={p}>
{t.providers[p]}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
) : type !== "cash" ? (
<Controller
name="institution"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="acc-institution">{f.institution}</FieldLabel>
<Input {...field} id="acc-institution" placeholder={f.institutionPlaceholder} />
</Field>
)}
/>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<MoneyField
control={form.control}
name="openingBalance"
label={isCard ? f.openingDebt : f.openingBalance}
hint={
balanceHidden
? t.hiddenHint
: isCard
? f.openingDebtHint
: type === "bank"
? f.openingBalanceHint
: undefined
}
allowNegative={type === "bank"}
disabled={balanceHidden}
/>
<Controller
name="openingDate"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-date">{f.openingDate}</FieldLabel>
<Input {...field} id="acc-date" type="date" aria-invalid={fieldState.invalid} />
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</div>
{isCard ? (
<FieldSet>
<FieldLegend>{f.card}</FieldLegend>
<FieldGroup>
<MoneyField control={form.control} name="cardLimit" label={f.cardLimit} />
<Controller
name="cardStatementDay"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="acc-card-statement">{f.cardStatementDay}</FieldLabel>
<DaySelect
id="acc-card-statement"
value={field.value}
onChange={field.onChange}
/>
<FieldDescription>{f.cardStatementDayHint}</FieldDescription>
</Field>
)}
/>
<div className="grid gap-4 sm:grid-cols-2">
<Controller
name="cardDueKind"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="acc-card-due-kind">{f.cardDueKind}</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="acc-card-due-kind" className="w-full">
<SelectValue>{f.dueKinds[field.value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{DUE_RULE_KINDS.map((k) => (
<SelectItem key={k} value={k}>
{f.dueKinds[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
<Controller
name="cardDueValue"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-card-due-value">
{dueKind === "daysAfter" ? f.cardDueValueDays : f.cardDueValueDay}
</FieldLabel>
<DaySelect
id="acc-card-due-value"
value={field.value}
onChange={field.onChange}
max={dueKind === "daysAfter" ? 30 : 31}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</div>
<Controller
name="cardPaymentAccountId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-card-payer">{f.cardPaymentAccount}</FieldLabel>
<Select
value={field.value || NONE}
onValueChange={(v) => field.onChange(v === NONE ? "" : v)}
>
<SelectTrigger
id="acc-card-payer"
className="w-full"
aria-invalid={fieldState.invalid}
>
<SelectValue>
{paymentAccounts.find((p) => p.id === field.value)?.name ??
f.cardPaymentAccountNone}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>{f.cardPaymentAccountNone}</SelectItem>
{paymentAccounts
.filter((p) => p.id !== account?.id)
.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</FieldGroup>
</FieldSet>
) : null}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" className="-ml-2 w-fit">
<ChevronDown
className={
advancedOpen ? "rotate-180 transition-transform" : "transition-transform"
}
/>
{f.advanced}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<FieldGroup>
{type === "bank" ? (
<FieldSet>
<FieldLegend>{f.overdraft}</FieldLegend>
<FieldGroup>
<Controller
name="overdraftEnabled"
control={form.control}
render={({ field }) => (
<Field orientation="horizontal">
<Checkbox
id="acc-od"
checked={field.value}
onCheckedChange={(v) => field.onChange(v === true)}
/>
<FieldLabel htmlFor="acc-od" className="font-normal">
{f.overdraftEnabled}
</FieldLabel>
</Field>
)}
/>
{overdraftEnabled ? (
<>
<MoneyField
control={form.control}
name="overdraftLimit"
label={f.overdraftLimit}
/>
<div className="grid gap-4 sm:grid-cols-2">
<Controller
name="overdraftMonthlyRate"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-od-rate">{f.overdraftRate}</FieldLabel>
<Input
{...field}
id="acc-od-rate"
inputMode="decimal"
placeholder="4,25"
className="w-28 tabular-nums"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? (
<FieldError errors={[fieldState.error]} />
) : null}
</Field>
)}
/>
<Controller
name="overdraftStatementDay"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="acc-od-day">
{f.overdraftStatementDay}
</FieldLabel>
<DaySelect
id="acc-od-day"
value={field.value}
onChange={field.onChange}
allowEmpty
/>
</Field>
)}
/>
</div>
</>
) : null}
</FieldGroup>
</FieldSet>
) : null}
<Controller
name="ownerUserId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-owner">{f.owner}</FieldLabel>
<Select
value={field.value || NONE}
onValueChange={(v) => field.onChange(v === NONE ? "" : v)}
>
<SelectTrigger
id="acc-owner"
className="w-full"
aria-invalid={fieldState.invalid}
>
<SelectValue>
{members.find((m) => m.id === field.value)?.name ?? f.ownerNone}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>{f.ownerNone}</SelectItem>
{members.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="isPrivate"
control={form.control}
render={({ field }) => (
<Field orientation="horizontal">
<Checkbox
id="acc-private"
checked={field.value}
onCheckedChange={(v) => field.onChange(v === true)}
/>
<FieldLabel htmlFor="acc-private" className="font-normal">
{f.isPrivate}
</FieldLabel>
</Field>
)}
/>
<Controller
name="color"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel id="acc-color-label">{tr.categories.color}</FieldLabel>
<SwatchPicker
value={field.value}
onChange={field.onChange}
labelledBy="acc-color-label"
/>
</Field>
)}
/>
<Controller
name="icon"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel id="acc-icon-label">{tr.categories.icon}</FieldLabel>
<IconPicker
value={field.value}
color={color}
onChange={field.onChange}
labelledBy="acc-icon-label"
/>
</Field>
)}
/>
</FieldGroup>
</CollapsibleContent>
</Collapsible>
{form.formState.errors.root ? (
<FieldError>{form.formState.errors.root.message}</FieldError>
) : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
@@ -0,0 +1,321 @@
"use client";
import {
Archive,
ArchiveRestore,
ArrowDown,
ArrowUp,
ChevronDown,
EyeOff,
MoreHorizontal,
Pencil,
Plus,
} from "lucide-react";
import Link from "next/link";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { IconBadge } from "@/components/appearance/swatch";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { ACCOUNT_TYPES, isLiabilityType } from "@/shared/accounts";
import { formatMoney } from "@/shared/format";
import { reorderAccountAction, setAccountArchivedAction } from "../actions";
import type { AccountTotals, AccountView } from "../service";
import { AccountFormDialog } from "./account-form-dialog";
const t = tr.accounts;
function BalanceText({ account }: { account: AccountView }) {
if (account.balanceMinor === null) {
return (
<span
className="inline-flex items-center gap-1 text-sm text-muted-foreground"
title={t.hiddenHint}
>
<EyeOff className="size-3.5" />
{t.hidden}
</span>
);
}
const liability = isLiabilityType(account.type);
const value = account.balanceMinor;
return (
<span className="flex flex-col items-end">
{liability ? <span className="text-xs text-muted-foreground">{t.debt}</span> : null}
<span
className={cn(
"font-semibold tabular-nums",
(liability ? value < 0 : value < 0) && "text-expense",
)}
>
{formatMoney(liability ? Math.abs(value) : value)}
</span>
</span>
);
}
export function AccountsView({
accounts,
totals,
members,
canEdit,
}: {
accounts: AccountView[];
totals: AccountTotals;
members: { id: string; name: string }[];
canEdit: boolean;
}) {
const [dialog, setDialog] = useState<{ account?: AccountView } | null>(null);
const [archiving, setArchiving] = useState<AccountView | null>(null);
const [pending, startTransition] = useTransition();
const active = accounts.filter((a) => !a.archived);
const archived = accounts.filter((a) => a.archived);
const memberName = (id: string | null) => members.find((m) => m.id === id)?.name;
const paymentAccounts = active
.filter((a) => ["bank", "cash", "savings"].includes(a.type))
.map((a) => ({ id: a.id, name: a.name }));
function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) {
startTransition(async () => {
const r = await promise;
if (r.ok) {
if (success) toast.success(success);
} else toast.error(r.error ?? tr.common.genericError);
});
}
function Row({ a, index }: { a: AccountView; index: number }) {
const owner = memberName(a.ownerUserId);
return (
<li className={cn("flex items-center gap-3 p-3", a.archived && "opacity-60")}>
<IconBadge icon={a.icon} color={a.color} />
<div className="min-w-0 flex-1">
<Link
href={`/islemler?hesap=${a.id}`}
className="block truncate font-medium hover:underline"
>
{a.name}
</Link>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{a.type === "meal_card" && a.mealCardProvider ? (
<span>{t.providers[a.mealCardProvider]}</span>
) : null}
{a.institution ? <span>{a.institution}</span> : null}
{owner ? <span>· {owner}</span> : null}
{a.isPrivate ? <Badge variant="outline">{t.privateBadge}</Badge> : null}
{a.overdraft ? (
<Badge variant="secondary">
{t.overdraftBadge(formatMoney(a.overdraft.limitMinor))}
</Badge>
) : null}
{a.creditCard ? (
<>
<Badge variant="secondary">
{t.limitLabel(formatMoney(a.creditCard.limitMinor))}
</Badge>
<span>{t.statementLabel(a.creditCard.statementDay)}</span>
</>
) : null}
</div>
</div>
<BalanceText account={a} />
{canEdit ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={`${a.name} — ${tr.common.actions}`}
disabled={pending}
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{a.archived ? (
<DropdownMenuItem
onSelect={() =>
run(setAccountArchivedAction(a.id, false), t.unarchivedToast(a.name))
}
>
<ArchiveRestore />
{t.unarchive}
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem onSelect={() => setDialog({ account: a })}>
<Pencil />
{tr.common.edit}
</DropdownMenuItem>
<DropdownMenuItem
disabled={index === 0}
onSelect={() => run(reorderAccountAction(a.id, "up"))}
>
<ArrowUp />
{tr.categories.moveUp}
</DropdownMenuItem>
<DropdownMenuItem
disabled={index === active.length - 1}
onSelect={() => run(reorderAccountAction(a.id, "down"))}
>
<ArrowDown />
{tr.categories.moveDown}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => setArchiving(a)}>
<Archive />
{t.archive}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</li>
);
}
const summary = [
{ label: t.summary.assets, value: totals.assetsMinor, tone: "text-income" },
{
label: t.summary.cardDebt,
value: Math.abs(totals.cardDebtMinor),
tone: totals.cardDebtMinor ? "text-expense" : "",
},
{
label: t.summary.net,
value: totals.netMinor,
tone: totals.netMinor < 0 ? "text-expense" : "",
},
];
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-3">
{summary.map((s) => (
<Card key={s.label} size="sm">
<CardHeader>
<CardDescription>{s.label}</CardDescription>
<CardTitle className={cn("text-base tabular-nums sm:text-lg", s.tone)}>
{formatMoney(s.value)}
</CardTitle>
</CardHeader>
</Card>
))}
</div>
{totals.includesHidden || totals.excludesHidden ? (
<p className="-mt-2 text-xs text-muted-foreground">
{totals.excludesHidden ? t.summary.excludesHidden : t.summary.includesHidden}
</p>
) : null}
<div className="flex items-center gap-3">
{!canEdit ? <p className="text-sm text-muted-foreground">{t.ownerOnly}</p> : null}
{canEdit ? (
<Button className="ml-auto" onClick={() => setDialog({})}>
<Plus />
{t.newAccount}
</Button>
) : null}
</div>
{active.length === 0 ? (
<div className="rounded-xl border border-dashed p-8 text-center text-muted-foreground">
<p>{t.empty}</p>
{canEdit ? <p className="mt-1 text-sm">{t.emptyOwnerHint}</p> : null}
</div>
) : (
ACCOUNT_TYPES.map((type) => {
const group = active.filter((a) => a.type === type);
if (!group.length) return null;
return (
<section
key={type}
aria-labelledby={`acc-group-${type}`}
className="flex flex-col gap-2"
>
<h2 id={`acc-group-${type}`} className="text-sm font-medium text-muted-foreground">
{t.typeGroups[type]}
</h2>
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
{group.map((a) => (
<Row key={a.id} a={a} index={active.indexOf(a)} />
))}
</ul>
</section>
);
})
)}
{archived.length ? (
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="-ml-2 w-fit">
<ChevronDown />
{t.archivedSection(archived.length)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ul className="mt-2 divide-y rounded-xl border bg-card">
{archived.map((a) => (
<Row key={a.id} a={a} index={-1} />
))}
</ul>
</CollapsibleContent>
</Collapsible>
) : null}
<AccountFormDialog
open={Boolean(dialog)}
account={dialog?.account}
members={members}
paymentAccounts={paymentAccounts}
onOpenChange={(o) => !o && setDialog(null)}
/>
<AlertDialog open={Boolean(archiving)} onOpenChange={(o) => !o && setArchiving(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
<AlertDialogDescription>
{archiving ? t.archiveDescription(archiving.name) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button
variant="destructive"
onClick={() => {
const a = archiving!;
setArchiving(null);
run(setAccountArchivedAction(a.id, true), t.archivedToast(a.name));
}}
>
{t.archive}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { AccountModel } from "@/models/account";
import { HouseholdModel } from "@/models/household";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import { createAccount, listAccounts, setAccountArchived, updateAccount } from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let annemler = "";
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
annemler = ids.household("Annemler");
});
afterAll(teardownDb);
const base = {
name: "Test Hesap",
type: "bank" as const,
institution: "",
mealCardProvider: null,
openingBalance: 100_00,
openingDate: "2026-05-01",
color: "blue" as const,
icon: "landmark" as const,
ownerUserId: null,
isPrivate: false,
overdraftEnabled: false,
overdraftLimit: 0,
overdraftMonthlyRate: null,
overdraftStatementDay: null,
cardLimit: 0,
cardStatementDay: 1,
cardDueKind: "daysAfter" as const,
cardDueValue: 10,
cardPaymentAccountId: null,
};
const accountId = (name: string, householdId = cakir) =>
AccountModel.findOne({ householdId, name })
.lean()
.then((a) => String(a!._id));
describe("roller ve izolasyon", () => {
it("yalnızca sahip hesap oluşturur", async () => {
expect((await createAccount(ids.user("emre"), cakir, base)).ok).toBe(false);
expect((await createAccount(ids.user("zeynep"), cakir, base)).ok).toBe(false);
expect((await createAccount(ids.user("fatma"), cakir, base)).ok).toBe(false);
expect((await createAccount(ids.user("ayse"), cakir, base)).ok).toBe(true);
});
it("başka hanenin hesaplarını göremez ve değiştiremez", async () => {
expect(await listAccounts(ids.user("fatma"), cakir)).toBeNull();
const cuzdan = await accountId("Cüzdan");
expect(
(
await updateAccount(ids.user("fatma"), annemler, cuzdan, {
...base,
type: "cash",
name: "X",
})
).ok,
).toBe(false);
expect((await setAccountArchived(ids.user("fatma"), annemler, cuzdan, true)).ok).toBe(false);
const own = (await listAccounts(ids.user("fatma"), annemler))!.accounts.map((a) => a.name);
expect(own.sort()).toEqual(["Nakit", "Vakıfbank Emekli"]);
});
it("başka hanenin hesabı kart ödeme hesabı veya hesap sahibi başka hanenin üyesi olamaz", async () => {
const vakif = await accountId("Vakıfbank Emekli", annemler);
const card = {
...base,
type: "credit_card" as const,
name: "Sızma Kart",
cardPaymentAccountId: vakif,
};
expect((await createAccount(ids.user("ayse"), cakir, card)).ok).toBe(false);
expect(
(await createAccount(ids.user("ayse"), cakir, { ...base, ownerUserId: ids.user("fatma") }))
.ok,
).toBe(false);
});
});
describe("“yalnızca ben” hesabı", () => {
it("bakiyeyi yalnızca hesap sahibi görür (hane sahibi dahil diğerleri göremez)", async () => {
const find = async (user: string) =>
(await listAccounts(ids.user(user), cakir))!.accounts.find(
(a) => a.name === "Ayşe — Pluxee",
)!;
// Hesap sahibi açılış ve güncel bakiyeyi görür (güncel = açılış + işlemler).
expect((await find("ayse")).openingBalanceMinor).toBe(3_120_00);
expect((await find("ayse")).balanceMinor).not.toBeNull();
expect((await find("admin")).balanceMinor).toBeNull();
expect((await find("emre")).balanceMinor).toBeNull();
expect((await find("emre")).openingBalanceMinor).toBeNull();
});
it("toplamlar hane ayarına uyar", async () => {
const withHidden = (await listAccounts(ids.user("emre"), cakir))!.totals;
expect(withHidden.includesHidden).toBe(true);
await HouseholdModel.updateOne(
{ _id: cakir },
{ $set: { "settings.includePrivateAccountsInTotals": false } },
);
const without = (await listAccounts(ids.user("emre"), cakir))!.totals;
expect(without.excludesHidden).toBe(true);
const pluxee = (await listAccounts(ids.user("ayse"), cakir))!.accounts.find(
(a) => a.name === "Ayşe — Pluxee",
)!;
expect(withHidden.assetsMinor - without.assetsMinor).toBe(pluxee.balanceMinor);
// Hesap sahibi kendi hesabını her zaman toplamda görür.
const ayse = (await listAccounts(ids.user("ayse"), cakir))!.totals;
expect(ayse.assetsMinor).toBe(withHidden.assetsMinor);
await HouseholdModel.updateOne(
{ _id: cakir },
{ $set: { "settings.includePrivateAccountsInTotals": true } },
);
});
it("bakiyeyi göremeyen sahip düzenlediğinde gizli bakiye korunur", async () => {
const id = await accountId("Ayşe — Pluxee");
const r = await updateAccount(ids.user("admin"), cakir, id, {
...base,
type: "meal_card",
mealCardProvider: "pluxee",
name: "Ayşe — Pluxee",
openingBalance: 0,
ownerUserId: ids.user("ayse"),
isPrivate: true,
});
expect(r.ok).toBe(true);
expect((await AccountModel.findById(id).lean())?.openingBalanceMinor).toBe(3_120_00);
});
});
describe("kart ve tür kuralları", () => {
it("kart açılış borcu negatif saklanır; tür değiştirilemez", async () => {
const payer = await accountId("Ziraat Maaş");
const r = await createAccount(ids.user("ayse"), cakir, {
...base,
type: "credit_card",
name: "Yeni Kart",
openingBalance: 1_500_00,
cardLimit: 20_000_00,
cardStatementDay: 31,
cardPaymentAccountId: payer,
});
expect(r.ok).toBe(true);
const card = await AccountModel.findById(r.ok ? r.data.id : "").lean();
expect(card?.openingBalanceMinor).toBe(-1_500_00);
expect(card?.creditCard?.statementDay).toBe(31);
expect(card?.creditCard?.paymentAccountId?.toString()).toBe(payer);
const change = await updateAccount(ids.user("ayse"), cakir, String(card!._id), {
...base,
name: "Yeni Kart",
});
expect(change.ok).toBe(false);
});
});
+380
View File
@@ -0,0 +1,380 @@
import "server-only";
import { Types } from "mongoose";
import { getHouseholdDetail, resolveMembership } from "@/features/households/service";
import { computeAccountDeltas } from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { diffFields, writeAudit } from "@/lib/audit";
import { toObjectId } from "@/lib/object-id";
import { AccountModel } from "@/models/account";
import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import {
isLiabilityType,
type AccountType,
type DueRuleKind,
type MealCardProvider,
} from "@/shared/accounts";
import type { IconKey, Swatch } from "@/shared/appearance";
import { istanbulDayStart, toIstanbulYmd } from "@/shared/dates";
import type { AccountForm } from "@/shared/schemas/account";
import { compareTr } from "@/shared/search";
/**
* Hesap servisi. Okuma: viewer; oluşturma/düzenleme/arşiv/sıralama: owner (Bölüm 5).
* "Yalnızca ben" hesaplarında bakiye yalnızca hesap sahibi kişiye görünür.
* Bakiye işareti: varlık pozitif, kredi kartı borcu negatif.
*/
const t = tr.accounts;
export type AccountView = {
id: string;
name: string;
type: AccountType;
institution: string;
mealCardProvider: MealCardProvider | null;
color: Swatch;
icon: IconKey;
order: number;
archived: boolean;
isPrivate: boolean;
ownerUserId: string | null;
/** Bu kullanıcıya gizliyse null. */
balanceMinor: number | null;
openingBalanceMinor: number | null;
openingDate: string;
overdraft: {
enabled: boolean;
limitMinor: number;
monthlyRateBp: number | null;
statementDay: number | null;
} | null;
creditCard: {
limitMinor: number;
statementDay: number;
dueRule: { kind: DueRuleKind; value: number };
paymentAccountId: string | null;
} | null;
};
export type AccountTotals = {
/** Nakit + banka + birikim + yemek kartı (pozitif bakiyeler). */
assetsMinor: number;
/** Kredi kartı borçları (negatif). */
cardDebtMinor: number;
netMinor: number;
/** Toplamlarda bu kullanıcının bakiyesini göremediği hesap var mı? */
includesHidden: boolean;
/** Gizli hesaplar hane ayarı gereği toplamlardan çıkarıldı mı? */
excludesHidden: boolean;
};
type Lean = {
_id: Types.ObjectId;
name: string;
type: AccountType;
institution: string;
mealCardProvider: MealCardProvider | null;
color: Swatch;
icon: IconKey;
order: number;
archivedAt: Date | null;
isPrivate: boolean;
ownerUserId: Types.ObjectId | null;
openingBalanceMinor: number;
openingDate: Date;
overdraft: AccountView["overdraft"];
creditCard:
| (Omit<NonNullable<AccountView["creditCard"]>, "paymentAccountId"> & {
paymentAccountId: Types.ObjectId | null;
})
| null;
};
const canSeeBalance = (a: Lean, viewerId: string) =>
!a.isPrivate || String(a.ownerUserId) === viewerId;
/** Güncel bakiye = açılış bakiyesi + gerçekleşmiş işlemlerin etkisi (tek doğruluk kaynağı: işlemler). */
function computeBalance(a: Lean, deltas: ReadonlyMap<string, number>): number {
return a.openingBalanceMinor + (deltas.get(String(a._id)) ?? 0);
}
function toView(a: Lean, viewerId: string, deltas: ReadonlyMap<string, number>): AccountView {
const visible = canSeeBalance(a, viewerId);
return {
id: String(a._id),
name: a.name,
type: a.type,
institution: a.institution ?? "",
mealCardProvider: a.mealCardProvider ?? null,
color: a.color,
icon: a.icon,
order: a.order,
archived: Boolean(a.archivedAt),
isPrivate: a.isPrivate,
ownerUserId: a.ownerUserId ? String(a.ownerUserId) : null,
balanceMinor: visible ? computeBalance(a, deltas) : null,
openingBalanceMinor: visible ? a.openingBalanceMinor : null,
openingDate: toIstanbulYmd(a.openingDate),
overdraft: a.overdraft?.enabled ? a.overdraft : null,
creditCard: a.creditCard
? {
...a.creditCard,
paymentAccountId: a.creditCard.paymentAccountId
? String(a.creditCard.paymentAccountId)
: null,
}
: null,
};
}
export async function listAccounts(
userId: string,
householdId: string,
opts: { includeArchived?: boolean } = {},
): Promise<{ accounts: AccountView[]; totals: AccountTotals } | null> {
const m = await resolveMembership(userId, householdId, "viewer");
if (!m) return null;
const filter: Record<string, unknown> = { householdId: m.householdId };
if (!opts.includeArchived) filter.archivedAt = null;
const rows = (await AccountModel.find(filter).lean<Lean[]>()).sort(
(a, b) => a.order - b.order || compareTr(a.name, b.name),
);
const [household, deltas] = await Promise.all([
HouseholdModel.findById(m.householdId).select({ settings: 1 }).lean(),
computeAccountDeltas(m.householdId),
]);
const includePrivate = household?.settings?.includePrivateAccountsInTotals ?? true;
const totals: AccountTotals = {
assetsMinor: 0,
cardDebtMinor: 0,
netMinor: 0,
includesHidden: false,
excludesHidden: false,
};
for (const a of rows) {
if (a.archivedAt) continue;
const visible = canSeeBalance(a, userId);
if (!visible && !includePrivate) {
totals.excludesHidden = true;
continue;
}
if (!visible) totals.includesHidden = true;
const balance = computeBalance(a, deltas);
if (isLiabilityType(a.type)) totals.cardDebtMinor += balance;
else totals.assetsMinor += balance;
}
totals.netMinor = totals.assetsMinor + totals.cardDebtMinor;
return { accounts: rows.map((a) => toView(a, userId, deltas)), totals };
}
/** Form → belge alanları. Kart için açılış borcu negatif saklanır. */
async function toDocFields(
householdId: Types.ObjectId,
input: AccountForm,
selfId?: Types.ObjectId,
): Promise<ActionResult<Record<string, unknown>>> {
const openingDate = istanbulDayStart(input.openingDate);
if (!openingDate) return fail(tr.validation.date, { openingDate: tr.validation.date });
let ownerUserId: Types.ObjectId | null = null;
if (input.ownerUserId) {
ownerUserId = toObjectId(input.ownerUserId);
const isMember =
ownerUserId && (await HouseholdMemberModel.exists({ householdId, userId: ownerUserId }));
if (!isMember) return fail(t.ownerNotMember, { ownerUserId: t.ownerNotMember });
}
let paymentAccountId: Types.ObjectId | null = null;
if (input.type === "credit_card" && input.cardPaymentAccountId) {
paymentAccountId = toObjectId(input.cardPaymentAccountId);
const payer =
paymentAccountId &&
!paymentAccountId.equals(selfId ?? new Types.ObjectId()) &&
(await AccountModel.exists({
_id: paymentAccountId,
householdId,
archivedAt: null,
type: { $in: ["bank", "cash", "savings"] },
}));
if (!payer)
return fail(t.invalidPaymentAccount, { cardPaymentAccountId: t.invalidPaymentAccount });
}
const isCard = input.type === "credit_card";
return ok({
name: input.name,
institution: input.institution,
mealCardProvider: input.type === "meal_card" ? input.mealCardProvider : null,
openingBalanceMinor: isCard ? -Math.abs(input.openingBalance) : input.openingBalance,
openingDate,
color: input.color,
icon: input.icon,
ownerUserId,
isPrivate: input.isPrivate,
overdraft:
input.type === "bank" && input.overdraftEnabled
? {
enabled: true,
limitMinor: input.overdraftLimit,
monthlyRateBp: input.overdraftMonthlyRate,
statementDay: input.overdraftStatementDay,
}
: null,
creditCard: isCard
? {
limitMinor: input.cardLimit,
statementDay: input.cardStatementDay,
dueRule: { kind: input.cardDueKind, value: input.cardDueValue },
paymentAccountId,
}
: null,
});
}
export async function createAccount(
actorId: string,
householdId: string,
input: AccountForm,
): Promise<ActionResult<{ id: string }>> {
const m = await resolveMembership(actorId, householdId, "owner");
if (!m) return fail(tr.common.unauthorized);
const fields = await toDocFields(m.householdId, input);
if (!fields.ok) return fields;
const last = await AccountModel.findOne({ householdId: m.householdId })
.sort({ order: -1 })
.select({ order: 1 })
.lean();
const actor = new Types.ObjectId(actorId);
const doc = await AccountModel.create({
...fields.data,
householdId: m.householdId,
type: input.type,
order: (last?.order ?? -1) + 1,
createdBy: actor,
updatedBy: actor,
});
await writeAudit({
action: "account.create",
actorId: actor,
householdId: m.householdId,
targetType: "account",
targetId: String(doc._id),
diff: { name: input.name, type: input.type },
});
return ok({ id: String(doc._id) });
}
export async function updateAccount(
actorId: string,
householdId: string,
accountId: string,
input: AccountForm,
): Promise<ActionResult> {
const m = await resolveMembership(actorId, householdId, "owner");
if (!m) return fail(tr.common.unauthorized);
const id = toObjectId(accountId);
const current = id
? await AccountModel.findOne({ _id: id, householdId: m.householdId }).lean<Lean>()
: null;
if (!current) return fail(tr.common.notFound);
// Tür değişikliği desteklenmez (işlemler ve kart dönemleri türe bağlıdır).
if (current.type !== input.type) return fail(t.typeLocked, { type: t.typeLocked });
const fields = await toDocFields(m.householdId, input, current._id);
if (!fields.ok) return fields;
// Bakiyesini göremediği gizli hesapta açılış bakiyesi korunur (ne görünür ne değişir).
if (!canSeeBalance(current, actorId)) delete fields.data.openingBalanceMinor;
const actor = new Types.ObjectId(actorId);
await AccountModel.updateOne(
{ _id: current._id, householdId: m.householdId },
{ $set: { ...fields.data, updatedBy: actor } },
);
await writeAudit({
action: "account.update",
actorId: actor,
householdId: m.householdId,
targetType: "account",
targetId: accountId,
// Gizli hesabın bakiyesi denetim kaydına yazılmaz.
diff: diffFields(
{
name: current.name,
institution: current.institution,
isPrivate: current.isPrivate,
ownerUserId: String(current.ownerUserId ?? ""),
},
{
name: input.name,
institution: input.institution,
isPrivate: input.isPrivate,
ownerUserId: input.ownerUserId ?? "",
},
),
});
return ok(undefined);
}
export async function setAccountArchived(
actorId: string,
householdId: string,
accountId: string,
archived: boolean,
): Promise<ActionResult> {
const m = await resolveMembership(actorId, householdId, "owner");
if (!m) return fail(tr.common.unauthorized);
const id = toObjectId(accountId);
const current = id
? await AccountModel.findOne({ _id: id, householdId: m.householdId }).lean<Lean>()
: null;
if (!current) return fail(tr.common.notFound);
const actor = new Types.ObjectId(actorId);
await AccountModel.updateOne(
{ _id: current._id, householdId: m.householdId },
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
);
await writeAudit({
action: archived ? "account.archive" : "account.unarchive",
actorId: actor,
householdId: m.householdId,
targetType: "account",
targetId: accountId,
diff: { name: current.name },
});
return ok(undefined);
}
export async function reorderAccount(
actorId: string,
householdId: string,
accountId: string,
direction: "up" | "down",
): Promise<ActionResult> {
const m = await resolveMembership(actorId, householdId, "owner");
if (!m) return fail(tr.common.unauthorized);
const list = (
await AccountModel.find({ householdId: m.householdId, archivedAt: null }).lean<Lean[]>()
).sort((a, b) => a.order - b.order || compareTr(a.name, b.name));
const i = list.findIndex((a) => String(a._id) === accountId);
const j = direction === "up" ? i - 1 : i + 1;
if (i < 0 || j < 0 || j >= list.length) return ok(undefined);
[list[i], list[j]] = [list[j]!, list[i]!];
await AccountModel.bulkWrite(
list.map((a, order) => ({
updateOne: {
filter: { _id: a._id, householdId: m.householdId },
update: { $set: { order } },
},
})),
);
return ok(undefined);
}
/** Hane üyeleri (hesap sahibi seçimi için). */
export async function listMemberOptions(userId: string, householdId: string) {
const detail = await getHouseholdDetail(userId, householdId);
if (!detail) return null;
return detail.members.map((x) => ({ id: x.userId, name: x.name }));
}
+74
View File
@@ -0,0 +1,74 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
adminAddMember,
adminCreateHousehold,
adminRemoveMember,
adminSetMemberRole,
} from "@/features/households/service";
import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result";
import { getActionUser } from "@/lib/dal";
import {
addMemberSchema,
householdCreateSchema,
householdRoleSchema,
} from "@/shared/schemas/household";
/** Sistem admini: hane ve üyelik yönetimi. Hanenin finansal verisine dokunmaz. */
const PATH = "/admin/haneler";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
export async function createHouseholdAction(input: unknown): Promise<ActionResult<{ id: string }>> {
const admin = await getActionUser("admin");
if (!admin) return fail(tr.common.unauthorized);
const parsed = householdCreateSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields);
const result = await adminCreateHousehold(admin.id, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
export async function addMemberAction(householdId: unknown, input: unknown): Promise<ActionResult> {
const admin = await getActionUser("admin");
if (!admin) return fail(tr.common.unauthorized);
const hid = idSchema.safeParse(householdId);
const parsed = addMemberSchema.safeParse(input);
if (!hid.success || !parsed.success) return fail(tr.adminUsers.errors.checkFields);
const result = await adminAddMember(admin.id, hid.data, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
export async function adminSetMemberRoleAction(
householdId: unknown,
userId: unknown,
role: unknown,
): Promise<ActionResult> {
const admin = await getActionUser("admin");
if (!admin) return fail(tr.common.unauthorized);
const hid = idSchema.safeParse(householdId);
const uid = idSchema.safeParse(userId);
const r = householdRoleSchema.safeParse(role);
if (!hid.success || !uid.success || !r.success) return fail(tr.common.genericError);
const result = await adminSetMemberRole(admin.id, hid.data, uid.data, r.data);
if (result.ok) revalidatePath(PATH);
return result;
}
export async function adminRemoveMemberAction(
householdId: unknown,
userId: unknown,
): Promise<ActionResult> {
const admin = await getActionUser("admin");
if (!admin) return fail(tr.common.unauthorized);
const hid = idSchema.safeParse(householdId);
const uid = idSchema.safeParse(userId);
if (!hid.success || !uid.success) return fail(tr.common.genericError);
const result = await adminRemoveMember(admin.id, hid.data, uid.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
@@ -0,0 +1,306 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Plus, UserPlus } from "lucide-react";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { toast } from "sonner";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { MembersList } from "@/features/households/components/members-list";
import type { AdminHouseholdRow, UserOption } from "@/features/households/service";
import { HOUSEHOLD_ROLES } from "@/features/households/types";
import { tr } from "@/i18n/tr";
import {
addMemberSchema,
householdCreateSchema,
type AddMemberInput,
type HouseholdCreateInput,
} from "@/shared/schemas/household";
import {
addMemberAction,
adminRemoveMemberAction,
adminSetMemberRoleAction,
createHouseholdAction,
} from "../actions";
import { UserSelect } from "./user-select";
const t = tr.adminHouseholds;
function CreateHouseholdDialog({
open,
onOpenChange,
users,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
users: UserOption[];
}) {
const form = useForm<HouseholdCreateInput>({
resolver: zodResolver(householdCreateSchema),
defaultValues: { name: "", ownerUserId: "" },
});
useEffect(() => {
if (open) form.reset({ name: "", ownerUserId: "" });
}, [open, form]);
async function onSubmit(values: HouseholdCreateInput) {
const result = await createHouseholdAction(values);
if (result.ok) {
toast.success(t.created(values.name));
onOpenChange(false);
} else {
form.setError("root", { message: result.error });
}
}
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={t.createTitle}
description={t.createDescription}
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
{tr.common.cancel}
</Button>
<Button type="submit" form="household-create" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
{tr.common.create}
</Button>
</>
}
>
<form id="household-create" onSubmit={form.handleSubmit(onSubmit)} noValidate>
<FieldGroup>
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="hh-create-name">{t.name}</FieldLabel>
<Input {...field} id="hh-create-name" aria-invalid={fieldState.invalid} />
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="ownerUserId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="hh-create-owner">{t.owner}</FieldLabel>
<UserSelect
id="hh-create-owner"
users={users}
value={field.value}
onChange={field.onChange}
invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
{form.formState.errors.root ? (
<FieldError>{form.formState.errors.root.message}</FieldError>
) : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
function AddMemberDialog({
household,
users,
onOpenChange,
}: {
household: AdminHouseholdRow | null;
users: UserOption[];
onOpenChange: (o: boolean) => void;
}) {
const form = useForm<AddMemberInput>({
resolver: zodResolver(addMemberSchema),
defaultValues: { userId: "", role: "member" },
});
useEffect(() => {
if (household) form.reset({ userId: "", role: "member" });
}, [household, form]);
async function onSubmit(values: AddMemberInput) {
if (!household) return;
const result = await addMemberAction(household.id, values);
if (result.ok) {
toast.success(t.memberAdded(users.find((u) => u.id === values.userId)?.name ?? ""));
onOpenChange(false);
} else {
form.setError("root", { message: result.error });
}
}
return (
<ResponsiveDialog
open={Boolean(household)}
onOpenChange={onOpenChange}
title={household ? t.addMemberTitle(household.name) : t.addMember}
description={t.addMemberDescription}
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
{tr.common.cancel}
</Button>
<Button type="submit" form="household-add-member" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
{t.addMember}
</Button>
</>
}
>
<form id="household-add-member" onSubmit={form.handleSubmit(onSubmit)} noValidate>
<FieldGroup>
<Controller
name="userId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="hh-member-user">{t.user}</FieldLabel>
<UserSelect
id="hh-member-user"
users={users}
value={field.value}
onChange={field.onChange}
invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="role"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="hh-member-role">{t.role}</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
id="hh-member-role"
className="w-full"
aria-describedby="hh-member-role-hint"
>
<SelectValue>{tr.household.roles[field.value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{HOUSEHOLD_ROLES.map((r) => (
<SelectItem key={r} value={r}>
{tr.household.roles[r]}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldDescription id="hh-member-role-hint">
{tr.household.roleHints[field.value]}
</FieldDescription>
</Field>
)}
/>
{form.formState.errors.root ? (
<FieldError>{form.formState.errors.root.message}</FieldError>
) : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
export function HouseholdsAdmin({
households,
users,
currentUserId,
}: {
households: AdminHouseholdRow[];
users: UserOption[];
currentUserId: string;
}) {
const [creating, setCreating] = useState(false);
const [addingTo, setAddingTo] = useState<AdminHouseholdRow | null>(null);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<p className="max-w-2xl text-sm text-muted-foreground">{t.privacyNote}</p>
<Button className="ml-auto" onClick={() => setCreating(true)}>
<Plus />
{t.newHousehold}
</Button>
</div>
{households.length === 0 ? (
<p className="rounded-xl border border-dashed p-8 text-center text-muted-foreground">
{t.empty}
</p>
) : (
<div className="grid gap-4 xl:grid-cols-2">
{households.map((h) => (
<Card key={h.id}>
<CardHeader>
<CardTitle>{h.name}</CardTitle>
<CardDescription className="flex flex-wrap items-center gap-2">
<span>{t.members(h.memberCount)}</span>
{h.owners.length ? (
<span>
· {t.owners}: {h.owners.join(", ")}
</span>
) : (
<Badge variant="destructive">{t.noOwner}</Badge>
)}
</CardDescription>
<CardAction>
<Button variant="outline" size="sm" onClick={() => setAddingTo(h)}>
<UserPlus />
{t.addMember}
</Button>
</CardAction>
</CardHeader>
<CardContent>
<MembersList
members={h.members}
householdName={h.name}
currentUserId={currentUserId}
canManage
onSetRole={(userId, role) => adminSetMemberRoleAction(h.id, userId, role)}
onRemove={(userId) => adminRemoveMemberAction(h.id, userId)}
/>
</CardContent>
</Card>
))}
</div>
)}
<CreateHouseholdDialog open={creating} onOpenChange={setCreating} users={users} />
<AddMemberDialog
household={addingTo}
users={users}
onOpenChange={(o) => !o && setAddingTo(null)}
/>
</div>
);
}
@@ -0,0 +1,48 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { UserOption } from "@/features/households/service";
import { tr } from "@/i18n/tr";
/** Kullanıcı seçici (askıdakiler işaretli). */
export function UserSelect({
id,
users,
value,
onChange,
invalid,
}: {
id: string;
users: UserOption[];
value: string;
onChange: (id: string) => void;
invalid?: boolean;
}) {
const selected = users.find((u) => u.id === value);
return (
<Select value={value || undefined} onValueChange={onChange}>
<SelectTrigger id={id} className="w-full" aria-invalid={invalid}>
<SelectValue placeholder={tr.adminHouseholds.selectUser}>
{selected ? `${selected.name} (@${selected.username})` : null}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name}
<span className="text-xs text-muted-foreground">
@{u.username}
{u.banned ? ` · ${tr.settings.banned}` : ""}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+10
View File
@@ -0,0 +1,10 @@
import "server-only";
import { adminListHouseholds, adminListUserOptions } from "@/features/households/service";
import { requireRole } from "@/lib/dal";
/** Admin: haneler (üyeleriyle) ve hane atamaları için kullanıcı listesi. */
export async function getAdminHouseholdsData() {
await requireRole("admin");
const [households, users] = await Promise.all([adminListHouseholds(), adminListUserOptions()]);
return { households, users };
}
+302
View File
@@ -0,0 +1,302 @@
"use server";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { getAuth } from "@/lib/auth";
import { authErrorCode } from "@/lib/auth-errors";
import { writeAudit } from "@/lib/audit";
import { getActionUser } from "@/lib/dal";
import { removeAllMembershipsOfUser } from "@/features/households/service";
import { storedEmail } from "@/shared/account-email";
import { systemRoleSchema, type SystemRole } from "@/shared/schemas/common";
import {
banUserSchema,
setPasswordSchema,
userCreateSchema,
userUpdateSchema,
} from "@/shared/schemas/user";
import { checkAdminAction, type AdminAction, type GuardViolation } from "./guards";
/**
* Admin kullanıcı yönetimi Server Action'ları.
* Sıra: yetki (sunucuda) → zod doğrulama → iş kuralları (guards) → Better Auth admin API → revalidate.
*/
const t = tr.adminUsers;
const PATH = "/admin/kullanicilar";
const userIdSchema = z.string().min(1).max(64);
const guardMessage = (v: GuardViolation) => (v === "self" ? t.guards.self : t.guards.lastAdmin);
/** Bilinen Better Auth hata kodlarını kullanıcıya gösterilecek alan hatalarına çevirir. */
function mapAuthError(error: unknown): ActionResult<never> {
const code = authErrorCode(error);
switch (code) {
case "USERNAME_IS_ALREADY_TAKEN":
return fail(t.errors.checkFields, { username: t.errors.usernameTaken });
case "USER_ALREADY_EXISTS":
case "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL":
return fail(t.errors.checkFields, { email: t.errors.emailTaken });
case "INVALID_USERNAME":
case "USERNAME_TOO_SHORT":
case "USERNAME_TOO_LONG":
return fail(t.errors.checkFields, { username: tr.validation.usernameFormat });
case "INVALID_EMAIL":
return fail(t.errors.checkFields, { email: tr.validation.email });
case "PASSWORD_TOO_SHORT":
case "PASSWORD_TOO_LONG":
return fail(t.errors.checkFields, { password: tr.validation.passwordMin });
case "YOU_CANNOT_BAN_YOURSELF":
case "YOU_CANNOT_REMOVE_YOURSELF":
return fail(t.guards.self);
default:
// İç detay kullanıcıya sızdırılmaz; sunucuda loglanır.
console.error("[admin-users] beklenmeyen hata", code ?? error);
return fail(tr.common.genericError);
}
}
/** Fiziksel kontrol: oturumdaki kullanıcı admin mi? Değilse işlem yapılmaz. */
async function adminContext() {
const actor = await getActionUser("admin");
if (!actor) return null;
return { actor, headers: await headers(), api: getAuth().api };
}
type Ctx = NonNullable<Awaited<ReturnType<typeof adminContext>>>;
async function loadTarget(ctx: Ctx, userId: string) {
try {
const user = await ctx.api.getUser({ headers: ctx.headers, query: { id: userId } });
return user
? {
id: user.id,
role: (user.role === "admin" ? "admin" : "user") as SystemRole,
name: user.name,
}
: null;
} catch {
return null;
}
}
async function countAdmins(ctx: Ctx): Promise<number> {
const result = await ctx.api.listUsers({
headers: ctx.headers,
query: { filterField: "role", filterOperator: "eq", filterValue: "admin", limit: 1 },
});
return result.total;
}
async function guard(ctx: Ctx, action: AdminAction, target: { id: string; role: SystemRole }) {
const adminCount = target.role === "admin" ? await countAdmins(ctx) : Number.POSITIVE_INFINITY;
return checkAdminAction(action, { actorId: ctx.actor.id, target, adminCount });
}
// --- Action'lar ---
export async function createUserAction(input: unknown): Promise<ActionResult> {
const ctx = await adminContext();
if (!ctx) return fail(tr.common.unauthorized);
const parsed = userCreateSchema.safeParse(input);
if (!parsed.success) return fail(t.errors.checkFields);
const v = parsed.data;
let createdId = "";
try {
const created = await ctx.api.createUser({
headers: ctx.headers,
body: {
name: v.name,
email: storedEmail(v.username, v.email),
password: v.password,
role: v.role,
data: { username: v.username },
},
});
createdId = created.user.id;
} catch (error) {
return mapAuthError(error);
}
await writeAudit({
action: "user.create",
actorId: ctx.actor.id,
targetType: "user",
targetId: createdId,
diff: { username: v.username, role: v.role },
});
revalidatePath(PATH);
return ok(undefined);
}
export async function updateUserAction(userId: unknown, input: unknown): Promise<ActionResult> {
const ctx = await adminContext();
if (!ctx) return fail(tr.common.unauthorized);
const id = userIdSchema.safeParse(userId);
const parsed = userUpdateSchema.safeParse(input);
if (!id.success || !parsed.success) return fail(t.errors.checkFields);
const target = await loadTarget(ctx, id.data);
if (!target) return fail(tr.common.notFound);
const v = parsed.data;
if (v.role !== target.role) {
const violation = await guard(ctx, { type: "setRole", role: v.role }, target);
if (violation) return fail(guardMessage(violation), { role: guardMessage(violation) });
}
try {
await ctx.api.adminUpdateUser({
headers: ctx.headers,
body: {
userId: target.id,
data: { name: v.name, username: v.username, email: storedEmail(v.username, v.email) },
},
});
if (v.role !== target.role) {
await ctx.api.setRole({ headers: ctx.headers, body: { userId: target.id, role: v.role } });
}
} catch (error) {
return mapAuthError(error);
}
await writeAudit({
action: "user.update",
actorId: ctx.actor.id,
targetType: "user",
targetId: target.id,
diff: { name: v.name, username: v.username, role: v.role },
});
revalidatePath(PATH);
return ok(undefined);
}
export async function setRoleAction(userId: unknown, role: unknown): Promise<ActionResult> {
const ctx = await adminContext();
if (!ctx) return fail(tr.common.unauthorized);
const id = userIdSchema.safeParse(userId);
const r = systemRoleSchema.safeParse(role);
if (!id.success || !r.success) return fail(tr.common.genericError);
const target = await loadTarget(ctx, id.data);
if (!target) return fail(tr.common.notFound);
const violation = await guard(ctx, { type: "setRole", role: r.data }, target);
if (violation) return fail(guardMessage(violation));
try {
await ctx.api.setRole({ headers: ctx.headers, body: { userId: target.id, role: r.data } });
} catch (error) {
return mapAuthError(error);
}
await writeAudit({
action: "user.role",
actorId: ctx.actor.id,
targetType: "user",
targetId: target.id,
diff: { role: { from: target.role, to: r.data } },
});
revalidatePath(PATH);
return ok(undefined);
}
export async function setPasswordAction(userId: unknown, input: unknown): Promise<ActionResult> {
const ctx = await adminContext();
if (!ctx) return fail(tr.common.unauthorized);
const id = userIdSchema.safeParse(userId);
const parsed = setPasswordSchema.safeParse(input);
if (!id.success || !parsed.success) return fail(t.errors.checkFields);
const target = await loadTarget(ctx, id.data);
if (!target) return fail(tr.common.notFound);
try {
await ctx.api.setUserPassword({
headers: ctx.headers,
body: { userId: target.id, newPassword: parsed.data.password },
});
// Başka bir kullanıcının şifresi sıfırlandıysa açık oturumlarını kapat.
if (target.id !== ctx.actor.id) {
await ctx.api.revokeUserSessions({ headers: ctx.headers, body: { userId: target.id } });
}
} catch (error) {
return mapAuthError(error);
}
await writeAudit({
action: "user.password.reset",
actorId: ctx.actor.id,
targetType: "user",
targetId: target.id,
});
return ok(undefined);
}
export async function banUserAction(userId: unknown, input: unknown): Promise<ActionResult> {
const ctx = await adminContext();
if (!ctx) return fail(tr.common.unauthorized);
const id = userIdSchema.safeParse(userId);
const parsed = banUserSchema.safeParse(input);
if (!id.success || !parsed.success) return fail(t.errors.checkFields);
const target = await loadTarget(ctx, id.data);
if (!target) return fail(tr.common.notFound);
const violation = await guard(ctx, { type: "ban" }, target);
if (violation) return fail(guardMessage(violation));
try {
// banUser açık oturumları da sonlandırır.
await ctx.api.banUser({
headers: ctx.headers,
body: { userId: target.id, banReason: parsed.data.reason },
});
} catch (error) {
return mapAuthError(error);
}
await writeAudit({
action: "user.ban",
actorId: ctx.actor.id,
targetType: "user",
targetId: target.id,
diff: { reason: parsed.data.reason ?? null },
});
revalidatePath(PATH);
return ok(undefined);
}
export async function unbanUserAction(userId: unknown): Promise<ActionResult> {
const ctx = await adminContext();
if (!ctx) return fail(tr.common.unauthorized);
const id = userIdSchema.safeParse(userId);
if (!id.success) return fail(tr.common.genericError);
try {
await ctx.api.unbanUser({ headers: ctx.headers, body: { userId: id.data } });
} catch (error) {
return mapAuthError(error);
}
await writeAudit({
action: "user.unban",
actorId: ctx.actor.id,
targetType: "user",
targetId: id.data,
});
revalidatePath(PATH);
return ok(undefined);
}
export async function removeUserAction(userId: unknown): Promise<ActionResult> {
const ctx = await adminContext();
if (!ctx) return fail(tr.common.unauthorized);
const id = userIdSchema.safeParse(userId);
if (!id.success) return fail(tr.common.genericError);
const target = await loadTarget(ctx, id.data);
if (!target) return fail(tr.common.notFound);
const violation = await guard(ctx, { type: "delete" }, target);
if (violation) return fail(guardMessage(violation));
try {
await ctx.api.removeUser({ headers: ctx.headers, body: { userId: target.id } });
} catch (error) {
return mapAuthError(error);
}
const removedMemberships = await removeAllMembershipsOfUser(target.id);
await writeAudit({
action: "user.remove",
actorId: ctx.actor.id,
targetType: "user",
targetId: target.id,
diff: { name: target.name, removedMemberships },
});
revalidatePath(PATH);
return ok(undefined);
}
@@ -59,18 +59,22 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result"; import { fail, type ActionResult } from "@/lib/action-result";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatDate } from "@/shared/format"; import { formatDate } from "@/shared/format";
import type { SystemRole } from "@/shared/schemas/common"; import type { SystemRole } from "@/shared/schemas/common";
import type { UserCreate, UserUpdate } from "@/shared/schemas/user"; import type { UserCreate, UserUpdate } from "@/shared/schemas/user";
import { compareTr, includesTr } from "@/shared/search"; import { compareTr, includesTr } from "@/shared/search";
import { import {
checkAdminAction, banUserAction,
findUniqueConflicts, createUserAction,
type AdminAction, removeUserAction,
type GuardViolation, setPasswordAction,
} from "../guards"; setRoleAction,
unbanUserAction,
updateUserAction,
} from "../actions";
import { checkAdminAction, type AdminAction, type GuardViolation } from "../guards";
import type { AdminUser } from "../types"; import type { AdminUser } from "../types";
import { BanUserDialog, DeleteUserDialog, SetPasswordDialog } from "./user-action-dialogs"; import { BanUserDialog, DeleteUserDialog, SetPasswordDialog } from "./user-action-dialogs";
import { UserFormDialog } from "./user-form-dialog"; import { UserFormDialog } from "./user-form-dialog";
@@ -311,15 +315,14 @@ const columns = helper.columns([
]); ]);
export function UsersTable({ export function UsersTable({
initialUsers, users,
currentUserId, currentUserId,
}: { }: {
initialUsers: AdminUser[]; users: AdminUser[];
currentUserId: string; currentUserId: string;
}) { }) {
// FAZ 3: yerel durum yerine Server Action + revalidatePath.
const [users, setUsers] = useState(initialUsers);
const [dialog, setDialog] = useState<DialogState>(null); const [dialog, setDialog] = useState<DialogState>(null);
// Arayüzdeki kural kontrolü yalnızca erken geri bildirim içindir; asıl kontrol sunucuda.
const adminCount = users.filter((u) => u.role === "admin").length; const adminCount = users.filter((u) => u.role === "admin").length;
const guard = (action: AdminAction, target: AdminUser): GuardViolation | null => const guard = (action: AdminAction, target: AdminUser): GuardViolation | null =>
@@ -327,84 +330,41 @@ export function UsersTable({
const guardMessage = (v: GuardViolation) => (v === "self" ? t.guards.self : t.guards.lastAdmin); const guardMessage = (v: GuardViolation) => (v === "self" ? t.guards.self : t.guards.lastAdmin);
// --- Mock işlemler (FAZ 3'te Server Action'lara taşınacak; aynı ActionResult sözleşmesi) --- /** Server Action'ı çağırır; başarıda bildirim gösterir. Sayfa revalidatePath ile tazelenir. */
async function run(promise: Promise<ActionResult>, success: string): Promise<ActionResult> {
async function createUser(values: UserCreate): Promise<ActionResult> { try {
const conflicts = findUniqueConflicts(users, values); const result = await promise;
if (conflicts.username || conflicts.email) { if (result.ok) toast.success(success);
return fail(t.errors.checkFields, { return result;
username: conflicts.username && t.errors.usernameTaken, } catch {
email: conflicts.email && t.errors.emailTaken, return fail(tr.common.genericError);
});
} }
const { password: _password, ...rest } = values;
setUsers((prev) => [
{
...rest,
id: `u_${crypto.randomUUID().slice(0, 8)}`,
banned: false,
createdAt: new Date().toISOString(),
},
...prev,
]);
toast.success(t.toast.created(values.name));
return ok(undefined);
} }
async function updateUser(id: string, values: UserUpdate): Promise<ActionResult> { const createUser = (values: UserCreate) =>
const target = users.find((u) => u.id === id); run(createUserAction(values), t.toast.created(values.name));
if (!target) return fail(tr.common.genericError);
const conflicts = findUniqueConflicts(users, values, id); const updateUser = (id: string, values: UserUpdate) =>
if (conflicts.username || conflicts.email) { run(updateUserAction(id, values), t.toast.updated(values.name));
return fail(t.errors.checkFields, {
username: conflicts.username && t.errors.usernameTaken, async function setRole(user: AdminUser, role: SystemRole) {
email: conflicts.email && t.errors.emailTaken, const result = await run(setRoleAction(user.id, role), t.toast.roleChanged(user.name));
}); if (!result.ok) toast.error(result.error);
}
const v =
values.role !== target.role ? guard({ type: "setRole", role: values.role }, target) : null;
if (v) return fail(guardMessage(v), { role: guardMessage(v) });
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, ...values } : u)));
toast.success(t.toast.updated(values.name));
return ok(undefined);
} }
function setRole(user: AdminUser, role: SystemRole) { const setPassword = (user: AdminUser, values: { password: string }) =>
const v = guard({ type: "setRole", role }, user); run(setPasswordAction(user.id, values), t.toast.passwordSet(user.name));
if (v) return void toast.error(guardMessage(v));
setUsers((prev) => prev.map((u) => (u.id === user.id ? { ...u, role } : u))); const banUser = (user: AdminUser, values: { reason?: string }) =>
toast.success(t.toast.roleChanged(user.name)); run(banUserAction(user.id, values), t.toast.banned(user.name));
async function unbanUser(user: AdminUser) {
const result = await run(unbanUserAction(user.id), t.toast.unbanned(user.name));
if (!result.ok) toast.error(result.error);
} }
async function setPassword(user: AdminUser): Promise<ActionResult> { const removeUser = (user: AdminUser) =>
toast.success(t.toast.passwordSet(user.name)); run(removeUserAction(user.id), t.toast.removed(user.name));
return ok(undefined);
}
async function banUser(user: AdminUser, { reason }: { reason?: string }): Promise<ActionResult> {
const v = guard({ type: "ban" }, user);
if (v) return fail(guardMessage(v));
setUsers((prev) =>
prev.map((u) => (u.id === user.id ? { ...u, banned: true, banReason: reason } : u)),
);
toast.success(t.toast.banned(user.name));
return ok(undefined);
}
function unbanUser(user: AdminUser) {
setUsers((prev) =>
prev.map((u) => (u.id === user.id ? { ...u, banned: false, banReason: undefined } : u)),
);
toast.success(t.toast.unbanned(user.name));
}
async function removeUser(user: AdminUser): Promise<ActionResult> {
const v = guard({ type: "delete" }, user);
if (v) return fail(guardMessage(v));
setUsers((prev) => prev.filter((u) => u.id !== user.id));
toast.success(t.toast.removed(user.name));
return ok(undefined);
}
const table = useTable({ const table = useTable({
features, features,
+1 -18
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { checkAdminAction, findUniqueConflicts } from "./guards"; import { checkAdminAction } from "./guards";
const admin = { id: "a1", role: "admin" as const }; const admin = { id: "a1", role: "admin" as const };
const user = { id: "u1", role: "user" as const }; const user = { id: "u1", role: "user" as const };
@@ -40,20 +40,3 @@ describe("checkAdminAction", () => {
expect(checkAdminAction({ type: "setRole", role: "admin" }, ctx)).toBeNull(); expect(checkAdminAction({ type: "setRole", role: "admin" }, ctx)).toBeNull();
}); });
}); });
describe("findUniqueConflicts", () => {
const users = [
{ id: "1", username: "ayse", email: "ayse@x.com" },
{ id: "2", username: "emre" },
];
it("çakışan kullanıcı adı ve e-postayı bulur", () => {
expect(findUniqueConflicts(users, { username: "ayse", email: "ayse@x.com" })).toEqual({
username: "taken",
email: "taken",
});
});
it("düzenlenen kaydın kendisini saymaz, boş e-postayı kontrol etmez", () => {
expect(findUniqueConflicts(users, { username: "ayse", email: "ayse@x.com" }, "1")).toEqual({});
expect(findUniqueConflicts(users, { username: "yeni" })).toEqual({});
});
});
-15
View File
@@ -35,18 +35,3 @@ export function checkAdminAction(action: AdminAction, ctx: GuardContext): GuardV
if (removesAdmin && ctx.adminCount <= 1) return "lastAdmin"; if (removesAdmin && ctx.adminCount <= 1) return "lastAdmin";
return null; return null;
} }
/** Kullanıcı adı / e-posta benzersizlik kontrolü (normalize edilmiş değerlerle). */
export function findUniqueConflicts(
users: ReadonlyArray<{ id: string; username: string; email?: string }>,
values: { username: string; email?: string },
excludeId?: string,
): { username?: "taken"; email?: "taken" } {
const others = users.filter((u) => u.id !== excludeId);
return {
...(others.some((u) => u.username === values.username) ? { username: "taken" as const } : {}),
...(values.email && others.some((u) => u.email === values.email)
? { email: "taken" as const }
: {}),
};
}
+27
View File
@@ -0,0 +1,27 @@
import { visibleEmail } from "@/shared/account-email";
import type { AdminUser } from "./types";
type RawAdminUser = {
id: string;
name: string;
email: string;
username?: string | null;
role?: string | null;
banned?: boolean | null;
banReason?: string | null;
createdAt: Date | string;
};
/** Better Auth kullanıcı kaydını arayüz satırına çevirir (yer tutucu e-posta gizlenir). */
export function toAdminUser(u: RawAdminUser): AdminUser {
return {
id: u.id,
name: u.name,
username: u.username ?? "",
email: visibleEmail(u.email),
role: u.role === "admin" ? "admin" : "user",
banned: Boolean(u.banned),
banReason: u.banReason ?? undefined,
createdAt: new Date(u.createdAt).toISOString(),
};
}
-111
View File
@@ -1,111 +0,0 @@
import type { AdminUser } from "./types";
/** FAZ 2 GEÇİCİ: Faz 3'te Better Auth admin API'sinden gelen listeyle değiştirilecek. */
export const MOCK_ADMIN_USERS: AdminUser[] = [
{
id: "u_admin",
name: "Suphi Çakır",
username: "suphi",
email: "admin@butcem.local",
role: "admin",
banned: false,
createdAt: "2026-05-02T08:00:00Z",
},
{
id: "u_ayse",
name: "Ayşe Çakır",
username: "ayse",
email: "ayse@butcem.local",
role: "user",
banned: false,
createdAt: "2026-05-02T08:10:00Z",
},
{
id: "u_emre",
name: "Emre Çakır",
username: "emre",
role: "user",
banned: false,
createdAt: "2026-05-03T17:45:00Z",
},
{
id: "u_zeynep",
name: "Zeynep Çakır",
username: "zeynep",
role: "user",
banned: false,
createdAt: "2026-05-03T17:50:00Z",
},
{
id: "u_fatma",
name: "Fatma Demir",
username: "fatma.demir",
email: "fatma@butcem.local",
role: "user",
banned: false,
createdAt: "2026-06-11T12:00:00Z",
},
{
id: "u_mehmet",
name: "Mehmet Demir",
username: "mehmet.demir",
email: "mehmet@butcem.local",
role: "user",
banned: false,
createdAt: "2026-06-11T12:05:00Z",
},
{
id: "u_ozan",
name: "Ozan Kaya",
username: "ozan",
email: "ozan@butcem.local",
role: "user",
banned: true,
banReason: "Eski ev arkadaşı",
createdAt: "2026-07-20T09:30:00Z",
},
{
id: "u_elif",
name: "Elif Şahin",
username: "elif_s",
email: "elif@butcem.local",
role: "user",
banned: false,
createdAt: "2026-08-14T19:15:00Z",
},
{
id: "u_burak",
name: "Burak Öztürk",
username: "burak",
email: "burak@butcem.local",
role: "admin",
banned: false,
createdAt: "2026-09-01T06:40:00Z",
},
{
id: "u_irem",
name: "İrem Güneş",
username: "irem",
role: "user",
banned: false,
createdAt: "2026-09-22T15:00:00Z",
},
{
id: "u_can",
name: "Can Yıldız",
username: "can.y",
email: "can@butcem.local",
role: "user",
banned: false,
createdAt: "2026-10-05T10:20:00Z",
},
{
id: "u_selin",
name: "Selin Aydın",
username: "selin",
email: "selin@butcem.local",
role: "user",
banned: false,
createdAt: "2026-10-30T21:05:00Z",
},
];
+16
View File
@@ -0,0 +1,16 @@
import "server-only";
import { headers } from "next/headers";
import { getAuth } from "@/lib/auth";
import { requireRole } from "@/lib/dal";
import { toAdminUser } from "./mapper";
import type { AdminUser } from "./types";
/** Admin: tüm kullanıcılar (en yeni önce). Kişisel kullanım ölçeğinde tek sayfa yeterli. */
export async function listAdminUsers(): Promise<AdminUser[]> {
await requireRole("admin");
const result = await getAuth().api.listUsers({
headers: await headers(),
query: { limit: 1000, sortBy: "createdAt", sortDirection: "desc" },
});
return result.users.map(toAdminUser);
}
-13
View File
@@ -1,13 +0,0 @@
import type { SessionUser } from "./types";
/**
* FAZ 2 GEÇİCİ: Better Auth bağlanana kadar arayüzü besleyen sahte oturum.
* Faz 3'te `requireSession()` (lib/dal.ts) ile değiştirilecek ve bu dosya silinecek.
*/
export const MOCK_SESSION_USER: SessionUser = {
id: "u_admin",
name: "Suphi Çakır",
username: "suphi",
email: "admin@butcem.local",
role: "admin",
};
+65
View File
@@ -0,0 +1,65 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { categoryFormSchema } from "@/shared/schemas/category";
import { createCategory, reorderCategory, setCategoryArchived, updateCategory } from "./service";
const PATH = "/kategoriler";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
const checkFields = tr.adminUsers.errors.checkFields;
export async function createCategoryAction(input: unknown): Promise<ActionResult<{ id: string }>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = categoryFormSchema.safeParse(input);
if (!parsed.success) return fail(checkFields);
const result = await createCategory(ctx.user.id, ctx.household.id, parsed.data);
if (result.ok) revalidatePath(PATH);
return result;
}
export async function updateCategoryAction(
categoryId: unknown,
input: unknown,
): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(categoryId);
const parsed = categoryFormSchema.safeParse(input);
if (!id.success || !parsed.success) return fail(checkFields);
const result = await updateCategory(ctx.user.id, ctx.household.id, id.data, parsed.data);
if (result.ok) revalidatePath(PATH);
return result;
}
export async function reorderCategoryAction(
categoryId: unknown,
direction: unknown,
): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(categoryId);
const dir = z.enum(["up", "down"]).safeParse(direction);
if (!id.success || !dir.success) return fail(tr.common.genericError);
const result = await reorderCategory(ctx.user.id, ctx.household.id, id.data, dir.data);
if (result.ok) revalidatePath(PATH);
return result;
}
export async function setCategoryArchivedAction(
categoryId: unknown,
archived: unknown,
): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(categoryId);
const a = z.boolean().safeParse(archived);
if (!id.success || !a.success) return fail(tr.common.genericError);
const result = await setCategoryArchived(ctx.user.id, ctx.household.id, id.data, a.data);
if (result.ok) revalidatePath(PATH);
return result;
}
@@ -0,0 +1,238 @@
"use client";
import {
Archive,
ArchiveRestore,
ArrowDown,
ArrowUp,
MoreHorizontal,
Pencil,
Plus,
} from "lucide-react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { IconBadge } from "@/components/appearance/swatch";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import type { CategoryKind } from "@/shared/schemas/category";
import { reorderCategoryAction, setCategoryArchivedAction } from "../actions";
import type { CategoryView } from "../service";
import { buildTree, MAX_DEPTH, type CategoryNode } from "../tree";
import { CategoryFormDialog, type CategoryDialogState } from "./category-form-dialog";
const t = tr.categories;
export function CategoriesManager({
categories,
canEdit,
}: {
categories: CategoryView[];
canEdit: boolean;
}) {
const [kind, setKind] = useState<CategoryKind>("expense");
const [showArchived, setShowArchived] = useState(false);
const [dialog, setDialog] = useState<CategoryDialogState>(null);
const [archiving, setArchiving] = useState<CategoryView | null>(null);
const [pending, startTransition] = useTransition();
const tree = useMemo(
() => buildTree(categories.filter((c) => c.kind === kind && (showArchived || !c.archived))),
[categories, kind, showArchived],
);
const archivedCount = categories.filter((c) => c.kind === kind && c.archived).length;
function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) {
startTransition(async () => {
const result = await promise;
if (result.ok) {
if (success) toast.success(success);
} else toast.error(result.error ?? tr.common.genericError);
});
}
function Row({
node,
index,
siblings,
}: {
node: CategoryNode<CategoryView>;
index: number;
siblings: number;
}) {
return (
<>
<li
className={cn("flex items-center gap-3 py-2 pr-2", node.archived && "opacity-60")}
style={{ paddingInlineStart: `${0.75 + node.depth * 1.5}rem` }}
>
<IconBadge icon={node.icon} color={node.color} size={node.depth ? "sm" : "md"} />
<span className={cn("min-w-0 flex-1 truncate", node.depth === 0 && "font-medium")}>
{node.name}
</span>
{node.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
{canEdit ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={`${node.name} — ${tr.common.actions}`}
disabled={pending}
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
{!node.archived ? (
<>
{node.depth < MAX_DEPTH - 1 ? (
<DropdownMenuItem
onSelect={() => setDialog({ mode: "create", kind, parentId: node.id })}
>
<Plus />
{t.addChild}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onSelect={() => setDialog({ mode: "edit", category: node })}>
<Pencil />
{t.edit}
</DropdownMenuItem>
<DropdownMenuItem
disabled={index === 0}
onSelect={() => run(reorderCategoryAction(node.id, "up"))}
>
<ArrowUp />
{t.moveUp}
</DropdownMenuItem>
<DropdownMenuItem
disabled={index === siblings - 1}
onSelect={() => run(reorderCategoryAction(node.id, "down"))}
>
<ArrowDown />
{t.moveDown}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => setArchiving(node)}>
<Archive />
{t.archive}
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem
onSelect={() =>
run(setCategoryArchivedAction(node.id, false), t.unarchivedToast(node.name))
}
>
<ArchiveRestore />
{t.unarchive}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</li>
{node.children.map((child, i) => (
<Row key={child.id} node={child} index={i} siblings={node.children.length} />
))}
</>
);
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-3">
<Tabs value={kind} onValueChange={(v) => setKind(v as CategoryKind)}>
<TabsList>
<TabsTrigger value="expense">{t.expense}</TabsTrigger>
<TabsTrigger value="income">{t.income}</TabsTrigger>
</TabsList>
</Tabs>
{archivedCount ? (
<div className="flex items-center gap-2">
<Checkbox
id="cat-show-archived"
checked={showArchived}
onCheckedChange={(v) => setShowArchived(v === true)}
/>
<Label htmlFor="cat-show-archived" className="font-normal">
{t.showArchived} ({archivedCount})
</Label>
</div>
) : null}
{canEdit ? (
<Button
className="ml-auto"
onClick={() => setDialog({ mode: "create", kind, parentId: null })}
>
<Plus />
{t.newCategory}
</Button>
) : null}
</div>
{!canEdit ? <p className="text-sm text-muted-foreground">{t.readOnly}</p> : null}
{tree.length ? (
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
{tree.map((node, i) => (
<Row key={node.id} node={node} index={i} siblings={tree.length} />
))}
</ul>
) : (
<p className="rounded-xl border border-dashed p-8 text-center text-muted-foreground">
{t.empty}
</p>
)}
<CategoryFormDialog
state={dialog}
categories={categories}
onOpenChange={(o) => !o && setDialog(null)}
/>
<AlertDialog open={Boolean(archiving)} onOpenChange={(o) => !o && setArchiving(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
<AlertDialogDescription>
{archiving ? t.archiveDescription(archiving.name) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button
variant="destructive"
onClick={() => {
const c = archiving!;
setArchiving(null);
run(setCategoryArchivedAction(c.id, true), t.archivedToast(c.name));
}}
>
{t.archive}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,217 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { toast } from "sonner";
import { IconPicker, SwatchPicker } from "@/components/appearance/pickers";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { tr } from "@/i18n/tr";
import {
categoryFormSchema,
type CategoryForm,
type CategoryFormInput,
type CategoryKind,
} from "@/shared/schemas/category";
import { createCategoryAction, updateCategoryAction } from "../actions";
import type { CategoryView } from "../service";
import { buildTree, descendantIds, MAX_DEPTH, toOptions } from "../tree";
const t = tr.categories;
const ROOT = "__root__";
export type CategoryDialogState =
| { mode: "create"; kind: CategoryKind; parentId: string | null }
| { mode: "edit"; category: CategoryView }
| null;
export function CategoryFormDialog({
state,
categories,
onOpenChange,
}: {
state: CategoryDialogState;
categories: CategoryView[];
onOpenChange: (open: boolean) => void;
}) {
const form = useForm<CategoryFormInput, unknown, CategoryForm>({
resolver: zodResolver(categoryFormSchema),
});
const kind = state?.mode === "edit" ? state.category.kind : (state?.kind ?? "expense");
useEffect(() => {
if (!state) return;
if (state.mode === "edit") {
const c = state.category;
form.reset({
kind: c.kind,
name: c.name,
parentId: c.parentId ?? "",
icon: c.icon,
color: c.color,
});
} else {
const parent = categories.find((c) => c.id === state.parentId);
form.reset({
kind: state.kind,
name: "",
parentId: state.parentId ?? "",
icon: parent?.icon ?? "tag",
color: parent?.color ?? "blue",
});
}
}, [state, categories, form]);
/** Üst kategori seçenekleri: aynı tür, aktif; düzenlemede kendisi ve alt kategorileri hariç. */
const parentOptions = useMemo(() => {
const sameKind = categories.filter((c) => c.kind === kind);
const excluded = new Set(
state?.mode === "edit"
? [state.category.id, ...descendantIds(sameKind, state.category.id)]
: [],
);
return toOptions(buildTree(sameKind)).filter(
(o) => !excluded.has(o.id) && o.depth < MAX_DEPTH - 1,
);
}, [categories, kind, state]);
/** Ham form değerleri gönderilir; sunucu aynı şemayla yeniden ayrıştırır (tek doğruluk kaynağı). */
async function onSubmit() {
const values = form.getValues();
const result =
state?.mode === "edit"
? await updateCategoryAction(state.category.id, values)
: await createCategoryAction(values);
if (result.ok) {
toast.success(state?.mode === "edit" ? t.updated(values.name) : t.created(values.name));
onOpenChange(false);
return;
}
for (const [name, message] of Object.entries(result.fieldErrors ?? {})) {
if (message) form.setError(name as keyof CategoryFormInput, { message });
}
if (!result.fieldErrors) form.setError("root", { message: result.error });
}
const color = useWatch({ control: form.control, name: "color" }) ?? "blue";
return (
<ResponsiveDialog
open={Boolean(state)}
onOpenChange={onOpenChange}
title={state?.mode === "edit" ? t.editTitle : t.createTitle}
description={t.formDescription}
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
{tr.common.cancel}
</Button>
<Button type="submit" form="category-form" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
{state?.mode === "edit" ? tr.common.save : tr.common.create}
</Button>
</>
}
>
<form id="category-form" onSubmit={form.handleSubmit(onSubmit)} noValidate>
<FieldGroup>
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="cat-name">{t.name}</FieldLabel>
<Input
{...field}
value={field.value ?? ""}
id="cat-name"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="parentId"
control={form.control}
render={({ field, fieldState }) => {
const current = parentOptions.find((o) => o.id === field.value);
return (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="cat-parent">{t.parent}</FieldLabel>
<Select
value={field.value || ROOT}
onValueChange={(v) => field.onChange(v === ROOT ? "" : v)}
>
<SelectTrigger
id="cat-parent"
className="w-full"
aria-invalid={fieldState.invalid}
>
<SelectValue>{current?.path ?? t.noParent}</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value={ROOT}>{t.noParent}</SelectItem>
{parentOptions.map((o) => (
<SelectItem key={o.id} value={o.id}>
<span style={{ paddingInlineStart: `${o.depth * 0.75}rem` }}>
{o.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
);
}}
/>
<Controller
name="color"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel id="cat-color-label">{t.color}</FieldLabel>
<SwatchPicker
value={field.value}
onChange={field.onChange}
labelledBy="cat-color-label"
/>
</Field>
)}
/>
<Controller
name="icon"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel id="cat-icon-label">{t.icon}</FieldLabel>
<IconPicker
value={field.value}
color={color}
onChange={field.onChange}
labelledBy="cat-icon-label"
/>
<FieldDescription className="sr-only">{t.icon}</FieldDescription>
</Field>
)}
/>
{form.formState.errors.root ? (
<FieldError>{form.formState.errors.root.message}</FieldError>
) : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
+117
View File
@@ -0,0 +1,117 @@
import type { IconKey, Swatch } from "@/shared/appearance";
import type { CategoryKind } from "@/shared/schemas/category";
/**
* Hane oluşturulunca yüklenen varsayılan Türkçe kategori seti (Bölüm 6.2).
* `key` sabittir: set tekrar yüklendiğinde kopya oluşmaz, kullanıcı adı değiştirse bile tanınır.
*/
export type DefaultCategory = {
key: string;
name: string;
icon: IconKey;
color: Swatch;
children?: Omit<DefaultCategory, "children">[];
};
export const DEFAULT_CATEGORIES: Record<CategoryKind, DefaultCategory[]> = {
expense: [
{
key: "market",
name: "Market",
icon: "shopping-cart",
color: "green",
children: [
{ key: "market.gida", name: "Gıda", icon: "shopping-basket", color: "green" },
{ key: "market.temizlik", name: "Temizlik", icon: "spray-can", color: "teal" },
{ key: "market.kisisel-bakim", name: "Kişisel bakım", icon: "sparkles", color: "pink" },
],
},
{
key: "faturalar",
name: "Faturalar",
icon: "receipt",
color: "amber",
children: [
{ key: "faturalar.elektrik", name: "Elektrik", icon: "zap", color: "amber" },
{ key: "faturalar.su", name: "Su", icon: "droplet", color: "sky" },
{ key: "faturalar.dogalgaz", name: "Doğalgaz", icon: "flame", color: "orange" },
{ key: "faturalar.internet", name: "İnternet", icon: "wifi", color: "indigo" },
{ key: "faturalar.telefon", name: "Telefon", icon: "smartphone", color: "purple" },
],
},
{ key: "kira", name: "Kira", icon: "house", color: "blue" },
{ key: "aidat", name: "Aidat", icon: "building", color: "slate" },
{
key: "ulasim",
name: "Ulaşım",
icon: "car",
color: "sky",
children: [
{ key: "ulasim.yakit", name: "Yakıt", icon: "fuel", color: "orange" },
{ key: "ulasim.toplu-tasima", name: "Toplu taşıma", icon: "bus", color: "sky" },
{ key: "ulasim.arac-bakim", name: "Araç bakım ve sigorta", icon: "wrench", color: "slate" },
],
},
{
key: "saglik",
name: "Sağlık",
icon: "heart-pulse",
color: "red",
children: [
{ key: "saglik.ilac", name: "İlaç", icon: "pill", color: "red" },
{ key: "saglik.muayene", name: "Muayene ve tahlil", icon: "heart-pulse", color: "rose" },
],
},
{ key: "egitim", name: "Eğitim", icon: "graduation-cap", color: "indigo" },
{ key: "giyim", name: "Giyim", icon: "shirt", color: "purple" },
{
key: "eglence",
name: "Eğlence",
icon: "film",
color: "pink",
children: [
{
key: "eglence.disarida-yemek",
name: "Dışarıda yemek",
icon: "utensils",
color: "orange",
},
{ key: "eglence.kafe", name: "Kafe", icon: "coffee", color: "amber" },
{ key: "eglence.tatil", name: "Tatil", icon: "plane", color: "sky" },
],
},
{ key: "abonelikler", name: "Abonelikler", icon: "repeat", color: "purple" },
{ key: "ev", name: "Ev ve mobilya", icon: "sofa", color: "teal" },
{ key: "cocuk", name: "Çocuk", icon: "baby", color: "rose" },
{ key: "evcil-hayvan", name: "Evcil hayvan", icon: "paw-print", color: "lime" },
{ key: "hediye", name: "Hediye ve bağış", icon: "gift", color: "pink" },
{
key: "banka",
name: "Banka ve kart giderleri",
icon: "percent",
color: "slate",
children: [
{ key: "banka.faiz", name: "Faiz", icon: "percent", color: "red" },
{ key: "banka.vergi", name: "KKDF / BSMV", icon: "receipt", color: "slate" },
{
key: "banka.ucret",
name: "Kart ve hesap ücretleri",
icon: "credit-card",
color: "slate",
},
],
},
{ key: "vergi", name: "Vergi ve harçlar", icon: "landmark", color: "slate" },
{ key: "diger-gider", name: "Diğer", icon: "circle-ellipsis", color: "slate" },
],
income: [
{ key: "maas", name: "Maaş", icon: "briefcase", color: "green" },
{ key: "ek-gelir", name: "Ek gelir", icon: "coins", color: "teal" },
{ key: "yemek-karti", name: "Yemek kartı yüklemesi", icon: "utensils", color: "lime" },
{ key: "kira-geliri", name: "Kira geliri", icon: "house", color: "blue" },
{ key: "getiri", name: "Faiz ve getiri", icon: "trending-up", color: "indigo" },
{ key: "iade", name: "İade", icon: "undo", color: "sky" },
{ key: "hediye-gelir", name: "Hediye", icon: "gift", color: "pink" },
{ key: "diger-gelir", name: "Diğer", icon: "circle-ellipsis", color: "slate" },
],
};
+159
View File
@@ -0,0 +1,159 @@
import { Types } from "mongoose";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { CategoryModel } from "@/models/category";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
createCategory,
installDefaultCategories,
listCategories,
setCategoryArchived,
updateCategory,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let annemler = "";
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
annemler = ids.household("Annemler");
});
afterAll(teardownDb);
const byKey = (key: string, householdId = cakir) =>
CategoryModel.findOne({ householdId, defaultKey: key })
.lean()
.then((c) => String(c!._id));
const form = (over: Partial<Parameters<typeof createCategory>[2]> = {}) => ({
kind: "expense" as const,
name: "Test",
parentId: null,
icon: "tag" as const,
color: "blue" as const,
...over,
});
describe("varsayılan kategoriler", () => {
it("tekrar yüklemede kopya oluşmaz", async () => {
const before = await CategoryModel.countDocuments({ householdId: cakir });
const added = await installDefaultCategories(
new Types.ObjectId(cakir),
new Types.ObjectId(ids.user("admin")),
);
expect(added).toBe(0);
expect(await CategoryModel.countDocuments({ householdId: cakir })).toBe(before);
});
it("alt kategoriler doğru ancestors zinciriyle yüklenir", async () => {
const elektrik = await CategoryModel.findOne({
householdId: cakir,
defaultKey: "faturalar.elektrik",
}).lean();
const faturalar = await byKey("faturalar");
expect(elektrik?.ancestors.map(String)).toEqual([faturalar]);
});
});
describe("izolasyon ve roller", () => {
it("başka hanenin kategorilerini okuyamaz ve değiştiremez", async () => {
expect(await listCategories(ids.user("fatma"), cakir)).toBeNull();
const kira = await byKey("kira");
// fatma kendi hanesinin kimliğiyle, Çakır'ın kategorisini hedefler → bulunamaz.
const r = await updateCategory(
ids.user("fatma"),
annemler,
kira,
form({ name: "Ele geçirildi" }),
);
expect(r.ok).toBe(false);
expect((await CategoryModel.findById(kira).lean())?.name).toBe("Kira");
const r2 = await setCategoryArchived(ids.user("fatma"), annemler, kira, true);
expect(r2.ok).toBe(false);
});
it("izleyici ekleyemez, üye ekler", async () => {
expect((await createCategory(ids.user("zeynep"), cakir, form({ name: "Harçlık" }))).ok).toBe(
false,
);
expect((await createCategory(ids.user("emre"), cakir, form({ name: "Harçlık" }))).ok).toBe(
true,
);
});
it("başka hanenin kategorisini üst kategori yapamaz", async () => {
const annemlerMarket = await byKey("market", annemler);
const r = await createCategory(
ids.user("ayse"),
cakir,
form({ name: "Sızma", parentId: annemlerMarket }),
);
expect(r.ok).toBe(false);
});
});
describe("ağaç kuralları", () => {
it("aynı düzeyde aynı ad (Türkçe normalize) reddedilir", async () => {
const r = await createCategory(ids.user("ayse"), cakir, form({ name: " KİRA " }));
expect(r.ok).toBe(false);
// farklı düzeyde aynı ad serbest
const market = await byKey("market");
expect(
(await createCategory(ids.user("ayse"), cakir, form({ name: "Kira", parentId: market }))).ok,
).toBe(true);
});
it("taşıma alt ağacın ancestors alanlarını günceller", async () => {
const ulasim = await byKey("ulasim");
const yakit = await byKey("ulasim.yakit");
const created = await createCategory(
ids.user("ayse"),
cakir,
form({ name: "LPG", parentId: yakit }),
);
const lpg = created.ok ? created.data.id : "";
const ev = await byKey("ev");
// Yakıt'ı (alt kategorisi LPG ile) "Ev ve mobilya" altına taşı.
const r = await updateCategory(
ids.user("ayse"),
cakir,
yakit,
form({ name: "Yakıt", parentId: ev, icon: "fuel" }),
);
expect(r.ok).toBe(true);
expect((await CategoryModel.findById(yakit).lean())?.ancestors.map(String)).toEqual([ev]);
expect((await CategoryModel.findById(lpg).lean())?.ancestors.map(String)).toEqual([ev, yakit]);
expect((await CategoryModel.findById(yakit).lean())?.parentId?.toString()).not.toBe(ulasim);
});
it("döngü ve tür uyuşmazlığı reddedilir", async () => {
const market = await byKey("market");
const gida = await byKey("market.gida");
expect(
(
await updateCategory(
ids.user("ayse"),
cakir,
market,
form({ name: "Market", parentId: gida }),
)
).ok,
).toBe(false);
const maas = await byKey("maas");
expect(
(await updateCategory(ids.user("ayse"), cakir, gida, form({ name: "Gıda", parentId: maas })))
.ok,
).toBe(false);
});
it("arşivleme alt ağaçla birlikte yapılır; üstü arşivliyken alt kategori geri alınamaz", async () => {
const saglik = await byKey("saglik");
const ilac = await byKey("saglik.ilac");
expect((await setCategoryArchived(ids.user("emre"), cakir, saglik, true)).ok).toBe(true);
const active = (await listCategories(ids.user("emre"), cakir))!.map((c) => c.id);
expect(active).not.toContain(saglik);
expect(active).not.toContain(ilac);
expect((await setCategoryArchived(ids.user("emre"), cakir, ilac, false)).ok).toBe(false);
expect((await setCategoryArchived(ids.user("emre"), cakir, saglik, false)).ok).toBe(true);
});
});
+331
View File
@@ -0,0 +1,331 @@
import "server-only";
import { Types, type ClientSession } from "mongoose";
import { resolveMembership } from "@/features/households/service";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { writeAudit } from "@/lib/audit";
import { connectMongoose } from "@/lib/db/mongoose";
import { toObjectId } from "@/lib/object-id";
import { CategoryModel } from "@/models/category";
import type { IconKey, Swatch } from "@/shared/appearance";
import type { CategoryForm, CategoryKind } from "@/shared/schemas/category";
import { foldTr } from "@/shared/search";
import { DEFAULT_CATEGORIES } from "./defaults";
import {
checkCreate,
checkMove,
descendantIds,
type FlatCategory,
type MoveViolation,
} from "./tree";
/**
* Kategori servisi. Okuma: viewer; yazma: member. Hane kapsamı her fonksiyonda doğrulanır.
* Silme yoktur: arşivleme (alt ağaçla birlikte). İşlemler arşivli kategoriyi göstermeye devam eder.
*/
const t = tr.categories;
export type CategoryView = FlatCategory & { icon: IconKey; color: Swatch; isDefault: boolean };
type Lean = {
_id: Types.ObjectId;
kind: CategoryKind;
name: string;
parentId: Types.ObjectId | null;
order: number;
archivedAt: Date | null;
icon: string;
color: string;
defaultKey: string | null;
ancestors: Types.ObjectId[];
};
const toView = (c: Lean): CategoryView => ({
id: String(c._id),
kind: c.kind,
name: c.name,
parentId: c.parentId ? String(c.parentId) : null,
order: c.order,
archived: Boolean(c.archivedAt),
icon: c.icon as IconKey,
color: c.color as Swatch,
isDefault: Boolean(c.defaultKey),
});
const violationMessage = (v: MoveViolation) => t.violations[v];
export async function listCategories(
userId: string,
householdId: string,
opts: { kind?: CategoryKind; includeArchived?: boolean } = {},
): Promise<CategoryView[] | null> {
const m = await resolveMembership(userId, householdId, "viewer");
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);
}
async function loadKind(householdId: Types.ObjectId, kind: CategoryKind, session?: ClientSession) {
return CategoryModel.find({ householdId, kind })
.session(session ?? null)
.lean<Lean[]>();
}
function hasSiblingNamed(all: Lean[], parentId: string | null, name: string, exceptId?: string) {
const key = foldTr(name);
return all.some(
(c) =>
!c.archivedAt &&
String(c._id) !== exceptId &&
(c.parentId ? String(c.parentId) : null) === parentId &&
foldTr(c.name) === 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.
*/
export async function installDefaultCategories(
householdId: Types.ObjectId,
actor: Types.ObjectId,
session?: ClientSession,
): Promise<number> {
await connectMongoose();
let inserted = 0;
const upsert = async (
kind: CategoryKind,
def: { key: string; name: string; icon: string; color: string },
order: number,
parent: { _id: Types.ObjectId; ancestors: Types.ObjectId[] } | null,
) => {
const res = await CategoryModel.findOneAndUpdate(
{ householdId, defaultKey: def.key },
{
$setOnInsert: {
householdId,
kind,
name: def.name,
nameKey: foldTr(def.name),
parentId: parent?._id ?? null,
ancestors: parent ? [...parent.ancestors, parent._id] : [],
icon: def.icon,
color: def.color,
order,
defaultKey: def.key,
createdBy: actor,
updatedBy: actor,
},
},
{ upsert: true, returnDocument: "after", includeResultMetadata: true, session },
);
if (!res.lastErrorObject?.updatedExisting) inserted++;
return res.value!;
};
for (const kind of ["expense", "income"] as const) {
for (const [i, def] of DEFAULT_CATEGORIES[kind].entries()) {
const parent = await upsert(kind, def, i, null);
for (const [j, child] of (def.children ?? []).entries()) {
await upsert(kind, child, j, { _id: parent._id, ancestors: parent.ancestors });
}
}
}
return inserted;
}
export async function createCategory(
actorId: string,
householdId: string,
input: CategoryForm,
): Promise<ActionResult<{ id: string }>> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const all = await loadKind(m.householdId, input.kind);
const violation = checkCreate(all.map(toView), input.kind, input.parentId);
if (violation)
return fail(violationMessage(violation), { parentId: violationMessage(violation) });
if (hasSiblingNamed(all, input.parentId, input.name))
return fail(t.duplicate, { name: t.duplicate });
const parent = input.parentId ? all.find((c) => String(c._id) === input.parentId) : null;
const siblings = all.filter((c) => (c.parentId ? String(c.parentId) : null) === input.parentId);
const actor = new Types.ObjectId(actorId);
const doc = await CategoryModel.create({
householdId: m.householdId,
kind: input.kind,
name: input.name,
nameKey: foldTr(input.name),
parentId: parent?._id ?? null,
ancestors: parent ? [...parent.ancestors, parent._id] : [],
icon: input.icon,
color: input.color,
order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0,
createdBy: actor,
updatedBy: actor,
});
return ok({ id: String(doc._id) });
}
async function loadOne(householdId: Types.ObjectId, categoryId: string) {
const id = toObjectId(categoryId);
if (!id) return null;
// householdId filtresi: başka hanenin kategorisi asla bulunmaz.
return CategoryModel.findOne({ _id: id, householdId }).lean<Lean>();
}
/** Ad, ikon, renk ve (değiştiyse) üst kategori. Taşıma alt ağaçla birlikte transaction'da. */
export async function updateCategory(
actorId: string,
householdId: string,
categoryId: string,
input: CategoryForm,
): Promise<ActionResult> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const current = await loadOne(m.householdId, categoryId);
if (!current || current.kind !== input.kind) return fail(tr.common.notFound);
const all = await loadKind(m.householdId, current.kind);
const currentParent = current.parentId ? String(current.parentId) : null;
const moving = currentParent !== input.parentId;
if (moving) {
const violation = checkMove(all.map(toView), categoryId, input.parentId);
if (violation)
return fail(violationMessage(violation), { parentId: violationMessage(violation) });
}
if (hasSiblingNamed(all, input.parentId, input.name, categoryId))
return fail(t.duplicate, { name: t.duplicate });
const actor = new Types.ObjectId(actorId);
const mongoose = await connectMongoose();
await mongoose.connection.transaction(async (session) => {
const set: Record<string, unknown> = {
name: input.name,
nameKey: foldTr(input.name),
icon: input.icon,
color: input.color,
updatedBy: actor,
};
if (moving) {
const parent = input.parentId ? all.find((c) => String(c._id) === input.parentId)! : null;
const newAncestors = parent ? [...parent.ancestors, parent._id] : [];
const siblings = all.filter(
(c) => (c.parentId ? String(c.parentId) : null) === input.parentId,
);
Object.assign(set, {
parentId: parent?._id ?? null,
ancestors: newAncestors,
order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0,
});
// Alt ağaç: yeni üst zincir + (taşınan düğüm) + düğümün altındaki eski yol.
for (const d of all.filter((c) => c.ancestors.some((a) => a.equals(current._id)))) {
const idx = d.ancestors.findIndex((a) => a.equals(current._id));
await CategoryModel.updateOne(
{ _id: d._id, householdId: m.householdId },
{ $set: { ancestors: [...newAncestors, current._id, ...d.ancestors.slice(idx + 1)] } },
{ session },
);
}
await writeAudit(
{
action: "category.move",
actorId: actor,
householdId: m.householdId,
targetType: "category",
targetId: categoryId,
diff: { parentId: { from: currentParent, to: input.parentId } },
},
session,
);
}
await CategoryModel.updateOne(
{ _id: current._id, householdId: m.householdId },
{ $set: set },
{ session },
);
});
return ok(undefined);
}
/** Kardeşler arasında bir yukarı/aşağı taşır. */
export async function reorderCategory(
actorId: string,
householdId: string,
categoryId: string,
direction: "up" | "down",
): Promise<ActionResult> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const current = await loadOne(m.householdId, categoryId);
if (!current) return fail(tr.common.notFound);
const siblings = (await loadKind(m.householdId, current.kind))
.filter((c) => !c.archivedAt && String(c.parentId ?? "") === String(current.parentId ?? ""))
.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr"));
const i = siblings.findIndex((c) => c._id.equals(current._id));
const j = direction === "up" ? i - 1 : i + 1;
if (i < 0 || j < 0 || j >= siblings.length) return ok(undefined);
[siblings[i], siblings[j]] = [siblings[j]!, siblings[i]!];
await CategoryModel.bulkWrite(
siblings.map((c, order) => ({
updateOne: {
filter: { _id: c._id, householdId: m.householdId },
update: { $set: { order } },
},
})),
);
return ok(undefined);
}
/** Arşivler (alt kategorilerle birlikte) veya arşivden çıkarır (üstü aktif olmalı). */
export async function setCategoryArchived(
actorId: string,
householdId: string,
categoryId: string,
archived: boolean,
): Promise<ActionResult> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const current = await loadOne(m.householdId, categoryId);
if (!current) return fail(tr.common.notFound);
const all = await loadKind(m.householdId, current.kind);
if (!archived && current.parentId) {
const parent = all.find((c) => c._id.equals(current.parentId!));
if (parent?.archivedAt) return fail(t.violations.parentArchived);
}
if (
!archived &&
hasSiblingNamed(
all,
current.parentId ? String(current.parentId) : null,
current.name,
categoryId,
)
) {
return fail(t.duplicate);
}
const ids = [
current._id,
...descendantIds(all.map(toView), categoryId).map((id) => new Types.ObjectId(id)),
];
const actor = new Types.ObjectId(actorId);
await CategoryModel.updateMany(
{ _id: { $in: ids }, householdId: m.householdId },
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
);
await writeAudit({
action: archived ? "category.archive" : "category.unarchive",
actorId: actor,
householdId: m.householdId,
targetType: "category",
targetId: categoryId,
diff: { name: current.name, affected: ids.length },
});
return ok(undefined);
}
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import {
ancestorIds,
buildTree,
checkCreate,
checkMove,
descendantIds,
rollupTotals,
toOptions,
type FlatCategory,
} from "./tree";
const c = (
id: string,
name: string,
parentId: string | null,
order = 0,
kind: "expense" | "income" = "expense",
): FlatCategory => ({
id,
kind,
name,
parentId,
order,
archived: false,
});
const items: FlatCategory[] = [
c("fat", "Faturalar", null, 1),
c("elk", "Elektrik", "fat", 0),
c("su", "Su", "fat", 1),
c("mar", "Market", null, 0),
c("gida", "Gıda", "mar", 0),
c("meyve", "Meyve", "gida", 0),
c("maas", "Maaş", null, 0, "income"),
];
describe("buildTree / toOptions", () => {
it("sıra ve derinlikle ağaç kurar, yol etiketleri üretir", () => {
const tree = buildTree(items.filter((i) => i.kind === "expense"));
expect(tree.map((n) => n.name)).toEqual(["Market", "Faturalar"]);
expect(toOptions(tree).map((o) => [o.path, o.depth])).toEqual([
["Market", 0],
["Market › Gıda", 1],
["Market › Gıda › Meyve", 2],
["Faturalar", 0],
["Faturalar › Elektrik", 1],
["Faturalar › Su", 1],
]);
});
it("arşivlenenleri varsayılan olarak seçeneklere koymaz", () => {
const tree = buildTree([c("a", "A", null), { ...c("b", "B", "a"), archived: true }]);
expect(toOptions(tree).map((o) => o.id)).toEqual(["a"]);
expect(toOptions(tree, { includeArchived: true }).map((o) => o.id)).toEqual(["a", "b"]);
});
it("üstü olmayan düğümü köke alır, döngüde takılmaz", () => {
const loop = [c("x", "X", "y"), c("y", "Y", "x"), c("z", "Z", "yok")];
expect(() => buildTree(loop)).not.toThrow();
expect(buildTree(loop).map((n) => n.id)).toContain("z");
});
});
describe("ancestorIds / descendantIds", () => {
it("üst ve alt kategorileri bulur", () => {
expect(ancestorIds(items, "meyve")).toEqual(["mar", "gida"]);
expect(descendantIds(items, "mar").sort()).toEqual(["gida", "meyve"]);
expect(descendantIds(items, "elk")).toEqual([]);
});
});
describe("checkMove", () => {
it("kendine, alt kategorisine ve farklı türe taşımayı reddeder", () => {
expect(checkMove(items, "mar", "mar")).toBe("self");
expect(checkMove(items, "mar", "meyve")).toBe("descendant");
expect(checkMove(items, "elk", "maas")).toBe("kind");
expect(checkMove(items, "elk", "yok")).toBe("parentNotFound");
});
it("geçerli taşımaya ve köke taşımaya izin verir", () => {
expect(checkMove(items, "su", "mar")).toBeNull();
expect(checkMove(items, "gida", null)).toBeNull();
});
it("derinlik sınırını uygular (en fazla 5 seviye: derinlik 0–4)", () => {
const deep = [
c("1", "1", null),
c("2", "2", "1"),
c("3", "3", "2"),
c("4", "4", "3"),
c("5", "5", "4"),
c("x", "X", null),
c("y", "Y", "x"),
];
expect(checkCreate(deep, "expense", "4")).toBeNull(); // yeni düğüm derinlik 4
expect(checkCreate(deep, "expense", "5")).toBe("depth"); // derinlik 5
expect(checkMove(deep, "y", "4")).toBeNull();
expect(checkMove(deep, "y", "5")).toBe("depth");
expect(checkMove(deep, "x", "4")).toBe("depth"); // x derinlik 4, alt kategorisi y derinlik 5
expect(checkCreate(deep, "income", "3")).toBe("kind");
});
});
describe("rollupTotals", () => {
it("alt kategori tutarlarını üst kategorilere toplar", () => {
const totals = rollupTotals(
items,
new Map([
["meyve", 300],
["gida", 500],
["elk", 1000],
["su", 250],
]),
);
expect(totals.get("mar")).toBe(800);
expect(totals.get("gida")).toBe(800);
expect(totals.get("fat")).toBe(1250);
expect(totals.get("maas") ?? 0).toBe(0);
});
});
+163
View File
@@ -0,0 +1,163 @@
import type { CategoryKind } from "@/shared/schemas/category";
import { compareTr } from "@/shared/search";
/**
* Kategori ağacı saf fonksiyonları (framework'ten bağımsız, birim testli).
* Derinlik pratikte 3 seviye; güvenlik için en fazla MAX_DEPTH seviye.
*/
export const MAX_DEPTH = 5;
export type FlatCategory = {
id: string;
kind: CategoryKind;
name: string;
parentId: string | null;
order: number;
archived: boolean;
};
export type CategoryNode<T extends FlatCategory = FlatCategory> = T & {
depth: number;
children: CategoryNode<T>[];
};
const bySortOrder = (a: FlatCategory, b: FlatCategory) =>
a.order - b.order || compareTr(a.name, b.name);
/** Düz listeden ağaç kurar. Üstü bulunamayan (veya döngülü) düğümler köke alınır. */
export function buildTree<T extends FlatCategory>(items: readonly T[]): CategoryNode<T>[] {
const nodes = new Map(
items.map((c) => [c.id, { ...c, depth: 0, children: [] as CategoryNode<T>[] }]),
);
const roots: CategoryNode<T>[] = [];
for (const node of nodes.values()) {
const parent = node.parentId ? nodes.get(node.parentId) : undefined;
if (parent && parent !== node) parent.children.push(node);
else roots.push(node);
}
const visit = (list: CategoryNode<T>[], depth: number, seen: Set<string>) => {
list.sort(bySortOrder);
for (const n of list) {
if (seen.has(n.id)) continue;
seen.add(n.id);
n.depth = depth;
visit(n.children, depth + 1, seen);
}
};
visit(roots, 0, new Set());
return roots;
}
export type CategoryOption = {
id: string;
label: string;
path: string;
depth: number;
kind: CategoryKind;
};
/** Seçim listesi (işlem formları): derinlik sırasıyla, "Faturalar › Elektrik" yoluyla. */
export function toOptions<T extends FlatCategory>(
tree: CategoryNode<T>[],
{ includeArchived = false } = {},
): CategoryOption[] {
const out: CategoryOption[] = [];
const walk = (list: CategoryNode<T>[], prefix: string[]) => {
for (const n of list) {
if (n.archived && !includeArchived) continue;
const path = [...prefix, n.name];
out.push({ id: n.id, label: n.name, path: path.join(" › "), depth: n.depth, kind: n.kind });
walk(n.children, path);
}
};
walk(tree, []);
return out;
}
/** `id`'nin tüm alt kategorileri (kendisi hariç). */
export function descendantIds(items: readonly FlatCategory[], id: string): string[] {
const children = new Map<string, string[]>();
for (const c of items)
if (c.parentId) children.set(c.parentId, [...(children.get(c.parentId) ?? []), c.id]);
const out: string[] = [];
const stack = [...(children.get(id) ?? [])];
while (stack.length) {
const next = stack.pop()!;
if (out.includes(next)) continue;
out.push(next);
stack.push(...(children.get(next) ?? []));
}
return out;
}
/** Kökten `id`'ye kadar üst kategori kimlikleri (id hariç). */
export function ancestorIds(items: readonly FlatCategory[], id: string): string[] {
const byId = new Map(items.map((c) => [c.id, c]));
const out: string[] = [];
let cur = byId.get(id)?.parentId ?? null;
while (cur && !out.includes(cur)) {
out.unshift(cur);
cur = byId.get(cur)?.parentId ?? null;
}
return out;
}
/** Alt ağacın yüksekliği (yaprak = 0). */
function subtreeHeight(items: readonly FlatCategory[], id: string): number {
const kids = items.filter((c) => c.parentId === id);
return kids.length ? 1 + Math.max(...kids.map((k) => subtreeHeight(items, k.id))) : 0;
}
export type MoveViolation =
"self" | "descendant" | "kind" | "depth" | "parentNotFound" | "parentArchived";
/** `id` kategorisi `newParentId` altına (null = kök) taşınabilir mi? */
export function checkMove(
items: readonly FlatCategory[],
id: string,
newParentId: string | null,
): MoveViolation | null {
if (newParentId === null) return null;
if (newParentId === id) return "self";
const parent = items.find((c) => c.id === newParentId);
const self = items.find((c) => c.id === id);
if (!parent || !self) return "parentNotFound";
if (parent.kind !== self.kind) return "kind";
if (parent.archived) return "parentArchived";
if (descendantIds(items, id).includes(newParentId)) return "descendant";
const newDepth = ancestorIds(items, newParentId).length + 1;
if (newDepth + subtreeHeight(items, id) >= MAX_DEPTH) return "depth";
return null;
}
/** Yeni kategori `parentId` altına eklenebilir mi? */
export function checkCreate(
items: readonly FlatCategory[],
kind: CategoryKind,
parentId: string | null,
): MoveViolation | null {
if (!parentId) return null;
const parent = items.find((c) => c.id === parentId);
if (!parent) return "parentNotFound";
if (parent.kind !== kind) return "kind";
if (parent.archived) return "parentArchived";
if (ancestorIds(items, parentId).length + 1 >= MAX_DEPTH) return "depth";
return null;
}
/**
* Kategori bazlı tutarları üst kategorilere toplar (raporlar ve bütçe için):
* her kategori kendi tutarı + tüm alt kategorilerinin tutarı.
*/
export function rollupTotals(
items: readonly FlatCategory[],
totals: ReadonlyMap<string, number>,
): Map<string, number> {
const out = new Map<string, number>();
for (const c of items) {
const own = totals.get(c.id) ?? 0;
for (const target of [c.id, ...ancestorIds(items, c.id)])
out.set(target, (out.get(target) ?? 0) + own);
}
return out;
}
+60
View File
@@ -0,0 +1,60 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { writeActiveHouseholdId } from "@/lib/active-household";
import { getCurrentUser, getHouseholdActionContext } from "@/lib/dal";
import { householdRoleSchema, householdSettingsSchema } from "@/shared/schemas/household";
import { removeMember, resolveMembership, setMemberRole, updateHouseholdSettings } from "./service";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
/** Aktif haneyi değiştirir: önce üyelik doğrulanır, sonra cookie yazılır. */
export async function switchHouseholdAction(
householdId: unknown,
): Promise<ActionResult<{ name: string }>> {
const user = await getCurrentUser();
const id = idSchema.safeParse(householdId);
if (!user || !id.success) return fail(tr.common.unauthorized);
const membership = await resolveMembership(user.id, id.data, "viewer");
if (!membership) return fail(tr.common.unauthorized);
await writeActiveHouseholdId(id.data);
revalidatePath("/", "layout");
return ok({ name: membership.name });
}
/** Owner: hane adı ve ayarları (aktif hane). */
export async function updateHouseholdSettingsAction(input: unknown): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("owner");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = householdSettingsSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields);
const result = await updateHouseholdSettings(ctx.user.id, ctx.household.id, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
/** Owner: aktif hanedeki üyenin rolü. */
export async function setMemberRoleAction(userId: unknown, role: unknown): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("owner");
if (!ctx) return fail(tr.common.unauthorized);
const uid = idSchema.safeParse(userId);
const r = householdRoleSchema.safeParse(role);
if (!uid.success || !r.success) return fail(tr.common.genericError);
const result = await setMemberRole(ctx.user.id, ctx.household.id, uid.data, r.data);
if (result.ok) revalidatePath("/ayarlar");
return result;
}
/** Owner: üyeyi aktif haneden çıkarır. */
export async function removeMemberAction(userId: unknown): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("owner");
if (!ctx) return fail(tr.common.unauthorized);
const uid = idSchema.safeParse(userId);
if (!uid.success) return fail(tr.common.genericError);
const result = await removeMember(ctx.user.id, ctx.household.id, uid.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
@@ -0,0 +1,228 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { Controller, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { tr } from "@/i18n/tr";
import { householdSettingsSchema, type HouseholdSettings } from "@/shared/schemas/household";
import { updateHouseholdSettingsAction } from "../actions";
const t = tr.settings.fields;
/** Form şeması: eşik TL (tam sayı) olarak girilir, kuruşa sunucuya giderken çevrilir. */
const formSchema = householdSettingsSchema
.omit({ extraStoreSavingThresholdMinor: true, budgetMonthStartDay: true })
.extend({
budgetMonthStartDay: z.number().int().min(1).max(28),
extraStoreSavingThresholdTl: z
.number({ error: tr.validation.wholeNumber })
.int(tr.validation.wholeNumber)
.min(0)
.max(100_000),
});
type FormValues = z.infer<typeof formSchema>;
const DAYS = Array.from({ length: 28 }, (_, i) => i + 1);
export function HouseholdSettingsForm({
settings,
canEdit,
}: {
settings: HouseholdSettings;
canEdit: boolean;
}) {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: settings.name,
budgetMonthStartDay: settings.budgetMonthStartDay,
installmentBudgetMode: settings.installmentBudgetMode,
dailyPlanMode: settings.dailyPlanMode,
includePrivateAccountsInTotals: settings.includePrivateAccountsInTotals,
extraStoreSavingThresholdTl: Math.round(settings.extraStoreSavingThresholdMinor / 100),
},
});
async function onSubmit({ extraStoreSavingThresholdTl, ...rest }: FormValues) {
const result = await updateHouseholdSettingsAction({
...rest,
extraStoreSavingThresholdMinor: extraStoreSavingThresholdTl * 100,
});
if (result.ok) {
toast.success(tr.settings.saved);
form.reset({ ...rest, extraStoreSavingThresholdTl });
} else {
toast.error(result.error);
}
}
const submitting = form.formState.isSubmitting;
return (
<form onSubmit={form.handleSubmit(onSubmit)} noValidate>
{/* Sahip olmayanlar için tüm alanlar salt okunur. */}
<FieldSet disabled={!canEdit || submitting}>
<FieldGroup>
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="hh-name">{t.name}</FieldLabel>
<Input {...field} id="hh-name" aria-invalid={fieldState.invalid} />
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="budgetMonthStartDay"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="hh-start-day">{t.budgetMonthStartDay}</FieldLabel>
<Select
value={String(field.value)}
onValueChange={(v) => field.onChange(Number(v))}
disabled={!canEdit}
>
<SelectTrigger
id="hh-start-day"
className="w-28"
aria-describedby="hh-start-day-hint"
>
<SelectValue>{field.value}</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-64">
{DAYS.map((d) => (
<SelectItem key={d} value={String(d)}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldDescription id="hh-start-day-hint">
{t.budgetMonthStartDayHint}
</FieldDescription>
</Field>
)}
/>
<Controller
name="installmentBudgetMode"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="hh-installment">{t.installmentBudgetMode}</FieldLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={!canEdit}>
<SelectTrigger id="hh-installment" className="w-full sm:w-80">
<SelectValue>{t.installmentModes[field.value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(["per_installment", "purchase_month"] as const).map((m) => (
<SelectItem key={m} value={m}>
{t.installmentModes[m]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
<Controller
name="dailyPlanMode"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="hh-daily">{t.dailyPlanMode}</FieldLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={!canEdit}>
<SelectTrigger id="hh-daily" className="w-full sm:w-80">
<SelectValue>{t.dailyPlanModes[field.value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(["even", "previous_month_pattern"] as const).map((m) => (
<SelectItem key={m} value={m}>
{t.dailyPlanModes[m]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
<Controller
name="extraStoreSavingThresholdTl"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="hh-threshold">{t.extraStoreSavingThreshold}</FieldLabel>
<Input
id="hh-threshold"
type="number"
inputMode="numeric"
min={0}
step={1}
className="w-36 tabular-nums"
name={field.name}
ref={field.ref}
onBlur={field.onBlur}
value={Number.isNaN(field.value) ? "" : field.value}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
aria-invalid={fieldState.invalid}
aria-describedby="hh-threshold-hint"
/>
<FieldDescription id="hh-threshold-hint">
{t.extraStoreSavingThresholdHint}
</FieldDescription>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="includePrivateAccountsInTotals"
control={form.control}
render={({ field }) => (
<Field orientation="horizontal">
<Checkbox
id="hh-private"
checked={field.value}
onCheckedChange={(v) => field.onChange(v === true)}
disabled={!canEdit}
/>
<FieldLabel htmlFor="hh-private" className="font-normal">
{t.includePrivateAccountsInTotals}
</FieldLabel>
</Field>
)}
/>
{canEdit ? (
<div>
<Button type="submit" disabled={submitting || !form.formState.isDirty}>
{submitting ? <Loader2 className="animate-spin" /> : null}
{tr.common.save}
</Button>
</div>
) : null}
</FieldGroup>
</FieldSet>
</form>
);
}
@@ -0,0 +1,169 @@
"use client";
import { UserMinus } from "lucide-react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { initials } from "@/components/layout/user-menu";
import { tr } from "@/i18n/tr";
import type { ActionResult } from "@/lib/action-result";
import { HOUSEHOLD_ROLES, type HouseholdRole } from "../types";
export type MemberRow = {
userId: string;
name: string;
username: string;
role: HouseholdRole;
banned: boolean;
};
type Props = {
members: MemberRow[];
householdName: string;
currentUserId: string;
canManage: boolean;
onSetRole: (userId: string, role: HouseholdRole) => Promise<ActionResult>;
onRemove: (userId: string) => Promise<ActionResult>;
};
/** Hane üyeleri: owner (veya admin) rol değiştirir / haneden çıkarır. Kurallar sunucuda. */
export function MembersList({
members,
householdName,
currentUserId,
canManage,
onSetRole,
onRemove,
}: Props) {
const [pending, startTransition] = useTransition();
const [removing, setRemoving] = useState<MemberRow | null>(null);
const t = tr.settings;
function changeRole(member: MemberRow, role: HouseholdRole) {
startTransition(async () => {
const result = await onSetRole(member.userId, role);
if (result.ok) toast.success(t.roleChanged(member.name));
else toast.error(result.error);
});
}
function confirmRemove() {
const member = removing;
if (!member) return;
startTransition(async () => {
const result = await onRemove(member.userId);
if (result.ok) {
toast.success(t.memberRemoved(member.name));
setRemoving(null);
} else {
toast.error(result.error);
}
});
}
return (
<>
<ul className="divide-y rounded-xl border" aria-busy={pending}>
{members.map((m) => (
<li key={m.userId} className="flex flex-wrap items-center gap-3 p-3">
<Avatar>
<AvatarFallback className="bg-primary/10 text-xs font-medium text-primary">
{initials(m.name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{m.name}
{m.userId === currentUserId ? (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
({tr.adminUsers.you})
</span>
) : null}
</p>
<p className="truncate text-xs text-muted-foreground">@{m.username}</p>
</div>
{m.banned ? <Badge variant="destructive">{t.banned}</Badge> : null}
{canManage ? (
<div className="flex items-center gap-1">
<Select
value={m.role}
onValueChange={(v) => changeRole(m, v as HouseholdRole)}
disabled={pending}
>
<SelectTrigger
size="sm"
className="w-32"
aria-label={`${m.name} — ${t.changeRole}`}
>
<SelectValue>{tr.household.roles[m.role]}</SelectValue>
</SelectTrigger>
<SelectContent align="end">
{HOUSEHOLD_ROLES.map((r) => (
<SelectItem key={r} value={r}>
<span className="flex flex-col">
<span>{tr.household.roles[r]}</span>
<span className="text-xs text-muted-foreground">
{tr.household.roleHints[r]}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setRemoving(m)}
disabled={pending}
aria-label={`${m.name} — ${t.removeMember}`}
title={t.removeMember}
>
<UserMinus />
</Button>
</div>
) : (
<Badge variant={m.role === "owner" ? "default" : "secondary"}>
{tr.household.roles[m.role]}
</Badge>
)}
</li>
))}
</ul>
<AlertDialog open={Boolean(removing)} onOpenChange={(open) => !open && setRemoving(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.removeTitle}</AlertDialogTitle>
<AlertDialogDescription>
{removing ? t.removeDescription(removing.name, householdName) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>{tr.common.cancel}</AlertDialogCancel>
<Button variant="destructive" onClick={confirmRemove} disabled={pending}>
{t.removeConfirm}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,21 @@
"use client";
import { removeMemberAction, setMemberRoleAction } from "../actions";
import type { HouseholdRole } from "../types";
import { MembersList, type MemberRow } from "./members-list";
/** /ayarlar: aktif hanenin üyeleri (owner action'larına bağlı). */
export function SettingsMembers(props: {
members: MemberRow[];
householdName: string;
currentUserId: string;
canManage: boolean;
}) {
return (
<MembersList
{...props}
onSetRole={(userId, role: HouseholdRole) => setMemberRoleAction(userId, role)}
onRemove={(userId) => removeMemberAction(userId)}
/>
);
}
-7
View File
@@ -1,7 +0,0 @@
import type { HouseholdSummary } from "./types";
/** FAZ 2 GEÇİCİ: Faz 5'te kullanıcının üye olduğu hanelerle değiştirilecek. */
export const MOCK_HOUSEHOLDS: HouseholdSummary[] = [
{ id: "h_cakir", name: "Çakır Ailesi", role: "owner" },
{ id: "h_anne", name: "Annemler", role: "viewer" },
];
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { checkMemberChange, hasHouseholdRole, pickActiveHousehold } from "./roles";
describe("hasHouseholdRole", () => {
it("viewer < member < owner sırasını uygular", () => {
expect(hasHouseholdRole("owner", "member")).toBe(true);
expect(hasHouseholdRole("member", "member")).toBe(true);
expect(hasHouseholdRole("viewer", "member")).toBe(false);
expect(hasHouseholdRole("member", "owner")).toBe(false);
expect(hasHouseholdRole("viewer", "viewer")).toBe(true);
});
});
describe("checkMemberChange", () => {
it("son sahip düşürülemez veya çıkarılamaz", () => {
const ctx = { targetRole: "owner" as const, ownerCount: 1 };
expect(checkMemberChange({ type: "remove" }, ctx)).toBe("lastOwner");
expect(checkMemberChange({ type: "setRole", role: "member" }, ctx)).toBe("lastOwner");
expect(checkMemberChange({ type: "setRole", role: "owner" }, ctx)).toBeNull();
});
it("birden çok sahip varken biri düşürülebilir; sahip olmayanlar serbest", () => {
expect(
checkMemberChange({ type: "remove" }, { targetRole: "owner", ownerCount: 2 }),
).toBeNull();
expect(
checkMemberChange({ type: "remove" }, { targetRole: "viewer", ownerCount: 1 }),
).toBeNull();
expect(
checkMemberChange(
{ type: "setRole", role: "owner" },
{ targetRole: "member", ownerCount: 1 },
),
).toBeNull();
});
});
describe("pickActiveHousehold", () => {
const list = [
{ id: "a", role: "viewer" as const },
{ id: "b", role: "owner" as const },
{ id: "c", role: "owner" as const },
];
it("üyesi olunan tercih edilen haneyi seçer", () => {
expect(pickActiveHousehold(list, "a")?.id).toBe("a");
});
it("tercih yoksa veya uydurmaysa en yüksek rolün olduğu ilk haneye düşer", () => {
expect(pickActiveHousehold(list, "x")?.id).toBe("b");
expect(pickActiveHousehold(list, undefined)?.id).toBe("b");
});
it("hiç üyelik yoksa null", () => {
expect(pickActiveHousehold([], "a")).toBeNull();
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { HouseholdRole } from "./types";
/** Hane içi yetki sırası: viewer < member < owner. */
const RANK: Record<HouseholdRole, number> = { viewer: 0, member: 1, owner: 2 };
/** `role`, en az `min` yetkisine sahip mi? */
export function hasHouseholdRole(role: HouseholdRole, min: HouseholdRole): boolean {
return RANK[role] >= RANK[min];
}
export type MemberChange = { type: "setRole"; role: HouseholdRole } | { type: "remove" };
export type MemberChangeViolation = "lastOwner";
/**
* Üyelik değişikliği kuralı: hanenin son sahibi (owner) düşürülemez veya çıkarılamaz.
* Böylece her hanenin ayarlarını ve üyelerini yönetebilecek en az bir kişi kalır.
*/
export function checkMemberChange(
change: MemberChange,
ctx: { targetRole: HouseholdRole; ownerCount: number },
): MemberChangeViolation | null {
const removesOwner =
ctx.targetRole === "owner" &&
(change.type === "remove" || (change.type === "setRole" && change.role !== "owner"));
return removesOwner && ctx.ownerCount <= 1 ? "lastOwner" : null;
}
/**
* Aktif hane seçimi: tercih edilen (cookie) hane kullanıcının üyelikleri arasındaysa o;
* değilse en yüksek rolün olduğu hane (eşitlikte listedeki ilk). Uydurma veya artık üye
* olunmayan bir hane kimliği asla seçilmez.
*/
export function pickActiveHousehold<T extends { id: string; role: HouseholdRole }>(
memberships: T[],
preferredId: string | undefined,
): T | null {
const preferred = memberships.find((m) => m.id === preferredId);
if (preferred) return preferred;
let best: T | null = null;
for (const m of memberships) if (!best || RANK[m.role] > RANK[best.role]) best = m;
return best;
}
+207
View File
@@ -0,0 +1,207 @@
import mongoose, { Types } from "mongoose";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getDb, getMongoClient } from "@/lib/db/mongo";
import { AuditLogModel } from "@/models/audit-log";
import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import { runSeed } from "../../../scripts/seed/run";
import {
adminCreateHousehold,
adminSetMemberRole,
getHouseholdDetail,
listMemberships,
removeAllMembershipsOfUser,
removeMember,
resolveMembership,
setMemberRole,
updateHouseholdSettings,
} from "./service";
/**
* VERİ İZOLASYONU (Bölüm 5): başka hanenin verisine erişim imkânsız olmalı.
* Seed verisi: Çakır Ailesi (admin+ayse owner, emre member, zeynep viewer, ozan member),
* Annemler (fatma owner, mehmet member, ayse viewer), burak = hanesi olmayan admin.
*/
const ids = new Map<string, string>();
let cakir = "";
let annemler = "";
const settings = {
name: "Çakır Ailesi",
budgetMonthStartDay: 15,
installmentBudgetMode: "purchase_month" as const,
dailyPlanMode: "even" as const,
includePrivateAccountsInTotals: false,
extraStoreSavingThresholdMinor: 25_000,
};
beforeAll(async () => {
expect(getDb().databaseName).toBe("butcem_test");
await getDb().dropDatabase();
await runSeed({ log: () => {} });
for (const u of await getDb().collection("user").find().toArray())
ids.set(String(u.username), String(u._id));
cakir = String((await HouseholdModel.findOne({ name: "Çakır Ailesi" }))!._id);
annemler = String((await HouseholdModel.findOne({ name: "Annemler" }))!._id);
});
afterAll(async () => {
await getDb().dropDatabase();
await mongoose.disconnect();
await getMongoClient().close();
});
const id = (username: string) => ids.get(username)!;
describe("üyelik çözümleme (izolasyon çekirdeği)", () => {
it("üye olmayan kullanıcı başka hanenin üyeliğini çözemez", async () => {
expect(await resolveMembership(id("fatma"), cakir)).toBeNull();
expect(await resolveMembership(id("emre"), annemler)).toBeNull();
});
it("rol yetmezse null döner", async () => {
expect((await resolveMembership(id("ayse"), annemler, "viewer"))?.role).toBe("viewer");
expect(await resolveMembership(id("ayse"), annemler, "member")).toBeNull();
expect(await resolveMembership(id("zeynep"), cakir, "member")).toBeNull();
expect((await resolveMembership(id("emre"), cakir, "member"))?.role).toBe("member");
});
it("hanesi olmayan admin hiçbir haneyi göremez (admin ayrıcalığı yok)", async () => {
expect(await listMemberships(id("burak"))).toEqual([]);
expect(await resolveMembership(id("burak"), cakir)).toBeNull();
expect(await getHouseholdDetail(id("burak"), cakir)).toBeNull();
});
it("geçersiz ve uydurma kimlikler reddedilir", async () => {
const forged = new Types.ObjectId().toHexString();
expect(await resolveMembership(id("ayse"), forged)).toBeNull();
expect(await resolveMembership(id("ayse"), "not-an-id")).toBeNull();
expect(await resolveMembership(id("ayse"), { $ne: null } as unknown as string)).toBeNull();
expect(await resolveMembership("", cakir)).toBeNull();
});
it("kullanıcı yalnızca kendi hanelerini listeler", async () => {
expect((await listMemberships(id("ayse"))).map((m) => [m.name, m.role])).toEqual([
["Annemler", "viewer"],
["Çakır Ailesi", "owner"],
]);
expect((await listMemberships(id("fatma"))).map((m) => m.name)).toEqual(["Annemler"]);
});
});
describe("hane ayrıntısı ve ayarlar", () => {
it("başka hanenin ayrıntısı okunamaz", async () => {
expect(await getHouseholdDetail(id("fatma"), cakir)).toBeNull();
const own = await getHouseholdDetail(id("emre"), cakir);
expect(own?.members.map((m) => m.username).sort()).toEqual([
"admin",
"ayse",
"emre",
"ozan",
"zeynep",
]);
});
it("başka hanenin sahibi, üye ve izleyici ayarları değiştiremez", async () => {
for (const u of ["fatma", "emre", "zeynep", "burak"]) {
const res = await updateHouseholdSettings(id(u), cakir, settings);
expect(res.ok, u).toBe(false);
}
const h = await HouseholdModel.findById(cakir).lean();
expect(h?.settings?.budgetMonthStartDay).toBe(1);
});
it("sahip ayarları değiştirir ve denetim kaydı yazılır", async () => {
const res = await updateHouseholdSettings(id("ayse"), cakir, settings);
expect(res.ok).toBe(true);
const h = await HouseholdModel.findById(cakir).lean();
expect(h?.settings?.budgetMonthStartDay).toBe(15);
const audit = await AuditLogModel.findOne({
action: "household.settings.update",
householdId: cakir,
}).lean();
expect(audit?.actorId.toString()).toBe(id("ayse"));
expect((audit?.diff as Record<string, unknown>).budgetMonthStartDay).toEqual({
from: 1,
to: 15,
});
});
});
describe("üyelik değişiklikleri", () => {
it("üye ve izleyici rol değiştiremez, üye çıkaramaz", async () => {
expect((await setMemberRole(id("emre"), cakir, id("zeynep"), "owner")).ok).toBe(false);
expect((await removeMember(id("zeynep"), cakir, id("emre"))).ok).toBe(false);
});
it("bir hanenin sahibi başka hanedeki üyeliği etkileyemez", async () => {
// ayse Çakır'ın sahibi; fatma Çakır üyesi değil → işlem bulunamaz, Annemler'deki rolü değişmez.
expect((await setMemberRole(id("ayse"), cakir, id("fatma"), "viewer")).ok).toBe(false);
expect((await removeMember(id("ayse"), cakir, id("mehmet"))).ok).toBe(false);
// fatma Annemler'in sahibi; Çakır üzerinde yetkisi yok.
expect((await setMemberRole(id("fatma"), cakir, id("emre"), "viewer")).ok).toBe(false);
const fatma = await HouseholdMemberModel.findOne({
householdId: annemler,
userId: id("fatma"),
}).lean();
const mehmet = await HouseholdMemberModel.findOne({
householdId: annemler,
userId: id("mehmet"),
}).lean();
const emre = await HouseholdMemberModel.findOne({
householdId: cakir,
userId: id("emre"),
}).lean();
expect([fatma?.role, mehmet?.role, emre?.role]).toEqual(["owner", "member", "member"]);
});
it("son sahip düşürülemez; birden çok sahip varken düşürülebilir", async () => {
expect((await adminSetMemberRole(id("admin"), annemler, id("fatma"), "member")).ok).toBe(false);
expect((await setMemberRole(id("fatma"), annemler, id("fatma"), "member")).ok).toBe(false);
// Çakır'da iki sahip var (admin + ayse).
expect((await setMemberRole(id("ayse"), cakir, id("admin"), "member")).ok).toBe(true);
// Artık ayse son sahip.
expect((await setMemberRole(id("ayse"), cakir, id("ayse"), "member")).ok).toBe(false);
expect((await setMemberRole(id("ayse"), cakir, id("admin"), "owner")).ok).toBe(true);
expect(
await AuditLogModel.countDocuments({ action: "household.member.role", householdId: cakir }),
).toBe(2);
});
it("sahip üyeyi haneden çıkarır; kullanıcı artık haneyi göremez", async () => {
expect((await removeMember(id("ayse"), cakir, id("ozan"))).ok).toBe(true);
expect(await resolveMembership(id("ozan"), cakir)).toBeNull();
expect(await listMemberships(id("ozan"))).toEqual([]);
});
});
describe("admin işlemleri", () => {
it("hane + ilk sahip + denetim kaydı tek transaction'da oluşur", async () => {
const res = await adminCreateHousehold(id("admin"), {
name: "Yazlık",
ownerUserId: id("burak"),
});
expect(res.ok).toBe(true);
const hid = res.ok ? res.data.id : "";
expect((await resolveMembership(id("burak"), hid, "owner"))?.name).toBe("Yazlık");
expect(
await AuditLogModel.countDocuments({ action: "household.create", householdId: hid }),
).toBe(1);
});
it("var olmayan kullanıcıyla hane oluşturulmaz", async () => {
const before = await HouseholdModel.countDocuments();
const res = await adminCreateHousehold(id("admin"), {
name: "Hayalet",
ownerUserId: new Types.ObjectId().toHexString(),
});
expect(res.ok).toBe(false);
expect(await HouseholdModel.countDocuments()).toBe(before);
});
it("kullanıcı silinince tüm üyelikleri temizlenir", async () => {
expect(await removeAllMembershipsOfUser(id("mehmet"))).toBe(1);
expect(await listMemberships(id("mehmet"))).toEqual([]);
});
});
+401
View File
@@ -0,0 +1,401 @@
import "server-only";
import { Types } from "mongoose";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { diffFields, writeAudit } from "@/lib/audit";
import { getDb } from "@/lib/db/mongo";
import { connectMongoose } from "@/lib/db/mongoose";
import { toObjectId } from "@/lib/object-id";
import { installDefaultCategories } from "@/features/categories/service";
import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import type { HouseholdSettings } from "@/shared/schemas/household";
import { compareTr } from "@/shared/search";
import { checkMemberChange, hasHouseholdRole, type MemberChange } from "./roles";
import type { HouseholdRole, HouseholdSummary } from "./types";
/**
* Hane servisi — framework'ten bağımsız (web Server Action'ları ve ileride /api/v1 aynı
* fonksiyonları çağırır). Hane kapsamlı her fonksiyon üyeliği KENDİSİ de doğrular:
* çağıran taraf DAL kontrolünü atlasa bile başka hanenin verisine erişilemez.
*/
const t = tr.households;
export type Membership = { householdId: Types.ObjectId; role: HouseholdRole; name: string };
export type HouseholdMemberView = {
userId: string;
name: string;
username: string;
role: HouseholdRole;
banned: boolean;
};
export type HouseholdDetail = {
id: string;
name: string;
settings: HouseholdSettings;
members: HouseholdMemberView[];
};
/** Kullanıcının üye olduğu (arşivlenmemiş) haneler, ada göre sıralı. */
export async function listMemberships(userId: string): Promise<HouseholdSummary[]> {
const uid = toObjectId(userId);
if (!uid) return [];
await connectMongoose();
const memberships = await HouseholdMemberModel.find({ userId: uid }).lean();
if (!memberships.length) return [];
const households = await HouseholdModel.find({
_id: { $in: memberships.map((m) => m.householdId) },
archivedAt: null,
})
.select({ name: 1 })
.lean();
const roleOf = new Map(memberships.map((m) => [String(m.householdId), m.role]));
return households
.map((h) => ({ id: String(h._id), name: h.name, role: roleOf.get(String(h._id))! }))
.sort((a, b) => compareTr(a.name, b.name));
}
/**
* VERİ İZOLASYONUNUN ÇEKİRDEĞİ: kullanıcı bu hanenin en az `minRole` yetkili üyesi mi?
* Değilse null. Geçersiz/uydurma kimlikler de null döner.
*/
export async function resolveMembership(
userId: string,
householdId: string,
minRole: HouseholdRole = "viewer",
): Promise<Membership | null> {
const uid = toObjectId(userId);
const hid = toObjectId(householdId);
if (!uid || !hid) return null;
await connectMongoose();
const member = await HouseholdMemberModel.findOne({ userId: uid, householdId: hid }).lean();
if (!member || !hasHouseholdRole(member.role, minRole)) return null;
const household = await HouseholdModel.findOne({ _id: hid, archivedAt: null })
.select({ name: 1 })
.lean();
if (!household) return null;
return { householdId: hid, role: member.role, name: household.name };
}
async function memberViews(householdId: Types.ObjectId): Promise<HouseholdMemberView[]> {
const members = await HouseholdMemberModel.find({ householdId }).lean();
const users = await getDb()
.collection("user")
.find(
{ _id: { $in: members.map((m) => m.userId) } },
{ projection: { name: 1, username: 1, banned: 1 } },
)
.toArray();
const byId = new Map(users.map((u) => [String(u._id), u]));
return members
.map((m) => {
const u = byId.get(String(m.userId));
return {
userId: String(m.userId),
name: (u?.name as string | undefined) ?? "—",
username: (u?.username as string | undefined) ?? "",
role: m.role,
banned: Boolean(u?.banned),
};
})
.sort((a, b) => compareTr(a.name, b.name));
}
function toSettings(h: {
name: string;
settings?: Partial<HouseholdSettings> | null;
}): HouseholdSettings {
const s = h.settings ?? {};
return {
name: h.name,
budgetMonthStartDay: s.budgetMonthStartDay ?? 1,
installmentBudgetMode: s.installmentBudgetMode ?? "per_installment",
dailyPlanMode: s.dailyPlanMode ?? "even",
includePrivateAccountsInTotals: s.includePrivateAccountsInTotals ?? true,
extraStoreSavingThresholdMinor: s.extraStoreSavingThresholdMinor ?? 10_000,
};
}
/** Üye için hane ayrıntısı (ayarlar + üyeler). Üye değilse null. */
export async function getHouseholdDetail(
userId: string,
householdId: string,
): Promise<HouseholdDetail | null> {
const membership = await resolveMembership(userId, householdId, "viewer");
if (!membership) return null;
const household = await HouseholdModel.findById(membership.householdId).lean();
if (!household) return null;
return {
id: String(household._id),
name: household.name,
settings: toSettings(household),
members: await memberViews(membership.householdId),
};
}
/** Owner: hane adı ve ayarları. */
export async function updateHouseholdSettings(
actorId: string,
householdId: string,
input: HouseholdSettings,
): Promise<ActionResult> {
const membership = await resolveMembership(actorId, householdId, "owner");
if (!membership) return fail(tr.common.unauthorized);
const before = await HouseholdModel.findById(membership.householdId).lean();
if (!before) return fail(tr.common.notFound);
const { name, ...settings } = input;
const actor = new Types.ObjectId(actorId);
await HouseholdModel.updateOne(
{ _id: membership.householdId },
{ $set: { name, settings, updatedBy: actor } },
);
await writeAudit({
action: "household.settings.update",
actorId: actor,
householdId: membership.householdId,
targetType: "household",
targetId: String(membership.householdId),
diff: diffFields(toSettings(before), input),
});
return ok(undefined);
}
/** Ortak üyelik değişikliği (owner yolu ve admin yolu aynı kuralı uygular). */
async function applyMemberChange(
actor: Types.ObjectId,
householdId: Types.ObjectId,
targetUserId: string,
change: MemberChange,
): Promise<ActionResult> {
const target = toObjectId(targetUserId);
if (!target) return fail(tr.common.notFound);
const member = await HouseholdMemberModel.findOne({ householdId, userId: target }).lean();
if (!member) return fail(tr.common.notFound);
if (change.type === "setRole" && change.role === member.role) return ok(undefined);
const ownerCount = await HouseholdMemberModel.countDocuments({ householdId, role: "owner" });
if (checkMemberChange(change, { targetRole: member.role, ownerCount })) return fail(t.lastOwner);
if (change.type === "remove") {
await HouseholdMemberModel.deleteOne({ _id: member._id });
} else {
await HouseholdMemberModel.updateOne(
{ _id: member._id },
{ $set: { role: change.role, updatedBy: actor } },
);
}
await writeAudit({
action: change.type === "remove" ? "household.member.remove" : "household.member.role",
actorId: actor,
householdId,
targetType: "user",
targetId: targetUserId,
diff:
change.type === "remove"
? { role: { from: member.role, to: null } }
: { role: { from: member.role, to: change.role } },
});
return ok(undefined);
}
/** Owner: üyenin hane içi rolünü değiştirir. */
export async function setMemberRole(
actorId: string,
householdId: string,
targetUserId: string,
role: HouseholdRole,
): Promise<ActionResult> {
const membership = await resolveMembership(actorId, householdId, "owner");
if (!membership) return fail(tr.common.unauthorized);
return applyMemberChange(new Types.ObjectId(actorId), membership.householdId, targetUserId, {
type: "setRole",
role,
});
}
/** Owner: üyeyi haneden çıkarır. */
export async function removeMember(
actorId: string,
householdId: string,
targetUserId: string,
): Promise<ActionResult> {
const membership = await resolveMembership(actorId, householdId, "owner");
if (!membership) return fail(tr.common.unauthorized);
return applyMemberChange(new Types.ObjectId(actorId), membership.householdId, targetUserId, {
type: "remove",
});
}
// --- Sistem admini (yönetim) — çağıran Server Action admin rolünü doğrular. ---
// Admin haneleri ve üyelikleri yönetir; hanenin finansal verisini GÖRMEZ (Faz 0 kararı).
export type AdminHouseholdRow = {
id: string;
name: string;
memberCount: number;
owners: string[];
members: HouseholdMemberView[];
createdAt: string;
};
export async function adminListHouseholds(): Promise<AdminHouseholdRow[]> {
await connectMongoose();
const households = await HouseholdModel.find({ archivedAt: null }).sort({ name: 1 }).lean();
const rows = await Promise.all(
households.map(async (h) => {
const members = await memberViews(h._id);
return {
id: String(h._id),
name: h.name,
memberCount: members.length,
owners: members.filter((m) => m.role === "owner").map((m) => m.name),
members,
createdAt: new Date(h.createdAt).toISOString(),
};
}),
);
return rows.sort((a, b) => compareTr(a.name, b.name));
}
export type UserOption = { id: string; name: string; username: string; banned: boolean };
export async function adminListUserOptions(): Promise<UserOption[]> {
const users = await getDb()
.collection("user")
.find({}, { projection: { name: 1, username: 1, banned: 1 } })
.toArray();
return users
.map((u) => ({
id: String(u._id),
name: String(u.name ?? ""),
username: String(u.username ?? ""),
banned: Boolean(u.banned),
}))
.sort((a, b) => compareTr(a.name, b.name));
}
/** Admin: hane + ilk sahip üyeliği tek transaction'da. */
export async function adminCreateHousehold(
actorId: string,
input: { name: string; ownerUserId: string },
): Promise<ActionResult<{ id: string }>> {
const actor = new Types.ObjectId(actorId);
const owner = toObjectId(input.ownerUserId);
if (!owner) return fail(tr.validation.selectUser);
const ownerExists = await getDb().collection("user").countDocuments({ _id: owner }, { limit: 1 });
if (!ownerExists) return fail(tr.validation.selectUser);
const mongoose = await connectMongoose();
let createdId = "";
await mongoose.connection.transaction(async (session) => {
const [household] = await HouseholdModel.create(
[{ name: input.name, createdBy: actor, updatedBy: actor }],
{
session,
},
);
createdId = String(household!._id);
await HouseholdMemberModel.create(
[
{
householdId: household!._id,
userId: owner,
role: "owner",
createdBy: actor,
updatedBy: actor,
},
],
{ session },
);
await writeAudit(
{
action: "household.create",
actorId: actor,
householdId: household!._id,
targetType: "household",
targetId: createdId,
diff: { name: input.name, ownerUserId: input.ownerUserId },
},
session,
);
// Varsayılan Türkçe kategori seti aynı transaction'da (Bölüm 6.2).
await installDefaultCategories(household!._id, actor, session);
});
return ok({ id: createdId });
}
/** Admin: kullanıcıyı haneye ekler (zaten üyeyse rolünü günceller). */
export async function adminAddMember(
actorId: string,
householdId: string,
input: { userId: string; role: HouseholdRole },
): Promise<ActionResult> {
const actor = new Types.ObjectId(actorId);
const hid = toObjectId(householdId);
const uid = toObjectId(input.userId);
if (!hid || !uid) return fail(tr.common.notFound);
await connectMongoose();
const household = await HouseholdModel.exists({ _id: hid, archivedAt: null });
const user = await getDb().collection("user").countDocuments({ _id: uid }, { limit: 1 });
if (!household || !user) return fail(tr.common.notFound);
const existing = await HouseholdMemberModel.findOne({ householdId: hid, userId: uid }).lean();
if (existing)
return applyMemberChange(actor, hid, input.userId, { type: "setRole", role: input.role });
await HouseholdMemberModel.create({
householdId: hid,
userId: uid,
role: input.role,
createdBy: actor,
updatedBy: actor,
});
await writeAudit({
action: "household.member.add",
actorId: actor,
householdId: hid,
targetType: "user",
targetId: input.userId,
diff: { role: { from: null, to: input.role } },
});
return ok(undefined);
}
export async function adminSetMemberRole(
actorId: string,
householdId: string,
targetUserId: string,
role: HouseholdRole,
): Promise<ActionResult> {
const hid = toObjectId(householdId);
if (!hid) return fail(tr.common.notFound);
await connectMongoose();
return applyMemberChange(new Types.ObjectId(actorId), hid, targetUserId, {
type: "setRole",
role,
});
}
export async function adminRemoveMember(
actorId: string,
householdId: string,
targetUserId: string,
): Promise<ActionResult> {
const hid = toObjectId(householdId);
if (!hid) return fail(tr.common.notFound);
await connectMongoose();
return applyMemberChange(new Types.ObjectId(actorId), hid, targetUserId, { type: "remove" });
}
/** Kullanıcı silindiğinde tüm üyeliklerini temizler (admin kullanıcı silme akışı). */
export async function removeAllMembershipsOfUser(userId: string): Promise<number> {
const uid = toObjectId(userId);
if (!uid) return 0;
await connectMongoose();
const res = await HouseholdMemberModel.deleteMany({ userId: uid });
return res.deletedCount;
}
+105
View File
@@ -0,0 +1,105 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { transactionFormSchema } from "@/shared/schemas/transaction";
import {
createTransaction,
getTransaction,
getTransactionFormOptions,
searchPayees,
setTransactionDeleted,
updateTransaction,
type PayeeSuggestion,
type TransactionDetail,
type TransactionFormOptions,
} from "./service";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
/** zod hatalarını alan bazlı mesajlara çevirir ("splits.1.amount" gibi yollar korunur). */
function fieldErrors(error: z.ZodError) {
const out: Record<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
function revalidate() {
// Bakiye, liste ve (ileride) dashboard aynı veriden beslenir.
revalidatePath("/", "layout");
}
export async function createTransactionAction(
input: unknown,
): Promise<ActionResult<{ id: string }>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = transactionFormSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await createTransaction(ctx.user.id, ctx.household.id, parsed.data);
if (result.ok) revalidate();
return result;
}
export async function updateTransactionAction(
transactionId: unknown,
input: unknown,
): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(transactionId);
if (!id.success) return fail(tr.common.notFound);
const parsed = transactionFormSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await updateTransaction(ctx.user.id, ctx.household.id, id.data, parsed.data);
if (result.ok) revalidate();
return result;
}
export async function setTransactionDeletedAction(
transactionId: unknown,
deleted: unknown,
): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(transactionId);
const d = z.boolean().safeParse(deleted);
if (!id.success || !d.success) return fail(tr.common.genericError);
const result = await setTransactionDeleted(ctx.user.id, ctx.household.id, id.data, d.data);
if (result.ok) revalidate();
return result;
}
/** Form açılırken: yazılabilir hesaplar ve kategoriler (hızlı ekle her ekrandan çağırır). */
export async function getTransactionFormOptionsAction(): Promise<
ActionResult<TransactionFormOptions>
> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const options = await getTransactionFormOptions(ctx.user.id, ctx.household.id);
return options ? ok(options) : fail(tr.common.unauthorized);
}
export async function getTransactionAction(
transactionId: unknown,
): Promise<ActionResult<TransactionDetail>> {
const ctx = await getHouseholdActionContext("viewer");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(transactionId);
if (!id.success) return fail(tr.common.notFound);
const detail = await getTransaction(ctx.user.id, ctx.household.id, id.data);
return detail ? ok(detail) : fail(tr.common.notFound);
}
export async function searchPayeesAction(query: unknown): Promise<PayeeSuggestion[]> {
const ctx = await getHouseholdActionContext("member");
const q = z.string().max(80).safeParse(query);
if (!ctx || !q.success) return [];
return searchPayees(ctx.user.id, ctx.household.id, q.data);
}
@@ -0,0 +1,821 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronDown, Loader2, Plus, Split, Trash2, Wand2 } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import { Controller, useFieldArray, useForm, useWatch, type Resolver } from "react-hook-form";
import { toast } from "sonner";
import { IconBadge } from "@/components/appearance/swatch";
import { MoneyInput } from "@/components/money-input";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { todayIstanbul } from "@/shared/dates";
import { formatMoney } from "@/shared/format";
import { formatMoneyInput, parseMoney } from "@/shared/money";
import { transactionFormSchema, type TransactionForm } from "@/shared/schemas/transaction";
import { MAX_SPLITS, TRANSACTION_TYPES, type TransactionType } from "@/shared/transactions";
import {
createTransactionAction,
getTransactionFormOptionsAction,
searchPayeesAction,
updateTransactionAction,
} from "../actions";
import type { PayeeSuggestion, TransactionDetail, TransactionFormOptions } from "../service";
const t = tr.transactions;
const f = t.fields;
export type TransactionFormMode =
| { kind: "create"; type: TransactionType }
| { kind: "edit"; transaction: TransactionDetail }
| { kind: "copy"; transaction: TransactionDetail };
type SplitValue = { categoryId: string; amount: string; note: string };
type FormValues = {
type: TransactionType;
amount: string;
date: string;
accountId: string;
toAccountId: string;
categoryId: string;
splits: SplitValue[];
payee: string;
description: string;
tags: string;
};
function toValues(mode: TransactionFormMode, options: TransactionFormOptions | null): FormValues {
if (mode.kind === "create") {
return {
type: mode.type,
amount: "",
date: todayIstanbul(),
accountId: options?.accounts[0]?.id ?? "",
toAccountId: "",
categoryId: "",
splits: [],
payee: "",
description: "",
tags: "",
};
}
const x = mode.transaction;
return {
type: x.type,
amount: formatMoneyInput(x.amountMinor),
date: mode.kind === "copy" ? todayIstanbul() : x.date,
accountId: x.account.id,
toAccountId: x.toAccount?.id ?? "",
categoryId: x.categoryId ?? "",
splits: x.splits.map((s) => ({
categoryId: s.categoryId,
amount: formatMoneyInput(s.amountMinor),
note: s.note,
})),
payee: x.payeeName,
description: x.description,
tags: x.tags.join(", "),
};
}
function AccountSelect({
id,
value,
onChange,
accounts,
invalid,
exclude,
}: {
id: string;
value: string;
onChange: (v: string) => void;
accounts: {
id: string;
name: string;
icon?: TransactionFormOptions["accounts"][number]["icon"];
color?: TransactionFormOptions["accounts"][number]["color"];
}[];
invalid?: boolean;
exclude?: string;
}) {
const current = accounts.find((a) => a.id === value);
return (
<Select value={value || undefined} onValueChange={onChange}>
<SelectTrigger id={id} className="w-full" aria-invalid={invalid}>
<SelectValue placeholder={f.selectAccount}>{current?.name}</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
{accounts
.filter((a) => a.id !== exclude)
.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.icon && a.color ? (
<IconBadge
icon={a.icon}
color={a.color}
size="sm"
className="size-5 [&_svg]:size-3"
/>
) : null}
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function CategorySelect({
id,
value,
onChange,
options,
appearance,
invalid,
className,
}: {
id: string;
value: string;
onChange: (v: string) => void;
options: TransactionFormOptions["categories"];
appearance: TransactionFormOptions["categoryAppearance"];
invalid?: boolean;
className?: string;
}) {
const current = options.find((o) => o.id === value);
return (
<Select value={value || undefined} onValueChange={onChange}>
<SelectTrigger id={id} className={cn("w-full", className)} aria-invalid={invalid}>
<SelectValue placeholder={f.selectCategory}>{current?.path}</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-80">
{options.map((o) => {
const look = appearance[o.id];
return (
<SelectItem key={o.id} value={o.id}>
<span
className="flex items-center gap-2"
style={{ paddingInlineStart: `${o.depth * 1}rem` }}
>
{look ? (
<IconBadge
icon={look.icon}
color={look.color}
size="sm"
className="size-5 [&_svg]:size-3"
/>
) : null}
<span className={o.depth === 0 ? "font-medium" : undefined}>{o.label}</span>
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
/** Satıcı alanı: yazdıkça öneri; seçilince son kullanılan kategori/hesap önerilir. */
function PayeeInput({
value,
onChange,
onPick,
}: {
value: string;
onChange: (v: string) => void;
onPick: (p: PayeeSuggestion) => void;
}) {
const [items, setItems] = useState<PayeeSuggestion[]>([]);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
function query(v: string) {
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(async () => {
const res = await searchPayeesAction(v);
setItems(
res.filter(
(p) =>
p.name.toLocaleLowerCase("tr-TR") !== v.trim().toLocaleLowerCase("tr-TR") ||
res.length > 1,
),
);
setActive(-1);
}, 150);
}
function pick(p: PayeeSuggestion) {
onChange(p.name);
onPick(p);
setOpen(false);
}
return (
<div className="relative">
<Input
id="tx-payee"
value={value}
placeholder={f.payeePlaceholder}
autoComplete="off"
role="combobox"
aria-expanded={open && items.length > 0}
aria-controls="tx-payee-list"
aria-autocomplete="list"
onChange={(e) => {
onChange(e.target.value);
setOpen(true);
query(e.target.value);
}}
onFocus={() => {
setOpen(true);
query(value);
}}
onBlur={() => setTimeout(() => setOpen(false), 150)}
onKeyDown={(e) => {
if (!open || !items.length) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((i) => Math.min(i + 1, items.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && active >= 0) {
e.preventDefault();
pick(items[active]!);
} else if (e.key === "Escape") setOpen(false);
}}
/>
{open && items.length > 0 ? (
<ul
id="tx-payee-list"
role="listbox"
className="absolute z-50 mt-1 max-h-56 w-full overflow-y-auto rounded-lg border bg-popover p-1 text-popover-foreground shadow-md"
>
{items.map((p, i) => (
<li
key={p.name}
role="option"
aria-selected={i === active}
onMouseDown={(e) => {
e.preventDefault();
pick(p);
}}
className="cursor-pointer rounded-md px-2 py-1.5 text-sm hover:bg-accent aria-selected:bg-accent"
>
{p.name}
</li>
))}
</ul>
) : null}
</div>
);
}
export function TransactionFormDialog({
mode,
onOpenChange,
}: {
mode: TransactionFormMode | null;
onOpenChange: (open: boolean) => void;
}) {
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [suggested, setSuggested] = useState(false);
const form = useForm<FormValues, unknown, TransactionForm>({
// FormValues, şemanın giriş şekliyle birebir (tutarlar string; zod kuruşa çevirir).
resolver: zodResolver(transactionFormSchema) as unknown as Resolver<
FormValues,
unknown,
TransactionForm
>,
defaultValues: toValues({ kind: "create", type: "expense" }, null),
});
const splits = useFieldArray({ control: form.control, name: "splits" });
// Form her açıldığında seçenekleri sunucudan al (hesaplar/kategoriler güncel olsun).
useEffect(() => {
if (!mode) return;
let cancelled = false;
getTransactionFormOptionsAction().then((res) => {
if (cancelled) return;
if (!res.ok) {
setLoadError(res.error);
return;
}
setLoadError(null);
setOptions(res.data);
form.reset(toValues(mode, res.data));
});
return () => {
cancelled = true;
};
}, [mode, form]);
const type = useWatch({ control: form.control, name: "type" });
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 splitMode = splits.fields.length > 0;
// Düzenlemede arşivli/kapalı hesap veya kategori seçenek listesinde yoksa ekle (kaybolmasın).
const accounts = useMemo(() => {
const list = [...(options?.accounts ?? [])];
if (mode && mode.kind !== "create") {
for (const a of [mode.transaction.account, mode.transaction.toAccount]) {
if (a && !list.some((x) => x.id === a.id))
list.push({ ...a, type: "bank", icon: "wallet", color: "slate" });
}
}
return list;
}, [options, mode]);
const categories = useMemo(() => {
const kind = type === "income" ? "income" : "expense";
const list = (options?.categories ?? []).filter((c) => c.kind === kind);
if (mode && mode.kind !== "create") {
const extra = [
...(mode.transaction.category
? [{ id: mode.transaction.category.id, path: mode.transaction.category.path }]
: []),
...mode.transaction.splits.map((s) => ({ id: s.categoryId, path: s.path })),
];
for (const c of extra) {
if (!list.some((x) => x.id === c.id))
list.push({ id: c.id, label: c.path, path: c.path, depth: 0, kind });
}
}
return list;
}, [options, type, mode]);
const totalMinor = parseMoney(amount ?? "") ?? 0;
const splitSum = (splitValues ?? []).reduce((a, s) => a + (parseMoney(s.amount ?? "") ?? 0), 0);
const remaining = totalMinor - splitSum;
function handleOpenChange(open: boolean) {
if (!open) {
setAdvancedOpen(false);
setSuggested(false);
}
onOpenChange(open);
}
function changeType(next: TransactionType) {
form.setValue("type", next);
// Kategoriler türe bağlıdır; tür değişince temizlenir.
form.setValue("categoryId", "");
splits.replace([]);
if (next !== "transfer") form.setValue("toAccountId", "");
form.clearErrors();
}
function toggleSplit() {
if (splitMode) {
splits.replace([]);
return;
}
const first = form.getValues("categoryId");
splits.replace([
{ categoryId: first, amount: form.getValues("amount"), note: "" },
{ categoryId: "", amount: "", note: "" },
]);
form.setValue("categoryId", "");
setAdvancedOpen(true);
}
function fillRemaining() {
const last = splits.fields.length - 1;
if (last < 0) return;
const current = parseMoney(form.getValues(`splits.${last}.amount`) ?? "") ?? 0;
const next = current + remaining;
if (next > 0)
form.setValue(`splits.${last}.amount`, formatMoneyInput(next), { shouldValidate: true });
}
function onPayeePicked(p: PayeeSuggestion) {
let changed = false;
// Kullanıcı seçimini ezme: yalnızca boş alanları doldur.
if (p.lastAccountId && !form.getValues("accountId")) {
form.setValue("accountId", p.lastAccountId);
changed = true;
} else if (p.lastAccountId && form.formState.dirtyFields.accountId === undefined) {
form.setValue("accountId", p.lastAccountId);
changed = true;
}
if (
p.lastCategoryId &&
!splitMode &&
!form.getValues("categoryId") &&
p.lastType === form.getValues("type")
) {
form.setValue("categoryId", p.lastCategoryId);
changed = true;
}
setSuggested(changed);
}
async function onSubmit() {
if (!mode) return;
const values = form.getValues();
const payload = {
...values,
toAccountId: values.type === "transfer" ? values.toAccountId : "",
categoryId: values.type === "transfer" || values.splits.length ? "" : values.categoryId,
splits: values.type === "transfer" ? [] : values.splits,
};
const result =
mode.kind === "edit"
? await updateTransactionAction(mode.transaction.id, payload)
: await createTransactionAction(payload);
if (result.ok) {
toast.success(mode.kind === "edit" ? t.toast.updated : t.toast.created);
handleOpenChange(false);
return;
}
for (const [name, message] of Object.entries(result.fieldErrors ?? {})) {
if (!message) continue;
form.setError(name as keyof FormValues, { message });
if (name.startsWith("splits") || name === "tags") setAdvancedOpen(true);
}
if (!result.fieldErrors) form.setError("root", { message: result.error });
}
const title =
mode?.kind === "edit"
? t.editTitle
: mode?.kind === "copy"
? t.copyTitle
: t.createTitle[mode?.type ?? "expense"];
const loading = Boolean(mode) && !options && !loadError;
const noAccounts = options && options.accounts.length === 0;
const errors = form.formState.errors;
return (
<ResponsiveDialog
open={Boolean(mode)}
onOpenChange={handleOpenChange}
title={title}
footer={
noAccounts || loadError ? null : (
<>
<Button variant="outline" type="button" onClick={() => handleOpenChange(false)}>
{tr.common.cancel}
</Button>
<Button
type="submit"
form="transaction-form"
disabled={form.formState.isSubmitting || loading}
>
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
{tr.common.save}
</Button>
</>
)
}
>
{loadError ? (
<p className="text-sm text-destructive">{loadError}</p>
) : loading ? (
<div className="flex flex-col gap-4" role="status" aria-label={tr.common.loading}>
<Skeleton className="h-9 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : noAccounts ? (
<div className="flex flex-col items-start gap-3 text-sm">
<p className="text-muted-foreground">{t.noAccounts}</p>
<Button asChild size="sm" onClick={() => handleOpenChange(false)}>
<Link href="/hesaplar">{t.noAccountsLink}</Link>
</Button>
</div>
) : (
<form id="transaction-form" onSubmit={form.handleSubmit(onSubmit)} noValidate>
<FieldGroup>
{mode?.kind !== "edit" ? (
<Tabs value={type} onValueChange={(v) => changeType(v as TransactionType)}>
<TabsList className="w-full">
{TRANSACTION_TYPES.map((x) => (
<TabsTrigger key={x} value={x} className="flex-1">
{t.types[x]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
<div className="grid gap-4 sm:grid-cols-[1fr_auto]">
<Controller
name="amount"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-amount">{f.amount}</FieldLabel>
<MoneyInput
id="tx-amount"
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="0,00"
className="h-11 text-lg"
autoFocus={mode?.kind === "create"}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="date"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-date">{f.date}</FieldLabel>
<Input
{...field}
id="tx-date"
type="date"
className="h-11"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</div>
<div className={cn("grid gap-4", type === "transfer" && "sm:grid-cols-2")}>
<Controller
name="accountId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-account">
{type === "transfer" ? f.fromAccount : f.account}
</FieldLabel>
<AccountSelect
id="tx-account"
value={field.value}
onChange={field.onChange}
accounts={accounts}
invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
{type === "transfer" ? (
<Controller
name="toAccountId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-to-account">{f.toAccount}</FieldLabel>
<AccountSelect
id="tx-to-account"
value={field.value}
onChange={field.onChange}
accounts={accounts}
exclude={accountId}
invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
) : null}
</div>
{type !== "transfer" && !splitMode ? (
<Controller
name="categoryId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-category">{f.category}</FieldLabel>
<div className="flex gap-2">
<CategorySelect
id="tx-category"
value={field.value}
onChange={field.onChange}
options={categories}
appearance={options?.categoryAppearance ?? {}}
invalid={fieldState.invalid}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={toggleSplit}
title={f.split}
aria-label={f.split}
>
<Split />
</Button>
</div>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
) : null}
<Controller
name="payee"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="tx-payee">{f.payee}</FieldLabel>
<PayeeInput
value={field.value}
onChange={field.onChange}
onPick={onPayeePicked}
/>
{suggested ? <FieldDescription>{f.suggested}</FieldDescription> : null}
</Field>
)}
/>
<Controller
name="description"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-description">{f.description}</FieldLabel>
<Input {...field} id="tx-description" aria-invalid={fieldState.invalid} />
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" className="-ml-2 w-fit">
<ChevronDown
className={cn("transition-transform", advancedOpen && "rotate-180")}
/>
{f.advanced}
{splitMode ? (
<span className="text-xs text-muted-foreground">
· {t.list.split(splits.fields.length)}
</span>
) : null}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<FieldGroup>
<Controller
name="tags"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="tx-tags">{f.tags}</FieldLabel>
<Input {...field} id="tx-tags" aria-describedby="tx-tags-hint" />
<FieldDescription id="tx-tags-hint">{f.tagsHint}</FieldDescription>
</Field>
)}
/>
{type !== "transfer" ? (
<div className="flex flex-col gap-3 rounded-xl border p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-medium">{f.split}</p>
<p className="text-xs text-muted-foreground">{f.splitHint}</p>
</div>
<Button
type="button"
variant={splitMode ? "outline" : "secondary"}
size="sm"
onClick={toggleSplit}
>
<Split />
{splitMode ? tr.common.cancel : f.split}
</Button>
</div>
{splits.fields.map((row, i) => (
<div
key={row.id}
className="grid grid-cols-[1fr_8rem_auto] items-start gap-2"
>
<Controller
name={`splits.${i}.categoryId`}
control={form.control}
render={({ field, fieldState }) => (
<div>
<CategorySelect
id={`tx-split-${i}-category`}
value={field.value}
onChange={field.onChange}
options={categories}
appearance={options?.categoryAppearance ?? {}}
invalid={fieldState.invalid}
/>
{fieldState.invalid ? (
<FieldError errors={[fieldState.error]} />
) : null}
</div>
)}
/>
<Controller
name={`splits.${i}.amount`}
control={form.control}
render={({ field, fieldState }) => (
<MoneyInput
id={`tx-split-${i}-amount`}
aria-label={`${f.amount} ${i + 1}`}
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="0,00"
aria-invalid={fieldState.invalid}
/>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => splits.remove(i)}
disabled={splits.fields.length <= 2}
aria-label={f.removeSplit}
>
<Trash2 />
</Button>
<Controller
name={`splits.${i}.note`}
control={form.control}
render={({ field }) => (
<Input
{...field}
placeholder={f.splitNote}
aria-label={`${f.splitNote} ${i + 1}`}
className="col-span-2 h-8 text-sm"
/>
)}
/>
</div>
))}
{splitMode ? (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => splits.append({ categoryId: "", amount: "", note: "" })}
disabled={splits.fields.length >= MAX_SPLITS}
>
<Plus />
{f.addSplit}
</Button>
<span
role="status"
className={cn(
"ml-auto text-sm tabular-nums",
remaining === 0 ? "text-income" : "text-expense",
)}
>
{remaining === 0
? f.remainingOk
: `${f.remaining}: ${formatMoney(remaining)}`}
</span>
{remaining !== 0 ? (
<Button type="button" variant="ghost" size="sm" onClick={fillRemaining}>
<Wand2 />
{f.fillRemaining}
</Button>
) : null}
</div>
) : null}
{errors.splits?.message || errors.splits?.root?.message ? (
<FieldError>
{errors.splits.message ?? errors.splits.root?.message}
</FieldError>
) : null}
</div>
) : null}
</FieldGroup>
</CollapsibleContent>
</Collapsible>
{errors.root ? <FieldError>{errors.root.message}</FieldError> : null}
</FieldGroup>
</form>
)}
</ResponsiveDialog>
);
}
@@ -0,0 +1,713 @@
"use client";
import {
ArrowLeftRight,
ChevronLeft,
ChevronRight,
Copy,
Download,
Filter,
Paperclip,
Pencil,
Plus,
Split,
Trash2,
X,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { IconBadge } from "@/components/appearance/swatch";
import { useQuickAdd } from "@/components/layout/quick-add";
import { MoneyInput } from "@/components/money-input";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatDate, formatDateTime, formatMoney } from "@/shared/format";
import type { TransactionFilters } from "@/shared/schemas/transaction";
import {
TRANSACTION_STATUSES,
TRANSACTION_TYPES,
type TransactionType,
} from "@/shared/transactions";
import { getTransactionAction, setTransactionDeletedAction } from "../actions";
import type {
TransactionDetail,
TransactionFormOptions,
TransactionListItem,
TransactionPage,
} from "../service";
const t = tr.transactions;
const ALL = "__all__";
const weekday = new Intl.DateTimeFormat("tr-TR", { weekday: "long", timeZone: "Europe/Istanbul" });
/** "2026-11-05" → "05.11.2026 · Perşembe" */
function dayLabel(ymd: string) {
const d = new Date(`${ymd}T12:00:00+03:00`);
return `${formatDate(d)} · ${weekday.format(d)}`;
}
function signed(type: TransactionType, amountMinor: number) {
return type === "income" ? amountMinor : type === "expense" ? -amountMinor : 0;
}
function Amount({
type,
amountMinor,
className,
}: {
type: TransactionType;
amountMinor: number;
className?: string;
}) {
const text =
type === "income"
? `+${formatMoney(amountMinor)}`
: type === "expense"
? `−${formatMoney(amountMinor)}`
: formatMoney(amountMinor);
return (
<span
className={cn(
"font-semibold whitespace-nowrap tabular-nums",
type === "income" && "text-income",
type === "expense" && "text-expense",
type === "transfer" && "text-muted-foreground",
className,
)}
>
{text}
</span>
);
}
function RowIcon({ item }: { item: TransactionListItem }) {
if (item.type === "transfer") {
return (
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ArrowLeftRight className="size-4.5" />
</span>
);
}
if (item.splitCount) {
return (
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-budget/15 text-budget">
<Split className="size-4.5" />
</span>
);
}
return item.category ? (
<IconBadge icon={item.category.icon} color={item.category.color} />
) : (
<IconBadge icon="tag" color="slate" />
);
}
function rowTitle(item: TransactionListItem) {
if (item.payeeName) return item.payeeName;
if (item.description) return item.description;
if (item.type === "transfer") return t.types.transfer;
return item.category?.name ?? t.types[item.type];
}
function rowSubtitle(item: TransactionListItem) {
if (item.type === "transfer")
return t.list.transfer(item.account.name, item.toAccount?.name ?? "—");
const what = item.splitCount ? t.list.split(item.splitCount) : (item.category?.path ?? "—");
return `${what} · ${item.account.name}`;
}
// --- Filtreler ---
type Options = TransactionFormOptions & { members: { id: string; name: string }[] };
function countActive(f: TransactionFilters) {
return (
[
"from",
"to",
"type",
"hesap",
"kategori",
"kisi",
"durum",
"min",
"max",
"etiket",
"q",
] as const
).filter((k) => f[k] !== undefined && f[k] !== "").length;
}
function FiltersDialog({
open,
onOpenChange,
filters,
raw,
options,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
filters: TransactionFilters;
raw: Record<string, string>;
options: Options;
}) {
const router = useRouter();
const pathname = usePathname();
const [v, setV] = useState<Record<string, string>>(raw);
const set = (k: string, value: string) => setV((prev) => ({ ...prev, [k]: value }));
const f = t.filters;
function apply(next: Record<string, string>) {
const params = new URLSearchParams();
for (const [k, value] of Object.entries(next)) if (value && k !== "sayfa") params.set(k, value);
router.push(params.size ? `${pathname}?${params}` : pathname);
onOpenChange(false);
}
const select = (key: string, label: string, items: { value: string; label: string }[]) => (
<Field>
<FieldLabel htmlFor={`flt-${key}`}>{label}</FieldLabel>
<Select value={v[key] || ALL} onValueChange={(x) => set(key, x === ALL ? "" : x)}>
<SelectTrigger id={`flt-${key}`} className="w-full">
<SelectValue>{items.find((i) => i.value === v[key])?.label ?? f.all}</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value={ALL}>{f.all}</SelectItem>
{items.map((i) => (
<SelectItem key={i.value} value={i.value}>
{i.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
return (
<ResponsiveDialog
open={open}
onOpenChange={(o) => {
if (o) setV(raw);
onOpenChange(o);
}}
title={f.title}
footer={
<>
<Button variant="outline" onClick={() => apply({})} disabled={!countActive(filters)}>
{f.clear}
</Button>
<Button onClick={() => apply(v)}>{f.apply}</Button>
</>
}
>
<form
onSubmit={(e) => {
e.preventDefault();
apply(v);
}}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="flt-q">{f.search}</FieldLabel>
<Input
id="flt-q"
value={v.q ?? ""}
onChange={(e) => set("q", e.target.value)}
placeholder={f.searchPlaceholder}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="flt-from">{f.from}</FieldLabel>
<Input
id="flt-from"
type="date"
value={v.from ?? ""}
onChange={(e) => set("from", e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="flt-to">{f.to}</FieldLabel>
<Input
id="flt-to"
type="date"
value={v.to ?? ""}
onChange={(e) => set("to", e.target.value)}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
{select(
"type",
f.type,
TRANSACTION_TYPES.map((x) => ({ value: x, label: t.types[x] })),
)}
{select(
"durum",
f.status,
TRANSACTION_STATUSES.map((x) => ({ value: x, label: t.statuses[x] })),
)}
</div>
{select(
"hesap",
f.account,
options.accounts.map((a) => ({ value: a.id, label: a.name })),
)}
{select(
"kategori",
f.category,
options.categories.map((c) => ({
value: c.id,
label: `${c.kind === "income" ? "↑" : "↓"} ${c.path}`,
})),
)}
{select(
"kisi",
f.person,
options.members.map((m) => ({ value: m.id, label: m.name })),
)}
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="flt-min">{f.min}</FieldLabel>
<MoneyInput id="flt-min" value={v.min ?? ""} onChange={(x) => set("min", x)} />
</Field>
<Field>
<FieldLabel htmlFor="flt-max">{f.max}</FieldLabel>
<MoneyInput id="flt-max" value={v.max ?? ""} onChange={(x) => set("max", x)} />
</Field>
</div>
<Field>
<FieldLabel htmlFor="flt-tag">{f.tag}</FieldLabel>
<Input
id="flt-tag"
value={v.etiket ?? ""}
onChange={(e) => set("etiket", e.target.value)}
/>
</Field>
<button type="submit" hidden />
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
// --- Ayrıntı ---
function DetailDialog({
detail,
loading,
canWrite,
onOpenChange,
onEdit,
onCopy,
onDelete,
}: {
detail: TransactionDetail | null;
loading: boolean;
canWrite: boolean;
onOpenChange: (o: boolean) => void;
onEdit: (d: TransactionDetail) => void;
onCopy: (d: TransactionDetail) => void;
onDelete: (d: TransactionDetail) => void;
}) {
const d = t.detail;
const row = (label: string, value: React.ReactNode) => (
<div className="flex items-start justify-between gap-4 py-2 text-sm">
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-right">{value}</dd>
</div>
);
return (
<ResponsiveDialog
open={loading || Boolean(detail)}
onOpenChange={onOpenChange}
title={d.title}
footer={
detail && canWrite ? (
<>
<Button variant="destructive" onClick={() => onDelete(detail)}>
<Trash2 />
{t.actions.delete}
</Button>
<Button variant="outline" onClick={() => onCopy(detail)}>
<Copy />
{t.actions.copy}
</Button>
<Button onClick={() => onEdit(detail)}>
<Pencil />
{t.actions.edit}
</Button>
</>
) : null
}
>
{!detail ? (
<div className="flex flex-col gap-3" role="status" aria-label={tr.common.loading}>
<Skeleton className="h-10 w-1/2" />
<Skeleton className="h-32 w-full" />
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<RowIcon item={detail} />
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{rowTitle(detail)}</p>
<p className="text-xs text-muted-foreground">{dayLabel(detail.date)}</p>
</div>
<Amount type={detail.type} amountMinor={detail.amountMinor} className="text-lg" />
</div>
<dl className="divide-y border-y">
{row(t.filters.type, <Badge variant="secondary">{t.types[detail.type]}</Badge>)}
{detail.status !== "posted" ? row(t.filters.status, t.statuses[detail.status]) : null}
{row(
detail.type === "transfer" ? t.fields.fromAccount : t.fields.account,
detail.account.name,
)}
{detail.toAccount ? row(t.fields.toAccount, detail.toAccount.name) : null}
{detail.category ? row(t.fields.category, detail.category.path) : null}
{detail.payeeName ? row(t.fields.payee, detail.payeeName) : null}
{detail.description ? row(t.fields.description, detail.description) : null}
{detail.tags.length
? row(
t.fields.tags,
<span className="flex flex-wrap justify-end gap-1">
{detail.tags.map((tag) => (
<Badge key={tag} variant="outline">
#{tag}
</Badge>
))}
</span>,
)
: null}
{row(d.createdBy, `${detail.createdBy.name} · ${formatDateTime(detail.createdAt)}`)}
{new Date(detail.updatedAt).getTime() - new Date(detail.createdAt).getTime() > 1000
? row(d.updatedBy, `${detail.updatedBy.name} · ${formatDateTime(detail.updatedAt)}`)
: null}
</dl>
{detail.splits.length ? (
<section aria-labelledby="tx-lines">
<h3 id="tx-lines" className="mb-1 text-sm font-medium">
{d.lines}
</h3>
<ul className="divide-y rounded-lg border">
{detail.splits.map((s, i) => (
<li key={i} className="flex items-center gap-3 p-2 text-sm">
<IconBadge icon={s.icon} color={s.color} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate">{s.path}</span>
{s.note ? (
<span className="block truncate text-xs text-muted-foreground">
{s.note}
</span>
) : null}
</span>
<span className="tabular-nums">{formatMoney(s.amountMinor)}</span>
</li>
))}
</ul>
</section>
) : null}
</div>
)}
</ResponsiveDialog>
);
}
// --- Ana görünüm ---
export function TransactionsView({
data,
filters,
raw,
options,
canWrite,
}: {
data: TransactionPage;
filters: TransactionFilters;
raw: Record<string, string>;
options: Options;
canWrite: boolean;
}) {
const pathname = usePathname();
const { openQuickAdd, openTransactionForm } = useQuickAdd();
const [filtersOpen, setFiltersOpen] = useState(false);
const [detail, setDetail] = useState<TransactionDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [deleting, setDeleting] = useState<TransactionDetail | null>(null);
const [, startTransition] = useTransition();
const active = countActive(filters);
const pageHref = (page: number) => {
const params = new URLSearchParams(raw);
if (page > 1) params.set("sayfa", String(page));
else params.delete("sayfa");
return params.size ? `${pathname}?${params}` : pathname;
};
const exportHref = () => {
const params = new URLSearchParams(raw);
params.delete("sayfa");
return `/islemler/disa-aktar${params.size ? `?${params}` : ""}`;
};
async function openDetail(id: string) {
setDetailLoading(true);
setDetail(null);
const res = await getTransactionAction(id);
setDetailLoading(false);
if (res.ok) setDetail(res.data);
else toast.error(res.error);
}
function closeDetail() {
setDetail(null);
setDetailLoading(false);
}
function remove(d: TransactionDetail) {
setDeleting(null);
closeDetail();
startTransition(async () => {
const res = await setTransactionDeletedAction(d.id, true);
if (!res.ok) return void toast.error(res.error);
toast.success(t.toast.deleted, {
duration: 10_000,
action: {
label: t.actions.restore,
onClick: async () => {
const r = await setTransactionDeletedAction(d.id, false);
if (r.ok) toast.success(t.toast.restored);
else toast.error(r.error);
},
},
});
});
}
// Sayfadaki işlemleri güne göre grupla (liste zaten tarihe göre sıralı).
const groups: { date: string; items: TransactionListItem[] }[] = [];
for (const item of data.items) {
const last = groups.at(-1);
if (last?.date === item.date) last.items.push(item);
else groups.push({ date: item.date, items: [item] });
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={() => setFiltersOpen(true)}>
<Filter />
{t.filters.open}
{active ? <Badge className="ml-1">{t.filters.active(active)}</Badge> : null}
</Button>
{active ? (
<Button variant="ghost" asChild>
<Link href={pathname}>
<X />
{t.filters.clear}
</Link>
</Button>
) : null}
<Button variant="ghost" asChild className="ml-auto">
{/* Dosya indirme: istemci gezinmesi değil, tam istek. */}
<a href={exportHref()} download>
<Download />
<span className="hidden sm:inline">{t.export}</span>
</a>
</Button>
{canWrite ? (
<Button onClick={() => openQuickAdd()}>
<Plus />
{t.new}
</Button>
) : null}
</div>
<div className="grid grid-cols-3 gap-3">
{(
[
["income", data.sums.incomeMinor, "text-income"],
["expense", data.sums.expenseMinor, "text-expense"],
["net", data.sums.incomeMinor - data.sums.expenseMinor, ""],
] as const
).map(([key, value, tone]) => (
<Card key={key} size="sm">
<CardHeader>
<CardDescription>{t.list.sums[key]}</CardDescription>
<CardTitle
className={cn(
"text-base tabular-nums sm:text-lg",
tone,
key === "net" && value < 0 && "text-expense",
)}
>
{formatMoney(value)}
</CardTitle>
</CardHeader>
</Card>
))}
</div>
{data.items.length === 0 ? (
<p className="rounded-xl border border-dashed p-8 text-center text-muted-foreground">
{active ? t.list.empty : t.list.emptyAll}
</p>
) : (
groups.map((g) => {
const net = g.items.reduce(
(a, i) => a + (i.status === "posted" ? signed(i.type, i.amountMinor) : 0),
0,
);
return (
<section key={g.date} aria-label={dayLabel(g.date)} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between px-1 text-xs font-medium text-muted-foreground">
<h2 className="first-letter:uppercase">{dayLabel(g.date)}</h2>
<span
className={cn(
"tabular-nums",
net > 0 && "text-income",
net < 0 && "text-expense",
)}
title={t.list.dayTotal}
>
{net === 0 ? "" : `${net > 0 ? "+" : "−"}${formatMoney(Math.abs(net))}`}
</span>
</div>
<ul className="divide-y rounded-xl border bg-card">
{g.items.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => openDetail(item.id)}
className="flex w-full items-center gap-3 p-3 text-left outline-none hover:bg-muted/50 focus-visible:bg-muted"
>
<RowIcon item={item} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate font-medium">{rowTitle(item)}</span>
{item.status !== "posted" ? (
<Badge variant="outline">{t.statuses[item.status]}</Badge>
) : null}
{item.attachmentCount ? (
<Paperclip className="size-3.5 shrink-0 text-muted-foreground" />
) : null}
</span>
<span className="block truncate text-xs text-muted-foreground">
{rowSubtitle(item)}
</span>
</span>
<Amount
type={item.type}
amountMinor={item.amountMinor}
className={cn(item.status !== "posted" && "opacity-60")}
/>
</button>
</li>
))}
</ul>
</section>
);
})
)}
{data.pageCount > 1 ? (
<nav
aria-label={tr.common.page(data.page, data.pageCount)}
className="flex items-center justify-between text-sm text-muted-foreground"
>
<span>{t.list.total(data.total)}</span>
<div className="flex items-center gap-2">
<span className="tabular-nums">{tr.common.page(data.page, data.pageCount)}</span>
<Button
variant="outline"
size="icon-sm"
asChild
disabled={data.page <= 1}
aria-label={tr.common.previous}
>
<Link
href={pageHref(data.page - 1)}
aria-disabled={data.page <= 1}
className={cn(data.page <= 1 && "pointer-events-none opacity-50")}
>
<ChevronLeft />
</Link>
</Button>
<Button variant="outline" size="icon-sm" asChild aria-label={tr.common.next}>
<Link
href={pageHref(data.page + 1)}
aria-disabled={data.page >= data.pageCount}
className={cn(data.page >= data.pageCount && "pointer-events-none opacity-50")}
>
<ChevronRight />
</Link>
</Button>
</div>
</nav>
) : data.total ? (
<p className="text-sm text-muted-foreground">{t.list.total(data.total)}</p>
) : null}
<FiltersDialog
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
raw={raw}
options={options}
/>
<DetailDialog
detail={detail}
loading={detailLoading}
canWrite={canWrite}
onOpenChange={(o) => !o && closeDetail()}
onEdit={(d) => {
closeDetail();
openTransactionForm({ kind: "edit", transaction: d });
}}
onCopy={(d) => {
closeDetail();
openTransactionForm({ kind: "copy", transaction: d });
}}
onDelete={setDeleting}
/>
<AlertDialog open={Boolean(deleting)} onOpenChange={(o) => !o && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
<AlertDialogDescription>{t.deleteDescription}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button variant="destructive" onClick={() => deleting && remove(deleting)}>
{t.actions.delete}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,434 @@
import { Types } from "mongoose";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createAccount, listAccounts } from "@/features/accounts/service";
import { setCategoryArchived } from "@/features/categories/service";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { TransactionModel } from "@/models/transaction";
import { transactionFilterSchema, type TransactionForm } from "@/shared/schemas/transaction";
import { transactionEffects } from "@/shared/transactions";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
createTransaction,
exportTransactions,
getTransaction,
listTransactions,
searchPayees,
setTransactionDeleted,
updateTransaction,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let annemler = "";
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
annemler = ids.household("Annemler");
});
afterAll(teardownDb);
const acc = (name: string, householdId = cakir) =>
AccountModel.findOne({ householdId, name })
.lean()
.then((a) => String(a!._id));
const cat = (key: string, householdId = cakir) =>
CategoryModel.findOne({ householdId, defaultKey: key })
.lean()
.then((c) => String(c!._id));
const filters = (x: Record<string, string> = {}) => transactionFilterSchema.parse(x);
function form(over: Partial<TransactionForm>): TransactionForm {
return {
type: "expense",
amount: 100_00,
date: "2026-06-15",
accountId: "",
toAccountId: null,
categoryId: null,
splits: [],
payee: "",
description: "",
tags: [],
...over,
};
}
async function mustCreate(user: string, f: TransactionForm, householdId = cakir) {
const r = await createTransaction(ids.user(user), householdId, f);
if (!r.ok) throw new Error(`oluşturulamadı: ${r.error} ${JSON.stringify(r.fieldErrors)}`);
return r.data.id;
}
describe("bakiye tutarlılığı", () => {
let bank = "";
let card = "";
it("gelir, gider, kart harcaması, karta transfer ve bölünmüş işlem bakiyeye doğru yansır", async () => {
const base = {
institution: "",
mealCardProvider: null,
openingDate: "2026-01-01",
color: "blue" as const,
icon: "landmark" as const,
ownerUserId: null,
isPrivate: false,
overdraftEnabled: false,
overdraftLimit: 0,
overdraftMonthlyRate: null,
overdraftStatementDay: null,
cardLimit: 0,
cardStatementDay: 1,
cardDueKind: "daysAfter" as const,
cardDueValue: 10,
cardPaymentAccountId: null,
};
const b = await createAccount(ids.user("ayse"), cakir, {
...base,
name: "T Banka",
type: "bank",
openingBalance: 1_000_00,
});
const c = await createAccount(ids.user("ayse"), cakir, {
...base,
name: "T Kart",
type: "credit_card",
openingBalance: 0,
});
bank = b.ok ? b.data.id : "";
card = c.ok ? c.data.id : "";
const gida = await cat("market.gida");
const temizlik = await cat("market.temizlik");
await mustCreate(
"ayse",
form({ type: "income", amount: 500_00, accountId: bank, categoryId: await cat("ek-gelir") }),
);
await mustCreate("ayse", form({ amount: 200_00, accountId: bank, categoryId: gida }));
await mustCreate("ayse", form({ amount: 300_00, accountId: card, categoryId: gida }));
await mustCreate(
"ayse",
form({ type: "transfer", amount: 300_00, accountId: bank, toAccountId: card }),
);
await mustCreate(
"ayse",
form({
amount: 120_00,
accountId: bank,
splits: [
{ categoryId: gida, amount: 80_00, note: "" },
{ categoryId: temizlik, amount: 40_00, note: "Deterjan" },
],
}),
);
const balances = (await listAccounts(ids.user("ayse"), cakir))!.accounts;
expect(balances.find((a) => a.id === bank)?.balanceMinor).toBe(
1_000_00 + 500_00 - 200_00 - 300_00 - 120_00,
);
expect(balances.find((a) => a.id === card)?.balanceMinor).toBe(-300_00 + 300_00);
});
it("silme bakiyeden düşer, geri alma geri getirir", async () => {
const id = await mustCreate(
"emre",
form({ amount: 50_00, accountId: bank, categoryId: await cat("market.gida") }),
);
const bal = async () =>
(await listAccounts(ids.user("ayse"), cakir))!.accounts.find((a) => a.id === bank)!
.balanceMinor;
const before = await bal();
expect((await setTransactionDeleted(ids.user("emre"), cakir, id, true)).ok).toBe(true);
expect(await bal()).toBe(before! + 50_00);
expect(await getTransaction(ids.user("emre"), cakir, id)).toBeNull();
expect((await setTransactionDeleted(ids.user("emre"), cakir, id, false)).ok).toBe(true);
expect(await bal()).toBe(before);
});
it("tüm hesaplarda bakiye = açılış + Σ transactionEffects (bağımsız doğrulama)", async () => {
const accounts = await AccountModel.find({ householdId: cakir }).lean();
const txs = await TransactionModel.find({ householdId: cakir }).lean();
const expected = new Map(accounts.map((a) => [String(a._id), a.openingBalanceMinor]));
for (const tx of txs) {
for (const e of transactionEffects({
type: tx.type,
status: tx.status,
amountMinor: tx.amountMinor,
accountId: String(tx.accountId),
toAccountId: tx.toAccountId ? String(tx.toAccountId) : null,
deleted: Boolean(tx.deletedAt),
})) {
expected.set(e.accountId, (expected.get(e.accountId) ?? 0) + e.deltaMinor);
}
}
// ayse "yalnızca ben" hesabının sahibi; tüm bakiyeleri görür.
for (const a of (await listAccounts(ids.user("ayse"), cakir, { includeArchived: true }))!
.accounts) {
expect(a.balanceMinor, a.name).toBe(expected.get(a.id));
}
});
});
describe("doğrulama ve izolasyon", () => {
it("gider için gelir kategorisi, başka hanenin hesabı/kategorisi reddedilir", async () => {
const ziraat = await acc("Ziraat Maaş");
const r1 = await createTransaction(
ids.user("ayse"),
cakir,
form({ accountId: ziraat, categoryId: await cat("maas") }),
);
expect(r1.ok).toBe(false);
const r2 = await createTransaction(
ids.user("ayse"),
cakir,
form({ accountId: await acc("Nakit", annemler), categoryId: await cat("market.gida") }),
);
expect(r2.ok).toBe(false);
const r3 = await createTransaction(
ids.user("ayse"),
cakir,
form({ accountId: ziraat, categoryId: await cat("market.gida", annemler) }),
);
expect(r3.ok).toBe(false);
const r4 = await createTransaction(
ids.user("ayse"),
cakir,
form({
accountId: ziraat,
splits: [
{ categoryId: await cat("market.gida"), amount: 50_00, note: "" },
{ categoryId: await cat("market.gida", annemler), amount: 50_00, note: "" },
],
}),
);
expect(r4.ok).toBe(false);
const r5 = await createTransaction(
ids.user("ayse"),
cakir,
form({
type: "transfer",
accountId: ziraat,
toAccountId: await acc("Vakıfbank Emekli", annemler),
}),
);
expect(r5.ok).toBe(false);
});
it("izleyici işlem giremez; başka hane üyesi işlemleri göremez", async () => {
const r = await createTransaction(
ids.user("zeynep"),
cakir,
form({ accountId: await acc("Cüzdan"), categoryId: await cat("market.gida") }),
);
expect(r.ok).toBe(false);
expect(await listTransactions(ids.user("fatma"), cakir, filters())).toBeNull();
const cakirTx = await TransactionModel.findOne({ householdId: cakir }).lean();
// fatma kendi hane kimliğiyle Çakır'ın işlemini isterse bulunamaz.
expect(await getTransaction(ids.user("fatma"), annemler, String(cakirTx!._id))).toBeNull();
expect(
(await setTransactionDeleted(ids.user("fatma"), annemler, String(cakirTx!._id), true)).ok,
).toBe(false);
});
});
describe("“yalnızca ben” hesabı işlemleri", () => {
it("başkasından tamamen gizlenir; o hesaba yalnızca sahibi işlem girer", async () => {
const pluxee = await acc("Ayşe — Pluxee");
const tx = await TransactionModel.findOne({ householdId: cakir, accountId: pluxee }).lean();
const ofEmre = await listTransactions(ids.user("emre"), cakir, filters({ hesap: pluxee }));
expect(ofEmre!.total).toBe(0);
expect(await getTransaction(ids.user("emre"), cakir, String(tx!._id))).toBeNull();
const ofAyse = await listTransactions(ids.user("ayse"), cakir, filters({ hesap: pluxee }));
expect(ofAyse!.total).toBeGreaterThan(0);
const exportEmre = await exportTransactions(ids.user("emre"), cakir, filters());
expect(exportEmre!.some((r) => r.account === "Ayşe — Pluxee")).toBe(false);
const kafe = await cat("eglence.kafe");
expect(
(
await createTransaction(
ids.user("emre"),
cakir,
form({ accountId: pluxee, categoryId: kafe }),
)
).ok,
).toBe(false);
expect(
(
await updateTransaction(
ids.user("emre"),
cakir,
String(tx!._id),
form({ accountId: pluxee, categoryId: kafe }),
)
).ok,
).toBe(false);
expect(
(
await createTransaction(
ids.user("ayse"),
cakir,
form({ accountId: pluxee, categoryId: kafe }),
)
).ok,
).toBe(true);
});
});
describe("arşivli kategori", () => {
it("yeni işlemde kullanılamaz, mevcut işlemde korunur", async () => {
const giyim = await cat("giyim");
const cuzdan = await acc("Cüzdan");
const id = await mustCreate("ayse", form({ accountId: cuzdan, categoryId: giyim }));
expect((await setCategoryArchived(ids.user("ayse"), cakir, giyim, true)).ok).toBe(true);
expect(
(
await createTransaction(
ids.user("ayse"),
cakir,
form({ accountId: cuzdan, categoryId: giyim }),
)
).ok,
).toBe(false);
expect(
(
await updateTransaction(
ids.user("ayse"),
cakir,
id,
form({ accountId: cuzdan, categoryId: giyim, description: "düzeltme" }),
)
).ok,
).toBe(true);
await setCategoryArchived(ids.user("ayse"), cakir, giyim, false);
});
});
describe("filtreler", () => {
it("üst kategori filtresi alt kategorileri ve bölünmüş kalemleri kapsar", async () => {
const market = await cat("market");
const page = await listTransactions(ids.user("ayse"), cakir, filters({ kategori: market }));
expect(page!.total).toBe(
await TransactionModel.countDocuments({
householdId: cakir,
deletedAt: null,
effectiveCategoryIds: {
$in: await CategoryModel.find({
householdId: cakir,
$or: [{ _id: market }, { ancestors: new Types.ObjectId(market) }],
}).distinct("_id"),
},
}),
);
expect(page!.items.some((i) => i.splitCount > 0)).toBe(true);
});
it("tarih aralığı İstanbul gününe göre (gün dahil)", async () => {
const id = await mustCreate(
"ayse",
form({
date: "2026-01-15",
accountId: await acc("Cüzdan"),
categoryId: await cat("market.gida"),
payee: "Tarih Testi",
}),
);
const same = await listTransactions(
ids.user("ayse"),
cakir,
filters({ from: "2026-01-15", to: "2026-01-15", q: "tarih testi" }),
);
expect(same!.items.map((i) => i.id)).toEqual([id]);
const after = await listTransactions(
ids.user("ayse"),
cakir,
filters({ from: "2026-01-16", q: "tarih testi" }),
);
expect(after!.total).toBe(0);
});
it("Türkçe arama, tutar aralığı, kişi, etiket ve hesap (transfer hedefi dahil)", async () => {
const q = await listTransactions(ids.user("ayse"), cakir, filters({ q: "MİGROS" }));
expect(q!.total).toBeGreaterThan(0);
expect(q!.items.every((i) => i.payeeName === "Migros")).toBe(true);
const range = await listTransactions(
ids.user("ayse"),
cakir,
filters({ min: "1.000", max: "2.000" }),
);
expect(range!.items.every((i) => i.amountMinor >= 1_000_00 && i.amountMinor <= 2_000_00)).toBe(
true,
);
const byEmre = await listTransactions(
ids.user("ayse"),
cakir,
filters({ kisi: ids.user("emre") }),
);
expect(byEmre!.items.every((i) => i.createdBy.id === ids.user("emre"))).toBe(true);
const tagged = await listTransactions(ids.user("ayse"), cakir, filters({ etiket: "İndirim" }));
expect(tagged!.items.every((i) => i.tags.includes("indirim"))).toBe(true);
const cuzdan = await acc("Cüzdan");
const byAccount = await listTransactions(
ids.user("ayse"),
cakir,
filters({ hesap: cuzdan, type: "transfer" }),
);
expect(byAccount!.total).toBeGreaterThan(0);
expect(
byAccount!.items.every((i) => i.toAccount?.id === cuzdan || i.account.id === cuzdan),
).toBe(true);
});
it("geçersiz filtre değerleri yok sayılır (enjeksiyon yok)", async () => {
const f = transactionFilterSchema.parse({
hesap: "{$ne:null}",
kategori: "x",
min: "abc",
sayfa: "-3",
q: ".*",
});
expect(f.hesap).toBeUndefined();
expect(f.kategori).toBeUndefined();
expect(f.min).toBeUndefined();
expect(f.sayfa).toBe(1);
// ".*" düz metin olarak aranır (regex kaçışı).
expect((await listTransactions(ids.user("ayse"), cakir, f))!.total).toBe(0);
});
});
describe("satıcı kuralları", () => {
it("son kullanılan kategori ve hesap önerilir", async () => {
const cuzdan = await acc("Cüzdan");
const ilac = await cat("saglik.ilac");
await mustCreate("ayse", form({ accountId: cuzdan, categoryId: ilac, payee: "Şifa Eczanesi" }));
const [s] = await searchPayees(ids.user("ayse"), cakir, "sifa");
expect(s).toMatchObject({
name: "Şifa Eczanesi",
lastAccountId: cuzdan,
lastCategoryId: ilac,
lastType: "expense",
});
// Başka hane satıcıyı görmez.
expect(await searchPayees(ids.user("fatma"), annemler, "sifa")).toEqual([]);
});
});
describe("CSV", () => {
it("bölünmüş işlem kalem başına satır üretir, işaret türe göre", async () => {
const rows = (await exportTransactions(ids.user("ayse"), cakir, filters()))!;
const split = await TransactionModel.findOne({
householdId: cakir,
"splits.1": { $exists: true },
deletedAt: null,
}).lean();
const lines = rows.filter((r) => r.no === String(split!._id));
expect(lines).toHaveLength(split!.splits.length);
expect(lines.reduce((a, r) => a + r.amountMinor, 0)).toBe(-split!.amountMinor);
expect(rows.find((r) => r.type === "income")!.amountMinor).toBeGreaterThan(0);
});
});
+727
View File
@@ -0,0 +1,727 @@
import "server-only";
import { Types, type PipelineStage } from "mongoose";
import { resolveMembership } from "@/features/households/service";
import { buildTree, toOptions, type CategoryOption } from "@/features/categories/tree";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { writeAudit } from "@/lib/audit";
import { getDb } from "@/lib/db/mongo";
import { toObjectId } from "@/lib/object-id";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { PayeeModel } from "@/models/payee";
import { TransactionModel } from "@/models/transaction";
import type { AccountType } from "@/shared/accounts";
import type { IconKey, Swatch } from "@/shared/appearance";
import { istanbulDayStart, toIstanbulYmd } from "@/shared/dates";
import type { CategoryKind } from "@/shared/schemas/category";
import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction";
import { compareTr, foldTr } from "@/shared/search";
import { normalizeTags, type TransactionStatus, type TransactionType } from "@/shared/transactions";
/**
* İşlem servisi (Bölüm 6.3). Okuma: viewer; yazma: member.
* Başkasının "yalnızca ben" hesabına dokunan işlemler o kullanıcıdan tamamen gizlenir
* (işlemler bakiyeyi ele verir) ve bu hesaplara yalnızca hesap sahibi işlem girebilir.
*/
const t = tr.transactions;
export const PAGE_SIZE = 25;
const DAY_MS = 24 * 60 * 60 * 1000;
// --- Hane bağlamı: hesaplar, kategoriler, görünürlük ---
type AccountInfo = {
id: Types.ObjectId;
name: string;
type: AccountType;
archived: boolean;
hidden: boolean;
icon: IconKey;
color: Swatch;
};
type CategoryInfo = {
id: Types.ObjectId;
kind: CategoryKind;
name: string;
path: string;
archived: boolean;
icon: IconKey;
color: Swatch;
ancestors: string[];
};
async function loadContext(userId: string, householdId: string, minRole: "viewer" | "member") {
const m = await resolveMembership(userId, householdId, minRole);
if (!m) return null;
const [accounts, categories] = await Promise.all([
AccountModel.find({ householdId: m.householdId })
.select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 })
.lean(),
CategoryModel.find({ householdId: m.householdId })
.select({
name: 1,
kind: 1,
parentId: 1,
order: 1,
archivedAt: 1,
icon: 1,
color: 1,
ancestors: 1,
})
.lean(),
]);
const accountMap = new Map<string, AccountInfo>(
accounts.map((a) => [
String(a._id),
{
id: a._id,
name: a.name,
type: a.type,
archived: Boolean(a.archivedAt),
hidden: Boolean(a.isPrivate) && String(a.ownerUserId) !== userId,
icon: a.icon as IconKey,
color: a.color as Swatch,
},
]),
);
const flat = categories.map((c) => ({
id: String(c._id),
kind: c.kind,
name: c.name,
parentId: c.parentId ? String(c.parentId) : null,
order: c.order,
archived: Boolean(c.archivedAt),
}));
const paths = new Map(
toOptions(buildTree(flat), { includeArchived: true }).map((o) => [o.id, o.path]),
);
const categoryMap = new Map<string, CategoryInfo>(
categories.map((c) => [
String(c._id),
{
id: c._id,
kind: c.kind,
name: c.name,
path: paths.get(String(c._id)) ?? c.name,
archived: Boolean(c.archivedAt),
icon: c.icon as IconKey,
color: c.color as Swatch,
ancestors: c.ancestors.map(String),
},
]),
);
const hiddenAccountIds = [...accountMap.values()].filter((a) => a.hidden).map((a) => a.id);
return { m, accountMap, categoryMap, hiddenAccountIds, flatCategories: flat };
}
type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
/** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */
function baseFilter(ctx: Ctx): Record<string, unknown> {
const f: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null };
if (ctx.hiddenAccountIds.length) {
f.accountId = { $nin: ctx.hiddenAccountIds };
f.toAccountId = { $nin: ctx.hiddenAccountIds };
}
return f;
}
// --- Bakiye ---
/**
* Hesap bazında işlem etkileri toplamı (kuruş): yalnızca posted ve silinmemiş işlemler.
* transactionEffects ile aynı kural: gelir +, gider −, transfer kaynak − / hedef +.
*/
export async function computeAccountDeltas(
householdId: Types.ObjectId,
): Promise<Map<string, number>> {
const pipeline: PipelineStage[] = [
{ $match: { householdId, status: "posted", deletedAt: null } },
{
$project: {
effects: [
{
a: "$accountId",
d: {
$cond: [
{ $eq: ["$type", "income"] },
"$amountMinor",
{ $multiply: ["$amountMinor", -1] },
],
},
},
{
a: { $cond: [{ $eq: ["$type", "transfer"] }, "$toAccountId", null] },
d: "$amountMinor",
},
],
},
},
{ $unwind: "$effects" },
{ $match: { "effects.a": { $ne: null } } },
{ $group: { _id: "$effects.a", delta: { $sum: "$effects.d" } } },
];
const rows = await TransactionModel.aggregate<{ _id: Types.ObjectId; delta: number }>(pipeline);
return new Map(rows.map((r) => [String(r._id), r.delta]));
}
// --- Doğrulama ---
function checkAccount(ctx: Ctx, id: string | null, keep?: string | null): Types.ObjectId | null {
if (!id) return null;
const a = ctx.accountMap.get(id);
// Düzenlemede işlemin zaten bağlı olduğu arşivli hesap korunabilir.
if (!a || a.hidden || (a.archived && id !== keep)) return null;
return a.id;
}
function checkCategory(
ctx: Ctx,
id: string,
kind: CategoryKind,
keep: Set<string>,
): Types.ObjectId | null {
const c = ctx.categoryMap.get(id);
if (!c || c.kind !== kind || (c.archived && !keep.has(id))) return null;
return c.id;
}
type Existing = {
accountId: Types.ObjectId;
toAccountId?: Types.ObjectId | null;
categoryId?: Types.ObjectId | null;
splits?: { categoryId: Types.ObjectId }[];
};
function buildDoc(
ctx: Ctx,
input: TransactionForm,
existing?: Existing,
): ActionResult<Record<string, unknown>> {
const date = istanbulDayStart(input.date);
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
const accountId = checkAccount(
ctx,
input.accountId,
existing ? String(existing.accountId) : null,
);
if (!accountId)
return fail(t.validation.accountInvalid, { accountId: t.validation.accountInvalid });
let toAccountId: Types.ObjectId | null = null;
let categoryId: Types.ObjectId | null = null;
let splits: { categoryId: Types.ObjectId; amountMinor: number; note: string }[] = [];
if (input.type === "transfer") {
toAccountId = checkAccount(
ctx,
input.toAccountId,
existing?.toAccountId ? String(existing.toAccountId) : null,
);
if (!toAccountId || toAccountId.equals(accountId)) {
return fail(t.validation.accountInvalid, { toAccountId: t.validation.accountInvalid });
}
} else {
const kind: CategoryKind = input.type;
const keep = new Set(
[existing?.categoryId, ...(existing?.splits ?? []).map((s) => s.categoryId)]
.filter(Boolean)
.map(String),
);
if (input.splits.length >= 2) {
for (const [i, s] of input.splits.entries()) {
const cid = checkCategory(ctx, s.categoryId, kind, keep);
if (!cid)
return fail(t.validation.categoryInvalid, {
[`splits.${i}.categoryId`]: t.validation.categoryInvalid,
});
splits.push({ categoryId: cid, amountMinor: s.amount, note: s.note });
}
} else {
categoryId = input.categoryId ? checkCategory(ctx, input.categoryId, kind, keep) : null;
if (!categoryId)
return fail(t.validation.categoryInvalid, { categoryId: t.validation.categoryInvalid });
splits = [];
}
}
const effective = [
...new Set([categoryId, ...splits.map((s) => s.categoryId)].filter(Boolean).map(String)),
].map((id) => new Types.ObjectId(id));
const payeeName = input.payee.trim();
return ok({
type: input.type,
date,
accountId,
toAccountId,
amountMinor: input.amount,
categoryId,
splits,
effectiveCategoryIds: effective,
payeeName,
description: input.description,
searchText: foldTr([payeeName, input.description, ...splits.map((s) => s.note)].join(" ")),
tags: normalizeTags(input.tags),
});
}
/** Satıcı kuralını günceller (son kategori ve hesap) ve kimliğini döner. */
async function touchPayee(
householdId: Types.ObjectId,
name: string,
type: TransactionType,
accountId: Types.ObjectId,
categoryId: Types.ObjectId | null,
): Promise<Types.ObjectId | null> {
if (!name) return null;
const doc = await PayeeModel.findOneAndUpdate(
{ householdId, nameKey: foldTr(name) },
{
$setOnInsert: { householdId, nameKey: foldTr(name) },
$set: {
name,
lastType: type,
lastAccountId: accountId,
lastCategoryId: categoryId,
lastUsedAt: new Date(),
},
$inc: { useCount: 1 },
},
{ upsert: true, returnDocument: "after" },
).lean();
return doc?._id ?? null;
}
// --- Yazma ---
export async function createTransaction(
actorId: string,
householdId: string,
input: TransactionForm,
): Promise<ActionResult<{ id: string }>> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const built = buildDoc(ctx, input);
if (!built.ok) return built;
const d = built.data as { accountId: Types.ObjectId; categoryId: Types.ObjectId | null };
// Bölünmüş işlemde tek bir kategori önerilemez; satıcı kuralı yalnızca hesabı hatırlar.
const payeeId = await touchPayee(
ctx.m.householdId,
input.payee,
input.type,
d.accountId,
d.categoryId,
);
const actor = new Types.ObjectId(actorId);
const doc = await TransactionModel.create({
...built.data,
householdId: ctx.m.householdId,
status: "posted",
payeeId,
createdBy: actor,
updatedBy: actor,
});
return ok({ id: String(doc._id) });
}
async function loadOwnTransaction(
ctx: Ctx,
transactionId: string,
{ includeDeleted = false } = {},
) {
const id = toObjectId(transactionId);
if (!id) return null;
const filter: Record<string, unknown> = { ...baseFilter(ctx), _id: id };
if (includeDeleted) delete filter.deletedAt;
return TransactionModel.findOne(filter).lean();
}
export async function updateTransaction(
actorId: string,
householdId: string,
transactionId: string,
input: TransactionForm,
): Promise<ActionResult> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const current = await loadOwnTransaction(ctx, transactionId);
if (!current) return fail(tr.common.notFound);
const built = buildDoc(ctx, input, current);
if (!built.ok) return built;
const d = built.data as { accountId: Types.ObjectId; categoryId: Types.ObjectId | null };
const payeeId = await touchPayee(
ctx.m.householdId,
input.payee,
input.type,
d.accountId,
d.categoryId,
);
await TransactionModel.updateOne(
{ _id: current._id, householdId: ctx.m.householdId },
{ $set: { ...built.data, payeeId, updatedBy: new Types.ObjectId(actorId) } },
);
return ok(undefined);
}
export async function setTransactionDeleted(
actorId: string,
householdId: string,
transactionId: string,
deleted: boolean,
): Promise<ActionResult> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const current = await loadOwnTransaction(ctx, transactionId, { includeDeleted: true });
if (!current || Boolean(current.deletedAt) === deleted) return fail(tr.common.notFound);
const actor = new Types.ObjectId(actorId);
await TransactionModel.updateOne(
{ _id: current._id, householdId: ctx.m.householdId },
{ $set: { deletedAt: deleted ? new Date() : null, updatedBy: actor } },
);
await writeAudit({
action: deleted ? "transaction.delete" : "transaction.restore",
actorId: actor,
householdId: ctx.m.householdId,
targetType: "transaction",
targetId: transactionId,
diff: {
type: current.type,
amountMinor: current.amountMinor,
date: toIstanbulYmd(current.date),
},
});
return ok(undefined);
}
// --- Okuma ---
export type TransactionListItem = {
id: string;
type: TransactionType;
status: TransactionStatus;
date: string;
amountMinor: number;
account: { id: string; name: string };
toAccount: { id: string; name: string } | null;
category: { id: string; name: string; path: string; icon: IconKey; color: Swatch } | null;
splitCount: number;
payeeName: string;
description: string;
tags: string[];
createdBy: { id: string; name: string };
attachmentCount: number;
};
export type TransactionDetail = TransactionListItem & {
splits: {
categoryId: string;
path: string;
icon: IconKey;
color: Swatch;
amountMinor: number;
note: string;
}[];
updatedBy: { id: string; name: string };
createdAt: string;
updatedAt: string;
/** Düzenleme formu için ham kimlikler. */
categoryId: string | null;
};
async function userNames(ids: Types.ObjectId[]): Promise<Map<string, string>> {
const unique = [...new Set(ids.map(String))].map((id) => new Types.ObjectId(id));
const users = await getDb()
.collection("user")
.find({ _id: { $in: unique } }, { projection: { name: 1 } })
.toArray();
return new Map(users.map((u) => [String(u._id), String(u.name ?? "—")]));
}
type LeanTx = Awaited<ReturnType<typeof TransactionModel.find>>[number] & {
_id: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
};
function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): TransactionListItem {
const acc = (id: Types.ObjectId | null | undefined) => {
if (!id) return null;
const a = ctx.accountMap.get(String(id));
return { id: String(id), name: a?.name ?? "—" };
};
const cat = tx.categoryId ? ctx.categoryMap.get(String(tx.categoryId)) : undefined;
return {
id: String(tx._id),
type: tx.type,
status: tx.status,
date: toIstanbulYmd(tx.date),
amountMinor: tx.amountMinor,
account: acc(tx.accountId)!,
toAccount: acc(tx.toAccountId),
category: cat
? { id: String(cat.id), name: cat.name, path: cat.path, icon: cat.icon, color: cat.color }
: null,
splitCount: tx.splits?.length ?? 0,
payeeName: tx.payeeName ?? "",
description: tx.description ?? "",
tags: tx.tags ?? [],
createdBy: { id: String(tx.createdBy), name: names.get(String(tx.createdBy)) ?? "—" },
attachmentCount: tx.attachmentCount ?? 0,
};
}
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
function filterQuery(ctx: Ctx, f: TransactionFilters): Record<string, unknown> {
const q: Record<string, unknown> = baseFilter(ctx);
const and: Record<string, unknown>[] = [];
const from = f.from ? istanbulDayStart(f.from) : null;
const to = f.to ? istanbulDayStart(f.to) : null;
if (from || to) {
q.date = {
...(from ? { $gte: from } : {}),
...(to ? { $lt: new Date(to.getTime() + DAY_MS) } : {}),
};
}
if (f.type) q.type = f.type;
if (f.durum) q.status = f.durum;
if (f.hesap) {
const id = toObjectId(f.hesap);
// Gizli hesap filtresi sonuç vermez (baseFilter zaten dışlar).
and.push({ $or: [{ accountId: id }, { toAccountId: id }] });
}
if (f.kategori) {
// Alt kategoriler dahil.
const ids = [...ctx.categoryMap.values()]
.filter((c) => String(c.id) === f.kategori || c.ancestors.includes(f.kategori!))
.map((c) => c.id);
q.effectiveCategoryIds = { $in: ids };
}
if (f.kisi) q.createdBy = toObjectId(f.kisi);
if (f.min !== undefined || f.max !== undefined) {
q.amountMinor = {
...(f.min !== undefined ? { $gte: f.min } : {}),
...(f.max !== undefined ? { $lte: f.max } : {}),
};
}
if (f.etiket) q.tags = normalizeTags(f.etiket)[0] ?? "";
if (f.q) q.searchText = { $regex: escapeRegex(foldTr(f.q)) };
if (and.length) q.$and = and;
return q;
}
export type TransactionPage = {
items: TransactionListItem[];
total: number;
page: number;
pageCount: number;
sums: { incomeMinor: number; expenseMinor: number };
};
export async function listTransactions(
userId: string,
householdId: string,
filters: TransactionFilters,
): Promise<TransactionPage | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const query = filterQuery(ctx, filters);
const [total, sumRows] = await Promise.all([
TransactionModel.countDocuments(query),
TransactionModel.aggregate<{ _id: TransactionType; sum: number }>([
{
$match: {
...query,
status: query.status ?? "posted",
type: query.type ?? { $in: ["income", "expense"] },
},
},
{ $group: { _id: "$type", sum: { $sum: "$amountMinor" } } },
]),
]);
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const page = Math.min(filters.sayfa, pageCount);
const rows = (await TransactionModel.find(query)
.sort({ date: -1, createdAt: -1 })
.skip((page - 1) * PAGE_SIZE)
.limit(PAGE_SIZE)
.lean()) as unknown as LeanTx[];
const names = await userNames(rows.map((r) => r.createdBy as Types.ObjectId));
return {
items: rows.map((r) => toItem(ctx, r, names)),
total,
page,
pageCount,
sums: {
incomeMinor: sumRows.find((r) => r._id === "income")?.sum ?? 0,
expenseMinor: sumRows.find((r) => r._id === "expense")?.sum ?? 0,
},
};
}
export async function getTransaction(
userId: string,
householdId: string,
transactionId: string,
): Promise<TransactionDetail | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const tx = (await loadOwnTransaction(ctx, transactionId)) as unknown as LeanTx | null;
if (!tx) return null;
const names = await userNames([tx.createdBy as Types.ObjectId, tx.updatedBy as Types.ObjectId]);
return {
...toItem(ctx, tx, names),
categoryId: tx.categoryId ? String(tx.categoryId) : null,
splits: (tx.splits ?? []).map((s) => {
const c = ctx.categoryMap.get(String(s.categoryId));
return {
categoryId: String(s.categoryId),
path: c?.path ?? "—",
icon: c?.icon ?? "tag",
color: c?.color ?? "slate",
amountMinor: s.amountMinor,
note: s.note ?? "",
};
}),
updatedBy: { id: String(tx.updatedBy), name: names.get(String(tx.updatedBy)) ?? "—" },
createdAt: new Date(tx.createdAt).toISOString(),
updatedAt: new Date(tx.updatedAt).toISOString(),
};
}
/** CSV için tüm eşleşen işlemler (sayfalama yok; üst sınır 20.000). Bölünmüş işlem kalem başına satır. */
export async function exportTransactions(
userId: string,
householdId: string,
filters: TransactionFilters,
) {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const rows = await TransactionModel.find(filterQuery(ctx, filters))
.sort({ date: -1, createdAt: -1 })
.limit(20_000)
.lean();
const names = await userNames(rows.map((r) => r.createdBy as Types.ObjectId));
const accountName = (id: Types.ObjectId | null | undefined) =>
id ? (ctx.accountMap.get(String(id))?.name ?? "") : "";
const categoryPath = (id: Types.ObjectId | null | undefined) =>
id ? (ctx.categoryMap.get(String(id))?.path ?? "") : "";
const sign = (type: TransactionType) => (type === "income" ? 1 : type === "expense" ? -1 : 1);
const out: {
no: string;
date: string;
type: TransactionType;
status: TransactionStatus;
account: string;
toAccount: string;
category: string;
payee: string;
description: string;
note: string;
tags: string;
amountMinor: number;
createdBy: string;
}[] = [];
for (const r of rows) {
const common = {
no: String(r._id),
date: toIstanbulYmd(r.date),
type: r.type,
status: r.status,
account: accountName(r.accountId),
toAccount: accountName(r.toAccountId),
payee: r.payeeName ?? "",
description: r.description ?? "",
tags: (r.tags ?? []).join(", "),
createdBy: names.get(String(r.createdBy)) ?? "",
};
if (r.splits?.length) {
for (const s of r.splits) {
out.push({
...common,
category: categoryPath(s.categoryId),
note: s.note ?? "",
amountMinor: sign(r.type) * s.amountMinor,
});
}
} else {
out.push({
...common,
category: categoryPath(r.categoryId),
note: "",
amountMinor: sign(r.type) * r.amountMinor,
});
}
}
return out;
}
// --- Form seçenekleri ve satıcı önerileri ---
export type TransactionFormOptions = {
accounts: { id: string; name: string; type: AccountType; icon: IconKey; color: Swatch }[];
categories: CategoryOption[];
categoryAppearance: Record<string, { icon: IconKey; color: Swatch }>;
};
export async function getTransactionFormOptions(
userId: string,
householdId: string,
): Promise<TransactionFormOptions | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const accounts = (
await AccountModel.find({ householdId: ctx.m.householdId, archivedAt: null })
.select({ name: 1, type: 1, icon: 1, color: 1, order: 1, isPrivate: 1, ownerUserId: 1 })
.lean()
)
.filter((a) => !a.isPrivate || String(a.ownerUserId) === userId)
.sort((a, b) => a.order - b.order || compareTr(a.name, b.name))
.map((a) => ({
id: String(a._id),
name: a.name,
type: a.type,
icon: a.icon as IconKey,
color: a.color as Swatch,
}));
const categories = toOptions(buildTree(ctx.flatCategories));
const categoryAppearance = Object.fromEntries(
[...ctx.categoryMap.values()].map((c) => [String(c.id), { icon: c.icon, color: c.color }]),
);
return { accounts, categories, categoryAppearance };
}
export type PayeeSuggestion = {
name: string;
lastType: TransactionType;
lastCategoryId: string | null;
lastAccountId: string | null;
};
/** Satıcı önerileri: ada göre (Türkçe normalize, baştan veya içinden), kullanım sıklığına göre. */
export async function searchPayees(
userId: string,
householdId: string,
query: string,
): Promise<PayeeSuggestion[]> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return [];
const key = foldTr(query);
const filter: Record<string, unknown> = { householdId: ctx.m.householdId };
if (key) filter.nameKey = { $regex: escapeRegex(key) };
const rows = await PayeeModel.find(filter).sort({ useCount: -1, lastUsedAt: -1 }).limit(8).lean();
return rows.map((p) => {
const acc = p.lastAccountId ? ctx.accountMap.get(String(p.lastAccountId)) : undefined;
const cat = p.lastCategoryId ? ctx.categoryMap.get(String(p.lastCategoryId)) : undefined;
return {
name: p.name,
lastType: p.lastType,
// Öneri yalnızca hâlâ kullanılabilir hesap/kategoriyi gösterir.
lastAccountId: acc && !acc.hidden && !acc.archived ? String(acc.id) : null,
lastCategoryId: cat && !cat.archived ? String(cat.id) : null,
};
});
}
+365 -1
View File
@@ -31,6 +31,23 @@ export const tr = {
showPassword: "Şifreyi göster", showPassword: "Şifreyi göster",
hidePassword: "Şifreyi gizle", hidePassword: "Şifreyi gizle",
genericError: "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", genericError: "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.",
unauthorized: "Bu işlem için yetkiniz yok.",
notFound: "Kayıt bulunamadı.",
},
swatches: {
blue: "Mavi",
sky: "Gök mavisi",
teal: "Turkuaz",
green: "Yeşil",
lime: "Fıstık yeşili",
amber: "Kehribar",
orange: "Turuncu",
red: "Kırmızı",
rose: "Gül",
pink: "Pembe",
purple: "Mor",
indigo: "Çivit",
slate: "Gri",
}, },
theme: { theme: {
label: "Tema", label: "Tema",
@@ -43,6 +60,7 @@ export const tr = {
dashboard: "Ana sayfa", dashboard: "Ana sayfa",
transactions: "İşlemler", transactions: "İşlemler",
accounts: "Hesaplar", accounts: "Hesaplar",
categories: "Kategoriler",
budget: "Bütçe", budget: "Bütçe",
planned: "Planlı işlemler", planned: "Planlı işlemler",
contacts: "Cari hesaplar", contacts: "Cari hesaplar",
@@ -61,6 +79,339 @@ export const tr = {
label: "Aktif hane", label: "Aktif hane",
select: "Hane seçin", select: "Hane seçin",
roles: { owner: "Sahip", member: "Üye", viewer: "İzleyici" }, roles: { owner: "Sahip", member: "Üye", viewer: "İzleyici" },
roleHints: {
owner: "Hane ayarları, üyeler ve hesaplar",
member: "İşlem girer, liste ve stok günceller",
viewer: "Yalnızca görüntüler",
},
switched: (name: string) => `Aktif hane: ${name}`,
},
households: {
lastOwner: "Hanenin son sahibi düşürülemez veya çıkarılamaz.",
noHousehold: {
title: "Henüz bir haneye üye değilsiniz",
description:
"Bütçe ve diğer verileri görmek için bir haneye üye olmanız gerekir. Yöneticiden sizi bir haneye eklemesini isteyin.",
adminHint:
"Yönetici olarak Yönetim › Haneler sayfasından hane oluşturabilir ve üye ekleyebilirsiniz.",
adminLink: "Hanelere git",
},
viewerReadOnly: "İzleyici rolündesiniz; bu hanede değişiklik yapamazsınız.",
},
settings: {
title: "Ayarlar",
description: "Aktif hanenin ayarları ve üyeleri.",
household: "Hane",
householdDescription: "Bütçe ve planlama tercihleri tüm hane için geçerlidir.",
ownerOnly: "Bu ayarları yalnızca hane sahipleri değiştirebilir.",
fields: {
name: "Hane adı",
budgetMonthStartDay: "Bütçe ayı başlangıç günü",
budgetMonthStartDayHint:
"1 = takvim ayı. Örneğin 15 seçilirse bütçe ayı her ayın 15. günü başlar.",
installmentBudgetMode: "Taksitlerin bütçeye etkisi",
installmentModes: {
per_installment: "Her taksit kendi ayına",
purchase_month: "Tamamı harcama ayına",
},
dailyPlanMode: "Günlük plan dağıtımı",
dailyPlanModes: {
even: "Değişken bütçe günlere eşit",
previous_month_pattern: "Önceki ayın harcama örüntüsüne göre",
},
includePrivateAccountsInTotals: "“Yalnızca ben” hesapları hane toplamlarına dahil edilsin",
extraStoreSavingThreshold: "Ek mağaza tasarruf eşiği (TL)",
extraStoreSavingThresholdHint:
"Alışveriş planlayıcı, ek bir mağazayı yalnızca tasarruf bu tutarı aşarsa önerir.",
},
saved: "Hane ayarları kaydedildi.",
members: "Üyeler",
membersDescription: "Hane içi roller. Yeni üyeyi yönetici ekler.",
memberActions: (name: string) => `${name} — işlemler`,
changeRole: "Rolü değiştir",
removeMember: "Haneden çıkar",
removeTitle: "Üye haneden çıkarılsın mı?",
removeDescription: (name: string, household: string) =>
`${name}, “${household}” hanesine artık erişemeyecek. Kullanıcı hesabı silinmez.`,
removeConfirm: "Haneden çıkar",
roleChanged: (name: string) => `${name} için rol güncellendi.`,
memberRemoved: (name: string) => `${name} haneden çıkarıldı.`,
banned: "Askıda",
},
accounts: {
title: "Hesaplar",
description: "Nakit, banka, birikim, kredi kartı ve yemek kartı hesapları.",
newAccount: "Yeni hesap",
createTitle: "Yeni hesap",
editTitle: "Hesabı düzenle",
formDescription: "Güncel bakiye işlemlerden hesaplanır; açılış bakiyesi başlangıç noktasıdır.",
types: {
cash: "Nakit",
bank: "Vadesiz banka",
savings: "Birikim / vadeli",
credit_card: "Kredi kartı",
meal_card: "Yemek kartı",
},
typeGroups: {
cash: "Nakit",
bank: "Banka hesapları",
savings: "Birikim hesapları",
credit_card: "Kredi kartları",
meal_card: "Yemek kartları",
},
providers: {
pluxee: "Pluxee (Sodexo)",
multinet: "Multinet",
edenred: "Edenred (Ticket)",
setcard: "Setcard",
metropol: "Metropol",
other: "Diğer",
},
fields: {
name: "Hesap adı",
type: "Tür",
typeLockedHint: "Hesap türü oluşturulduktan sonra değiştirilemez.",
institution: "Banka / kurum",
institutionPlaceholder: "ör. Ziraat Bankası",
provider: "Kart sağlayıcısı",
openingBalance: "Açılış bakiyesi (TL)",
openingDebt: "Açılış borcu (TL)",
openingBalanceHint: "Banka hesabı KMH kullanıyorsa eksi girilebilir (ör. -2.500,00).",
openingDebtHint: "Karttaki mevcut toplam borç; pozitif girin.",
openingDate: "Açılış tarihi",
owner: "Hesap sahibi",
ownerNone: "— Ortak hesap —",
isPrivate: "Yalnızca ben (bakiyeyi yalnızca hesap sahibi görür)",
appearance: "Görünüm",
advanced: "Gelişmiş ayarlar",
overdraft: "Esnek hesap (KMH)",
overdraftEnabled: "Bu hesapta KMH var",
overdraftLimit: "KMH limiti (TL)",
overdraftRate: "Aylık faiz oranı (%)",
overdraftStatementDay: "Hesap kesim günü",
card: "Kart bilgileri",
cardLimit: "Kart limiti (TL)",
cardStatementDay: "Hesap kesim günü",
cardStatementDayHint: "Ayda o gün yoksa (ör. 31) ayın son günü kullanılır.",
cardDueKind: "Son ödeme günü",
dueKinds: { daysAfter: "Kesimden N gün sonra", fixedDay: "Ayın sabit günü" },
cardDueValueDays: "Gün sayısı",
cardDueValueDay: "Ayın günü",
cardPaymentAccount: "Ödeme hesabı",
cardPaymentAccountNone: "— Seçilmedi —",
},
summary: {
assets: "Varlıklar",
cardDebt: "Kart borçları",
net: "Net",
includesHidden: "Toplamlara gizli hesaplar dahil.",
excludesHidden: "Gizli hesaplar toplamlara dahil değil (hane ayarı).",
},
balance: "Bakiye",
debt: "Borç",
hidden: "Gizli",
hiddenHint: "Bu hesap “yalnızca ben” olarak işaretli; bakiyeyi yalnızca hesap sahibi görür.",
privateBadge: "Yalnızca ben",
overdraftBadge: (limit: string) => `KMH ${limit}`,
limitLabel: (limit: string) => `Limit ${limit}`,
statementLabel: (day: number) => `Kesim: ${day}`,
empty: "Henüz hesap yok.",
emptyOwnerHint: "Başlamak için ilk hesabınızı ekleyin (ör. nakit cüzdan, maaş hesabı).",
archivedSection: (n: number) => `Arşivlenmiş hesaplar (${n})`,
archive: "Arşivle",
unarchive: "Arşivden çıkar",
archiveTitle: "Hesap arşivlensin mi?",
archiveDescription: (name: string) =>
`“${name}” yeni işlemlerde seçilemez; geçmiş işlemler ve raporlar korunur. İstediğiniz zaman arşivden çıkarabilirsiniz.`,
created: (name: string) => `${name} oluşturuldu.`,
updated: (name: string) => `${name} güncellendi.`,
archivedToast: (name: string) => `${name} arşivlendi.`,
unarchivedToast: (name: string) => `${name} arşivden çıkarıldı.`,
ownerNotMember: "Hesap sahibi bu hanenin üyesi olmalı.",
invalidPaymentAccount:
"Ödeme hesabı bu hanedeki aktif bir banka, nakit veya birikim hesabı olmalı.",
typeLocked: "Hesap türü değiştirilemez.",
ownerOnly: "Hesapları yalnızca hane sahipleri yönetebilir.",
},
transactions: {
title: "İşlemler",
description: "Gelir, gider ve transferler.",
new: "Yeni işlem",
types: { expense: "Gider", income: "Gelir", transfer: "Transfer" },
statuses: { posted: "Gerçekleşti", planned: "Planlı", void: "İptal" },
createTitle: { expense: "Yeni gider", income: "Yeni gelir", transfer: "Yeni transfer" },
editTitle: "İşlemi düzenle",
copyTitle: "İşlemi kopyala",
fields: {
amount: "Tutar",
date: "Tarih",
account: "Hesap",
fromAccount: "Kaynak hesap",
toAccount: "Hedef hesap",
category: "Kategori",
selectCategory: "Kategori seçin",
selectAccount: "Hesap seçin",
payee: "Satıcı / karşı taraf",
payeePlaceholder: "ör. Migros",
description: "Açıklama",
tags: "Etiketler",
tagsHint: "Virgülle ayırın (ör. tatil, indirim)",
advanced: "Gelişmiş: etiket, bölme",
split: "Bölünmüş işlem",
splitHint: "Tutarı birden çok kategoriye bölün; kalemlerin toplamı tutara eşit olmalı.",
addSplit: "Kalem ekle",
removeSplit: "Kalemi kaldır",
splitNote: "Not",
remaining: "Kalan",
remainingOk: "Kalemler toplamı tutara eşit",
fillRemaining: "Kalanı son kaleme ekle",
suggested: "Bu satıcı için son kullanılan kategori ve hesap önerildi.",
},
validation: {
amountPositive: "Tutar sıfırdan büyük olmalı",
selectAccount: "Hesap seçin",
selectToAccount: "Hedef hesabı seçin",
sameAccount: "Kaynak ve hedef hesap aynı olamaz",
selectCategory: "Kategori seçin",
splitMin: "Bölünmüş işlemde en az 2 kalem olmalı",
splitSum: "Kalemlerin toplamı tutara eşit olmalı",
accountInvalid: "Hesap bu hanede yok, arşivli veya size kapalı.",
categoryInvalid: "Kategori bu hanede yok, arşivli veya işlem türüyle uyuşmuyor.",
},
list: {
empty: "Bu filtrelerle işlem bulunamadı.",
emptyAll: "Henüz işlem yok.",
split: (n: number) => `Bölünmüş · ${n} kalem`,
transfer: (from: string, to: string) => `${from} → ${to}`,
dayTotal: "Günlük net",
total: (n: number) => `${n} işlem`,
sums: { income: "Gelir", expense: "Gider", net: "Net" },
hiddenAccount: "Gizli hesap",
},
filters: {
title: "Filtreler",
open: "Filtrele",
apply: "Uygula",
clear: "Temizle",
from: "Başlangıç",
to: "Bitiş",
type: "Tür",
account: "Hesap",
category: "Kategori",
person: "Ekleyen",
status: "Durum",
min: "En az (TL)",
max: "En çok (TL)",
tag: "Etiket",
search: "Ara",
searchPlaceholder: "Açıklama, satıcı veya not…",
all: "Tümü",
active: (n: number) => `${n} filtre`,
},
detail: {
title: "İşlem ayrıntısı",
lines: "Kalemler",
createdBy: "Ekleyen",
updatedBy: "Son düzenleyen",
createdAt: "Oluşturulma",
},
actions: { edit: "Düzenle", copy: "Kopyala", delete: "Sil", restore: "Geri al" },
toast: {
created: "İşlem kaydedildi.",
updated: "İşlem güncellendi.",
deleted: "İşlem silindi.",
restored: "İşlem geri alındı.",
},
deleteTitle: "İşlem silinsin mi?",
deleteDescription:
"İşlem listeden ve bakiyelerden kaldırılır. Bildirimdeki “Geri al” ile geri getirebilirsiniz.",
export: "CSV indir",
csv: {
header: [
"İşlem No",
"Tarih",
"Tür",
"Durum",
"Hesap",
"Hedef hesap",
"Kategori",
"Satıcı",
"Açıklama",
"Kalem notu",
"Etiketler",
"Tutar",
"Ekleyen",
],
filename: (date: string) => `butcem-islemler-${date}.csv`,
},
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
noAccountsLink: "Hesaplara git",
},
categories: {
title: "Kategoriler",
description: "Gelir ve gider kategorileri. Alt kategoriler raporlarda üst kategoriye toplanır.",
expense: "Gider",
income: "Gelir",
newCategory: "Yeni kategori",
addChild: "Alt kategori ekle",
edit: "Düzenle",
moveUp: "Yukarı taşı",
moveDown: "Aşağı taşı",
archive: "Arşivle",
unarchive: "Arşivden çıkar",
showArchived: "Arşivi göster",
archived: "Arşivli",
defaultBadge: "Varsayılan",
empty: "Bu türde kategori yok.",
createTitle: "Yeni kategori",
editTitle: "Kategoriyi düzenle",
formDescription: "Alt kategori için bir üst kategori seçin; boş bırakılırsa ana kategori olur.",
name: "Ad",
parent: "Üst kategori",
noParent: "— Ana kategori —",
icon: "İkon",
color: "Renk",
created: (name: string) => `${name} oluşturuldu.`,
updated: (name: string) => `${name} güncellendi.`,
archivedToast: (name: string) => `${name} arşivlendi.`,
unarchivedToast: (name: string) => `${name} arşivden çıkarıldı.`,
archiveTitle: "Kategori arşivlensin mi?",
archiveDescription: (name: string) =>
`“${name}” ve alt kategorileri yeni işlemlerde seçilemez. Geçmiş işlemler ve raporlar etkilenmez.`,
duplicate: "Bu düzeyde aynı adlı bir kategori zaten var.",
violations: {
self: "Kategori kendi altına taşınamaz.",
descendant: "Kategori kendi alt kategorisinin altına taşınamaz.",
kind: "Gelir ve gider kategorileri birbirinin altına konamaz.",
depth: "En fazla 5 seviye derinliğe izin verilir.",
parentNotFound: "Üst kategori bulunamadı.",
parentArchived: "Üst kategori arşivli; önce onu arşivden çıkarın.",
},
readOnly: "İzleyici rolündesiniz; kategorileri değiştiremezsiniz.",
},
adminHouseholds: {
title: "Haneler",
description: "Hane oluşturma, kullanıcıları haneye atama ve hane içi roller.",
newHousehold: "Yeni hane",
createTitle: "Yeni hane",
createDescription: "Hane oluşturulur ve seçilen kullanıcı ilk sahibi olur.",
name: "Hane adı",
owner: "İlk sahip",
selectUser: "Kullanıcı seçin",
created: (name: string) => `${name} hanesi oluşturuldu.`,
members: (n: number) => `${n} üye`,
owners: "Sahipler",
noOwner: "Sahibi yok",
addMember: "Üye ekle",
addMemberTitle: (household: string) => `${household} — üye ekle`,
addMemberDescription: "Kullanıcı zaten üyeyse rolü güncellenir.",
user: "Kullanıcı",
role: "Hane içi rol",
memberAdded: (name: string) => `${name} haneye eklendi.`,
empty: "Henüz hane yok.",
privacyNote:
"Yöneticiler haneleri ve üyelikleri yönetir; üyesi olmadıkları hanenin finansal verisini göremez.",
}, },
userMenu: { userMenu: {
open: "Kullanıcı menüsü", open: "Kullanıcı menüsü",
@@ -79,7 +430,7 @@ export const tr = {
expenseHint: "Harcama, fatura, alışveriş", expenseHint: "Harcama, fatura, alışveriş",
incomeHint: "Maaş, ek gelir, iade", incomeHint: "Maaş, ek gelir, iade",
transferHint: "Hesaplar ve cariler arası", transferHint: "Hesaplar ve cariler arası",
notYet: "İşlem formu Faz 6'da eklenecek.", noAccounts: "İşlem girmek için önce bir hesap gerekli.",
}, },
auth: { auth: {
loginTitle: "Giriş yap", loginTitle: "Giriş yap",
@@ -90,6 +441,8 @@ export const tr = {
submit: "Giriş yap", submit: "Giriş yap",
submitting: "Giriş yapılıyor…", submitting: "Giriş yapılıyor…",
invalidCredentials: "Kullanıcı adı/e-posta veya şifre hatalı.", invalidCredentials: "Kullanıcı adı/e-posta veya şifre hatalı.",
banned: "Hesabınız askıya alınmış. Lütfen yöneticiyle iletişime geçin.",
tooManyAttempts: "Çok fazla deneme yapıldı. Lütfen bir dakika sonra tekrar deneyin.",
noSignUp: "Hesaplar yalnızca yönetici tarafından açılır.", noSignUp: "Hesaplar yalnızca yönetici tarafından açılır.",
identifierRequired: "Kullanıcı adı veya e-posta girin", identifierRequired: "Kullanıcı adı veya e-posta girin",
passwordRequired: "Şifre girin", passwordRequired: "Şifre girin",
@@ -192,6 +545,17 @@ export const tr = {
passwordMin: "Şifre en az 8 karakter olmalı", passwordMin: "Şifre en az 8 karakter olmalı",
passwordMax: "Şifre en fazla 128 karakter olabilir", passwordMax: "Şifre en fazla 128 karakter olabilir",
banReasonMax: "Neden en fazla 200 karakter olabilir", banReasonMax: "Neden en fazla 200 karakter olabilir",
householdNameMin: "Hane adı en az 2 karakter olmalı",
householdNameMax: "Hane adı en fazla 80 karakter olabilir",
selectUser: "Bir kullanıcı seçin",
wholeNumber: "Tam sayı girin",
money: "Geçerli bir tutar girin (ör. 1.250,50)",
moneyNonNegative: "Tutar negatif olamaz",
percent: "0 ile 100 arasında bir oran girin (ör. 4,25)",
date: "Geçerli bir tarih seçin",
selectProvider: "Kart sağlayıcısını seçin",
privateNeedsOwner: "“Yalnızca ben” hesabı için hesap sahibini seçin",
dueDays: "Kesimden sonra en fazla 30 gün olabilir",
}, },
errors: { errors: {
notFoundTitle: "Sayfa bulunamadı", notFoundTitle: "Sayfa bulunamadı",
+24
View File
@@ -0,0 +1,24 @@
import "server-only";
import { cookies } from "next/headers";
import { env } from "@/lib/env";
/**
* Aktif hane seçimi cookie'de tutulur. Cookie yalnızca bir TERCİHtir: DAL her istekte
* kullanıcının gerçekten o hanenin üyesi olduğunu doğrular; değilse ilk haneye düşer.
*/
export const ACTIVE_HOUSEHOLD_COOKIE = "butcem.hh";
export async function readActiveHouseholdId(): Promise<string | undefined> {
return (await cookies()).get(ACTIVE_HOUSEHOLD_COOKIE)?.value;
}
/** Yalnızca Server Action / Route Handler içinde çağrılabilir. */
export async function writeActiveHouseholdId(householdId: string): Promise<void> {
(await cookies()).set(ACTIVE_HOUSEHOLD_COOKIE, householdId, {
httpOnly: true,
sameSite: "lax",
secure: env().NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 365,
});
}

Some files were not shown because too many files have changed in this diff Show More