Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b18f918baf | |||
| c711eb8d28 | |||
| 6ae98a1cb4 | |||
| 0ad52757ad | |||
| cc624af69f | |||
| 57067b0fd0 | |||
| 8c4771908c | |||
| 5bd2c372bf | |||
| cfc2b2c974 | |||
| 04a17d2775 | |||
| eaafe5dd9b | |||
| 2d3a9e3a68 | |||
| 43053898b4 | |||
| 8a9f5ddc7d | |||
| 2e2e33233f | |||
| 2e1f4d7929 | |||
| 99fc9dfea6 | |||
| 00f8809823 | |||
| 3acd71fd59 | |||
| dcfa2f8c8d | |||
| 1208e2d1eb | |||
| d18c8a0d76 | |||
| a00c953dc0 | |||
| 904a969b18 | |||
| 0558d92203 | |||
| d2aa9d8ae2 | |||
| 8dd5fd42e3 | |||
| 380ddaf303 | |||
| eaf80c6bb4 | |||
| 7fda139199 | |||
| be9f2a5c3a | |||
| b79951601c | |||
| 3644e081b1 | |||
| de91f2505a |
+126
-1
@@ -6,6 +6,130 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
|||||||
|
|
||||||
## [Yayınlanmamış]
|
## [Yayınlanmamış]
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-11-05
|
||||||
|
|
||||||
|
Faz 7: planlı/düzenli işlemler, kredi kartı ve KMH dönemleri, taksitler (7a ve 7b alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 7b: kart ve KMH dönemleri, taksitler
|
||||||
|
|
||||||
|
- Dönem hesabı (`shared/card-periods.ts`): kesim günü dahil "Hesap Kesim Dönemi", sonrası "Güncel Dönem"; kesim günü ayda yoksa ayın son günü; kesim günü bitmeden dönem kapanmış sayılmaz. Son ödeme: kesimden N gün sonra veya ayın sabit günü.
|
||||||
|
- Kart özeti (`/hesaplar/:id`, kredi kartı): toplam borç, kullanılabilir limit (gelecek taksitler dahil düşülür) ve kullanım çubuğu; hesap kesim dönemi borcu, asgari ödeme, ödenen, kalan, son ödeme tarihi ve kalan gün; güncel dönem harcaması; dönem dönem yaklaşan taksitler; planlı harcamalar; son 6 ekstre.
|
||||||
|
- Asgari ödeme: limit 50.000 TL altı %20, üstü %40; kart bazında oran girilebilir (hesap formu).
|
||||||
|
- "Borcu öde": ödeme hesabından karta transfer formu kalan dönem borcuyla dolu açılır; ödemeler dönem borcundan düşer (kısmi ödeme desteklenir).
|
||||||
|
- Taksitli harcama: kredi kartı giderinde taksit sayısı (2–36). Her taksit ayrı işlem ("Hepsiburada 4/9"); toplam kuruş kaybı olmadan bölünür; vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler planlıdır ve bakiyeyi etkilemez. Ayrıntıdan "Kalan taksitleri iptal et".
|
||||||
|
- KMH özeti (KMH'li banka hesabı): limit, kullanılan, kullanılabilir; geçen ve bu dönem için gün sonu eksi bakiyelerden tahmini faiz; "Faiz kaydı ekle" kesim tarihli, "Banka › Faiz" kategorili gider formunu doldurur (vergiler hariç not).
|
||||||
|
- `statements` koleksiyonu: kart/KMH dönem kayıtları (borç, asgari, ödenen, harcama, tahmini faiz); kart özeti okunurken işlemlerden yeniden hesaplanır.
|
||||||
|
- İşlem formuna ön doldurma desteği (kart ödemesi, faiz kaydı).
|
||||||
|
- Seed: iki taksitli kart alışverişi.
|
||||||
|
- `006` migration'ı: `statements` index'leri, taksit planı index'i.
|
||||||
|
- Testler: dönem/son ödeme/asgari/taksit planı/KMH faizi (birim); kart özeti tutarlılığı (bağımsız bakiye hesabıyla), ödeme etkisi, taksit üretimi ve otomatik gerçekleşme, taksit iptali, taksit kuralları, KMH faiz tahmini ve yetki (entegrasyon).
|
||||||
|
|
||||||
|
### Düzeltildi — Faz 7b
|
||||||
|
|
||||||
|
- Seed: aynı aydaki satıcısız/kategorisiz transferler aynı `seedRef`'i aldığı için kart borcu ödemesi ve aylık birikim transferleri hiç oluşmuyordu; artık oluşuyor (mevcut kayıtlar kopyalanmaz). Kart ödemesi son ödeme gününden önceye alındı.
|
||||||
|
|
||||||
|
### Eklendi — Faz 7a: planlı ve düzenli işlemler
|
||||||
|
|
||||||
|
- Düzenli işlem kuralları (`recurringRules`): gelir, gider, transfer; sabit veya tahmini tutar; günlük/haftalık/aylık/yıllık, "her N birimde bir", ayın N. günü (ayda yoksa son gün) veya ayın son günü; bitiş yok, tekrar sayısı (ör. 12) veya bitiş tarihi. Formda Türkçe tekrar açıklaması ve ilk tarihlerin önizlemesi.
|
||||||
|
- Her tekrar için `planned` durumunda planlı kayıt; bitiş tarihsiz kurallarda 12 ay ileriye kadar. Ufuk sayfa okunurken ilerletilir (zamanlanmış görev gerekmez); üretim idempotent (`recurringRuleId + occurrenceIndex` benzersiz). Planlı kayıtlar bakiyeyi etkilemez.
|
||||||
|
- `/planli`: vadesi geçmiş, yaklaşan (60 gün) ve kurallar sekmeleri; "Onayla" (tutar ve tarih düzeltilebilir), "Ertele", "Atla"; seri pozisyonu ("3/12") ve "Tahmini" rozetleri.
|
||||||
|
- Seri düzenleme: "Yalnızca bu" (kayıt seriden ayrılır), "Bu ve sonrakiler" (seri bölünür), "Tümü" (onaylanmamış planlı kayıtlar yeniden üretilir; gerçekleşen, atlanan ve ayrılmış kayıtlar korunur). Silmede "bu ve sonrakiler" / "tüm seri"; gerçekleşmiş kayıtlar silinmez.
|
||||||
|
- Dashboard'da vadesi geçmiş planlı işlem uyarısı.
|
||||||
|
- Seed: 11 örnek düzenli işlem (maaş, kira, aidat, tahmini faturalar, abonelikler, emekli maaşı; vadesi geçmiş örnek olarak 12 taksitli spor salonu).
|
||||||
|
- `005` migration'ı: `recurringRules` index'leri, işlemlerde tekrar alanları. Denetim kaydı: `recurring.delete`.
|
||||||
|
- Testler: tekrar tarihi hesabı (ay sonu, artık yıl, aralık, sayı/bitiş/ufuk) ve form şeması (birim); üretim, onay/atla/ertele, seri düzenleme ve silme, bakiye etkisizliği, yetki ve gizli hesap izolasyonu (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 7a
|
||||||
|
|
||||||
|
- İşlem listesi varsayılan olarak planlı kayıtları göstermez (durum filtresiyle görülebilir); özet toplamları seçili duruma göre hesaplanır.
|
||||||
|
|
||||||
|
## [0.6.0] - 2026-11-05
|
||||||
|
|
||||||
|
Faz 6: hesaplar, kategoriler, işlemler, ek dosyalar ve mutabakat (6a, 6b, 6c alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 6c: ek dosyalar, carousel, ekstre ve mutabakat
|
||||||
|
|
||||||
|
- Depolama soyutlaması `lib/storage/` (`put`, `get`, `delete`); yerel disk sürücüsü (`UPLOAD_DIR`), atomik yazma, rastgele anahtarlar, yol geçişine karşı anahtar doğrulaması.
|
||||||
|
- Ek dosya yükleme `POST /api/files`: tür dosya içeriğinden (imza) tespit edilir — JPEG, PNG, WebP, HEIC, PDF; uzantıya güvenilmez. Dosya başına `MAX_UPLOAD_MB` (10 MB), kayıt başına 10 dosya. Origin kontrolü.
|
||||||
|
- Görseller sharp ile EXIF yönüne göre döndürülür, en fazla 2000 px'e küçültülür, WebP'ye çevrilir; EXIF/GPS dahil tüm meta veri silinir; 320 px küçük resim üretilir. iPhone HEIC dosyaları libheif (WASM, `heic-decode`) ile çözülür. PDF'ler yapı kontrolünden sonra olduğu gibi saklanır.
|
||||||
|
- Yetkili servis `GET /api/files/:id` (`?v=thumb`, `&indir=1`): oturum + hane üyeliği; bekleyen ek yalnızca yükleyene; silinmiş işlemin veya başkasının gizli hesabındaki işlemin eki görünmez (404). Dosyalara özel sıkı CSP, `nosniff`, `private` önbellek.
|
||||||
|
- `attachments` koleksiyonu (`ownerType`, `ownerId`, tür, boyut, yükleyen, sıra). Ekler işlemle aynı MongoDB transaction'ında bağlanır; hata olursa işlem de oluşmaz. Gider: yalnızca görsel; gelir ve transfer: görsel veya PDF.
|
||||||
|
- İşlem formunda küçük "Ek ekle" alanı (çoklu seçim, yükleme göstergesi, küçük önizleme, kaldırma, sayaç); yüklemeler bitmeden kaydedilemez. Kopyada ekler taşınmaz.
|
||||||
|
- Görüntüleyici: shadcn Carousel (Embla) — oklar, kaydırma, klavye okları, tam ekran, "2 / 5" göstergesi, indirme; PDF'ler ikon + "Aç" düğmesiyle. Ayrıntıda en fazla 4 küçük resim + "+N".
|
||||||
|
- Temizlik `npm run cleanup`: 24 saatten eski bağlanmamış ekler, 30 günden önce kayıttan çıkarılmış ekler ve 30 günden önce silinmiş işlemlerin ekleri (dosyalarıyla) silinir.
|
||||||
|
- Hesap ekstresi `/hesaplar/:id?ay=YYYY-MM`: ay başı/ay sonu bakiyesi, giriş/çıkış, yürüyen bakiye, mutabakat işaretleri; işlem ayrıntısı ekstreden açılır. Başkasının gizli hesabına transfer ekstrede görünür ama karşı hesap adı gizlenir.
|
||||||
|
- Mutabakat: ekstre tarihi ve bakiyesi girilir, uygulama bakiyesiyle karşılaştırılır (kredi kartında borç pozitif girilir). Fark varsa onayla "Mutabakat düzeltmesi" işlemi oluşturulur; o tarihe kadarki işlemler hesap bazında işaretlenir; `reconciliations` kaydı ve denetim kaydı (`account.reconcile`) tek transaction'da.
|
||||||
|
- `004` migration'ı: `attachments` ve `reconciliations` index'leri; işlemlerde `reconciledAccountIds` ve `isAdjustment`.
|
||||||
|
- Testler: dosya türü tespiti, depolama, ekstre yardımcıları (birim); yükleme/dönüştürme (EXIF silme, yön, boyut, HEIC), bağlama kuralları, erişim izolasyonu, temizlik, ekstre tutarlılığı, mutabakat (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 6c
|
||||||
|
|
||||||
|
- Hesap adı artık hesap ekstresini açar (gizli hesaplarda bağlantı yok).
|
||||||
|
- Bakiyeyi etkileyen alanı (tutar, tarih, tür, hesap) değiştirilen işlemin mutabakat işareti kalkar. Mutabakat düzeltmesi düzenlenemez, yalnızca silinebilir.
|
||||||
|
- İşlemlerdeki kullanılmayan `reconciledAt` alanı, hesap bazında `reconciledAccountIds` ile değiştirildi.
|
||||||
|
|
||||||
|
### Düzeltildi
|
||||||
|
|
||||||
|
- `seed:reset` işlemleri ve satıcıları silmiyordu; artık işlemler, satıcılar, ekler (diskteki dosyalarıyla) ve mutabakatlar da temizlenir.
|
||||||
|
|
||||||
|
### 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
|
## [0.4.0] - 2026-11-05
|
||||||
|
|
||||||
Faz 4: Örnek test verisi.
|
Faz 4: Örnek test verisi.
|
||||||
@@ -94,7 +218,8 @@ 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.4.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.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0
|
||||||
[0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0
|
[0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0
|
||||||
[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
|
||||||
|
|||||||
@@ -52,16 +52,25 @@ Giriş kullanıcı adı **veya** e-posta ile yapılır. Kayıt kapalıdır; kull
|
|||||||
|
|
||||||
## Scriptler
|
## Scriptler
|
||||||
|
|
||||||
| Komut | Açıklama |
|
| Komut | Açıklama |
|
||||||
| ----------------------------- | -------------------------------- |
|
| ----------------------------- | --------------------------------- |
|
||||||
| `npm run dev` | Geliştirme sunucusu |
|
| `npm run dev` | Geliştirme sunucusu |
|
||||||
| `npm run build` / `start` | Üretim build'i / sunucusu |
|
| `npm run build` / `start` | Üretim build'i / sunucusu |
|
||||||
| `npm run lint` | ESLint |
|
| `npm run lint` | ESLint |
|
||||||
| `npm run typecheck` | `tsc --noEmit` |
|
| `npm run typecheck` | `tsc --noEmit` |
|
||||||
| `npm run test` | Vitest birim testleri |
|
| `npm run test` | Vitest birim testleri |
|
||||||
| `npm run format` | Prettier |
|
| `npm run format` | Prettier |
|
||||||
| `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) |
|
||||||
|
| `npm run test:integration` | Entegrasyon testleri (MongoDB) |
|
||||||
|
| `npm run migrate` | Bekleyen migration'lar |
|
||||||
|
| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil |
|
||||||
|
|
||||||
|
## 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ı
|
||||||
|
|
||||||
|
|||||||
@@ -317,6 +317,8 @@ 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.
|
||||||
|
- **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir.
|
||||||
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+24
-1
@@ -42,10 +42,24 @@ const securityHeaders = [
|
|||||||
[{ key: "Strict-Transport-Security", value: "max-age=31536000" }]),
|
[{ key: "Strict-Transport-Security", value: "max-age=31536000" }]),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /api/files: kullanıcı dosyaları (WebP görsel, PDF). Sayfa CSP'si yerine daha sıkı bir politika:
|
||||||
|
* betik yok; PDF'in tarayıcı görüntüleyicisinde açılabilmesi için object-src 'self'.
|
||||||
|
*/
|
||||||
|
const fileCsp = [
|
||||||
|
"default-src 'none'",
|
||||||
|
"img-src 'self'",
|
||||||
|
"style-src 'unsafe-inline'",
|
||||||
|
"object-src 'self'",
|
||||||
|
"frame-ancestors 'self'",
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
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",
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
|
// HEIC çözücü (libheif WASM) bundle'a girmesin; Node require ile yüklensin.
|
||||||
|
serverExternalPackages: ["heic-decode", "libheif-js"],
|
||||||
experimental: {
|
experimental: {
|
||||||
agentFeedback: true,
|
agentFeedback: true,
|
||||||
},
|
},
|
||||||
@@ -60,7 +74,16 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
return [{ source: "/:path*", headers: securityHeaders }];
|
return [
|
||||||
|
{ source: "/:path((?!api/files/).*)", headers: securityHeaders },
|
||||||
|
{
|
||||||
|
source: "/api/files/:path*",
|
||||||
|
headers: [
|
||||||
|
...securityHeaders.filter((h) => h.key !== "Content-Security-Policy"),
|
||||||
|
{ key: "Content-Security-Policy", value: fileCsp },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Generated
+53
-2
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.1.0",
|
"version": "0.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.1.0",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^7.0.2",
|
"@hookform/resolvers": "^7.0.2",
|
||||||
"@tanstack/react-table": "^9.3.1",
|
"@tanstack/react-table": "^9.3.1",
|
||||||
"better-auth": "^1.7.17",
|
"better-auth": "^1.7.17",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cn": "^0.5.2",
|
"cn": "^0.5.2",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"heic-decode": "^2.1.0",
|
||||||
"lucide-react": "^1.68.0",
|
"lucide-react": "^1.68.0",
|
||||||
"mongodb": "~7.7",
|
"mongodb": "~7.7",
|
||||||
"mongoose": "^9.11.4",
|
"mongoose": "^9.11.4",
|
||||||
@@ -6861,6 +6863,34 @@
|
|||||||
"integrity": "sha512-LnPnRZ0esKB9NjsSIaC+mffW+5dAtBui6kbK5f9Qykblj4Q33cn6yIhuCcTVjwpvL3YJPqqQ4IlsNyriGNpyFQ==",
|
"integrity": "sha512-LnPnRZ0esKB9NjsSIaC+mffW+5dAtBui6kbK5f9Qykblj4Q33cn6yIhuCcTVjwpvL3YJPqqQ4IlsNyriGNpyFQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/embla-carousel": {
|
||||||
|
"version": "8.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||||
|
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/embla-carousel-react": {
|
||||||
|
"version": "8.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
|
||||||
|
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"embla-carousel": "8.6.0",
|
||||||
|
"embla-carousel-reactive-utils": "8.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/embla-carousel-reactive-utils": {
|
||||||
|
"version": "8.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
|
||||||
|
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"embla-carousel": "8.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
@@ -8205,6 +8235,18 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/heic-decode": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"libheif-js": "^1.19.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hermes-estree": {
|
"node_modules/hermes-estree": {
|
||||||
"version": "0.25.1",
|
"version": "0.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||||
@@ -9076,6 +9118,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/libheif-js": {
|
||||||
|
"version": "1.23.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.5.tgz",
|
||||||
|
"integrity": "sha512-umXZPthnWZtF3iG/5mQG+AZ2V6i0UeYpo3z2CkggTEgL8dKmIIwJT6TDuAMuabXa1yTW8uJ1ZAgR+CxNTklYYw==",
|
||||||
|
"license": "LGPL-3.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.33.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||||
|
|||||||
+7
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.4.0",
|
"version": "0.7.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,7 +11,7 @@
|
|||||||
"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 .",
|
||||||
@@ -21,7 +21,9 @@
|
|||||||
"seed": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts",
|
"seed": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts",
|
||||||
"seed:reset": "tsx --conditions=react-server --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"
|
"test:integration": "vitest run --config vitest.integration.config.mts",
|
||||||
|
"migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts",
|
||||||
|
"cleanup": "tsx --conditions=react-server --env-file=.env.local scripts/cleanup.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^7.0.2",
|
"@hookform/resolvers": "^7.0.2",
|
||||||
@@ -29,6 +31,8 @@
|
|||||||
"better-auth": "^1.7.17",
|
"better-auth": "^1.7.17",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cn": "^0.5.2",
|
"cn": "^0.5.2",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"heic-decode": "^2.1.0",
|
||||||
"lucide-react": "^1.68.0",
|
"lucide-react": "^1.68.0",
|
||||||
"mongodb": "~7.7",
|
"mongodb": "~7.7",
|
||||||
"mongoose": "^9.11.4",
|
"mongoose": "^9.11.4",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Ek dosya temizliği: npm run cleanup (canlıda günlük zamanlanmış görev; bkz. Faz 15).
|
||||||
|
* 24 saatten eski bağlanmamış ekler ve 30 günden önce silinmiş kayıtların ekleri fiziksel olarak silinir.
|
||||||
|
*/
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { purgeAttachments } from "@/features/attachments/service";
|
||||||
|
import { getMongoClient } from "@/lib/db/mongo";
|
||||||
|
|
||||||
|
purgeAttachments()
|
||||||
|
.then(({ removed }) => console.log(`✓ Temizlenen ek dosya: ${removed}`))
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error(err instanceof Error ? err.message : err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await getMongoClient().close();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { AttachmentModel } from "@/models/attachment";
|
||||||
|
import { ReconciliationModel } from "@/models/reconciliation";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "004-attachments-reconciliation",
|
||||||
|
description: "attachments ve reconciliations index'leri; işlemlerde mutabakat alanları",
|
||||||
|
async up(db) {
|
||||||
|
await AttachmentModel.createIndexes();
|
||||||
|
await ReconciliationModel.createIndexes();
|
||||||
|
const tx = db.collection("transactions");
|
||||||
|
// reconciledAt (hiç kullanılmadı) → hesap bazında reconciledAccountIds.
|
||||||
|
await tx.updateMany({ reconciledAt: { $exists: true } }, { $unset: { reconciledAt: "" } });
|
||||||
|
await tx.updateMany(
|
||||||
|
{ reconciledAccountIds: { $exists: false } },
|
||||||
|
{ $set: { reconciledAccountIds: [] } },
|
||||||
|
);
|
||||||
|
await tx.updateMany({ isAdjustment: { $exists: false } }, { $set: { isAdjustment: false } });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { RecurringRuleModel } from "@/models/recurring-rule";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "005-recurring",
|
||||||
|
description: "recurringRules index'leri; işlemlerde tekrar alanları ve benzersiz tekrar index'i",
|
||||||
|
async up(db) {
|
||||||
|
const tx = db.collection("transactions");
|
||||||
|
await tx.updateMany(
|
||||||
|
{ occurrenceIndex: { $exists: false } },
|
||||||
|
{ $set: { occurrenceIndex: null, detached: false, isEstimate: false } },
|
||||||
|
);
|
||||||
|
await RecurringRuleModel.createIndexes();
|
||||||
|
await TransactionModel.createIndexes();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { CardStatementModel } from "@/models/card-statement";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "006-card-statements",
|
||||||
|
description: "statements (kart/KMH dönemleri) index'leri; taksit planı index'i",
|
||||||
|
async up() {
|
||||||
|
await CardStatementModel.createIndexes();
|
||||||
|
await TransactionModel.createIndexes();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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 { migration as m004 } from "./004-attachments-reconciliation";
|
||||||
|
import { migration as m005 } from "./005-recurring";
|
||||||
|
import { migration as m006 } from "./006-card-statements";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||||
|
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
};
|
||||||
+4
-2
@@ -11,8 +11,10 @@ import { closeSeedConnections, runSeed } from "./seed/run";
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const summary = await runSeed({ reset: process.argv.includes("--reset") });
|
const summary = await runSeed({ reset: process.argv.includes("--reset") });
|
||||||
const { users, households, memberships } = summary.created;
|
const { users, households, memberships, categories, accounts, transactions } = summary.created;
|
||||||
console.log(`\nOluşturulan: ${users} kullanıcı, ${households} hane, ${memberships} üyelik.`);
|
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.
|
// Hesap tablosu yalnızca geliştirme ortamında gösterilir.
|
||||||
if (env().NODE_ENV !== "production" && summary.accounts.length) {
|
if (env().NODE_ENV !== "production" && summary.accounts.length) {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { HouseholdRole } from "@/features/households/types";
|
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";
|
import type { SystemRole } from "@/shared/schemas/common";
|
||||||
|
|
||||||
/** Örnek (geliştirme) kullanıcıları. E-postası olmayanlar kullanıcı adıyla giriş yapar. */
|
/** Örnek (geliştirme) kullanıcıları. E-postası olmayanlar kullanıcı adıyla giriş yapar. */
|
||||||
@@ -56,3 +58,97 @@ export const SEED_HOUSEHOLDS: SeedHousehold[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Ö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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Types } from "mongoose";
|
||||||
|
import { createTransaction } from "@/features/transactions/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Örnek taksitli kart harcamaları (Faz 7b): bir kısmı gerçekleşmiş, kalanı planlı taksitler.
|
||||||
|
* İdempotent: aynı satıcıyla taksit planı varsa atlanır.
|
||||||
|
*/
|
||||||
|
const PURCHASES = [
|
||||||
|
{
|
||||||
|
payee: "Hepsiburada",
|
||||||
|
description: "Çamaşır makinesi",
|
||||||
|
amountMinor: 18_000_00,
|
||||||
|
count: 9,
|
||||||
|
daysAgo: 75,
|
||||||
|
category: "ev",
|
||||||
|
by: "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
payee: "Teknosa",
|
||||||
|
description: "Telefon",
|
||||||
|
amountMinor: 12_000_00,
|
||||||
|
count: 6,
|
||||||
|
daysAgo: 20,
|
||||||
|
category: "diger-gider",
|
||||||
|
by: "emre",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function seedInstallments(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
userIds: Map<string, string>,
|
||||||
|
): Promise<number> {
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const card = await AccountModel.findOne({ householdId, name: "Garanti Bonus" }).lean();
|
||||||
|
if (!card) return 0;
|
||||||
|
let created = 0;
|
||||||
|
for (const p of PURCHASES) {
|
||||||
|
const exists = await TransactionModel.exists({
|
||||||
|
householdId,
|
||||||
|
payeeName: p.payee,
|
||||||
|
"installment.planId": { $type: "objectId" },
|
||||||
|
});
|
||||||
|
if (exists) continue;
|
||||||
|
const category = await CategoryModel.findOne({ householdId, defaultKey: p.category }).lean();
|
||||||
|
const r = await createTransaction(userIds.get(p.by)!, String(householdId), {
|
||||||
|
type: "expense",
|
||||||
|
amount: p.amountMinor,
|
||||||
|
date: addDays(today, -p.daysAgo),
|
||||||
|
accountId: String(card._id),
|
||||||
|
toAccountId: null,
|
||||||
|
categoryId: String(category!._id),
|
||||||
|
splits: [],
|
||||||
|
payee: p.payee,
|
||||||
|
description: p.description,
|
||||||
|
tags: [],
|
||||||
|
installments: p.count,
|
||||||
|
attachmentIds: [],
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`Seed taksiti oluşturulamadı (${p.payee}): ${r.error}`);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import type { Types } from "mongoose";
|
||||||
|
import { createRule } from "@/features/recurring/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { RecurringRuleModel } from "@/models/recurring-rule";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays, addMonths } from "@/shared/recurrence";
|
||||||
|
import { recurringRuleFormSchema, type RecurringRuleFormInput } from "@/shared/schemas/recurring";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Örnek düzenli işlemler (Faz 7a). Seed işlemleri bugüne kadarki günleri doldurur; kurallar bir
|
||||||
|
* sonraki gerçekleşme gününden başlar (çakışma yok). "Spor salonu" kasıtlı olarak 3 gün önce
|
||||||
|
* başlar: vadesi geçmiş planlı kayıt örneği. İdempotent (`seedRef`).
|
||||||
|
*/
|
||||||
|
type SeedRule = {
|
||||||
|
ref: string;
|
||||||
|
by: string;
|
||||||
|
type: "income" | "expense" | "transfer";
|
||||||
|
amountMinor: number;
|
||||||
|
account: string;
|
||||||
|
category?: string;
|
||||||
|
payee: string;
|
||||||
|
/** Ayın günü (bir sonraki gerçekleşme) veya bugüne göre gün farkı. */
|
||||||
|
day?: number;
|
||||||
|
offsetDays?: number;
|
||||||
|
isEstimate?: boolean;
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RULES: Record<string, SeedRule[]> = {
|
||||||
|
"Çakır Ailesi": [
|
||||||
|
{
|
||||||
|
ref: "maas",
|
||||||
|
by: "admin",
|
||||||
|
type: "income",
|
||||||
|
amountMinor: 52_000_00,
|
||||||
|
account: "Ziraat Maaş",
|
||||||
|
category: "maas",
|
||||||
|
payee: "Anadolu Yazılım A.Ş.",
|
||||||
|
day: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "kira",
|
||||||
|
by: "admin",
|
||||||
|
type: "expense",
|
||||||
|
amountMinor: 17_500_00,
|
||||||
|
account: "Ziraat Maaş",
|
||||||
|
category: "kira",
|
||||||
|
payee: "Ev sahibi",
|
||||||
|
day: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "aidat",
|
||||||
|
by: "admin",
|
||||||
|
type: "expense",
|
||||||
|
amountMinor: 1_250_00,
|
||||||
|
account: "Ziraat Maaş",
|
||||||
|
category: "aidat",
|
||||||
|
payee: "Site yönetimi",
|
||||||
|
day: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "elektrik",
|
||||||
|
by: "ayse",
|
||||||
|
type: "expense",
|
||||||
|
amountMinor: 1_200_00,
|
||||||
|
account: "Ziraat Maaş",
|
||||||
|
category: "faturalar.elektrik",
|
||||||
|
payee: "Enerjisa",
|
||||||
|
day: 10,
|
||||||
|
isEstimate: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "su",
|
||||||
|
by: "ayse",
|
||||||
|
type: "expense",
|
||||||
|
amountMinor: 450_00,
|
||||||
|
account: "Ziraat Maaş",
|
||||||
|
category: "faturalar.su",
|
||||||
|
payee: "İSKİ",
|
||||||
|
day: 12,
|
||||||
|
isEstimate: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "dogalgaz",
|
||||||
|
by: "ayse",
|
||||||
|
type: "expense",
|
||||||
|
amountMinor: 2_500_00,
|
||||||
|
account: "Ziraat Maaş",
|
||||||
|
category: "faturalar.dogalgaz",
|
||||||
|
payee: "İGDAŞ",
|
||||||
|
day: 14,
|
||||||
|
isEstimate: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "internet",
|
||||||
|
by: "admin",
|
||||||
|
type: "expense",
|
||||||
|
amountMinor: 499_90,
|
||||||
|
account: "Garanti Bonus",
|
||||||
|
category: "faturalar.internet",
|
||||||
|
payee: "Türk Telekom",
|
||||||
|
day: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "netflix",
|
||||||
|
by: "admin",
|
||||||
|
type: "expense",
|
||||||
|
amountMinor: 229_99,
|
||||||
|
account: "Garanti Bonus",
|
||||||
|
category: "abonelikler",
|
||||||
|
payee: "Netflix",
|
||||||
|
day: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "spor",
|
||||||
|
by: "emre",
|
||||||
|
type: "expense",
|
||||||
|
amountMinor: 1_250_00,
|
||||||
|
account: "Garanti Bonus",
|
||||||
|
category: "eglence",
|
||||||
|
payee: "Spor salonu",
|
||||||
|
offsetDays: -3,
|
||||||
|
count: 12,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Annemler: [
|
||||||
|
{
|
||||||
|
ref: "emekli",
|
||||||
|
by: "fatma",
|
||||||
|
type: "income",
|
||||||
|
amountMinor: 24_350_00,
|
||||||
|
account: "Vakıfbank Emekli",
|
||||||
|
category: "maas",
|
||||||
|
payee: "SGK",
|
||||||
|
day: 17,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "elektrik",
|
||||||
|
by: "fatma",
|
||||||
|
type: "expense",
|
||||||
|
amountMinor: 700_00,
|
||||||
|
account: "Vakıfbank Emekli",
|
||||||
|
category: "faturalar.elektrik",
|
||||||
|
payee: "Enerjisa",
|
||||||
|
day: 11,
|
||||||
|
isEstimate: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Bugünden sonraki ilk "ayın N. günü". */
|
||||||
|
function nextMonthDay(today: string, day: number): string {
|
||||||
|
const thisMonth = addMonths(today, 0, day);
|
||||||
|
return thisMonth > today ? thisMonth : addMonths(today, 1, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedRecurring(
|
||||||
|
householdIds: Map<string, Types.ObjectId>,
|
||||||
|
userIds: Map<string, string>,
|
||||||
|
): Promise<number> {
|
||||||
|
const today = todayIstanbul();
|
||||||
|
let created = 0;
|
||||||
|
for (const [household, rules] of Object.entries(RULES)) {
|
||||||
|
const householdId = householdIds.get(household)!;
|
||||||
|
const [accounts, categories] = await Promise.all([
|
||||||
|
AccountModel.find({ householdId }).select({ name: 1 }).lean(),
|
||||||
|
CategoryModel.find({ householdId, defaultKey: { $ne: null } })
|
||||||
|
.select({ defaultKey: 1 })
|
||||||
|
.lean(),
|
||||||
|
]);
|
||||||
|
for (const r of rules) {
|
||||||
|
const seedRef = `rule:${r.ref}`;
|
||||||
|
if (await RecurringRuleModel.exists({ householdId, seedRef })) continue;
|
||||||
|
const input: RecurringRuleFormInput = {
|
||||||
|
type: r.type,
|
||||||
|
amount: formatMoneyInput(r.amountMinor),
|
||||||
|
isEstimate: r.isEstimate ?? false,
|
||||||
|
accountId: String(accounts.find((a) => a.name === r.account)!._id),
|
||||||
|
categoryId: r.category
|
||||||
|
? String(categories.find((c) => c.defaultKey === r.category)!._id)
|
||||||
|
: null,
|
||||||
|
payee: r.payee,
|
||||||
|
startDate: r.day ? nextMonthDay(today, r.day) : addDays(today, r.offsetDays ?? 0),
|
||||||
|
frequency: "monthly",
|
||||||
|
endKind: r.count ? "count" : "never",
|
||||||
|
count: r.count ?? null,
|
||||||
|
};
|
||||||
|
const result = await createRule(
|
||||||
|
userIds.get(r.by)!,
|
||||||
|
String(householdId),
|
||||||
|
recurringRuleFormSchema.parse(input),
|
||||||
|
);
|
||||||
|
if (!result.ok) throw new Error(`Seed kuralı oluşturulamadı (${seedRef}): ${result.error}`);
|
||||||
|
await RecurringRuleModel.updateOne({ _id: result.data.id }, { $set: { seedRef } });
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
@@ -24,22 +24,49 @@ describe("seed", () => {
|
|||||||
it("ilk çalıştırmada admin, örnek kullanıcılar, haneler ve üyelikleri oluşturur", async () => {
|
it("ilk çalıştırmada admin, örnek kullanıcılar, haneler ve üyelikleri oluşturur", async () => {
|
||||||
const summary = await runSeed({ log: silent });
|
const summary = await runSeed({ log: silent });
|
||||||
const memberCount = SEED_HOUSEHOLDS.reduce((n, h) => n + h.members.length, 0);
|
const memberCount = SEED_HOUSEHOLDS.reduce((n, h) => n + h.members.length, 0);
|
||||||
expect(summary.created).toEqual({
|
expect(summary.created).toMatchObject({
|
||||||
users: SEED_USERS.length + 1,
|
users: SEED_USERS.length + 1,
|
||||||
households: SEED_HOUSEHOLDS.length,
|
households: SEED_HOUSEHOLDS.length,
|
||||||
memberships: memberCount,
|
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 getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||||
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("idempotenttir: ikinci çalıştırma kopya oluşturmaz", async () => {
|
it("idempotenttir: ikinci çalıştırma kopya oluşturmaz", async () => {
|
||||||
const summary = await runSeed({ log: silent });
|
const summary = await runSeed({ log: silent });
|
||||||
expect(summary.created).toEqual({ users: 0, households: 0, memberships: 0 });
|
expect(summary.created).toEqual({
|
||||||
|
users: 0,
|
||||||
|
households: 0,
|
||||||
|
memberships: 0,
|
||||||
|
categories: 0,
|
||||||
|
accounts: 0,
|
||||||
|
transactions: 0,
|
||||||
|
recurringRules: 0,
|
||||||
|
installmentPlans: 0,
|
||||||
|
});
|
||||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("aynı aydaki transferlerin hepsi oluşur (seedRef çakışması yok)", async () => {
|
||||||
|
const tx = getDb().collection("transactions");
|
||||||
|
const months = new Set(
|
||||||
|
(await tx.find({ description: "ATM" }).toArray()).map((t) => t.seedRef.split(":")[1]),
|
||||||
|
);
|
||||||
|
expect(months.size).toBeGreaterThanOrEqual(5);
|
||||||
|
// Her ay ATM'nin yanında kart ödemesi ve birikim de var (ayın 7'si geçmişse).
|
||||||
|
expect(await tx.countDocuments({ description: "Kart borcu ödemesi" })).toBeGreaterThanOrEqual(
|
||||||
|
months.size - 1,
|
||||||
|
);
|
||||||
|
expect(await tx.countDocuments({ description: "Aylık birikim" })).toBeGreaterThanOrEqual(
|
||||||
|
months.size - 1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("roller ve askı durumu doğru", async () => {
|
it("roller ve askı durumu doğru", async () => {
|
||||||
const users = await getDb()
|
const users = await getDb()
|
||||||
.collection("user")
|
.collection("user")
|
||||||
|
|||||||
+118
-8
@@ -3,10 +3,23 @@ import { getAuth } from "@/lib/auth";
|
|||||||
import { getDb } from "@/lib/db/mongo";
|
import { getDb } from "@/lib/db/mongo";
|
||||||
import { connectMongoose } from "@/lib/db/mongoose";
|
import { connectMongoose } from "@/lib/db/mongoose";
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
|
import { getStorage } from "@/lib/storage";
|
||||||
|
import { installDefaultCategories } from "@/features/categories/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
import { HouseholdModel } from "@/models/household";
|
import { HouseholdModel } from "@/models/household";
|
||||||
import { HouseholdMemberModel } from "@/models/household-member";
|
import { HouseholdMemberModel } from "@/models/household-member";
|
||||||
import { storedEmail } from "@/shared/account-email";
|
import { storedEmail } from "@/shared/account-email";
|
||||||
import { ADMIN_USERNAME, SEED_HOUSEHOLDS, SEED_USERS, type SeedUser } from "./fixtures";
|
import { runMigrations } from "../migrations";
|
||||||
|
import { seedInstallments } from "./installments";
|
||||||
|
import { seedRecurring } from "./recurring";
|
||||||
|
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);
|
* İdempotent seed. Kullanıcılar Better Auth API'siyle oluşturulur (şifreler doğru hashlenir);
|
||||||
@@ -25,12 +38,33 @@ export type SeedAccountRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SeedSummary = {
|
export type SeedSummary = {
|
||||||
created: { users: number; households: number; memberships: number };
|
created: {
|
||||||
|
users: number;
|
||||||
|
households: number;
|
||||||
|
memberships: number;
|
||||||
|
categories: number;
|
||||||
|
accounts: number;
|
||||||
|
transactions: number;
|
||||||
|
recurringRules: number;
|
||||||
|
installmentPlans: number;
|
||||||
|
};
|
||||||
accounts: SeedAccountRow[];
|
accounts: SeedAccountRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUTH_COLLECTIONS = ["user", "account", "session", "verification", "rateLimit"];
|
const AUTH_COLLECTIONS = ["user", "account", "session", "verification", "rateLimit"];
|
||||||
const DOMAIN_COLLECTIONS = ["households", "householdMembers"];
|
const DOMAIN_COLLECTIONS = [
|
||||||
|
"households",
|
||||||
|
"householdMembers",
|
||||||
|
"auditLogs",
|
||||||
|
"categories",
|
||||||
|
"accounts",
|
||||||
|
"transactions",
|
||||||
|
"payees",
|
||||||
|
"attachments",
|
||||||
|
"reconciliations",
|
||||||
|
"recurringRules",
|
||||||
|
"statements",
|
||||||
|
];
|
||||||
|
|
||||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||||
|
|
||||||
@@ -72,20 +106,36 @@ export async function runSeed({
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
await connectMongoose();
|
await connectMongoose();
|
||||||
const summary: SeedSummary = {
|
const summary: SeedSummary = {
|
||||||
created: { users: 0, households: 0, memberships: 0 },
|
created: {
|
||||||
|
users: 0,
|
||||||
|
households: 0,
|
||||||
|
memberships: 0,
|
||||||
|
categories: 0,
|
||||||
|
accounts: 0,
|
||||||
|
transactions: 0,
|
||||||
|
recurringRules: 0,
|
||||||
|
installmentPlans: 0,
|
||||||
|
},
|
||||||
accounts: [],
|
accounts: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (reset) {
|
if (reset) {
|
||||||
|
// Ek dosyaların diskteki kopyaları da silinsin (yetim dosya kalmasın).
|
||||||
|
const storage = getStorage();
|
||||||
|
for await (const a of db
|
||||||
|
.collection("attachments")
|
||||||
|
.find({}, { projection: { storageKey: 1, thumbKey: 1 } })) {
|
||||||
|
await storage.delete(String(a.storageKey));
|
||||||
|
if (a.thumbKey) await storage.delete(String(a.thumbKey));
|
||||||
|
}
|
||||||
for (const name of [...AUTH_COLLECTIONS, ...DOMAIN_COLLECTIONS]) {
|
for (const name of [...AUTH_COLLECTIONS, ...DOMAIN_COLLECTIONS]) {
|
||||||
await db.collection(name).deleteMany({});
|
await db.collection(name).deleteMany({});
|
||||||
}
|
}
|
||||||
log("• Kimlik doğrulama ve hane koleksiyonları temizlendi.");
|
log("• Kimlik doğrulama ve hane koleksiyonları temizlendi.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index'ler (production'da Mongoose autoIndex kapalı olduğu için açıkça oluşturulur).
|
// Index'ler ve şema değişiklikleri (production'da Mongoose autoIndex kapalı).
|
||||||
await HouseholdModel.createIndexes();
|
await runMigrations(log);
|
||||||
await HouseholdMemberModel.createIndexes();
|
|
||||||
|
|
||||||
// 1) Admin
|
// 1) Admin
|
||||||
const adminEmail = e.SEED_ADMIN_EMAIL.toLowerCase();
|
const adminEmail = e.SEED_ADMIN_EMAIL.toLowerCase();
|
||||||
@@ -180,7 +230,67 @@ export async function runSeed({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) Geliştirme hesapları tablosu
|
// 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}`);
|
||||||
|
summary.created.recurringRules = await seedRecurring(householdIds, userIdMap);
|
||||||
|
if (summary.created.recurringRules)
|
||||||
|
log(`✓ Örnek düzenli işlemler: ${summary.created.recurringRules}`);
|
||||||
|
summary.created.installmentPlans = await seedInstallments(
|
||||||
|
householdIds.get("Çakır Ailesi")!,
|
||||||
|
userIdMap,
|
||||||
|
);
|
||||||
|
if (summary.created.installmentPlans)
|
||||||
|
log(`✓ Örnek taksitli alışverişler: ${summary.created.installmentPlans}`);
|
||||||
|
|
||||||
|
// 7) Geliştirme hesapları tablosu
|
||||||
const all = [
|
const all = [
|
||||||
{ username: ADMIN_USERNAME, name: "Yönetici", email: adminEmail, role: "admin" as const },
|
{ username: ADMIN_USERNAME, name: "Yönetici", email: adminEmail, role: "admin" as const },
|
||||||
...SEED_USERS,
|
...SEED_USERS,
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
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. Satıcı/kategori olmadığından seedRef
|
||||||
|
// türden üretilir; çakışmasın diye sıra numarası verilir (ATM 0 — mevcut veriyle uyumlu).
|
||||||
|
add({
|
||||||
|
type: "transfer",
|
||||||
|
ymd: d(2),
|
||||||
|
amountMinor: 2_000_00,
|
||||||
|
account: "Ziraat Maaş",
|
||||||
|
toAccount: "Cüzdan",
|
||||||
|
description: "ATM",
|
||||||
|
by: "admin",
|
||||||
|
});
|
||||||
|
add(
|
||||||
|
{
|
||||||
|
type: "transfer",
|
||||||
|
// Kesim 29, son ödeme ~8: ödeme son ödeme gününden önce.
|
||||||
|
ymd: d(7),
|
||||||
|
amountMinor: tl(11_000, 15_000),
|
||||||
|
account: "Ziraat Maaş",
|
||||||
|
toAccount: "Garanti Bonus",
|
||||||
|
description: "Kart borcu ödemesi",
|
||||||
|
by: "admin",
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
add(
|
||||||
|
{
|
||||||
|
type: "transfer",
|
||||||
|
ymd: d(16),
|
||||||
|
amountMinor: 5_000_00,
|
||||||
|
account: "Ziraat Maaş",
|
||||||
|
toAccount: "Birikim (Vadeli)",
|
||||||
|
description: "Aylık birikim",
|
||||||
|
by: "ayse",
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ import { Suspense } from "react";
|
|||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||||
|
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireSession } from "@/lib/dal";
|
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";
|
||||||
|
|
||||||
@@ -52,13 +53,16 @@ async function SummaryCards() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const user = await requireSession();
|
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={null}>
|
||||||
|
<OverdueBanner userId={user.id} householdId={household.id} />
|
||||||
|
</Suspense>
|
||||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||||
<SummaryCards />
|
<SummaryCards />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { StatementView } from "@/features/transactions/components/statement-view";
|
||||||
|
import { getCardSummary, getOverdraftSummary } from "@/features/transactions/card";
|
||||||
|
import { currentMonth, getAccountStatement } from "@/features/transactions/statement";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
import { MONTH_PATTERN } from "@/shared/statement";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.statement.title };
|
||||||
|
|
||||||
|
/** /hesaplar/:id?ay=2026-11 — aylık hesap ekstresi (yürüyen bakiye) ve mutabakat. */
|
||||||
|
export default async function AccountStatementPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: PageProps<"/hesaplar/[id]">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
||||||
|
const now = currentMonth();
|
||||||
|
const requested = typeof sp.ay === "string" && MONTH_PATTERN.test(sp.ay) ? sp.ay : now;
|
||||||
|
const month = requested > now ? now : requested;
|
||||||
|
const statement = await getAccountStatement(user.id, household.id, id, month);
|
||||||
|
if (!statement) notFound();
|
||||||
|
const [card, overdraft] = await Promise.all([
|
||||||
|
statement.account.type === "credit_card"
|
||||||
|
? getCardSummary(user.id, household.id, id)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
statement.account.type === "bank"
|
||||||
|
? getOverdraftSummary(user.id, household.id, id)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
<StatementView
|
||||||
|
key={`${id}-${month}`}
|
||||||
|
statement={statement}
|
||||||
|
card={card}
|
||||||
|
overdraft={overdraft}
|
||||||
|
canWrite={hasHouseholdRole(household.role, "member")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
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 { AppShellFallback } from "@/components/layout/app-shell-fallback";
|
||||||
@@ -5,8 +6,8 @@ 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_HOUSEHOLDS } from "@/features/households/mock";
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
import { requireSession } from "@/lib/dal";
|
import { getHouseholdContext } from "@/lib/dal";
|
||||||
import { APP_VERSION } from "@/lib/version";
|
import { APP_VERSION } from "@/lib/version";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,18 +24,26 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
|
async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
|
||||||
const user = await requireSession();
|
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>
|
||||||
{/* FAZ 5: kullanıcının üye olduğu haneler */}
|
<AppHeader
|
||||||
<AppHeader user={user} households={MOCK_HOUSEHOLDS} version={APP_VERSION} />
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { PlannedView } from "@/features/recurring/components/planned-view";
|
||||||
|
import { getPlannedOverview } from "@/features/recurring/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.recurring.title };
|
||||||
|
|
||||||
|
export default async function PlannedPage() {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const data = await getPlannedOverview(user.id, household.id);
|
||||||
|
if (!data) notFound();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title={tr.recurring.title} description={tr.recurring.description} />
|
||||||
|
<PlannedView data={data} canWrite={hasHouseholdRole(household.role, "member")} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { readAttachmentForUser } from "@/features/attachments/service";
|
||||||
|
import { getCurrentUser } from "@/lib/dal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/files/:id[?v=thumb][&indir=1] — oturum ve hane yetkisi kontrol edilerek ek dosyayı
|
||||||
|
* servis eder. Erişim yoksa (başka hane, gizli hesap, silinmiş kayıt) 404: varlık sızdırılmaz.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest, ctx: RouteContext<"/api/files/[id]">) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return new Response(null, { status: 401 });
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const params = request.nextUrl.searchParams;
|
||||||
|
const file = await readAttachmentForUser(
|
||||||
|
user.id,
|
||||||
|
id,
|
||||||
|
params.get("v") === "thumb" ? "thumb" : "full",
|
||||||
|
);
|
||||||
|
if (!file) return new Response(null, { status: 404 });
|
||||||
|
|
||||||
|
const disposition = params.get("indir") === "1" ? "attachment" : "inline";
|
||||||
|
const fallbackName = file.mimeType === "application/pdf" ? "belge.pdf" : "gorsel.webp";
|
||||||
|
const name = toServedName(file.name || fallbackName, file.mimeType);
|
||||||
|
return new Response(new Uint8Array(file.bytes), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": file.mimeType,
|
||||||
|
"Content-Length": String(file.bytes.length),
|
||||||
|
"Content-Disposition": `${disposition}; filename="${asciiName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`,
|
||||||
|
// Yalnızca kullanıcının tarayıcısında; paylaşılan önbelleklere girmez.
|
||||||
|
"Cache-Control": "private, max-age=3600",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Görseller WebP'ye çevrildiği için indirilen adın uzantısı içerikle uyumlu olsun. */
|
||||||
|
function toServedName(name: string, mimeType: string): string {
|
||||||
|
const ext = mimeType === "application/pdf" ? ".pdf" : ".webp";
|
||||||
|
return name.replace(/\.[a-z0-9]{1,5}$/i, "") + ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asciiName(name: string): string {
|
||||||
|
return (
|
||||||
|
name
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[^\x20-\x7e]/g, "")
|
||||||
|
.replace(/["\\]/g, "") || "dosya"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { uploadAttachment } from "@/features/attachments/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail } from "@/lib/action-result";
|
||||||
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
|
||||||
|
/** Multipart başlıkları için tolerans. */
|
||||||
|
const MULTIPART_OVERHEAD = 64 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/files — tek dosya yükler (`file` alanı), aktif haneye bekleyen ek olarak kaydeder.
|
||||||
|
* Kayıt (ör. işlem) kaydedilirken ek kimlikleri gönderilir ve ek kayda bağlanır.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isSameOrigin(request)) return Response.json(fail(tr.common.unauthorized), { status: 403 });
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return Response.json(fail(tr.common.unauthorized), { status: 401 });
|
||||||
|
|
||||||
|
const maxMb = env().MAX_UPLOAD_MB;
|
||||||
|
const length = Number(request.headers.get("content-length") ?? 0);
|
||||||
|
if (length > maxMb * 1024 * 1024 + MULTIPART_OVERHEAD) {
|
||||||
|
return Response.json(fail(tr.attachments.errors.tooLarge(maxMb)), { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let file: FormDataEntryValue | null;
|
||||||
|
try {
|
||||||
|
file = (await request.formData()).get("file");
|
||||||
|
} catch {
|
||||||
|
return Response.json(fail(tr.attachments.errors.invalid), { status: 400 });
|
||||||
|
}
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return Response.json(fail(tr.attachments.errors.empty), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = Buffer.from(await file.arrayBuffer());
|
||||||
|
const result = await uploadAttachment(ctx.user.id, ctx.household.id, { name: file.name, bytes });
|
||||||
|
return Response.json(result, { status: result.ok ? 201 : 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF savunması: tarayıcı isteğinde Origin varsa uygulamanın kendi host'u olmalı.
|
||||||
|
* (Oturum cookie'si SameSite=Lax; bu ek bir katman.)
|
||||||
|
*/
|
||||||
|
function isSameOrigin(request: Request): boolean {
|
||||||
|
const origin = request.headers.get("origin");
|
||||||
|
if (!origin) return true;
|
||||||
|
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
||||||
|
try {
|
||||||
|
return new URL(origin).host === host;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Settings,
|
Settings,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
|
Tags,
|
||||||
Target,
|
Target,
|
||||||
UserCog,
|
UserCog,
|
||||||
Users,
|
Users,
|
||||||
@@ -25,15 +26,17 @@ 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 = 7.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: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7 },
|
{ href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 },
|
||||||
|
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 },
|
||||||
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 },
|
{ href: "/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 },
|
||||||
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
|
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "cn";
|
||||||
|
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1];
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||||
|
type CarouselOptions = UseCarouselParameters[0];
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1];
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
opts?: CarouselOptions;
|
||||||
|
plugins?: CarouselPlugin;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
setApi?: (api: CarouselApi) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||||
|
scrollPrev: () => void;
|
||||||
|
scrollNext: () => void;
|
||||||
|
canScrollPrev: boolean;
|
||||||
|
canScrollNext: boolean;
|
||||||
|
} & CarouselProps;
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useCarousel must be used within a <Carousel />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Carousel({
|
||||||
|
orientation = "horizontal",
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins,
|
||||||
|
);
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) return;
|
||||||
|
setCanScrollPrev(api.canScrollPrev());
|
||||||
|
setCanScrollNext(api.canScrollNext());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollPrev();
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollNext();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) return;
|
||||||
|
setApi(api);
|
||||||
|
}, [api, setApi]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
// İlk durum bir sonraki karede okunur (effect içinde senkron setState yapılmaz).
|
||||||
|
const frame = requestAnimationFrame(() => onSelect(api));
|
||||||
|
api.on("reInit", onSelect);
|
||||||
|
api.on("select", onSelect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
api.off("select", onSelect);
|
||||||
|
api.off("reInit", onSelect);
|
||||||
|
};
|
||||||
|
}, [api, onSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
data-slot="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselContent({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { containerClassName?: string }) {
|
||||||
|
const { carouselRef, orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
className={cn("overflow-hidden", containerClassName)}
|
||||||
|
data-slot="carousel-content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
data-slot="carousel-item"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
|
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselPrevious({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon-sm",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-previous"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute touch-manipulation rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "inset-y-0 -left-12 my-auto"
|
||||||
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselNext({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon-sm",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-next"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute touch-manipulation rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "inset-y-0 -right-12 my-auto"
|
||||||
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
useCarousel,
|
||||||
|
};
|
||||||
@@ -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,706 @@
|
|||||||
|
"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;
|
||||||
|
cardMinimumRate: 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: "",
|
||||||
|
cardMinimumRate: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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 ?? "",
|
||||||
|
cardMinimumRate:
|
||||||
|
a.creditCard?.minimumPaymentRateBp != null
|
||||||
|
? formatMoneyInput(a.creditCard.minimumPaymentRateBp)
|
||||||
|
: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="cardMinimumRate"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="acc-card-min">{f.cardMinimumRate}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id="acc-card-min"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="20"
|
||||||
|
className="w-28 tabular-nums"
|
||||||
|
aria-describedby="acc-card-min-hint"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
<FieldDescription id="acc-card-min-hint">
|
||||||
|
{f.cardMinimumRateHint}
|
||||||
|
</FieldDescription>
|
||||||
|
{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,323 @@
|
|||||||
|
"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">
|
||||||
|
{/* Gizli hesabın ekstresi yalnızca sahibine açılır. */}
|
||||||
|
{a.balanceMinor === null ? (
|
||||||
|
<span className="block truncate font-medium">{a.name}</span>
|
||||||
|
) : (
|
||||||
|
<Link href={`/hesaplar/${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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
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,
|
||||||
|
cardMinimumRate: 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
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;
|
||||||
|
minimumPaymentRateBp: number | 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,
|
||||||
|
minimumPaymentRateBp: input.cardMinimumRate,
|
||||||
|
}
|
||||||
|
: 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 }));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import { tr } from "@/i18n/tr";
|
|||||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
import { getAuth } from "@/lib/auth";
|
import { getAuth } from "@/lib/auth";
|
||||||
import { authErrorCode } from "@/lib/auth-errors";
|
import { authErrorCode } from "@/lib/auth-errors";
|
||||||
|
import { writeAudit } from "@/lib/audit";
|
||||||
import { getActionUser } from "@/lib/dal";
|
import { getActionUser } from "@/lib/dal";
|
||||||
|
import { removeAllMembershipsOfUser } from "@/features/households/service";
|
||||||
import { storedEmail } from "@/shared/account-email";
|
import { storedEmail } from "@/shared/account-email";
|
||||||
import { systemRoleSchema, type SystemRole } from "@/shared/schemas/common";
|
import { systemRoleSchema, type SystemRole } from "@/shared/schemas/common";
|
||||||
import {
|
import {
|
||||||
@@ -102,8 +104,9 @@ export async function createUserAction(input: unknown): Promise<ActionResult> {
|
|||||||
const parsed = userCreateSchema.safeParse(input);
|
const parsed = userCreateSchema.safeParse(input);
|
||||||
if (!parsed.success) return fail(t.errors.checkFields);
|
if (!parsed.success) return fail(t.errors.checkFields);
|
||||||
const v = parsed.data;
|
const v = parsed.data;
|
||||||
|
let createdId = "";
|
||||||
try {
|
try {
|
||||||
await ctx.api.createUser({
|
const created = await ctx.api.createUser({
|
||||||
headers: ctx.headers,
|
headers: ctx.headers,
|
||||||
body: {
|
body: {
|
||||||
name: v.name,
|
name: v.name,
|
||||||
@@ -113,9 +116,17 @@ export async function createUserAction(input: unknown): Promise<ActionResult> {
|
|||||||
data: { username: v.username },
|
data: { username: v.username },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
createdId = created.user.id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return mapAuthError(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);
|
revalidatePath(PATH);
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
@@ -148,6 +159,13 @@ export async function updateUserAction(userId: unknown, input: unknown): Promise
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return mapAuthError(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);
|
revalidatePath(PATH);
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
@@ -167,6 +185,13 @@ export async function setRoleAction(userId: unknown, role: unknown): Promise<Act
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return mapAuthError(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);
|
revalidatePath(PATH);
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
@@ -191,6 +216,12 @@ export async function setPasswordAction(userId: unknown, input: unknown): Promis
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return mapAuthError(error);
|
return mapAuthError(error);
|
||||||
}
|
}
|
||||||
|
await writeAudit({
|
||||||
|
action: "user.password.reset",
|
||||||
|
actorId: ctx.actor.id,
|
||||||
|
targetType: "user",
|
||||||
|
targetId: target.id,
|
||||||
|
});
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +244,13 @@ export async function banUserAction(userId: unknown, input: unknown): Promise<Ac
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return mapAuthError(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);
|
revalidatePath(PATH);
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
@@ -227,6 +265,12 @@ export async function unbanUserAction(userId: unknown): Promise<ActionResult> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return mapAuthError(error);
|
return mapAuthError(error);
|
||||||
}
|
}
|
||||||
|
await writeAudit({
|
||||||
|
action: "user.unban",
|
||||||
|
actorId: ctx.actor.id,
|
||||||
|
targetType: "user",
|
||||||
|
targetId: id.data,
|
||||||
|
});
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
@@ -245,6 +289,14 @@ export async function removeUserAction(userId: unknown): Promise<ActionResult> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return mapAuthError(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);
|
revalidatePath(PATH);
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2, Paperclip, X } from "lucide-react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import type { ActionResult } from "@/lib/action-result";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
acceptAttribute,
|
||||||
|
MAX_ATTACHMENTS_PER_RECORD,
|
||||||
|
type AttachmentKind,
|
||||||
|
} from "@/shared/attachments";
|
||||||
|
import type { AttachmentInfo } from "../service";
|
||||||
|
import { AttachmentThumb } from "./attachment-strip";
|
||||||
|
import { AttachmentViewer } from "./attachment-viewer";
|
||||||
|
|
||||||
|
const t = tr.attachments;
|
||||||
|
|
||||||
|
/** Tek dosyayı /api/files'a yükler. */
|
||||||
|
async function uploadFile(file: File): Promise<ActionResult<AttachmentInfo>> {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", file);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/files", { method: "POST", body });
|
||||||
|
return (await res.json()) as ActionResult<AttachmentInfo>;
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: t.errors.upload };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formdaki küçük "Ek ekle" alanı (Bölüm 6.3): seçilen dosyalar hemen yüklenir (bekleyen ek),
|
||||||
|
* küçük önizleme ve sayaç gösterilir; kayıt kaydedilince ekler kayda bağlanır.
|
||||||
|
*/
|
||||||
|
export function AttachmentPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
allowedKinds,
|
||||||
|
onBusyChange,
|
||||||
|
}: {
|
||||||
|
value: AttachmentInfo[];
|
||||||
|
onChange: (items: AttachmentInfo[]) => void;
|
||||||
|
allowedKinds: readonly AttachmentKind[];
|
||||||
|
onBusyChange?: (busy: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploading, setUploading] = useState(0);
|
||||||
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
const [viewer, setViewer] = useState<number | null>(null);
|
||||||
|
const invalid = value.filter((a) => !allowedKinds.includes(a.kind));
|
||||||
|
const room = MAX_ATTACHMENTS_PER_RECORD - value.length;
|
||||||
|
|
||||||
|
async function onFiles(files: FileList | null) {
|
||||||
|
if (!files?.length) return;
|
||||||
|
const list = [...files];
|
||||||
|
const accepted = list.slice(0, Math.max(0, room));
|
||||||
|
const nextErrors: string[] = [];
|
||||||
|
if (list.length > accepted.length)
|
||||||
|
nextErrors.push(t.errors.tooMany(MAX_ATTACHMENTS_PER_RECORD));
|
||||||
|
setUploading(accepted.length);
|
||||||
|
onBusyChange?.(true);
|
||||||
|
let items = value;
|
||||||
|
for (const file of accepted) {
|
||||||
|
const result = await uploadFile(file);
|
||||||
|
if (result.ok) {
|
||||||
|
items = [...items, result.data];
|
||||||
|
onChange(items);
|
||||||
|
} else {
|
||||||
|
nextErrors.push(`${file.name}: ${result.error}`);
|
||||||
|
}
|
||||||
|
setUploading((n) => n - 1);
|
||||||
|
}
|
||||||
|
setErrors(nextErrors);
|
||||||
|
onBusyChange?.(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: string) {
|
||||||
|
onChange(value.filter((a) => a.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={room <= 0 || uploading > 0}
|
||||||
|
>
|
||||||
|
{uploading > 0 ? <Loader2 className="animate-spin" /> : <Paperclip />}
|
||||||
|
{uploading > 0 ? t.uploading : t.add}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{value.length
|
||||||
|
? t.count(value.length)
|
||||||
|
: allowedKinds.includes("pdf")
|
||||||
|
? t.addHint.imagePdf
|
||||||
|
: t.addHint.image}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept={acceptAttribute(allowedKinds)}
|
||||||
|
className="sr-only"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={t.add}
|
||||||
|
onChange={(e) => onFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{value.length || uploading ? (
|
||||||
|
<ul className="flex flex-wrap gap-1.5">
|
||||||
|
{value.map((item, i) => (
|
||||||
|
<li key={item.id} className="relative">
|
||||||
|
<AttachmentThumb
|
||||||
|
item={item}
|
||||||
|
className={cn(
|
||||||
|
"size-10",
|
||||||
|
!allowedKinds.includes(item.kind) && "border-destructive ring-1 ring-destructive",
|
||||||
|
)}
|
||||||
|
onClick={() => setViewer(i)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(item.id)}
|
||||||
|
className="absolute -top-1.5 -right-1.5 flex size-5 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-sm hover:text-foreground"
|
||||||
|
aria-label={`${t.remove}: ${item.name}`}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: uploading }, (_, i) => (
|
||||||
|
<li
|
||||||
|
key={`up-${i}`}
|
||||||
|
className="flex size-10 items-center justify-center rounded-md border border-dashed"
|
||||||
|
aria-label={t.uploading}
|
||||||
|
>
|
||||||
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{invalid.length ? (
|
||||||
|
<p className="text-xs text-destructive" role="alert">
|
||||||
|
{t.errors.kindNotAllowed}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{errors.map((e) => (
|
||||||
|
<p key={e} className="text-xs text-destructive" role="alert">
|
||||||
|
{e}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<AttachmentViewer
|
||||||
|
items={value}
|
||||||
|
startIndex={viewer ?? 0}
|
||||||
|
open={viewer !== null}
|
||||||
|
onOpenChange={(open) => !open && setViewer(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FileText, Paperclip } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import type { AttachmentInfo } from "../service";
|
||||||
|
import { AttachmentViewer } from "./attachment-viewer";
|
||||||
|
|
||||||
|
const t = tr.attachments;
|
||||||
|
const VISIBLE = 4;
|
||||||
|
|
||||||
|
/** Ayrıntıda küçük önizleme şeridi (en fazla 4 + "+N"); tıklayınca görüntüleyici açılır. */
|
||||||
|
export function AttachmentStrip({ items }: { items: AttachmentInfo[] }) {
|
||||||
|
const [viewer, setViewer] = useState<number | null>(null);
|
||||||
|
if (!items.length) return null;
|
||||||
|
const shown = items.slice(0, VISIBLE);
|
||||||
|
const rest = items.length - shown.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Paperclip className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="sr-only">{t.count(items.length)}</span>
|
||||||
|
<ul className="flex gap-1.5">
|
||||||
|
{shown.map((item, i) => (
|
||||||
|
<li key={item.id} className="relative">
|
||||||
|
<AttachmentThumb item={item} onClick={() => setViewer(i)} />
|
||||||
|
{rest > 0 && i === shown.length - 1 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewer(i + 1)}
|
||||||
|
className="absolute inset-0 flex items-center justify-center rounded-md bg-black/55 text-sm font-medium text-white"
|
||||||
|
aria-label={`${t.viewerTitle}: ${t.moreCount(rest)}`}
|
||||||
|
>
|
||||||
|
{t.moreCount(rest)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<AttachmentViewer
|
||||||
|
items={items}
|
||||||
|
startIndex={viewer ?? 0}
|
||||||
|
open={viewer !== null}
|
||||||
|
onOpenChange={(open) => !open && setViewer(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentThumb({
|
||||||
|
item,
|
||||||
|
onClick,
|
||||||
|
className = "size-12",
|
||||||
|
}: {
|
||||||
|
item: AttachmentInfo;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={item.name}
|
||||||
|
aria-label={item.name}
|
||||||
|
className={`${className} flex items-center justify-center overflow-hidden rounded-md border bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none`}
|
||||||
|
>
|
||||||
|
{item.thumbUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- yetkili özel dosya
|
||||||
|
<img src={item.thumbUrl} alt="" className="size-full object-cover" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<FileText className="size-5 text-muted-foreground" aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Download, ExternalLink, FileText, Maximize, Minimize } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselNext,
|
||||||
|
CarouselPrevious,
|
||||||
|
type CarouselApi,
|
||||||
|
} from "@/components/ui/carousel";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { formatFileSize } from "@/shared/attachments";
|
||||||
|
import type { AttachmentInfo } from "../service";
|
||||||
|
|
||||||
|
const t = tr.attachments;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ek dosya görüntüleyici (Bölüm 6.3): carousel (oklar, kaydırma, klavye okları), tam ekran ve
|
||||||
|
* "2 / 5" göstergesi. PDF'ler ikon + "Aç" düğmesiyle gösterilir (tarayıcının PDF görüntüleyicisi).
|
||||||
|
*/
|
||||||
|
export function AttachmentViewer({
|
||||||
|
items,
|
||||||
|
startIndex,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
items: AttachmentInfo[];
|
||||||
|
startIndex: number;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [api, setApi] = useState<CarouselApi>();
|
||||||
|
const [index, setIndex] = useState(startIndex);
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
const update = () => setIndex(api.selectedScrollSnap());
|
||||||
|
api.on("select", update);
|
||||||
|
api.on("reInit", update);
|
||||||
|
return () => {
|
||||||
|
api.off("select", update);
|
||||||
|
api.off("reInit", update);
|
||||||
|
};
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setFullscreen(document.fullscreenElement === containerRef.current);
|
||||||
|
document.addEventListener("fullscreenchange", onChange);
|
||||||
|
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
if (!next && document.fullscreenElement) void document.exitFullscreen();
|
||||||
|
if (next) setIndex(startIndex);
|
||||||
|
onOpenChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
if (document.fullscreenElement) await document.exitFullscreen();
|
||||||
|
else await containerRef.current?.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Odak carousel dışındayken de (ör. kapat düğmesi) ok tuşları çalışsın. */
|
||||||
|
function onKeyDown(event: React.KeyboardEvent) {
|
||||||
|
if (event.defaultPrevented) return;
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
api?.scrollPrev();
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
api?.scrollNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = items[Math.min(index, items.length - 1)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="flex h-[min(90dvh,900px)] w-[calc(100vw-1rem)] max-w-5xl flex-col gap-0 p-0 sm:max-w-5xl"
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
<div ref={containerRef} className="flex min-h-0 flex-1 flex-col bg-background">
|
||||||
|
<div className="flex items-center gap-2 border-b px-3 py-2 pr-12">
|
||||||
|
<DialogTitle className="min-w-0 flex-1 truncate text-sm">
|
||||||
|
{current?.name ?? t.viewerTitle}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">{t.viewerTitle}</DialogDescription>
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-xs text-muted-foreground tabular-nums"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{t.position(index + 1, items.length)}
|
||||||
|
</span>
|
||||||
|
{current ? (
|
||||||
|
<Button asChild variant="ghost" size="icon-sm" aria-label={t.download}>
|
||||||
|
<a href={`${current.url}?indir=1`} download>
|
||||||
|
<Download />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
aria-label={fullscreen ? t.exitFullscreen : t.fullscreen}
|
||||||
|
>
|
||||||
|
{fullscreen ? <Minimize /> : <Maximize />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Carousel
|
||||||
|
setApi={setApi}
|
||||||
|
opts={{ startIndex, loop: false }}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
aria-label={t.viewerTitle}
|
||||||
|
>
|
||||||
|
<CarouselContent className="h-full" containerClassName="h-full min-h-0 flex-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<CarouselItem
|
||||||
|
key={item.id}
|
||||||
|
className="flex h-full items-center justify-center p-4 pl-8"
|
||||||
|
>
|
||||||
|
{item.kind === "image" ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- yetkili özel dosya; optimizasyon hattına girmez
|
||||||
|
<img
|
||||||
|
src={item.url}
|
||||||
|
alt={item.name}
|
||||||
|
width={item.width ?? undefined}
|
||||||
|
height={item.height ?? undefined}
|
||||||
|
className="max-h-full max-w-full rounded-md object-contain"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<FileText className="size-16 text-muted-foreground" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium break-all">{item.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t.pdf} · {formatFileSize(item.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<a href={item.url} target="_blank" rel="noopener">
|
||||||
|
<ExternalLink />
|
||||||
|
{t.open}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CarouselItem>
|
||||||
|
))}
|
||||||
|
</CarouselContent>
|
||||||
|
{items.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<CarouselPrevious className="left-3" aria-label={t.previous} />
|
||||||
|
<CarouselNext className="right-3" aria-label={t.next} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Carousel>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import { readFile, rm } from "node:fs/promises";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getTransaction,
|
||||||
|
createTransaction,
|
||||||
|
updateTransaction,
|
||||||
|
setTransactionDeleted,
|
||||||
|
} from "@/features/transactions/service";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { getStorage } from "@/lib/storage";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { AttachmentModel } from "@/models/attachment";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { TransactionForm } from "@/shared/schemas/transaction";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
purgeAttachments,
|
||||||
|
readAttachmentForUser,
|
||||||
|
uploadAttachment,
|
||||||
|
type AttachmentInfo,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let annemler = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await rm(env().UPLOAD_DIR, { recursive: true, force: true });
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
annemler = ids.household("Annemler");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
emre = ids.user("emre");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await teardownDb();
|
||||||
|
await rm(env().UPLOAD_DIR, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const acc = (name: string, householdId = cakir) =>
|
||||||
|
AccountModel.findOne({ householdId, name })
|
||||||
|
.lean()
|
||||||
|
.then((a) => String(a!._id));
|
||||||
|
const cat = (key: string) =>
|
||||||
|
CategoryModel.findOne({ householdId: cakir, defaultKey: key })
|
||||||
|
.lean()
|
||||||
|
.then((c) => String(c!._id));
|
||||||
|
|
||||||
|
async function form(over: Partial<TransactionForm>): Promise<TransactionForm> {
|
||||||
|
return {
|
||||||
|
type: "expense",
|
||||||
|
amount: 100_00,
|
||||||
|
date: "2026-06-15",
|
||||||
|
accountId: await acc("Cüzdan"),
|
||||||
|
toAccountId: null,
|
||||||
|
categoryId: await cat("market"),
|
||||||
|
splits: [],
|
||||||
|
payee: "",
|
||||||
|
description: "",
|
||||||
|
tags: [],
|
||||||
|
attachmentIds: [],
|
||||||
|
installments: 1,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const png = (w = 40, h = 30) =>
|
||||||
|
sharp({ create: { width: w, height: h, channels: 3, background: "#3366cc" } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
const pdf = Buffer.from("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n", "latin1");
|
||||||
|
|
||||||
|
async function upload(userId: string, bytes: Buffer, name = "fis.png", householdId = cakir) {
|
||||||
|
const r = await uploadAttachment(userId, householdId, { name, bytes });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("yükleme ve dönüştürme", () => {
|
||||||
|
it("JPEG: EXIF/GPS silinir, yön uygulanır, 2000 px'e küçültülür, WebP + küçük resim", async () => {
|
||||||
|
const jpeg = await sharp({
|
||||||
|
create: { width: 3000, height: 1200, channels: 3, background: "#cc3333" },
|
||||||
|
})
|
||||||
|
.jpeg()
|
||||||
|
.withMetadata({ orientation: 6 })
|
||||||
|
.withExif({ IFD0: { Copyright: "gizli", Artist: "Ayşe" } })
|
||||||
|
.toBuffer();
|
||||||
|
expect((await sharp(jpeg).metadata()).exif).toBeDefined();
|
||||||
|
|
||||||
|
const info = await upload(ayse, jpeg, "../../etc/fiş.JPG");
|
||||||
|
expect(info.kind).toBe("image");
|
||||||
|
expect(info.name).toBe("fiş.JPG");
|
||||||
|
// Yön 6 (90°): 3000x1200 → 1200x3000 → en fazla 2000: 800x2000
|
||||||
|
expect([info.width, info.height]).toEqual([800, 2000]);
|
||||||
|
|
||||||
|
const doc = await AttachmentModel.findById(info.id).lean();
|
||||||
|
expect(doc!.mimeType).toBe("image/webp");
|
||||||
|
expect(doc!.ownerId).toBeNull();
|
||||||
|
const stored = await getStorage().get(doc!.storageKey);
|
||||||
|
const meta = await sharp(stored!).metadata();
|
||||||
|
expect(meta.format).toBe("webp");
|
||||||
|
expect(meta.exif).toBeUndefined();
|
||||||
|
expect(meta.orientation).toBeUndefined();
|
||||||
|
const thumb = await sharp((await getStorage().get(doc!.thumbKey!))!).metadata();
|
||||||
|
expect(Math.max(thumb.width!, thumb.height!)).toBe(320);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PNG ve PDF kabul edilir; uzantıya değil içeriğe bakılır", async () => {
|
||||||
|
expect((await upload(ayse, await png(), "resim.pdf")).kind).toBe("image");
|
||||||
|
const p = await upload(ayse, pdf, "bordro.jpg");
|
||||||
|
expect(p.kind).toBe("pdf");
|
||||||
|
expect(p.thumbUrl).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("desteklenmeyen, bozuk ve boş dosyalar reddedilir", async () => {
|
||||||
|
const text = await uploadAttachment(ayse, cakir, {
|
||||||
|
name: "fis.jpg",
|
||||||
|
bytes: Buffer.from("<script>alert(1)</script>"),
|
||||||
|
});
|
||||||
|
expect(text.ok).toBe(false);
|
||||||
|
const broken = await uploadAttachment(ayse, cakir, {
|
||||||
|
name: "x.jpg",
|
||||||
|
bytes: Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 4, 5, 6]),
|
||||||
|
});
|
||||||
|
expect(broken).toMatchObject({ ok: false });
|
||||||
|
const fakePdf = await uploadAttachment(ayse, cakir, {
|
||||||
|
name: "x.pdf",
|
||||||
|
bytes: Buffer.from("%PDF-1.4 yarım"),
|
||||||
|
});
|
||||||
|
expect(fakePdf.ok).toBe(false);
|
||||||
|
expect((await uploadAttachment(ayse, cakir, { name: "x", bytes: Buffer.alloc(0) })).ok).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("boyut sınırı uygulanır", async () => {
|
||||||
|
const big = Buffer.alloc(env().MAX_UPLOAD_MB * 1024 * 1024 + 1);
|
||||||
|
big.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
expect((await uploadAttachment(ayse, cakir, { name: "b.png", bytes: big })).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("izleyici ve hane dışı kullanıcı yükleyemez", async () => {
|
||||||
|
expect((await uploadAttachment(zeynep, cakir, { name: "a.png", bytes: await png() })).ok).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect((await uploadAttachment(fatma, cakir, { name: "a.png", bytes: await png() })).ok).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skipIf(!process.env.HEIC_SAMPLE)("HEIC (iPhone) WebP'ye dönüştürülür", async () => {
|
||||||
|
const heic = await readFile(process.env.HEIC_SAMPLE!);
|
||||||
|
const info = await upload(ayse, heic, "IMG_0001.HEIC");
|
||||||
|
expect(info.kind).toBe("image");
|
||||||
|
const doc = await AttachmentModel.findById(info.id).lean();
|
||||||
|
expect(doc!.sourceType).toBe("heic");
|
||||||
|
expect((await sharp((await getStorage().get(doc!.storageKey))!).metadata()).format).toBe(
|
||||||
|
"webp",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("işleme bağlama", () => {
|
||||||
|
let a1: AttachmentInfo;
|
||||||
|
let a2: AttachmentInfo;
|
||||||
|
let a3: AttachmentInfo;
|
||||||
|
let txId = "";
|
||||||
|
|
||||||
|
it("gider işlemine görseller bağlanır, sıra korunur", async () => {
|
||||||
|
a1 = await upload(ayse, await png(10, 10));
|
||||||
|
a2 = await upload(ayse, await png(20, 10));
|
||||||
|
a3 = await upload(ayse, await png(30, 10));
|
||||||
|
const r = await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({ attachmentIds: [a2.id, a1.id, a3.id] }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
txId = r.ok ? r.data.id : "";
|
||||||
|
const detail = await getTransaction(ayse, cakir, txId);
|
||||||
|
expect(detail!.attachmentCount).toBe(3);
|
||||||
|
expect(detail!.attachments.map((a) => a.id)).toEqual([a2.id, a1.id, a3.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gider işlemine PDF eklenemez; gelirde eklenir; hata olursa işlem oluşmaz", async () => {
|
||||||
|
const p = await upload(ayse, pdf, "bordro.pdf");
|
||||||
|
const before = await TransactionModel.countDocuments({ householdId: cakir });
|
||||||
|
const bad = await createTransaction(ayse, cakir, await form({ attachmentIds: [p.id] }));
|
||||||
|
expect(bad).toMatchObject({ ok: false });
|
||||||
|
expect(await TransactionModel.countDocuments({ householdId: cakir })).toBe(before);
|
||||||
|
expect((await AttachmentModel.findById(p.id).lean())!.ownerId).toBeNull();
|
||||||
|
|
||||||
|
const good = await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({
|
||||||
|
type: "income",
|
||||||
|
categoryId: await cat("maas"),
|
||||||
|
accountId: await acc("Ziraat Maaş"),
|
||||||
|
attachmentIds: [p.id],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(good.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("başkasının bekleyen eki, başka hanenin eki veya başka işlemin eki bağlanamaz", async () => {
|
||||||
|
const emres = await upload(emre, await png());
|
||||||
|
expect(
|
||||||
|
(await createTransaction(ayse, cakir, await form({ attachmentIds: [emres.id] }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
const other = await upload(fatma, await png(), "x.png", annemler);
|
||||||
|
expect(
|
||||||
|
(await createTransaction(ayse, cakir, await form({ attachmentIds: [other.id] }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
// a1 zaten txId'ye bağlı: başka işleme taşınamaz.
|
||||||
|
expect((await createTransaction(ayse, cakir, await form({ attachmentIds: [a1.id] }))).ok).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kayıt başına en fazla 10 ek", async () => {
|
||||||
|
const many = [];
|
||||||
|
for (let i = 0; i < 11; i++) many.push((await upload(emre, await png(5 + i, 5))).id);
|
||||||
|
expect((await createTransaction(emre, cakir, await form({ attachmentIds: many }))).ok).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await createTransaction(emre, cakir, await form({ attachmentIds: many.slice(0, 10) }))).ok,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("düzenlemede çıkarılan ek kayıttan düşer, yeni ek eklenir (başka üye de düzenleyebilir)", async () => {
|
||||||
|
const a4 = await upload(emre, await png(44, 10));
|
||||||
|
const detail = await getTransaction(emre, cakir, txId);
|
||||||
|
const r = await updateTransaction(
|
||||||
|
emre,
|
||||||
|
cakir,
|
||||||
|
txId,
|
||||||
|
await form({
|
||||||
|
accountId: detail!.account.id,
|
||||||
|
categoryId: detail!.categoryId,
|
||||||
|
attachmentIds: [a3.id, a4.id],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const after = await getTransaction(ayse, cakir, txId);
|
||||||
|
expect(after!.attachments.map((a) => a.id)).toEqual([a3.id, a4.id]);
|
||||||
|
expect(after!.attachmentCount).toBe(2);
|
||||||
|
expect((await AttachmentModel.findById(a1.id).lean())!.deletedAt).toBeInstanceOf(Date);
|
||||||
|
expect(await readAttachmentForUser(ayse, a1.id, "full")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dosya erişimi: hane üyeleri (izleyici dahil) okur; hane dışı ve silinmiş işlem okuyamaz", async () => {
|
||||||
|
const file = await readAttachmentForUser(zeynep, a3.id, "thumb");
|
||||||
|
expect(file?.mimeType).toBe("image/webp");
|
||||||
|
expect(await readAttachmentForUser(fatma, a3.id, "full")).toBeNull();
|
||||||
|
expect(await readAttachmentForUser(ayse, "not-an-id", "full")).toBeNull();
|
||||||
|
|
||||||
|
await setTransactionDeleted(ayse, cakir, txId, true);
|
||||||
|
expect(await readAttachmentForUser(ayse, a3.id, "full")).toBeNull();
|
||||||
|
await setTransactionDeleted(ayse, cakir, txId, false);
|
||||||
|
expect(await readAttachmentForUser(ayse, a3.id, "full")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bekleyen ek yalnızca yükleyene görünür", async () => {
|
||||||
|
const pending = await upload(ayse, await png());
|
||||||
|
expect(await readAttachmentForUser(ayse, pending.id, "full")).not.toBeNull();
|
||||||
|
expect(await readAttachmentForUser(emre, pending.id, "full")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gizli hesaptaki işlemin eki hesap sahibi dışındakilere kapalı", async () => {
|
||||||
|
const img = await upload(ayse, await png());
|
||||||
|
const r = await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({ accountId: await acc("Ayşe — Pluxee"), attachmentIds: [img.id] }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(await readAttachmentForUser(ayse, img.id, "full")).not.toBeNull();
|
||||||
|
expect(await readAttachmentForUser(emre, img.id, "full")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("temizlik", () => {
|
||||||
|
it("eski bekleyen ekler, çıkarılmış ekler ve 30 günden önce silinmiş işlemlerin ekleri silinir", async () => {
|
||||||
|
const pending = await upload(ayse, await png());
|
||||||
|
const old = new Date(Date.now() - 25 * 60 * 60 * 1000);
|
||||||
|
await AttachmentModel.collection.updateOne(
|
||||||
|
{ _id: (await AttachmentModel.findById(pending.id))!._id },
|
||||||
|
{ $set: { createdAt: old } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const kept = await upload(ayse, await png());
|
||||||
|
const r = await createTransaction(ayse, cakir, await form({ attachmentIds: [kept.id] }));
|
||||||
|
const tx = r.ok ? r.data.id : "";
|
||||||
|
await setTransactionDeleted(ayse, cakir, tx, true);
|
||||||
|
const keptDoc = (await AttachmentModel.findById(kept.id).lean())!;
|
||||||
|
|
||||||
|
// Silme 31 gün önce yapılmış gibi.
|
||||||
|
const now = new Date(Date.now() + 31 * 24 * 60 * 60 * 1000);
|
||||||
|
const pendingDoc = (await AttachmentModel.findById(pending.id).lean())!;
|
||||||
|
const { removed } = await purgeAttachments(now);
|
||||||
|
expect(removed).toBeGreaterThan(0);
|
||||||
|
expect(await AttachmentModel.findById(pending.id)).toBeNull();
|
||||||
|
expect(await getStorage().get(pendingDoc.storageKey)).toBeNull();
|
||||||
|
expect(await AttachmentModel.findById(kept.id)).toBeNull();
|
||||||
|
expect(await getStorage().get(keptDoc.storageKey)).toBeNull();
|
||||||
|
expect((await TransactionModel.findById(tx).lean())!.attachmentCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("taze bekleyen ekler ve bağlı ekler korunur", async () => {
|
||||||
|
const fresh = await upload(ayse, await png());
|
||||||
|
const linked = await upload(ayse, await png());
|
||||||
|
await createTransaction(ayse, cakir, await form({ attachmentIds: [linked.id] }));
|
||||||
|
await purgeAttachments();
|
||||||
|
expect(await AttachmentModel.findById(fresh.id)).not.toBeNull();
|
||||||
|
expect(await AttachmentModel.findById(linked.id)).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
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 { connectMongoose } from "@/lib/db/mongoose";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
import { getStorage } from "@/lib/storage";
|
||||||
|
import { isPlausiblePdf, processImage, UnreadableImageError } from "@/lib/uploads/process";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { AttachmentModel, type AttachmentOwnerType } from "@/models/attachment";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import {
|
||||||
|
detectFileType,
|
||||||
|
MAX_ATTACHMENTS_PER_RECORD,
|
||||||
|
type AttachmentKind,
|
||||||
|
} from "@/shared/attachments";
|
||||||
|
|
||||||
|
const t = tr.attachments;
|
||||||
|
|
||||||
|
/** Bir kullanıcının bir hanede aynı anda bekletebileceği (kayda bağlanmamış) ek sayısı. */
|
||||||
|
export const MAX_PENDING_PER_USER = 30;
|
||||||
|
/** Bağlanmamış eklerin ömrü. */
|
||||||
|
export const PENDING_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
/** Silinen kayıt/ek dosyalarının fiziksel olarak silinmeden önce bekleme süresi (geri alma payı). */
|
||||||
|
export const SOFT_DELETE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type AttachmentInfo = {
|
||||||
|
id: string;
|
||||||
|
kind: AttachmentKind;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
url: string;
|
||||||
|
thumbUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LeanAttachment = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
kind: AttachmentKind;
|
||||||
|
originalName?: string | null;
|
||||||
|
size: number;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
thumbKey?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toAttachmentInfo(a: LeanAttachment): AttachmentInfo {
|
||||||
|
const id = String(a._id);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: a.kind,
|
||||||
|
name: a.originalName || (a.kind === "pdf" ? "belge.pdf" : "gorsel.webp"),
|
||||||
|
size: a.size,
|
||||||
|
width: a.width ?? null,
|
||||||
|
height: a.height ?? null,
|
||||||
|
url: `/api/files/${id}`,
|
||||||
|
thumbUrl: a.thumbKey ? `/api/files/${id}?v=thumb` : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kullanıcının verdiği dosya adını güvenli hale getirir (yol, kontrol karakteri, uzunluk). */
|
||||||
|
export function sanitizeFileName(name: string): string {
|
||||||
|
const base = name.split(/[\\/]/).pop() ?? "";
|
||||||
|
return base
|
||||||
|
.replace(/[\u0000-\u001f\u007f"<>]/g, "")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newKey(): string {
|
||||||
|
const id = randomBytes(16).toString("hex");
|
||||||
|
return `${id.slice(0, 2)}/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Yükleme ---
|
||||||
|
|
||||||
|
export async function uploadAttachment(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
file: { name: string; bytes: Buffer },
|
||||||
|
): Promise<ActionResult<AttachmentInfo>> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const maxBytes = env().MAX_UPLOAD_MB * 1024 * 1024;
|
||||||
|
if (file.bytes.length === 0) return fail(t.errors.empty);
|
||||||
|
if (file.bytes.length > maxBytes) return fail(t.errors.tooLarge(env().MAX_UPLOAD_MB));
|
||||||
|
|
||||||
|
const type = detectFileType(file.bytes);
|
||||||
|
if (!type) return fail(t.errors.unsupported);
|
||||||
|
|
||||||
|
const uploader = new Types.ObjectId(userId);
|
||||||
|
const pending = await AttachmentModel.countDocuments({
|
||||||
|
householdId: m.householdId,
|
||||||
|
ownerId: null,
|
||||||
|
uploadedBy: uploader,
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
if (pending >= MAX_PENDING_PER_USER) return fail(t.errors.tooManyPending);
|
||||||
|
|
||||||
|
const key = newKey();
|
||||||
|
const storage = getStorage();
|
||||||
|
let doc: Record<string, unknown>;
|
||||||
|
if (type === "pdf") {
|
||||||
|
if (!isPlausiblePdf(file.bytes)) return fail(t.errors.unreadable);
|
||||||
|
await storage.put(`${key}.pdf`, file.bytes);
|
||||||
|
doc = {
|
||||||
|
kind: "pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
size: file.bytes.length,
|
||||||
|
storageKey: `${key}.pdf`,
|
||||||
|
thumbKey: null,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
let processed;
|
||||||
|
try {
|
||||||
|
processed = await processImage(file.bytes, type);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UnreadableImageError) return fail(t.errors.unreadable);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await storage.put(`${key}.webp`, processed.main);
|
||||||
|
await storage.put(`${key}.thumb.webp`, processed.thumb);
|
||||||
|
doc = {
|
||||||
|
kind: "image",
|
||||||
|
mimeType: "image/webp",
|
||||||
|
size: processed.main.length,
|
||||||
|
width: processed.width,
|
||||||
|
height: processed.height,
|
||||||
|
storageKey: `${key}.webp`,
|
||||||
|
thumbKey: `${key}.thumb.webp`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await AttachmentModel.create({
|
||||||
|
...doc,
|
||||||
|
householdId: m.householdId,
|
||||||
|
sourceType: type,
|
||||||
|
originalName: sanitizeFileName(file.name),
|
||||||
|
originalSize: file.bytes.length,
|
||||||
|
uploadedBy: uploader,
|
||||||
|
});
|
||||||
|
return ok(toAttachmentInfo(created.toObject() as LeanAttachment));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kayda bağlama ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kaydın ek listesini `ids` ile eşitler (sıra korunur). Yeni kimlikler, bu kullanıcının bu hanede
|
||||||
|
* yüklediği bekleyen ekler olmalı; listede olmayan mevcut ekler kayıttan çıkarılır (`deletedAt`).
|
||||||
|
* Kayıttaki güncel ek sayısını döner. Transaction içinde çağrılmalı (session).
|
||||||
|
*/
|
||||||
|
export async function syncOwnerAttachments(opts: {
|
||||||
|
householdId: Types.ObjectId;
|
||||||
|
actorId: string;
|
||||||
|
ownerType: AttachmentOwnerType;
|
||||||
|
ownerId: Types.ObjectId;
|
||||||
|
ids: string[];
|
||||||
|
allowedKinds: readonly AttachmentKind[];
|
||||||
|
session?: ClientSession;
|
||||||
|
}): Promise<ActionResult<number>> {
|
||||||
|
const { householdId, ownerType, ownerId, session } = opts;
|
||||||
|
const unique = [...new Set(opts.ids)];
|
||||||
|
if (unique.length > MAX_ATTACHMENTS_PER_RECORD)
|
||||||
|
return fail(t.errors.tooMany(MAX_ATTACHMENTS_PER_RECORD));
|
||||||
|
const objectIds = unique.map(toObjectId);
|
||||||
|
if (objectIds.some((id) => !id)) return fail(t.errors.invalid);
|
||||||
|
|
||||||
|
const current = await AttachmentModel.find(
|
||||||
|
{ householdId, ownerType, ownerId, deletedAt: null },
|
||||||
|
{ _id: 1 },
|
||||||
|
{ session },
|
||||||
|
).lean();
|
||||||
|
const currentIds = new Set(current.map((a) => String(a._id)));
|
||||||
|
const newIds = objectIds.filter((id) => !currentIds.has(String(id))) as Types.ObjectId[];
|
||||||
|
|
||||||
|
const candidates = await AttachmentModel.find(
|
||||||
|
{ _id: { $in: objectIds }, householdId, deletedAt: null },
|
||||||
|
{ kind: 1, ownerId: 1, uploadedBy: 1 },
|
||||||
|
{ session },
|
||||||
|
).lean();
|
||||||
|
const byId = new Map(candidates.map((a) => [String(a._id), a]));
|
||||||
|
for (const id of newIds) {
|
||||||
|
const a = byId.get(String(id));
|
||||||
|
// Yalnızca kendi yüklediği, henüz hiçbir kayda bağlanmamış ekler bağlanabilir.
|
||||||
|
if (!a || a.ownerId || String(a.uploadedBy) !== opts.actorId) return fail(t.errors.invalid);
|
||||||
|
}
|
||||||
|
for (const id of objectIds) {
|
||||||
|
const a = byId.get(String(id));
|
||||||
|
if (a && !opts.allowedKinds.includes(a.kind)) return fail(t.errors.kindNotAllowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = current.filter((a) => !objectIds.some((id) => id!.equals(a._id)));
|
||||||
|
if (removed.length) {
|
||||||
|
await AttachmentModel.updateMany(
|
||||||
|
{ _id: { $in: removed.map((a) => a._id) }, householdId },
|
||||||
|
{ $set: { deletedAt: new Date() } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (objectIds.length) {
|
||||||
|
await AttachmentModel.bulkWrite(
|
||||||
|
objectIds.map((id, order) => ({
|
||||||
|
updateOne: {
|
||||||
|
filter: { _id: id!, householdId },
|
||||||
|
update: { $set: { ownerType, ownerId, order } },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ok(objectIds.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOwnerAttachments(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
ownerType: AttachmentOwnerType,
|
||||||
|
ownerId: Types.ObjectId,
|
||||||
|
): Promise<AttachmentInfo[]> {
|
||||||
|
const rows = await AttachmentModel.find({ householdId, ownerType, ownerId, deletedAt: null })
|
||||||
|
.sort({ order: 1, createdAt: 1 })
|
||||||
|
.lean();
|
||||||
|
return rows.map((a) => toAttachmentInfo(a as unknown as LeanAttachment));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Servis etme ---
|
||||||
|
|
||||||
|
export type AttachmentFile = {
|
||||||
|
bytes: Buffer;
|
||||||
|
mimeType: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yetki kontrollü dosya okuma: kullanıcı ekin hanesinin üyesi olmalı; bekleyen ek yalnızca
|
||||||
|
* yükleyene; işleme bağlı ek, işlem kullanıcıya görünürse (silinmemiş ve başkasının gizli
|
||||||
|
* hesabına dokunmuyorsa). Erişim yoksa null (çağıran 404 döner; varlık sızdırılmaz).
|
||||||
|
*/
|
||||||
|
export async function readAttachmentForUser(
|
||||||
|
userId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
variant: "full" | "thumb",
|
||||||
|
): Promise<AttachmentFile | null> {
|
||||||
|
const id = toObjectId(attachmentId);
|
||||||
|
if (!id) return null;
|
||||||
|
await connectMongoose();
|
||||||
|
const a = await AttachmentModel.findOne({ _id: id, deletedAt: null }).lean();
|
||||||
|
if (!a) return null;
|
||||||
|
const m = await resolveMembership(userId, String(a.householdId), "viewer");
|
||||||
|
if (!m) return null;
|
||||||
|
|
||||||
|
if (!a.ownerId) {
|
||||||
|
if (String(a.uploadedBy) !== userId) return null;
|
||||||
|
} else if (a.ownerType === "transaction") {
|
||||||
|
const tx = await TransactionModel.findOne(
|
||||||
|
{ _id: a.ownerId, householdId: a.householdId, deletedAt: null },
|
||||||
|
{ accountId: 1, toAccountId: 1 },
|
||||||
|
).lean();
|
||||||
|
if (!tx) return null;
|
||||||
|
const hidden = await AccountModel.exists({
|
||||||
|
_id: { $in: [tx.accountId, tx.toAccountId].filter(Boolean) },
|
||||||
|
householdId: a.householdId,
|
||||||
|
isPrivate: true,
|
||||||
|
ownerUserId: { $ne: new Types.ObjectId(userId) },
|
||||||
|
});
|
||||||
|
if (hidden) return null;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = variant === "thumb" && a.thumbKey ? a.thumbKey : a.storageKey;
|
||||||
|
const bytes = await getStorage().get(key);
|
||||||
|
if (!bytes) return null;
|
||||||
|
return {
|
||||||
|
bytes,
|
||||||
|
mimeType: variant === "thumb" && a.thumbKey ? "image/webp" : a.mimeType,
|
||||||
|
name: a.originalName ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Temizlik ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fiziksel temizlik (Bölüm 7.4): 24 saatten eski bekleyen ekler, 30 günden önce çıkarılmış
|
||||||
|
* ekler ve 30 günden önce silinmiş işlemlerin ekleri. Dosyalar silinir, kayıtlar kaldırılır.
|
||||||
|
*/
|
||||||
|
export async function purgeAttachments(now = new Date()): Promise<{ removed: number }> {
|
||||||
|
await connectMongoose();
|
||||||
|
const retentionCutoff = new Date(now.getTime() - SOFT_DELETE_RETENTION_MS);
|
||||||
|
const pendingCutoff = new Date(now.getTime() - PENDING_TTL_MS);
|
||||||
|
|
||||||
|
const deletedTx = await TransactionModel.find(
|
||||||
|
{ deletedAt: { $ne: null, $lt: retentionCutoff }, attachmentCount: { $gt: 0 } },
|
||||||
|
{ _id: 1 },
|
||||||
|
).lean();
|
||||||
|
|
||||||
|
const victims = await AttachmentModel.find(
|
||||||
|
{
|
||||||
|
$or: [
|
||||||
|
{ ownerId: null, createdAt: { $lt: pendingCutoff } },
|
||||||
|
{ deletedAt: { $ne: null, $lt: retentionCutoff } },
|
||||||
|
...(deletedTx.length
|
||||||
|
? [{ ownerType: "transaction" as const, ownerId: { $in: deletedTx.map((d) => d._id) } }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ storageKey: 1, thumbKey: 1 },
|
||||||
|
).lean();
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
for (const v of victims) {
|
||||||
|
await storage.delete(v.storageKey);
|
||||||
|
if (v.thumbKey) await storage.delete(v.thumbKey);
|
||||||
|
await AttachmentModel.deleteOne({ _id: v._id });
|
||||||
|
}
|
||||||
|
if (deletedTx.length) {
|
||||||
|
await TransactionModel.updateMany(
|
||||||
|
{ _id: { $in: deletedTx.map((d) => d._id) } },
|
||||||
|
{ $set: { attachmentCount: 0 } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { removed: victims.length };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" },
|
|
||||||
];
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"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 { confirmOccurrenceSchema, recurringRuleFormSchema } from "@/shared/schemas/recurring";
|
||||||
|
import {
|
||||||
|
confirmOccurrence,
|
||||||
|
createRule,
|
||||||
|
deleteRule,
|
||||||
|
getRuleForm,
|
||||||
|
postponeOccurrence,
|
||||||
|
skipOccurrence,
|
||||||
|
updateRule,
|
||||||
|
type RuleFormValues,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||||
|
const optionalId = idSchema.optional();
|
||||||
|
const scopeSchema = z.enum(["all", "following"]);
|
||||||
|
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
|
||||||
|
function fieldErrors(error: z.ZodError) {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const issue of error.issues) {
|
||||||
|
const key = issue.path.join(".");
|
||||||
|
if (key && !out[key]) out[key] = issue.message;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidate() {
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRuleAction(input: unknown): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const parsed = recurringRuleFormSchema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||||
|
const result = await createRule(ctx.user.id, ctx.household.id, parsed.data);
|
||||||
|
if (result.ok) revalidate();
|
||||||
|
return result.ok ? ok({ id: result.data.id }) : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRuleAction(
|
||||||
|
ruleId: unknown,
|
||||||
|
input: unknown,
|
||||||
|
scope: unknown,
|
||||||
|
fromTransactionId?: unknown,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const id = idSchema.safeParse(ruleId);
|
||||||
|
const s = scopeSchema.safeParse(scope);
|
||||||
|
const from = optionalId.safeParse(fromTransactionId ?? undefined);
|
||||||
|
if (!id.success || !s.success || !from.success) return fail(tr.common.notFound);
|
||||||
|
const parsed = recurringRuleFormSchema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||||
|
const result = await updateRule(
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.household.id,
|
||||||
|
id.data,
|
||||||
|
parsed.data,
|
||||||
|
s.data,
|
||||||
|
from.data,
|
||||||
|
);
|
||||||
|
if (result.ok) revalidate();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRuleAction(
|
||||||
|
ruleId: unknown,
|
||||||
|
scope: unknown,
|
||||||
|
fromTransactionId?: unknown,
|
||||||
|
): Promise<ActionResult<{ removed: number }>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const id = idSchema.safeParse(ruleId);
|
||||||
|
const s = scopeSchema.safeParse(scope);
|
||||||
|
const from = optionalId.safeParse(fromTransactionId ?? undefined);
|
||||||
|
if (!id.success || !s.success || !from.success) return fail(tr.common.notFound);
|
||||||
|
const result = await deleteRule(ctx.user.id, ctx.household.id, id.data, s.data, from.data);
|
||||||
|
if (result.ok) revalidate();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRuleFormAction(ruleId: unknown): Promise<ActionResult<RuleFormValues>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const id = idSchema.safeParse(ruleId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
const values = await getRuleForm(ctx.user.id, ctx.household.id, id.data);
|
||||||
|
return values ? ok(values) : fail(tr.common.notFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmOccurrenceAction(
|
||||||
|
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 = confirmOccurrenceSchema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||||
|
const result = await confirmOccurrence(ctx.user.id, ctx.household.id, id.data, parsed.data);
|
||||||
|
if (result.ok) revalidate();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function skipOccurrenceAction(transactionId: 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 result = await skipOccurrence(ctx.user.id, ctx.household.id, id.data);
|
||||||
|
if (result.ok) revalidate();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postponeOccurrenceAction(
|
||||||
|
transactionId: unknown,
|
||||||
|
date: unknown,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const id = idSchema.safeParse(transactionId);
|
||||||
|
const d = ymd.safeParse(date);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
if (!d.success) return fail(tr.validation.date, { date: tr.validation.date });
|
||||||
|
const result = await postponeOccurrence(ctx.user.id, ctx.household.id, id.data, d.data);
|
||||||
|
if (result.ok) revalidate();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { TriangleAlert } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { countOverdue, ensureHorizon } from "../service";
|
||||||
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
|
||||||
|
/** Dashboard: vadesi geçmiş onaylanmamış planlı işlemler uyarısı (Bölüm 6.7). */
|
||||||
|
export async function OverdueBanner({
|
||||||
|
userId,
|
||||||
|
householdId,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
householdId: string;
|
||||||
|
}) {
|
||||||
|
const hid = toObjectId(householdId);
|
||||||
|
if (hid) await ensureHorizon(hid);
|
||||||
|
const n = await countOverdue(userId, householdId);
|
||||||
|
if (!n) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="flex flex-wrap items-center gap-3 rounded-xl border border-expense/30 bg-expense/10 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<TriangleAlert className="size-4 shrink-0 text-expense" aria-hidden />
|
||||||
|
<span className="flex-1">{tr.recurring.overdueBanner(n)}</span>
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link href="/planli">{tr.recurring.overdueLink}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CalendarClock,
|
||||||
|
Check,
|
||||||
|
EllipsisVertical,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
SkipForward,
|
||||||
|
Trash2,
|
||||||
|
Repeat,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MoneyInput } from "@/components/money-input";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { useQuickAdd } from "@/components/layout/quick-add";
|
||||||
|
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 } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { getTransactionAction } from "@/features/transactions/actions";
|
||||||
|
import {
|
||||||
|
Amount,
|
||||||
|
RowIcon,
|
||||||
|
rowSubtitle,
|
||||||
|
rowTitle,
|
||||||
|
} from "@/features/transactions/components/transaction-parts";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import {
|
||||||
|
confirmOccurrenceAction,
|
||||||
|
deleteRuleAction,
|
||||||
|
getRuleFormAction,
|
||||||
|
postponeOccurrenceAction,
|
||||||
|
skipOccurrenceAction,
|
||||||
|
} from "../actions";
|
||||||
|
import type { PlannedItem, PlannedOverview, RuleView } from "../service";
|
||||||
|
import { RuleFormDialog, type RuleFormMode } from "./rule-form";
|
||||||
|
|
||||||
|
const t = tr.recurring;
|
||||||
|
const dateLabel = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
|
||||||
|
type Pending =
|
||||||
|
| { kind: "confirm"; item: PlannedItem }
|
||||||
|
| { kind: "postpone"; item: PlannedItem }
|
||||||
|
| { kind: "scope"; item: PlannedItem }
|
||||||
|
| { kind: "delete"; ruleId: string; item?: PlannedItem }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function PlannedView({ data, canWrite }: { data: PlannedOverview; canWrite: boolean }) {
|
||||||
|
const [tab, setTab] = useState(data.overdue.length ? "overdue" : "upcoming");
|
||||||
|
const [pending, setPending] = useState<Pending>(null);
|
||||||
|
const [ruleForm, setRuleForm] = useState<RuleFormMode | null>(null);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const { openTransactionForm } = useQuickAdd();
|
||||||
|
|
||||||
|
function skip(item: PlannedItem) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const r = await skipOccurrenceAction(item.id);
|
||||||
|
if (r.ok) toast.success(t.toast.skipped);
|
||||||
|
else toast.error(r.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editScope(item: PlannedItem, scope: "one" | "following" | "all") {
|
||||||
|
setPending(null);
|
||||||
|
if (scope === "one" || !item.recurringRuleId) {
|
||||||
|
const d = await getTransactionAction(item.id);
|
||||||
|
if (d.ok) openTransactionForm({ kind: "edit", transaction: d.data });
|
||||||
|
else toast.error(d.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rule = await getRuleFormAction(item.recurringRuleId);
|
||||||
|
if (!rule.ok) return void toast.error(rule.error);
|
||||||
|
const [pos, total] = (item.seriesPosition ?? "").split("/").map(Number);
|
||||||
|
setRuleForm({
|
||||||
|
kind: "edit",
|
||||||
|
rule: rule.data,
|
||||||
|
scope,
|
||||||
|
fromTransactionId: scope === "following" ? item.id : undefined,
|
||||||
|
fromDate: scope === "following" ? item.date : undefined,
|
||||||
|
remainingCount: pos && total ? total - pos + 1 : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editRule(rule: RuleView) {
|
||||||
|
const r = await getRuleFormAction(rule.id);
|
||||||
|
if (r.ok) setRuleForm({ kind: "edit", rule: r.data, scope: "all" });
|
||||||
|
else toast.error(r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemList = (items: PlannedItem[], empty: string) =>
|
||||||
|
items.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>{empty}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="flex items-center gap-3 p-3">
|
||||||
|
<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.seriesPosition ? (
|
||||||
|
<Badge variant="outline" className="hidden sm:inline-flex">
|
||||||
|
{item.seriesPosition}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{item.isEstimate ? (
|
||||||
|
<Badge variant="secondary" className="hidden sm:inline-flex">
|
||||||
|
{t.estimate}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{/* Mobilde rozetler alt satırda (başlık daralmasın). */}
|
||||||
|
<span className="sm:hidden">
|
||||||
|
{item.seriesPosition ? `${item.seriesPosition} · ` : ""}
|
||||||
|
{item.isEstimate ? `${t.estimate} · ` : ""}
|
||||||
|
</span>
|
||||||
|
{dateLabel(item.date)} · {rowSubtitle(item)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Amount type={item.type} amountMinor={item.amountMinor} />
|
||||||
|
{canWrite ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPending({ kind: "confirm", item })}
|
||||||
|
>
|
||||||
|
<Check />
|
||||||
|
<span className="hidden sm:inline">{t.actions.confirm}</span>
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" aria-label={t.actions.more}>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={() => setPending({ kind: "postpone", item })}>
|
||||||
|
<CalendarClock />
|
||||||
|
{t.actions.postpone}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => skip(item)}>
|
||||||
|
<SkipForward />
|
||||||
|
{t.actions.skip}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
item.recurringRuleId
|
||||||
|
? setPending({ kind: "scope", item })
|
||||||
|
: void editScope(item, "one")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
{t.actions.edit}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{item.recurringRuleId ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() =>
|
||||||
|
setPending({ kind: "delete", ruleId: item.recurringRuleId!, item })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
{t.actions.delete}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{canWrite ? (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => setRuleForm({ kind: "create" })}>
|
||||||
|
<Plus />
|
||||||
|
{t.newRule}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overdue">
|
||||||
|
{t.tabs.overdue}
|
||||||
|
{data.overdue.length ? (
|
||||||
|
<Badge variant="destructive" className="ml-1">
|
||||||
|
{data.overdue.length}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="upcoming">{t.tabs.upcoming}</TabsTrigger>
|
||||||
|
<TabsTrigger value="rules">{t.tabs.rules}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="overdue" className="mt-3">
|
||||||
|
{itemList(data.overdue, t.empty.overdue)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="upcoming" className="mt-3">
|
||||||
|
{itemList(data.upcoming, t.empty.upcoming)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="rules" className="mt-3">
|
||||||
|
{data.rules.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>{t.empty.rules}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{data.rules.map((rule) => (
|
||||||
|
<li key={rule.id} className="flex items-center gap-3 p-3">
|
||||||
|
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||||
|
<Repeat className="size-4.5" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="truncate font-medium">{rule.title}</span>
|
||||||
|
{rule.isEstimate ? <Badge variant="secondary">{t.estimate}</Badge> : null}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{rule.recurrence} ·{" "}
|
||||||
|
{rule.toAccount ? `${rule.account} → ${rule.toAccount}` : rule.account}
|
||||||
|
{rule.nextDate ? ` · ${t.next(dateLabel(rule.nextDate))}` : ""}
|
||||||
|
{rule.remaining !== null
|
||||||
|
? ` · ${t.remaining(rule.remaining)}`
|
||||||
|
: ` · ${t.endless}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Amount type={rule.type} amountMinor={rule.amountMinor} />
|
||||||
|
{canWrite ? (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="icon-sm" variant="ghost" aria-label={t.actions.more}>
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={() => void editRule(rule)}>
|
||||||
|
<Pencil />
|
||||||
|
{t.actions.edit}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() => setPending({ kind: "delete", ruleId: rule.id })}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
{t.actions.delete}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
item={pending?.kind === "confirm" ? pending.item : null}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
/>
|
||||||
|
<PostponeDialog
|
||||||
|
item={pending?.kind === "postpone" ? pending.item : null}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
/>
|
||||||
|
<ScopeDialog
|
||||||
|
item={pending?.kind === "scope" ? pending.item : null}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
onPick={(item, scope) => void editScope(item, scope)}
|
||||||
|
/>
|
||||||
|
<DeleteDialog
|
||||||
|
target={pending?.kind === "delete" ? pending : null}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
/>
|
||||||
|
<RuleFormDialog mode={ruleForm} onOpenChange={(o) => !o && setRuleForm(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmDialog({ item, onClose }: { item: PlannedItem | null; onClose: () => void }) {
|
||||||
|
return item ? <ConfirmForm key={item.id} item={item} onClose={onClose} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmForm({ item, onClose }: { item: PlannedItem; onClose: () => void }) {
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const [amount, setAmount] = useState(formatMoneyInput(item.amountMinor));
|
||||||
|
// Gelecekteki kaydı erken onaylarken bugünün tarihi; geçmiştekini kendi tarihiyle.
|
||||||
|
const [date, setDate] = useState(item.date > today ? today : item.date);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true);
|
||||||
|
const r = await confirmOccurrenceAction(item.id, { amount, date });
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.confirmed);
|
||||||
|
onClose();
|
||||||
|
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={`${t.confirmTitle}: ${rowTitle(item)}`}
|
||||||
|
description={t.confirmDescription}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="animate-spin" /> : <Check />}
|
||||||
|
{t.actions.confirm}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={Boolean(errors.amount)}>
|
||||||
|
<FieldLabel htmlFor="confirm-amount">{tr.transactions.fields.amount}</FieldLabel>
|
||||||
|
<MoneyInput id="confirm-amount" value={amount} onChange={setAmount} autoFocus />
|
||||||
|
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.date)}>
|
||||||
|
<FieldLabel htmlFor="confirm-date">{tr.transactions.fields.date}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="confirm-date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
{item.isEstimate ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.estimate}: {formatMoney(item.amountMinor)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostponeDialog({ item, onClose }: { item: PlannedItem | null; onClose: () => void }) {
|
||||||
|
return item ? <PostponeForm key={item.id} item={item} onClose={onClose} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostponeForm({ item, onClose }: { item: PlannedItem; onClose: () => void }) {
|
||||||
|
const base = item.date > todayIstanbul() ? item.date : todayIstanbul();
|
||||||
|
const [date, setDate] = useState(addDays(base, 7));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true);
|
||||||
|
const r = await postponeOccurrenceAction(item.id, date);
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.postponed);
|
||||||
|
onClose();
|
||||||
|
} else setError(r.fieldErrors?.date ?? r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={`${t.postponeTitle}: ${rowTitle(item)}`}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="animate-spin" /> : <CalendarClock />}
|
||||||
|
{t.actions.postpone}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Field data-invalid={Boolean(error)}>
|
||||||
|
<FieldLabel htmlFor="postpone-date">{t.postponeDate}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="postpone-date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
min={addDays(item.date, 1)}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error ? <FieldError>{error}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeDialog({
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
item: PlannedItem | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onPick: (item: PlannedItem, scope: "one" | "following" | "all") => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={Boolean(item)}
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={t.scopeTitle}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{(["one", "following", "all"] as const).map((scope) => (
|
||||||
|
<button
|
||||||
|
key={scope}
|
||||||
|
type="button"
|
||||||
|
onClick={() => item && onPick(item, scope)}
|
||||||
|
className="rounded-xl border p-3 text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<span className="block font-medium">{t.scopes[scope]}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{t.scopeHints[scope]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteDialog({
|
||||||
|
target,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
target: { ruleId: string; item?: PlannedItem } | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
async function run(scope: "all" | "following") {
|
||||||
|
if (!target) return;
|
||||||
|
setBusy(true);
|
||||||
|
const r = await deleteRuleAction(target.ruleId, scope, target.item?.id);
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.toast.deleted);
|
||||||
|
onClose();
|
||||||
|
} else toast.error(r.error);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<AlertDialog open={Boolean(target)} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t.deleteDescription}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
{target?.item ? (
|
||||||
|
<Button variant="outline" disabled={busy} onClick={() => run("following")}>
|
||||||
|
{t.deleteScopes.following}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button variant="destructive" disabled={busy} onClick={() => run("all")}>
|
||||||
|
{t.deleteScopes.all}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Controller, useForm, useWatch, type Resolver } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MoneyInput } from "@/components/money-input";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
|
||||||
|
import {
|
||||||
|
AccountSelect,
|
||||||
|
CategorySelect,
|
||||||
|
PayeeInput,
|
||||||
|
} from "@/features/transactions/components/transaction-form";
|
||||||
|
import type { TransactionFormOptions } from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { formatDate } from "@/shared/format";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import {
|
||||||
|
describeRecurrence,
|
||||||
|
FREQUENCIES,
|
||||||
|
listOccurrences,
|
||||||
|
MONTHLY_MODES,
|
||||||
|
type Frequency,
|
||||||
|
type MonthlyMode,
|
||||||
|
} from "@/shared/recurrence";
|
||||||
|
import {
|
||||||
|
END_KINDS,
|
||||||
|
recurringRuleFormSchema,
|
||||||
|
type RecurringRuleForm,
|
||||||
|
} from "@/shared/schemas/recurring";
|
||||||
|
import { TRANSACTION_TYPES, type TransactionType } from "@/shared/transactions";
|
||||||
|
import { createRuleAction, updateRuleAction } from "../actions";
|
||||||
|
import type { RuleFormValues } from "../service";
|
||||||
|
|
||||||
|
const t = tr.recurring;
|
||||||
|
const tf = tr.transactions.fields;
|
||||||
|
|
||||||
|
export type RuleFormMode =
|
||||||
|
| { kind: "create" }
|
||||||
|
| {
|
||||||
|
kind: "edit";
|
||||||
|
rule: RuleFormValues;
|
||||||
|
scope: "all" | "following";
|
||||||
|
/** "Bu ve sonrakiler" için başlangıç tekrarı ve onun tarihi / kalan tekrar sayısı. */
|
||||||
|
fromTransactionId?: string;
|
||||||
|
fromDate?: string;
|
||||||
|
remainingCount?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
type: TransactionType;
|
||||||
|
amount: string;
|
||||||
|
isEstimate: boolean;
|
||||||
|
accountId: string;
|
||||||
|
toAccountId: string;
|
||||||
|
categoryId: string;
|
||||||
|
payee: string;
|
||||||
|
description: string;
|
||||||
|
tags: string;
|
||||||
|
startDate: string;
|
||||||
|
frequency: Frequency;
|
||||||
|
interval: string;
|
||||||
|
monthlyMode: MonthlyMode;
|
||||||
|
endKind: (typeof END_KINDS)[number];
|
||||||
|
count: string;
|
||||||
|
endDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toValues(mode: RuleFormMode, options: TransactionFormOptions | null): FormValues {
|
||||||
|
if (mode.kind === "create") {
|
||||||
|
return {
|
||||||
|
type: "expense",
|
||||||
|
amount: "",
|
||||||
|
isEstimate: false,
|
||||||
|
accountId: options?.accounts[0]?.id ?? "",
|
||||||
|
toAccountId: "",
|
||||||
|
categoryId: "",
|
||||||
|
payee: "",
|
||||||
|
description: "",
|
||||||
|
tags: "",
|
||||||
|
startDate: todayIstanbul(),
|
||||||
|
frequency: "monthly",
|
||||||
|
interval: "1",
|
||||||
|
monthlyMode: "day",
|
||||||
|
endKind: "never",
|
||||||
|
count: "",
|
||||||
|
endDate: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const r = mode.rule;
|
||||||
|
const following = mode.scope === "following" && mode.fromDate;
|
||||||
|
const count = following ? (mode.remainingCount ?? null) : r.count;
|
||||||
|
return {
|
||||||
|
type: r.type,
|
||||||
|
amount: formatMoneyInput(r.amountMinor),
|
||||||
|
isEstimate: r.isEstimate,
|
||||||
|
accountId: r.accountId,
|
||||||
|
toAccountId: r.toAccountId ?? "",
|
||||||
|
categoryId: r.categoryId ?? "",
|
||||||
|
payee: r.payee,
|
||||||
|
description: r.description,
|
||||||
|
tags: r.tags.join(", "),
|
||||||
|
startDate: following ? mode.fromDate! : r.startDate,
|
||||||
|
frequency: r.frequency,
|
||||||
|
interval: String(r.interval),
|
||||||
|
monthlyMode: r.monthlyMode,
|
||||||
|
endKind: count ? "count" : r.endDate ? "date" : "never",
|
||||||
|
count: count ? String(count) : "",
|
||||||
|
endDate: r.endDate ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Düzenli işlem kuralı formu (oluştur / tümünü düzenle / bu ve sonrakileri düzenle). */
|
||||||
|
export function RuleFormDialog({
|
||||||
|
mode,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
mode: RuleFormMode | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const form = useForm<FormValues, unknown, RecurringRuleForm>({
|
||||||
|
resolver: zodResolver(recurringRuleFormSchema) as unknown as Resolver<
|
||||||
|
FormValues,
|
||||||
|
unknown,
|
||||||
|
RecurringRuleForm
|
||||||
|
>,
|
||||||
|
defaultValues: toValues({ kind: "create" }, null),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mode) return;
|
||||||
|
let cancelled = false;
|
||||||
|
getTransactionFormOptionsAction().then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!res.ok) return setLoadError(res.error);
|
||||||
|
setLoadError(null);
|
||||||
|
setOptions(res.data);
|
||||||
|
form.reset(toValues(mode, res.data));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [mode, form]);
|
||||||
|
|
||||||
|
const w = useWatch({ control: form.control });
|
||||||
|
const type = w.type ?? "expense";
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const kind = type === "income" ? "income" : "expense";
|
||||||
|
return (options?.categories ?? []).filter((c) => c.kind === kind);
|
||||||
|
}, [options, type]);
|
||||||
|
|
||||||
|
// Önizleme: açıklama ve ilk 3 tarih (istemcide aynı saf fonksiyonla).
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
const interval = Math.min(12, Math.max(1, Number(w.interval) || 1));
|
||||||
|
const r = {
|
||||||
|
frequency: w.frequency ?? "monthly",
|
||||||
|
interval,
|
||||||
|
monthlyMode: w.monthlyMode ?? "day",
|
||||||
|
startDate: w.startDate ?? todayIstanbul(),
|
||||||
|
endDate: w.endKind === "date" && w.endDate ? w.endDate : null,
|
||||||
|
count: w.endKind === "count" && Number(w.count) > 0 ? Number(w.count) : null,
|
||||||
|
} as const;
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(r.startDate)) return null;
|
||||||
|
return {
|
||||||
|
text: describeRecurrence(r),
|
||||||
|
dates: listOccurrences(r, "9999-12-31")
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((o) => o.date),
|
||||||
|
};
|
||||||
|
}, [w]);
|
||||||
|
|
||||||
|
function changeType(next: TransactionType) {
|
||||||
|
form.setValue("type", next);
|
||||||
|
form.setValue("categoryId", "");
|
||||||
|
if (next !== "transfer") form.setValue("toAccountId", "");
|
||||||
|
form.clearErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!mode) return;
|
||||||
|
const values = form.getValues();
|
||||||
|
const payload = {
|
||||||
|
...values,
|
||||||
|
toAccountId: values.type === "transfer" ? values.toAccountId : "",
|
||||||
|
categoryId: values.type === "transfer" ? "" : values.categoryId,
|
||||||
|
count: values.endKind === "count" ? values.count : null,
|
||||||
|
endDate: values.endKind === "date" ? values.endDate : null,
|
||||||
|
};
|
||||||
|
const result =
|
||||||
|
mode.kind === "edit"
|
||||||
|
? await updateRuleAction(mode.rule.id, payload, mode.scope, mode.fromTransactionId)
|
||||||
|
: await createRuleAction(payload);
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success(mode.kind === "edit" ? t.toast.updated : t.toast.created);
|
||||||
|
onOpenChange(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [name, message] of Object.entries(result.fieldErrors ?? {})) {
|
||||||
|
if (message) form.setError(name as keyof FormValues, { message });
|
||||||
|
}
|
||||||
|
if (!result.fieldErrors) form.setError("root", { message: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = Boolean(mode) && !options && !loadError;
|
||||||
|
const errors = form.formState.errors;
|
||||||
|
const title = mode?.kind === "edit" ? `${t.editTitle} · ${t.scopes[mode.scope]}` : t.createTitle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={Boolean(mode)}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={title}
|
||||||
|
description={mode?.kind === "edit" ? t.scopeHints[mode.scope] : undefined}
|
||||||
|
footer={
|
||||||
|
loadError ? null : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="rule-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 || !options ? (
|
||||||
|
<div className="flex flex-col gap-4" role="status" aria-label={tr.common.loading}>
|
||||||
|
<Skeleton className="h-9 w-full" />
|
||||||
|
<Skeleton className="h-9 w-full" />
|
||||||
|
<Skeleton className="h-9 w-full" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form id="rule-form" onSubmit={form.handleSubmit(onSubmit)} noValidate>
|
||||||
|
<FieldGroup>
|
||||||
|
<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">
|
||||||
|
{tr.transactions.types[x]}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="amount"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="rule-amount">{tf.amount}</FieldLabel>
|
||||||
|
<MoneyInput
|
||||||
|
id="rule-amount"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="isEstimate"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="-mt-2 flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(v) => field.onChange(v === true)}
|
||||||
|
/>
|
||||||
|
{t.fields.estimate}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="accountId"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="rule-account">
|
||||||
|
{type === "transfer" ? tf.fromAccount : tf.account}
|
||||||
|
</FieldLabel>
|
||||||
|
<AccountSelect
|
||||||
|
id="rule-account"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
accounts={options.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="rule-to">{tf.toAccount}</FieldLabel>
|
||||||
|
<AccountSelect
|
||||||
|
id="rule-to"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
accounts={options.accounts}
|
||||||
|
exclude={w.accountId}
|
||||||
|
invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Controller
|
||||||
|
name="categoryId"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="rule-category">{tf.category}</FieldLabel>
|
||||||
|
<CategorySelect
|
||||||
|
id="rule-category"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
options={categories}
|
||||||
|
appearance={options.categoryAppearance}
|
||||||
|
invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Controller
|
||||||
|
name="payee"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="tx-payee">{tf.payee}</FieldLabel>
|
||||||
|
<PayeeInput value={field.value} onChange={field.onChange} onPick={() => {}} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="description"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="rule-description">{tf.description}</FieldLabel>
|
||||||
|
<Input {...field} id="rule-description" />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Controller
|
||||||
|
name="startDate"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="rule-start">{t.fields.startDate}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id="rule-start"
|
||||||
|
type="date"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="frequency"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="rule-frequency">{t.fields.frequency}</FieldLabel>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger id="rule-frequency" className="w-full">
|
||||||
|
<SelectValue>{t.frequencies[field.value]}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FREQUENCIES.map((x) => (
|
||||||
|
<SelectItem key={x} value={x}>
|
||||||
|
{t.frequencies[x]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Controller
|
||||||
|
name="interval"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="rule-interval">
|
||||||
|
{t.fields.interval} ({t.units[w.frequency ?? "monthly"]})
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id="rule-interval"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
max={12}
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{w.frequency === "monthly" ? (
|
||||||
|
<Controller
|
||||||
|
name="monthlyMode"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="rule-monthly">{t.fields.monthlyMode}</FieldLabel>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger id="rule-monthly" className="w-full">
|
||||||
|
<SelectValue>{t.monthlyModes[field.value]}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MONTHLY_MODES.map((x) => (
|
||||||
|
<SelectItem key={x} value={x}>
|
||||||
|
{t.monthlyModes[x]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Controller
|
||||||
|
name="endKind"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="rule-end">{t.fields.end}</FieldLabel>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger id="rule-end" className="w-full">
|
||||||
|
<SelectValue>{t.endKinds[field.value]}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{END_KINDS.map((x) => (
|
||||||
|
<SelectItem key={x} value={x}>
|
||||||
|
{t.endKinds[x]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{w.endKind === "count" ? (
|
||||||
|
<Controller
|
||||||
|
name="count"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="rule-count">{t.fields.count}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id="rule-count"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : w.endKind === "date" ? (
|
||||||
|
<Controller
|
||||||
|
name="endDate"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="rule-enddate">{t.fields.endDate}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id="rule-enddate"
|
||||||
|
type="date"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{preview ? (
|
||||||
|
<FieldDescription>
|
||||||
|
{preview.text} · {t.fields.preview}:{" "}
|
||||||
|
{preview.dates.map((d) => formatDate(`${d}T12:00:00+03:00`)).join(", ")}
|
||||||
|
</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
{errors.root ? <FieldError>{errors.root.message}</FieldError> : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { listAccounts } from "@/features/accounts/service";
|
||||||
|
import {
|
||||||
|
listTransactions,
|
||||||
|
updateTransaction,
|
||||||
|
getTransaction,
|
||||||
|
} from "@/features/transactions/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { RecurringRuleModel } from "@/models/recurring-rule";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { recurringRuleFormSchema, type RecurringRuleFormInput } from "@/shared/schemas/recurring";
|
||||||
|
import { transactionFilterSchema } from "@/shared/schemas/transaction";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
confirmOccurrence,
|
||||||
|
createRule,
|
||||||
|
deleteRule,
|
||||||
|
ensureHorizon,
|
||||||
|
getPlannedOverview,
|
||||||
|
postponeOccurrence,
|
||||||
|
skipOccurrence,
|
||||||
|
updateRule,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
const today = todayIstanbul();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
emre = ids.user("emre");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
const acc = (name: string) =>
|
||||||
|
AccountModel.findOne({ householdId: cakir, name })
|
||||||
|
.lean()
|
||||||
|
.then((a) => String(a!._id));
|
||||||
|
const cat = (key: string) =>
|
||||||
|
CategoryModel.findOne({ householdId: cakir, defaultKey: key })
|
||||||
|
.lean()
|
||||||
|
.then((c) => String(c!._id));
|
||||||
|
|
||||||
|
async function form(over: Partial<RecurringRuleFormInput> = {}) {
|
||||||
|
return recurringRuleFormSchema.parse({
|
||||||
|
type: "expense",
|
||||||
|
amount: "1.000,00",
|
||||||
|
accountId: await acc("Ziraat Maaş"),
|
||||||
|
categoryId: await cat("faturalar.elektrik"),
|
||||||
|
payee: "Elektrik",
|
||||||
|
startDate: addDays(today, -40),
|
||||||
|
frequency: "monthly",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const occurrences = (ruleId: string) =>
|
||||||
|
TransactionModel.find({ recurringRuleId: ruleId }).sort({ occurrenceIndex: 1 }).lean();
|
||||||
|
|
||||||
|
async function balance(accountId: string) {
|
||||||
|
const list = await listAccounts(ayse, cakir);
|
||||||
|
return list!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("kural ve planlı kayıtlar", () => {
|
||||||
|
let ruleId = "";
|
||||||
|
|
||||||
|
it("kural 12 ay ileriye planlı kayıt üretir; planlı kayıtlar bakiyeyi etkilemez", async () => {
|
||||||
|
const ziraat = await acc("Ziraat Maaş");
|
||||||
|
const before = await balance(ziraat);
|
||||||
|
const r = await createRule(ayse, cakir, await form({ isEstimate: true }));
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
ruleId = r.ok ? r.data.id : "";
|
||||||
|
const occ = await occurrences(ruleId);
|
||||||
|
// Başlangıç 40 gün önce: 2 geçmiş + 12 ay ileri (13 ya da 14 kayıt, ay uzunluğuna göre).
|
||||||
|
expect(occ.length).toBeGreaterThanOrEqual(13);
|
||||||
|
expect(occ.every((o) => o.status === "planned" && o.isEstimate)).toBe(true);
|
||||||
|
expect(await balance(ziraat)).toBe(before);
|
||||||
|
expect(await ensureHorizon((await RecurringRuleModel.findById(ruleId))!.householdId)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("genel bakış: vadesi geçmiş ve yaklaşan; işlem listesi planlıları varsayılan göstermez", async () => {
|
||||||
|
const o = (await getPlannedOverview(ayse, cakir))!;
|
||||||
|
const mine = o.overdue.filter((x) => x.recurringRuleId === ruleId);
|
||||||
|
expect(mine.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(o.upcoming.every((x) => x.date >= today)).toBe(true);
|
||||||
|
expect(o.rules.find((x) => x.id === ruleId)).toMatchObject({
|
||||||
|
recurrence: expect.stringContaining("Her ay"),
|
||||||
|
remaining: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = await listTransactions(ayse, cakir, transactionFilterSchema.parse({}));
|
||||||
|
expect(list!.items.some((i) => i.status === "planned")).toBe(false);
|
||||||
|
const planned = await listTransactions(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
transactionFilterSchema.parse({ durum: "planned" }),
|
||||||
|
);
|
||||||
|
expect(planned!.total).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onayla (tutar/tarih düzeltilerek) bakiyeye yansır; atla iptal eder", async () => {
|
||||||
|
const ziraat = await acc("Ziraat Maaş");
|
||||||
|
const before = await balance(ziraat);
|
||||||
|
const [first, second] = await occurrences(ruleId);
|
||||||
|
const c = await confirmOccurrence(ayse, cakir, String(first!._id), {
|
||||||
|
amount: 1_234_56,
|
||||||
|
date: today,
|
||||||
|
});
|
||||||
|
expect(c.ok).toBe(true);
|
||||||
|
const posted = (await TransactionModel.findById(first!._id).lean())!;
|
||||||
|
expect(posted).toMatchObject({ status: "posted", amountMinor: 1_234_56, isEstimate: false });
|
||||||
|
expect(await balance(ziraat)).toBe(before - 1_234_56);
|
||||||
|
// İkinci kez onaylanamaz.
|
||||||
|
expect(
|
||||||
|
(await confirmOccurrence(ayse, cakir, String(first!._id), { amount: 1, date: today })).ok,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect((await skipOccurrence(emre, cakir, String(second!._id))).ok).toBe(true);
|
||||||
|
expect((await TransactionModel.findById(second!._id).lean())!.status).toBe("void");
|
||||||
|
expect(await balance(ziraat)).toBe(before - 1_234_56);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ertele: tarih ileri alınır, kayıt seriden ayrılır; geçmişe ertelenemez", async () => {
|
||||||
|
const occ = await occurrences(ruleId);
|
||||||
|
const third = occ[2]!;
|
||||||
|
const current = third.date.toISOString();
|
||||||
|
expect((await postponeOccurrence(ayse, cakir, String(third._id), "2000-01-01")).ok).toBe(false);
|
||||||
|
const target = addDays(today, 200);
|
||||||
|
expect((await postponeOccurrence(ayse, cakir, String(third._id), target)).ok).toBe(true);
|
||||||
|
const after = (await TransactionModel.findById(third._id).lean())!;
|
||||||
|
expect(after.detached).toBe(true);
|
||||||
|
expect(after.date.toISOString()).not.toBe(current);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tümünü güncelle: planlılar yeni tutarla yeniden üretilir; gerçekleşen, atlanan ve ayrılan korunur", async () => {
|
||||||
|
const r = await updateRule(ayse, cakir, ruleId, await form({ amount: "1.500,00" }), "all");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const occ = await occurrences(ruleId);
|
||||||
|
expect(occ[0]).toMatchObject({ status: "posted", amountMinor: 1_234_56 });
|
||||||
|
expect(occ[1]).toMatchObject({ status: "void" });
|
||||||
|
expect(occ[2]).toMatchObject({ detached: true, amountMinor: 1_000_00 });
|
||||||
|
expect(occ.slice(3).every((o) => o.status === "planned" && o.amountMinor === 1_500_00)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// Sıra numaraları benzersiz.
|
||||||
|
expect(new Set(occ.map((o) => o.occurrenceIndex)).size).toBe(occ.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bu ve sonrakiler: seri bölünür", async () => {
|
||||||
|
const occ = await occurrences(ruleId);
|
||||||
|
const k = 5;
|
||||||
|
const from = occ.find((o) => o.occurrenceIndex === k)!;
|
||||||
|
const r = await updateRule(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
ruleId,
|
||||||
|
await form({ amount: "2.000,00", startDate: addDays(today, 100) }),
|
||||||
|
"following",
|
||||||
|
String(from._id),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const newId = r.ok ? r.data.id : "";
|
||||||
|
expect(newId).not.toBe(ruleId);
|
||||||
|
expect((await RecurringRuleModel.findById(ruleId).lean())!.count).toBe(k);
|
||||||
|
const old = await occurrences(ruleId);
|
||||||
|
expect(old.every((o) => o.occurrenceIndex! < k)).toBe(true);
|
||||||
|
const fresh = await occurrences(newId);
|
||||||
|
expect(fresh.length).toBeGreaterThan(0);
|
||||||
|
expect(fresh.every((o) => o.amountMinor === 2_000_00)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tek kaydı düzenlemek (yalnızca bu) kaydı seriden ayırır", async () => {
|
||||||
|
const r = await createRule(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({ startDate: addDays(today, 3), endKind: "count", count: 3 }),
|
||||||
|
);
|
||||||
|
const id = r.ok ? r.data.id : "";
|
||||||
|
const occ = await occurrences(id);
|
||||||
|
expect(occ).toHaveLength(3);
|
||||||
|
const d = (await getTransaction(ayse, cakir, String(occ[1]!._id)))!;
|
||||||
|
expect(d.status).toBe("planned");
|
||||||
|
const u = await updateTransaction(ayse, cakir, d.id, {
|
||||||
|
type: "expense",
|
||||||
|
amount: 777_00,
|
||||||
|
date: d.date,
|
||||||
|
accountId: d.account.id,
|
||||||
|
toAccountId: null,
|
||||||
|
categoryId: d.categoryId,
|
||||||
|
splits: [],
|
||||||
|
payee: d.payeeName,
|
||||||
|
description: "",
|
||||||
|
tags: [],
|
||||||
|
attachmentIds: [],
|
||||||
|
installments: 1,
|
||||||
|
});
|
||||||
|
expect(u.ok).toBe(true);
|
||||||
|
expect(await TransactionModel.findById(d.id).lean()).toMatchObject({
|
||||||
|
detached: true,
|
||||||
|
status: "planned",
|
||||||
|
amountMinor: 777_00,
|
||||||
|
});
|
||||||
|
// Silme: bu ve sonrakiler (index 1'den) → yalnızca ilk kalır, kural 1 tekrarla biter.
|
||||||
|
expect((await deleteRule(ayse, cakir, id, "following", d.id)).ok).toBe(true);
|
||||||
|
expect(await occurrences(id)).toHaveLength(1);
|
||||||
|
expect((await deleteRule(ayse, cakir, id, "all")).ok).toBe(true);
|
||||||
|
expect(await occurrences(id)).toHaveLength(0);
|
||||||
|
expect((await RecurringRuleModel.findById(id).lean())!.deletedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("yetki ve izolasyon", () => {
|
||||||
|
it("izleyici kural oluşturamaz/onaylayamaz; hane dışı göremez", async () => {
|
||||||
|
expect((await createRule(zeynep, cakir, await form())).ok).toBe(false);
|
||||||
|
const o = (await getPlannedOverview(zeynep, cakir))!;
|
||||||
|
const any = o.upcoming[0]!;
|
||||||
|
expect((await confirmOccurrence(zeynep, cakir, any.id, { amount: 1, date: today })).ok).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(await getPlannedOverview(fatma, cakir)).toBeNull();
|
||||||
|
expect((await skipOccurrence(fatma, cakir, any.id)).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gizli hesap kuralı ve kayıtları yalnızca sahibine", async () => {
|
||||||
|
const pluxee = await acc("Ayşe — Pluxee");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await createRule(
|
||||||
|
emre,
|
||||||
|
cakir,
|
||||||
|
await form({ accountId: pluxee, startDate: addDays(today, 1) }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
const r = await createRule(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({ accountId: pluxee, startDate: addDays(today, 1), payee: "Gizli kural" }),
|
||||||
|
);
|
||||||
|
const id = r.ok ? r.data.id : "";
|
||||||
|
const mine = (await getPlannedOverview(ayse, cakir))!;
|
||||||
|
expect(mine.rules.some((x) => x.id === id)).toBe(true);
|
||||||
|
const theirs = (await getPlannedOverview(emre, cakir))!;
|
||||||
|
expect(theirs.rules.some((x) => x.id === id)).toBe(false);
|
||||||
|
expect(theirs.upcoming.some((x) => x.recurringRuleId === id)).toBe(false);
|
||||||
|
expect((await deleteRule(emre, cakir, id, "all")).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("form doğrulaması: bitiş tarihi başlangıçtan önce olamaz; transferde hedef hesap", () => {
|
||||||
|
expect(
|
||||||
|
recurringRuleFormSchema.safeParse({
|
||||||
|
type: "expense",
|
||||||
|
amount: "10",
|
||||||
|
accountId: "a".repeat(24),
|
||||||
|
categoryId: "b".repeat(24),
|
||||||
|
startDate: "2026-05-01",
|
||||||
|
frequency: "monthly",
|
||||||
|
endKind: "date",
|
||||||
|
endDate: "2026-04-01",
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
recurringRuleFormSchema.safeParse({
|
||||||
|
type: "transfer",
|
||||||
|
amount: "10",
|
||||||
|
accountId: "a".repeat(24),
|
||||||
|
startDate: "2026-05-01",
|
||||||
|
frequency: "monthly",
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
import "server-only";
|
||||||
|
import mongoose, { Types, type ClientSession } from "mongoose";
|
||||||
|
import {
|
||||||
|
baseFilter,
|
||||||
|
buildDoc,
|
||||||
|
loadContext,
|
||||||
|
loadOwnTransaction,
|
||||||
|
toItem,
|
||||||
|
touchPayee,
|
||||||
|
userNames,
|
||||||
|
type Ctx,
|
||||||
|
type LeanTx,
|
||||||
|
type TransactionListItem,
|
||||||
|
} from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { writeAudit } from "@/lib/audit";
|
||||||
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
import { RecurringRuleModel } from "@/models/recurring-rule";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
describeRecurrence,
|
||||||
|
horizonDate,
|
||||||
|
listOccurrences,
|
||||||
|
type Recurrence,
|
||||||
|
} from "@/shared/recurrence";
|
||||||
|
import type { ConfirmOccurrenceForm, RecurringRuleForm } from "@/shared/schemas/recurring";
|
||||||
|
import type { TransactionForm } from "@/shared/schemas/transaction";
|
||||||
|
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||||
|
import { foldTr } from "@/shared/search";
|
||||||
|
import type { TransactionType } from "@/shared/transactions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Planlı ve düzenli işlemler (Bölüm 6.7). Okuma: viewer; yazma: member.
|
||||||
|
* Planlı kayıtlar bakiyeyi etkilemez; "Onayla" ile posted olur. Gizli hesap kuralları ve
|
||||||
|
* kayıtları işlemlerle aynı kuralla yalnızca hesap sahibine görünür.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.recurring;
|
||||||
|
/** "Yaklaşan" listesinin kapsadığı gün sayısı. */
|
||||||
|
export const UPCOMING_DAYS = 60;
|
||||||
|
|
||||||
|
type RuleDoc = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
householdId: Types.ObjectId;
|
||||||
|
type: TransactionType;
|
||||||
|
amountMinor: number;
|
||||||
|
isEstimate: boolean;
|
||||||
|
accountId: Types.ObjectId;
|
||||||
|
toAccountId?: Types.ObjectId | null;
|
||||||
|
categoryId?: Types.ObjectId | null;
|
||||||
|
payeeName: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
frequency: Recurrence["frequency"];
|
||||||
|
interval: number;
|
||||||
|
monthlyMode: Recurrence["monthlyMode"];
|
||||||
|
startDate: string;
|
||||||
|
endDate?: string | null;
|
||||||
|
count?: number | null;
|
||||||
|
generatedUntil?: string | null;
|
||||||
|
createdBy: Types.ObjectId;
|
||||||
|
updatedBy: Types.ObjectId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toRecurrence = (r: RuleDoc): Recurrence => ({
|
||||||
|
frequency: r.frequency,
|
||||||
|
interval: r.interval,
|
||||||
|
monthlyMode: r.monthlyMode,
|
||||||
|
startDate: r.startDate,
|
||||||
|
endDate: r.endDate ?? null,
|
||||||
|
count: r.count ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function asTransactionForm(input: RecurringRuleForm): TransactionForm {
|
||||||
|
return {
|
||||||
|
type: input.type,
|
||||||
|
amount: input.amount,
|
||||||
|
date: input.startDate,
|
||||||
|
accountId: input.accountId,
|
||||||
|
toAccountId: input.toAccountId,
|
||||||
|
categoryId: input.type === "transfer" ? null : input.categoryId,
|
||||||
|
splits: [],
|
||||||
|
payee: input.payee,
|
||||||
|
description: input.description,
|
||||||
|
tags: input.tags,
|
||||||
|
attachmentIds: [],
|
||||||
|
installments: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Form → kural alanları (doğrulanmış işlem şablonu + tekrar). */
|
||||||
|
function ruleFields(ctx: Ctx, input: RecurringRuleForm, existing?: RuleDoc) {
|
||||||
|
const built = buildDoc(ctx, asTransactionForm(input), existing);
|
||||||
|
if (!built.ok) return built;
|
||||||
|
const d = built.data as {
|
||||||
|
type: TransactionType;
|
||||||
|
accountId: Types.ObjectId;
|
||||||
|
toAccountId: Types.ObjectId | null;
|
||||||
|
categoryId: Types.ObjectId | null;
|
||||||
|
amountMinor: number;
|
||||||
|
payeeName: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
return ok({
|
||||||
|
type: d.type,
|
||||||
|
amountMinor: d.amountMinor,
|
||||||
|
isEstimate: input.isEstimate,
|
||||||
|
accountId: d.accountId,
|
||||||
|
toAccountId: d.toAccountId,
|
||||||
|
categoryId: d.categoryId,
|
||||||
|
payeeName: d.payeeName,
|
||||||
|
description: d.description,
|
||||||
|
tags: d.tags,
|
||||||
|
frequency: input.frequency,
|
||||||
|
interval: input.interval,
|
||||||
|
monthlyMode: input.monthlyMode,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
count: input.count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kuralın ufka kadar eksik planlı kayıtlarını üretir (mevcut sıra numaraları atlanır). */
|
||||||
|
async function generate(rule: RuleDoc, today: string, session?: ClientSession): Promise<number> {
|
||||||
|
const until = horizonDate(today);
|
||||||
|
const occurrences = listOccurrences(toRecurrence(rule), until);
|
||||||
|
const existing = new Set(
|
||||||
|
(
|
||||||
|
await TransactionModel.find(
|
||||||
|
{ recurringRuleId: rule._id },
|
||||||
|
{ occurrenceIndex: 1 },
|
||||||
|
{ session },
|
||||||
|
).lean()
|
||||||
|
).map((x) => x.occurrenceIndex),
|
||||||
|
);
|
||||||
|
const missing = occurrences.filter((o) => !existing.has(o.index));
|
||||||
|
if (missing.length) {
|
||||||
|
const docs = missing.map((o) => ({
|
||||||
|
householdId: rule.householdId,
|
||||||
|
type: rule.type,
|
||||||
|
status: "planned",
|
||||||
|
date: istanbulDayStart(o.date)!,
|
||||||
|
accountId: rule.accountId,
|
||||||
|
toAccountId: rule.toAccountId ?? null,
|
||||||
|
amountMinor: rule.amountMinor,
|
||||||
|
categoryId: rule.categoryId ?? null,
|
||||||
|
splits: [],
|
||||||
|
effectiveCategoryIds: rule.categoryId ? [rule.categoryId] : [],
|
||||||
|
payeeName: rule.payeeName,
|
||||||
|
description: rule.description,
|
||||||
|
searchText: foldTr(`${rule.payeeName} ${rule.description}`),
|
||||||
|
tags: rule.tags,
|
||||||
|
isEstimate: rule.isEstimate,
|
||||||
|
recurringRuleId: rule._id,
|
||||||
|
occurrenceIndex: o.index,
|
||||||
|
createdBy: rule.createdBy,
|
||||||
|
updatedBy: rule.updatedBy,
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await TransactionModel.insertMany(docs, { session, ordered: false });
|
||||||
|
} catch (error) {
|
||||||
|
// Eşzamanlı üretim: benzersiz index kopyayı reddeder (transaction dışında).
|
||||||
|
if (session || (error as { code?: number }).code !== 11000) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await RecurringRuleModel.updateOne(
|
||||||
|
{ _id: rule._id },
|
||||||
|
{ $set: { generatedUntil: until } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
return missing.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hanedeki kuralların ufkunu ilerletir (okuma sırasında; zamanlanmış görev gerekmez). */
|
||||||
|
export async function ensureHorizon(householdId: Types.ObjectId, today = todayIstanbul()) {
|
||||||
|
const until = horizonDate(today);
|
||||||
|
const rules = await RecurringRuleModel.find({
|
||||||
|
householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
$or: [{ generatedUntil: null }, { generatedUntil: { $lt: until } }],
|
||||||
|
}).lean<RuleDoc[]>();
|
||||||
|
let created = 0;
|
||||||
|
for (const rule of rules) created += await generate(rule, today);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleVisible(ctx: Ctx, rule: Pick<RuleDoc, "accountId" | "toAccountId">) {
|
||||||
|
return [rule.accountId, rule.toAccountId]
|
||||||
|
.filter(Boolean)
|
||||||
|
.every((id) => !ctx.accountMap.get(String(id))?.hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRule(ctx: Ctx, ruleId: string, session?: ClientSession) {
|
||||||
|
const id = toObjectId(ruleId);
|
||||||
|
if (!id) return null;
|
||||||
|
const rule = await RecurringRuleModel.findOne(
|
||||||
|
{ _id: id, householdId: ctx.m.householdId, deletedAt: null },
|
||||||
|
null,
|
||||||
|
{ session },
|
||||||
|
).lean<RuleDoc>();
|
||||||
|
return rule && ruleVisible(ctx, rule) ? rule : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Bu ve sonrakiler" için başlangıç tekrarı (kurala ait olmalı). */
|
||||||
|
async function occurrenceIndexOf(rule: RuleDoc, transactionId: string | undefined) {
|
||||||
|
const id = transactionId ? toObjectId(transactionId) : null;
|
||||||
|
if (!id) return null;
|
||||||
|
const tx = await TransactionModel.findOne(
|
||||||
|
{ _id: id, recurringRuleId: rule._id, householdId: rule.householdId },
|
||||||
|
{ occurrenceIndex: 1 },
|
||||||
|
).lean();
|
||||||
|
return tx?.occurrenceIndex ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kural yazma ---
|
||||||
|
|
||||||
|
export async function createRule(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: RecurringRuleForm,
|
||||||
|
): Promise<ActionResult<{ id: string; generated: number }>> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const fields = ruleFields(ctx, input);
|
||||||
|
if (!fields.ok) return fields;
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
let out = { id: "", generated: 0 };
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
const [rule] = await RecurringRuleModel.create(
|
||||||
|
[{ ...fields.data, householdId: ctx.m.householdId, createdBy: actor, updatedBy: actor }],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
const generated = await generate(rule!.toObject() as RuleDoc, todayIstanbul(), session);
|
||||||
|
out = { id: String(rule!._id), generated };
|
||||||
|
});
|
||||||
|
return ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seri düzenleme. "all": kural güncellenir, seriden ayrılmamış planlı kayıtlar yeniden üretilir
|
||||||
|
* (gerçekleşmiş/atlanmış kayıtlar korunur). "following": seri bu tekrardan bölünür — eski kural
|
||||||
|
* önceki tekrarda biter, yeni kural bu tekrardan itibaren yeni değerlerle başlar.
|
||||||
|
*/
|
||||||
|
export async function updateRule(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
ruleId: string,
|
||||||
|
input: RecurringRuleForm,
|
||||||
|
scope: "all" | "following",
|
||||||
|
fromTransactionId?: string,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const rule = await loadRule(ctx, ruleId);
|
||||||
|
if (!rule) return fail(tr.common.notFound);
|
||||||
|
const fields = ruleFields(ctx, input, rule);
|
||||||
|
if (!fields.ok) return fields;
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const from = scope === "following" ? await occurrenceIndexOf(rule, fromTransactionId) : 0;
|
||||||
|
if (from === null) return fail(tr.common.notFound);
|
||||||
|
const today = todayIstanbul();
|
||||||
|
let id = String(rule._id);
|
||||||
|
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
if (from === 0) {
|
||||||
|
await RecurringRuleModel.updateOne(
|
||||||
|
{ _id: rule._id },
|
||||||
|
{ $set: { ...fields.data, generatedUntil: null, updatedBy: actor } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
await TransactionModel.deleteMany(
|
||||||
|
{
|
||||||
|
recurringRuleId: rule._id,
|
||||||
|
status: "planned",
|
||||||
|
...(scope === "all" ? { detached: false } : {}),
|
||||||
|
},
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
const fresh = await RecurringRuleModel.findById(rule._id, null, { session }).lean<RuleDoc>();
|
||||||
|
await generate(fresh!, today, session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Böl: eski kural `from` tekrarından önce biter.
|
||||||
|
await RecurringRuleModel.updateOne(
|
||||||
|
{ _id: rule._id },
|
||||||
|
{ $set: { count: from, updatedBy: actor } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
await TransactionModel.deleteMany(
|
||||||
|
{ recurringRuleId: rule._id, status: "planned", occurrenceIndex: { $gte: from } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
const [next] = await RecurringRuleModel.create(
|
||||||
|
[{ ...fields.data, householdId: ctx.m.householdId, createdBy: actor, updatedBy: actor }],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
id = String(next!._id);
|
||||||
|
await generate(next!.toObject() as RuleDoc, today, session);
|
||||||
|
});
|
||||||
|
return ok({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRule(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
ruleId: string,
|
||||||
|
scope: "all" | "following",
|
||||||
|
fromTransactionId?: string,
|
||||||
|
): Promise<ActionResult<{ removed: number }>> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const rule = await loadRule(ctx, ruleId);
|
||||||
|
if (!rule) return fail(tr.common.notFound);
|
||||||
|
const from = scope === "following" ? await occurrenceIndexOf(rule, fromTransactionId) : 0;
|
||||||
|
if (from === null) return fail(tr.common.notFound);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
let removed = 0;
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
const res = await TransactionModel.deleteMany(
|
||||||
|
{ recurringRuleId: rule._id, status: "planned", occurrenceIndex: { $gte: from } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
removed = res.deletedCount;
|
||||||
|
await RecurringRuleModel.updateOne(
|
||||||
|
{ _id: rule._id },
|
||||||
|
from === 0
|
||||||
|
? { $set: { deletedAt: new Date(), updatedBy: actor } }
|
||||||
|
: { $set: { count: from, updatedBy: actor } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
await writeAudit(
|
||||||
|
{
|
||||||
|
action: "recurring.delete",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
targetType: "recurringRule",
|
||||||
|
targetId: String(rule._id),
|
||||||
|
diff: { scope, fromIndex: from, removedPlanned: removed },
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return ok({ removed });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Planlı kayıt işlemleri ---
|
||||||
|
|
||||||
|
async function loadPlanned(ctx: Ctx, transactionId: string) {
|
||||||
|
const tx = await loadOwnTransaction(ctx, transactionId);
|
||||||
|
return tx && tx.status === "planned" ? tx : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Onayla: planlı → gerçekleşti; tutar ve tarih düzeltilebilir. */
|
||||||
|
export async function confirmOccurrence(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
transactionId: string,
|
||||||
|
input: ConfirmOccurrenceForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const tx = await loadPlanned(ctx, transactionId);
|
||||||
|
if (!tx) return fail(tr.common.notFound);
|
||||||
|
const date = istanbulDayStart(input.date);
|
||||||
|
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
|
||||||
|
if (tx.splits?.length && input.amount !== tx.amountMinor) return fail(t.errors.splitAmount);
|
||||||
|
// Arşivlenmiş/kapalı hesap veya kategoriye bağlı kayıt onaylanamaz.
|
||||||
|
for (const id of [tx.accountId, tx.toAccountId].filter(Boolean)) {
|
||||||
|
const a = ctx.accountMap.get(String(id));
|
||||||
|
if (!a || a.archived) return fail(tr.transactions.validation.accountInvalid);
|
||||||
|
}
|
||||||
|
const payeeId = await touchPayee(
|
||||||
|
ctx.m.householdId,
|
||||||
|
tx.payeeName ?? "",
|
||||||
|
tx.type,
|
||||||
|
tx.accountId,
|
||||||
|
tx.categoryId ?? null,
|
||||||
|
);
|
||||||
|
await TransactionModel.updateOne(
|
||||||
|
{ _id: tx._id, householdId: ctx.m.householdId, status: "planned" },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
status: "posted",
|
||||||
|
amountMinor: input.amount,
|
||||||
|
date,
|
||||||
|
isEstimate: false,
|
||||||
|
payeeId,
|
||||||
|
updatedBy: new Types.ObjectId(actorId),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Atla: bu tekrar gerçekleşmeyecek (iptal); seri devam eder. */
|
||||||
|
export async function skipOccurrence(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
transactionId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const tx = await loadPlanned(ctx, transactionId);
|
||||||
|
if (!tx) return fail(tr.common.notFound);
|
||||||
|
await TransactionModel.updateOne(
|
||||||
|
{ _id: tx._id, householdId: ctx.m.householdId, status: "planned" },
|
||||||
|
{ $set: { status: "void", updatedBy: new Types.ObjectId(actorId) } },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ertele: tarihi ileri al (seri tarihleri değişmez; kayıt seriden ayrılır). */
|
||||||
|
export async function postponeOccurrence(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
transactionId: string,
|
||||||
|
ymd: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const tx = await loadPlanned(ctx, transactionId);
|
||||||
|
if (!tx) return fail(tr.common.notFound);
|
||||||
|
const date = istanbulDayStart(ymd);
|
||||||
|
if (!date || ymd <= toIstanbulYmd(tx.date))
|
||||||
|
return fail(t.errors.postponeDate, { date: t.errors.postponeDate });
|
||||||
|
await TransactionModel.updateOne(
|
||||||
|
{ _id: tx._id, householdId: ctx.m.householdId, status: "planned" },
|
||||||
|
{ $set: { date, detached: true, updatedBy: new Types.ObjectId(actorId) } },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Okuma ---
|
||||||
|
|
||||||
|
export type PlannedItem = TransactionListItem & {
|
||||||
|
recurringRuleId: string | null;
|
||||||
|
isEstimate: boolean;
|
||||||
|
/** "3/12" (tekrar sayılı seride). */
|
||||||
|
seriesPosition: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuleView = {
|
||||||
|
id: string;
|
||||||
|
type: TransactionType;
|
||||||
|
title: string;
|
||||||
|
amountMinor: number;
|
||||||
|
isEstimate: boolean;
|
||||||
|
recurrence: string;
|
||||||
|
account: string;
|
||||||
|
toAccount: string | null;
|
||||||
|
category: string | null;
|
||||||
|
nextDate: string | null;
|
||||||
|
/** Kalan planlı kayıt (tekrar sayılı seride kalan tekrar). */
|
||||||
|
remaining: number | null;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string | null;
|
||||||
|
count: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlannedOverview = {
|
||||||
|
overdue: PlannedItem[];
|
||||||
|
upcoming: PlannedItem[];
|
||||||
|
rules: RuleView[];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function plannedItems(ctx: Ctx, filter: Record<string, unknown>, limit = 500) {
|
||||||
|
const rows = (await TransactionModel.find({ ...baseFilter(ctx), status: "planned", ...filter })
|
||||||
|
.sort({ date: 1, createdAt: 1 })
|
||||||
|
.limit(limit)
|
||||||
|
.lean()) as unknown as LeanTx[];
|
||||||
|
const ruleIds = [
|
||||||
|
...new Set(
|
||||||
|
rows
|
||||||
|
.map((r) => r.recurringRuleId)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(String),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const rules = await RecurringRuleModel.find({ _id: { $in: ruleIds } }, { count: 1 }).lean();
|
||||||
|
const counts = new Map(rules.map((r) => [String(r._id), r.count ?? null]));
|
||||||
|
const names = await userNames(rows.map((r) => r.createdBy as Types.ObjectId));
|
||||||
|
return rows.map((r): PlannedItem => {
|
||||||
|
const ruleId = r.recurringRuleId ? String(r.recurringRuleId) : null;
|
||||||
|
const count = ruleId ? counts.get(ruleId) : null;
|
||||||
|
return {
|
||||||
|
...toItem(ctx, r, names),
|
||||||
|
recurringRuleId: ruleId,
|
||||||
|
isEstimate: Boolean(r.isEstimate),
|
||||||
|
seriesPosition:
|
||||||
|
count && r.occurrenceIndex !== null && r.occurrenceIndex !== undefined
|
||||||
|
? `${r.occurrenceIndex + 1}/${count}`
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlannedOverview(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<PlannedOverview | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
await ensureHorizon(ctx.m.householdId);
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const todayStart = istanbulDayStart(today)!;
|
||||||
|
const end = istanbulDayStart(addDays(today, UPCOMING_DAYS + 1))!;
|
||||||
|
const [overdue, upcoming, rules] = await Promise.all([
|
||||||
|
plannedItems(ctx, { date: { $lt: todayStart } }),
|
||||||
|
plannedItems(ctx, { date: { $gte: todayStart, $lt: end } }),
|
||||||
|
listRules(ctx),
|
||||||
|
]);
|
||||||
|
return { overdue, upcoming, rules };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRules(ctx: Ctx): Promise<RuleView[]> {
|
||||||
|
const rules = (
|
||||||
|
await RecurringRuleModel.find({ householdId: ctx.m.householdId, deletedAt: null })
|
||||||
|
.sort({ createdAt: 1 })
|
||||||
|
.lean<RuleDoc[]>()
|
||||||
|
).filter((r) => ruleVisible(ctx, r));
|
||||||
|
const next = await TransactionModel.aggregate<{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
date: Date | null;
|
||||||
|
planned: number;
|
||||||
|
done: number;
|
||||||
|
}>([
|
||||||
|
{ $match: { recurringRuleId: { $in: rules.map((r) => r._id) }, deletedAt: null } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: "$recurringRuleId",
|
||||||
|
date: { $min: { $cond: [{ $eq: ["$status", "planned"] }, "$date", null] } },
|
||||||
|
planned: { $sum: { $cond: [{ $eq: ["$status", "planned"] }, 1, 0] } },
|
||||||
|
done: { $sum: { $cond: [{ $eq: ["$status", "planned"] }, 0, 1] } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const nextMap = new Map(next.map((x) => [String(x._id), x]));
|
||||||
|
const name = (id?: Types.ObjectId | null) =>
|
||||||
|
id ? (ctx.accountMap.get(String(id))?.name ?? "—") : null;
|
||||||
|
return rules
|
||||||
|
.map((r): RuleView => {
|
||||||
|
const n = nextMap.get(String(r._id));
|
||||||
|
const category = r.categoryId ? ctx.categoryMap.get(String(r.categoryId)) : undefined;
|
||||||
|
return {
|
||||||
|
id: String(r._id),
|
||||||
|
type: r.type,
|
||||||
|
title: r.payeeName || r.description || category?.name || tr.transactions.types[r.type],
|
||||||
|
amountMinor: r.amountMinor,
|
||||||
|
isEstimate: r.isEstimate,
|
||||||
|
recurrence: describeRecurrence(r),
|
||||||
|
account: name(r.accountId)!,
|
||||||
|
toAccount: name(r.toAccountId),
|
||||||
|
category: category?.path ?? null,
|
||||||
|
nextDate: n?.date ? toIstanbulYmd(n.date) : null,
|
||||||
|
// Tekrar sayılı seride: toplam − gerçekleşen/atlanan; bitiş tarihlide: kalan planlı kayıt.
|
||||||
|
remaining: r.count
|
||||||
|
? Math.max(0, r.count - (n?.done ?? 0))
|
||||||
|
: r.endDate
|
||||||
|
? (n?.planned ?? 0)
|
||||||
|
: null,
|
||||||
|
startDate: r.startDate,
|
||||||
|
endDate: r.endDate ?? null,
|
||||||
|
count: r.count ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((r) => r.nextDate !== null || r.remaining === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Düzenleme formu için kural değerleri. */
|
||||||
|
export async function getRuleForm(userId: string, householdId: string, ruleId: string) {
|
||||||
|
const ctx = await loadContext(userId, householdId, "member");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const r = await loadRule(ctx, ruleId);
|
||||||
|
if (!r) return null;
|
||||||
|
return {
|
||||||
|
id: String(r._id),
|
||||||
|
type: r.type,
|
||||||
|
amountMinor: r.amountMinor,
|
||||||
|
isEstimate: r.isEstimate,
|
||||||
|
accountId: String(r.accountId),
|
||||||
|
toAccountId: r.toAccountId ? String(r.toAccountId) : null,
|
||||||
|
categoryId: r.categoryId ? String(r.categoryId) : null,
|
||||||
|
payee: r.payeeName,
|
||||||
|
description: r.description,
|
||||||
|
tags: r.tags,
|
||||||
|
startDate: r.startDate,
|
||||||
|
frequency: r.frequency,
|
||||||
|
interval: r.interval,
|
||||||
|
monthlyMode: r.monthlyMode,
|
||||||
|
endDate: r.endDate ?? null,
|
||||||
|
count: r.count ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type RuleFormValues = NonNullable<Awaited<ReturnType<typeof getRuleForm>>>;
|
||||||
|
|
||||||
|
/** Dashboard uyarısı: vadesi geçmiş onaylanmamış planlı kayıt sayısı. */
|
||||||
|
export async function countOverdue(userId: string, householdId: string): Promise<number> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return 0;
|
||||||
|
return TransactionModel.countDocuments({
|
||||||
|
...baseFilter(ctx),
|
||||||
|
status: "planned",
|
||||||
|
date: { $lt: istanbulDayStart(todayIstanbul())! },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"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 { reconcileSchema } from "@/shared/schemas/reconciliation";
|
||||||
|
import { transactionFormSchema } from "@/shared/schemas/transaction";
|
||||||
|
import {
|
||||||
|
cancelRemainingInstallments,
|
||||||
|
createTransaction,
|
||||||
|
getTransaction,
|
||||||
|
getTransactionFormOptions,
|
||||||
|
searchPayees,
|
||||||
|
setTransactionDeleted,
|
||||||
|
updateTransaction,
|
||||||
|
type PayeeSuggestion,
|
||||||
|
type TransactionDetail,
|
||||||
|
type TransactionFormOptions,
|
||||||
|
} from "./service";
|
||||||
|
import { previewReconciliation, reconcileAccount, type ReconcilePreview } from "./statement";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Taksitli alışverişin kalan (gerçekleşmemiş) taksitlerini iptal eder. */
|
||||||
|
export async function cancelRemainingInstallmentsAction(
|
||||||
|
transactionId: unknown,
|
||||||
|
): Promise<ActionResult<{ cancelled: number }>> {
|
||||||
|
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 result = await cancelRemainingInstallments(ctx.user.id, ctx.household.id, id.data);
|
||||||
|
if (result.ok) revalidate();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutabakat önizlemesi: ekstre bakiyesi ile uygulama bakiyesi arasındaki fark. */
|
||||||
|
export async function previewReconciliationAction(
|
||||||
|
accountId: unknown,
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ActionResult<ReconcilePreview>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const id = idSchema.safeParse(accountId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
const parsed = reconcileSchema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||||
|
return previewReconciliation(ctx.user.id, ctx.household.id, id.data, parsed.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconcileAccountAction(
|
||||||
|
accountId: unknown,
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ActionResult<{ adjustmentId: string | null; markedCount: number }>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const id = idSchema.safeParse(accountId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
const parsed = reconcileSchema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||||
|
const result = await reconcileAccount(ctx.user.id, ctx.household.id, id.data, parsed.data);
|
||||||
|
if (result.ok) revalidate();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { createAccount, listAccounts } from "@/features/accounts/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CardStatementModel } from "@/models/card-statement";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { cardPeriods, minimumPayment } from "@/shared/card-periods";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { transactionFormSchema, type TransactionForm } from "@/shared/schemas/transaction";
|
||||||
|
import { transactionEffects } from "@/shared/transactions";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { getCardSummary, getOverdraftSummary } from "./card";
|
||||||
|
import {
|
||||||
|
cancelRemainingInstallments,
|
||||||
|
createTransaction,
|
||||||
|
listTransactions,
|
||||||
|
postDueInstallments,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let fatma = "";
|
||||||
|
const today = todayIstanbul();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
const acc = (name: string) =>
|
||||||
|
AccountModel.findOne({ householdId: cakir, name })
|
||||||
|
.lean()
|
||||||
|
.then((a) => String(a!._id));
|
||||||
|
const cat = (key: string) =>
|
||||||
|
CategoryModel.findOne({ householdId: cakir, defaultKey: key })
|
||||||
|
.lean()
|
||||||
|
.then((c) => String(c!._id));
|
||||||
|
|
||||||
|
async function form(over: Partial<TransactionForm>): Promise<TransactionForm> {
|
||||||
|
return {
|
||||||
|
type: "expense",
|
||||||
|
amount: 100_00,
|
||||||
|
date: today,
|
||||||
|
accountId: await acc("Garanti Bonus"),
|
||||||
|
toAccountId: null,
|
||||||
|
categoryId: await cat("giyim"),
|
||||||
|
splits: [],
|
||||||
|
payee: "",
|
||||||
|
description: "",
|
||||||
|
tags: [],
|
||||||
|
installments: 1,
|
||||||
|
attachmentIds: [],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bağımsız hesap: açılış + belirli andan önceki gerçekleşmiş işlemler. */
|
||||||
|
async function balanceAt(accountId: string, before?: Date) {
|
||||||
|
const account = (await AccountModel.findById(accountId).lean())!;
|
||||||
|
const txs = await TransactionModel.find({
|
||||||
|
householdId: cakir,
|
||||||
|
deletedAt: null,
|
||||||
|
...(before ? { date: { $lt: before } } : {}),
|
||||||
|
}).lean();
|
||||||
|
let b = account.openingBalanceMinor;
|
||||||
|
for (const t of txs) {
|
||||||
|
for (const e of transactionEffects({
|
||||||
|
type: t.type,
|
||||||
|
status: t.status,
|
||||||
|
amountMinor: t.amountMinor,
|
||||||
|
accountId: String(t.accountId),
|
||||||
|
toAccountId: t.toAccountId ? String(t.toAccountId) : null,
|
||||||
|
}))
|
||||||
|
if (e.accountId === accountId) b += e.deltaMinor;
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("kart özeti", () => {
|
||||||
|
it("anlık borç, ekstre borcu (kesim günü dahil) ve asgari ödeme işlemlerle tutarlı", async () => {
|
||||||
|
const card = await acc("Garanti Bonus");
|
||||||
|
const s = (await getCardSummary(ayse, cakir, card))!;
|
||||||
|
const { statement, current } = cardPeriods(today, 29);
|
||||||
|
expect(s.statement.period).toEqual(statement);
|
||||||
|
expect(s.current.start).toBe(current.start);
|
||||||
|
expect(s.debtMinor).toBe(Math.max(0, -(await balanceAt(card))));
|
||||||
|
const endEx = new Date(istanbulDayStart(statement.end)!.getTime() + 24 * 3600 * 1000);
|
||||||
|
expect(s.statement.debtMinor).toBe(Math.max(0, -(await balanceAt(card, endEx))));
|
||||||
|
// Limit 60.000 TL ≥ 50.000 → %40.
|
||||||
|
expect(s.minimumRateBp).toBe(4000);
|
||||||
|
expect(s.statement.minimumMinor).toBe(minimumPayment(s.statement.debtMinor, 4000));
|
||||||
|
expect(s.history).toHaveLength(6);
|
||||||
|
expect(s.paymentAccount?.name).toBe("Ziraat Maaş");
|
||||||
|
expect(await CardStatementModel.countDocuments({ accountId: card })).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("karta ödeme ekstre borcunu düşürür", async () => {
|
||||||
|
const card = await acc("Garanti Bonus");
|
||||||
|
const before = (await getCardSummary(ayse, cakir, card))!;
|
||||||
|
const r = await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({
|
||||||
|
type: "transfer",
|
||||||
|
amount: 1_000_00,
|
||||||
|
accountId: await acc("Ziraat Maaş"),
|
||||||
|
toAccountId: card,
|
||||||
|
categoryId: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const after = (await getCardSummary(ayse, cakir, card))!;
|
||||||
|
expect(after.statement.paidMinor).toBe(before.statement.paidMinor + 1_000_00);
|
||||||
|
expect(after.debtMinor).toBe(before.debtMinor - 1_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yetki: hane dışı ve kart olmayan hesap", async () => {
|
||||||
|
expect(await getCardSummary(fatma, cakir, await acc("Garanti Bonus"))).toBeNull();
|
||||||
|
expect(await getCardSummary(ayse, cakir, await acc("Cüzdan"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("taksitli harcama", () => {
|
||||||
|
let first = "";
|
||||||
|
|
||||||
|
it("her taksit ayrı işlem; vadesi gelenler gerçekleşir, gelecekler planlı ve limitten düşer", async () => {
|
||||||
|
const card = await acc("Garanti Bonus");
|
||||||
|
const balanceBefore = await balanceAt(card);
|
||||||
|
const summaryBefore = (await getCardSummary(ayse, cakir, card))!;
|
||||||
|
const date = addDays(today, -65);
|
||||||
|
const r = await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({ amount: 9_000_00, installments: 9, payee: "Hepsiburada", date }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
first = r.ok ? r.data.id : "";
|
||||||
|
const plan = (await TransactionModel.findById(first).lean())!.installment!.planId;
|
||||||
|
const all = await TransactionModel.find({ "installment.planId": plan })
|
||||||
|
.sort({ "installment.index": 1 })
|
||||||
|
.lean();
|
||||||
|
expect(all).toHaveLength(9);
|
||||||
|
expect(all.map((t) => t.installment!.index)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||||
|
expect(all.reduce((a, t) => a + t.amountMinor, 0)).toBe(9_000_00);
|
||||||
|
const posted = all.filter((t) => t.status === "posted");
|
||||||
|
expect(posted.length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(all.filter((t) => t.status === "planned").every((t) => t.date > new Date())).toBe(true);
|
||||||
|
expect(await balanceAt(card)).toBe(
|
||||||
|
balanceBefore - posted.reduce((a, t) => a + t.amountMinor, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const s = (await getCardSummary(ayse, cakir, card))!;
|
||||||
|
const pending = all
|
||||||
|
.filter((t) => t.status === "planned")
|
||||||
|
.reduce((a, t) => a + t.amountMinor, 0);
|
||||||
|
expect(s.pendingInstallmentsMinor).toBe(summaryBefore.pendingInstallmentsMinor + pending);
|
||||||
|
expect(s.availableMinor).toBe(s.limitMinor - s.debtMinor - s.pendingInstallmentsMinor);
|
||||||
|
expect(s.upcomingInstallments.reduce((a, x) => a + x.totalMinor, 0)).toBe(
|
||||||
|
s.pendingInstallmentsMinor,
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = await listTransactions(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
(await import("@/shared/schemas/transaction")).transactionFilterSchema.parse({
|
||||||
|
q: "Hepsiburada",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(list!.items[0]!.installment).toMatchObject({ count: 9 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("vadesi gelen taksit kendi döneminde otomatik gerçekleşir", async () => {
|
||||||
|
const plan = (await TransactionModel.findById(first).lean())!.installment!.planId;
|
||||||
|
const plannedBefore = await TransactionModel.countDocuments({
|
||||||
|
"installment.planId": plan,
|
||||||
|
status: "planned",
|
||||||
|
});
|
||||||
|
await postDueInstallments(
|
||||||
|
(await AccountModel.findById(await acc("Garanti Bonus")).lean())!.householdId,
|
||||||
|
addDays(today, 40),
|
||||||
|
);
|
||||||
|
const plannedAfter = await TransactionModel.countDocuments({
|
||||||
|
"installment.planId": plan,
|
||||||
|
status: "planned",
|
||||||
|
});
|
||||||
|
expect(plannedAfter).toBe(plannedBefore - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kalan taksitler iptal edilebilir; gerçekleşenler korunur", async () => {
|
||||||
|
const plan = (await TransactionModel.findById(first).lean())!.installment!.planId;
|
||||||
|
const posted = await TransactionModel.countDocuments({
|
||||||
|
"installment.planId": plan,
|
||||||
|
status: "posted",
|
||||||
|
});
|
||||||
|
const r = await cancelRemainingInstallments(ayse, cakir, first);
|
||||||
|
expect(r).toMatchObject({ ok: true });
|
||||||
|
expect(
|
||||||
|
await TransactionModel.countDocuments({
|
||||||
|
"installment.planId": plan,
|
||||||
|
status: "planned",
|
||||||
|
deletedAt: null,
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
await TransactionModel.countDocuments({
|
||||||
|
"installment.planId": plan,
|
||||||
|
status: "posted",
|
||||||
|
deletedAt: null,
|
||||||
|
}),
|
||||||
|
).toBe(posted);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("taksit yalnızca kredi kartı giderinde", async () => {
|
||||||
|
const onBank = await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({ installments: 3, accountId: await acc("Ziraat Maaş") }),
|
||||||
|
);
|
||||||
|
expect(onBank.ok).toBe(false);
|
||||||
|
const base = {
|
||||||
|
type: "income",
|
||||||
|
amount: "100",
|
||||||
|
date: today,
|
||||||
|
accountId: "a".repeat(24),
|
||||||
|
categoryId: "b".repeat(24),
|
||||||
|
installments: "3",
|
||||||
|
};
|
||||||
|
expect(transactionFormSchema.safeParse(base).success).toBe(false);
|
||||||
|
expect(transactionFormSchema.safeParse({ ...base, type: "expense" }).success).toBe(true);
|
||||||
|
expect(
|
||||||
|
transactionFormSchema.safeParse({ ...base, type: "expense", installments: "37" }).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
transactionFormSchema.parse({ ...base, type: "expense", installments: "" }).installments,
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("KMH özeti", () => {
|
||||||
|
it("dönem içi eksi bakiyeden faiz tahmini; kullanılabilir KMH", async () => {
|
||||||
|
const created = await createAccount(ayse, cakir, {
|
||||||
|
name: "KMH Test",
|
||||||
|
type: "bank",
|
||||||
|
institution: "",
|
||||||
|
mealCardProvider: null,
|
||||||
|
openingBalance: 0,
|
||||||
|
openingDate: "2020-01-01",
|
||||||
|
color: "blue",
|
||||||
|
icon: "landmark",
|
||||||
|
ownerUserId: null,
|
||||||
|
isPrivate: false,
|
||||||
|
overdraftEnabled: true,
|
||||||
|
overdraftLimit: 10_000_00,
|
||||||
|
overdraftMonthlyRate: 425,
|
||||||
|
overdraftStatementDay: 1,
|
||||||
|
cardLimit: 0,
|
||||||
|
cardStatementDay: 1,
|
||||||
|
cardDueKind: "daysAfter",
|
||||||
|
cardDueValue: 10,
|
||||||
|
cardPaymentAccountId: null,
|
||||||
|
cardMinimumRate: null,
|
||||||
|
});
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
const id = created.ok ? created.data.id : "";
|
||||||
|
const { statement } = cardPeriods(today, 1);
|
||||||
|
await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({ accountId: id, amount: 3_000_00, date: statement.start }),
|
||||||
|
);
|
||||||
|
const s = (await getOverdraftSummary(ayse, cakir, id))!;
|
||||||
|
const days =
|
||||||
|
Math.round(
|
||||||
|
(istanbulDayStart(statement.end)!.getTime() -
|
||||||
|
istanbulDayStart(statement.start)!.getTime()) /
|
||||||
|
(24 * 3600 * 1000),
|
||||||
|
) + 1;
|
||||||
|
expect(s.statement.interestMinor).toBe(Math.round((days * 3_000_00 * 425) / 10_000 / 30));
|
||||||
|
expect(s.usedMinor).toBe(3_000_00);
|
||||||
|
expect(s.availableMinor).toBe(7_000_00);
|
||||||
|
// Artı bakiyeli hesapta kullanılabilir KMH limitin kendisi.
|
||||||
|
const z = (await getOverdraftSummary(ayse, cakir, await acc("Ziraat Maaş")))!;
|
||||||
|
expect(z.usedMinor).toBe(0);
|
||||||
|
expect(z.availableMinor).toBe(z.limitMinor);
|
||||||
|
expect(s.interestCategoryId).not.toBeNull();
|
||||||
|
expect(s.interestBooked).toBe(false);
|
||||||
|
expect((await listAccounts(ayse, cakir))!.accounts.find((a) => a.id === id)!.balanceMinor).toBe(
|
||||||
|
-3_000_00,
|
||||||
|
);
|
||||||
|
expect(await getOverdraftSummary(ayse, cakir, await acc("Cüzdan"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types, type PipelineStage } from "mongoose";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CardStatementModel } from "@/models/card-statement";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import {
|
||||||
|
cardPeriods,
|
||||||
|
closingOnOrAfter,
|
||||||
|
dueDate,
|
||||||
|
estimateOverdraftInterest,
|
||||||
|
minimumPayment,
|
||||||
|
minimumRateBp,
|
||||||
|
nextPeriod,
|
||||||
|
previousPeriod,
|
||||||
|
type DueRule,
|
||||||
|
type Period,
|
||||||
|
} from "@/shared/card-periods";
|
||||||
|
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { loadContext, toItem, userNames, type LeanTx, type TransactionListItem } from "./service";
|
||||||
|
import { accountMatch, balanceBefore } from "./statement";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kredi kartı özeti ve KMH dönem özeti (Bölüm 6.8). Tüm tutarlar işlemlerden hesaplanır;
|
||||||
|
* dönem kayıtları (`statements`) okunurken güncellenir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const HISTORY = 6;
|
||||||
|
const dayStart = (ymd: string) => istanbulDayStart(ymd)!;
|
||||||
|
const endExclusive = (ymd: string) => new Date(dayStart(ymd).getTime() + DAY_MS);
|
||||||
|
|
||||||
|
type AccountDoc = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
householdId: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
openingBalanceMinor: number;
|
||||||
|
archivedAt?: Date | null;
|
||||||
|
creditCard?: {
|
||||||
|
limitMinor: number;
|
||||||
|
statementDay: number;
|
||||||
|
dueRule: DueRule;
|
||||||
|
paymentAccountId?: Types.ObjectId | null;
|
||||||
|
minimumPaymentRateBp?: number | null;
|
||||||
|
} | null;
|
||||||
|
overdraft?: {
|
||||||
|
enabled: boolean;
|
||||||
|
limitMinor: number;
|
||||||
|
monthlyRateBp?: number | null;
|
||||||
|
statementDay?: number | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hesaba giriş (gelir, gelen transfer) ve çıkış (gider, giden transfer) toplamları. */
|
||||||
|
async function flows(householdId: Types.ObjectId, accountId: Types.ObjectId, from: Date, to: Date) {
|
||||||
|
const pipeline: PipelineStage[] = [
|
||||||
|
{ $match: { ...accountMatch(householdId, accountId), date: { $gte: from, $lt: to } } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: null,
|
||||||
|
inflow: {
|
||||||
|
$sum: {
|
||||||
|
$cond: [
|
||||||
|
{
|
||||||
|
$or: [
|
||||||
|
{ $and: [{ $eq: ["$type", "income"] }, { $eq: ["$accountId", accountId] }] },
|
||||||
|
{ $and: [{ $eq: ["$type", "transfer"] }, { $eq: ["$toAccountId", accountId] }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"$amountMinor",
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outflow: {
|
||||||
|
$sum: {
|
||||||
|
$cond: [
|
||||||
|
{
|
||||||
|
$and: [
|
||||||
|
{ $in: ["$type", ["expense", "transfer"]] },
|
||||||
|
{ $eq: ["$accountId", accountId] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"$amountMinor",
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const [row] = await TransactionModel.aggregate<{ inflow: number; outflow: number }>(pipeline);
|
||||||
|
return { inflow: row?.inflow ?? 0, outflow: row?.outflow ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccount(userId: string, householdId: string, accountId: string) {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const info = ctx.accountMap.get(accountId);
|
||||||
|
if (!info || info.hidden) return null;
|
||||||
|
const account = await AccountModel.findOne({
|
||||||
|
_id: info.id,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
}).lean<AccountDoc>();
|
||||||
|
return account ? { ctx, account } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kredi kartı ---
|
||||||
|
|
||||||
|
export type StatementSummary = {
|
||||||
|
period: Period;
|
||||||
|
dueDate: string;
|
||||||
|
debtMinor: number;
|
||||||
|
minimumMinor: number;
|
||||||
|
paidMinor: number;
|
||||||
|
spentMinor: number;
|
||||||
|
remainingMinor: number;
|
||||||
|
minimumRemainingMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CardSummary = {
|
||||||
|
limitMinor: number;
|
||||||
|
debtMinor: number;
|
||||||
|
availableMinor: number;
|
||||||
|
/** Henüz gerçekleşmemiş taksitlerin toplamı (limitten düşer). */
|
||||||
|
pendingInstallmentsMinor: number;
|
||||||
|
minimumRateBp: number;
|
||||||
|
current: Period & { spentMinor: number };
|
||||||
|
statement: StatementSummary;
|
||||||
|
daysToDue: number;
|
||||||
|
history: StatementSummary[];
|
||||||
|
/** Dönem dönem yaklaşan taksit toplamları. */
|
||||||
|
upcomingInstallments: { periodEnd: string; dueDate: string; totalMinor: number; count: number }[];
|
||||||
|
/** Güncel ve sonraki dönemdeki planlı (taksit dışı) harcamalar. */
|
||||||
|
planned: TransactionListItem[];
|
||||||
|
paymentAccount: { id: string; name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCardSummary(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
accountId: string,
|
||||||
|
today = todayIstanbul(),
|
||||||
|
): Promise<CardSummary | null> {
|
||||||
|
const loaded = await loadAccount(userId, householdId, accountId);
|
||||||
|
if (!loaded || loaded.account.type !== "credit_card" || !loaded.account.creditCard) return null;
|
||||||
|
const { ctx, account } = loaded;
|
||||||
|
const card = account.creditCard!;
|
||||||
|
const hh = ctx.m.householdId;
|
||||||
|
const S = card.statementDay;
|
||||||
|
const rate = minimumRateBp(card.limitMinor, card.minimumPaymentRateBp);
|
||||||
|
const { current, statement } = cardPeriods(today, S);
|
||||||
|
|
||||||
|
const balanceNow = await balanceBefore(hh, account, null);
|
||||||
|
const debtMinor = Math.max(0, -balanceNow);
|
||||||
|
|
||||||
|
// Son ekstre + geçmiş dönemler (yeniden eskiye).
|
||||||
|
const periods: Period[] = [statement];
|
||||||
|
while (periods.length < HISTORY) periods.push(previousPeriod(periods.at(-1)!, S));
|
||||||
|
const history = await Promise.all(
|
||||||
|
periods.map(async (p): Promise<StatementSummary> => {
|
||||||
|
const next = nextPeriod(p, S);
|
||||||
|
const [debtAt, paidFlows, spentFlows] = await Promise.all([
|
||||||
|
balanceBefore(hh, account, endExclusive(p.end)),
|
||||||
|
flows(hh, account._id, endExclusive(p.end), endExclusive(next.end)),
|
||||||
|
flows(hh, account._id, dayStart(p.start), endExclusive(p.end)),
|
||||||
|
]);
|
||||||
|
const debt = Math.max(0, -debtAt);
|
||||||
|
const minimum = minimumPayment(debt, rate);
|
||||||
|
return {
|
||||||
|
period: p,
|
||||||
|
dueDate: dueDate(p.end, card.dueRule),
|
||||||
|
debtMinor: debt,
|
||||||
|
minimumMinor: minimum,
|
||||||
|
paidMinor: paidFlows.inflow,
|
||||||
|
spentMinor: spentFlows.outflow,
|
||||||
|
remainingMinor: Math.max(0, debt - paidFlows.inflow),
|
||||||
|
minimumRemainingMinor: Math.max(0, minimum - paidFlows.inflow),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const now = new Date();
|
||||||
|
await CardStatementModel.bulkWrite(
|
||||||
|
history.map((h) => ({
|
||||||
|
updateOne: {
|
||||||
|
filter: { accountId: account._id, periodEnd: h.period.end },
|
||||||
|
update: {
|
||||||
|
$set: {
|
||||||
|
householdId: hh,
|
||||||
|
kind: "credit_card",
|
||||||
|
periodStart: h.period.start,
|
||||||
|
dueDate: h.dueDate,
|
||||||
|
statementDebtMinor: h.debtMinor,
|
||||||
|
minimumPaymentMinor: h.minimumMinor,
|
||||||
|
paidMinor: h.paidMinor,
|
||||||
|
spentMinor: h.spentMinor,
|
||||||
|
computedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
upsert: true,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentFlows = await flows(
|
||||||
|
hh,
|
||||||
|
account._id,
|
||||||
|
dayStart(current.start),
|
||||||
|
endExclusive(current.end),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Planlı taksitler (dönemlerine göre) ve diğer planlı harcamalar.
|
||||||
|
const planned = (await TransactionModel.find({
|
||||||
|
householdId: hh,
|
||||||
|
accountId: account._id,
|
||||||
|
status: "planned",
|
||||||
|
deletedAt: null,
|
||||||
|
})
|
||||||
|
.sort({ date: 1 })
|
||||||
|
.lean()) as unknown as LeanTx[];
|
||||||
|
const byPeriod = new Map<string, { totalMinor: number; count: number }>();
|
||||||
|
let pendingInstallmentsMinor = 0;
|
||||||
|
const horizon = nextPeriod(current, S).end;
|
||||||
|
const other: LeanTx[] = [];
|
||||||
|
for (const tx of planned) {
|
||||||
|
if (tx.installment?.planId) {
|
||||||
|
const end = closingOnOrAfter(toIstanbulYmd(tx.date), S);
|
||||||
|
const x = byPeriod.get(end) ?? { totalMinor: 0, count: 0 };
|
||||||
|
x.totalMinor += tx.amountMinor;
|
||||||
|
x.count++;
|
||||||
|
byPeriod.set(end, x);
|
||||||
|
pendingInstallmentsMinor += tx.amountMinor;
|
||||||
|
} else if (toIstanbulYmd(tx.date) <= horizon && tx.type !== "income") {
|
||||||
|
other.push(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const names = await userNames(other.map((t) => t.createdBy as Types.ObjectId));
|
||||||
|
const statementSummary = history[0]!;
|
||||||
|
const payer = card.paymentAccountId ? ctx.accountMap.get(String(card.paymentAccountId)) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
limitMinor: card.limitMinor,
|
||||||
|
debtMinor,
|
||||||
|
availableMinor: card.limitMinor - debtMinor - pendingInstallmentsMinor,
|
||||||
|
pendingInstallmentsMinor,
|
||||||
|
minimumRateBp: rate,
|
||||||
|
current: { ...current, spentMinor: currentFlows.outflow },
|
||||||
|
statement: statementSummary,
|
||||||
|
daysToDue: Math.round(
|
||||||
|
(dayStart(statementSummary.dueDate).getTime() - dayStart(today).getTime()) / DAY_MS,
|
||||||
|
),
|
||||||
|
history,
|
||||||
|
upcomingInstallments: [...byPeriod.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.slice(0, 12)
|
||||||
|
.map(([periodEnd, x]) => ({ periodEnd, dueDate: dueDate(periodEnd, card.dueRule), ...x })),
|
||||||
|
planned: other.slice(0, 10).map((tx) => toItem(ctx, tx, names)),
|
||||||
|
paymentAccount:
|
||||||
|
payer && !payer.hidden && !payer.archived ? { id: String(payer.id), name: payer.name } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- KMH (esnek hesap) ---
|
||||||
|
|
||||||
|
export type OverdraftSummary = {
|
||||||
|
limitMinor: number;
|
||||||
|
balanceMinor: number;
|
||||||
|
usedMinor: number;
|
||||||
|
availableMinor: number;
|
||||||
|
monthlyRateBp: number;
|
||||||
|
current: Period & { interestSoFarMinor: number };
|
||||||
|
statement: Period & { interestMinor: number; averageUsedMinor: number };
|
||||||
|
/** Faiz kaydı önerisi için "Banka › Faiz" kategorisi (varsa). */
|
||||||
|
interestCategoryId: string | null;
|
||||||
|
/** Kesimden sonra bu hesapta faiz kategorisinde gider girilmiş mi? */
|
||||||
|
interestBooked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function dailyDeltas(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
accountId: Types.ObjectId,
|
||||||
|
period: Period,
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
const rows = await TransactionModel.aggregate<{ _id: string; delta: number }>([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
...accountMatch(householdId, accountId),
|
||||||
|
date: { $gte: dayStart(period.start), $lt: endExclusive(period.end) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: { $dateToString: { format: "%Y-%m-%d", date: "$date", timezone: "Europe/Istanbul" } },
|
||||||
|
delta: {
|
||||||
|
$sum: {
|
||||||
|
$cond: [
|
||||||
|
{ $eq: ["$type", "transfer"] },
|
||||||
|
{
|
||||||
|
$cond: [
|
||||||
|
{ $eq: ["$accountId", accountId] },
|
||||||
|
{ $multiply: ["$amountMinor", -1] },
|
||||||
|
"$amountMinor",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$cond: [
|
||||||
|
{ $eq: ["$type", "income"] },
|
||||||
|
"$amountMinor",
|
||||||
|
{ $multiply: ["$amountMinor", -1] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return new Map(rows.map((r) => [r._id, r.delta]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOverdraftSummary(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
accountId: string,
|
||||||
|
today = todayIstanbul(),
|
||||||
|
): Promise<OverdraftSummary | null> {
|
||||||
|
const loaded = await loadAccount(userId, householdId, accountId);
|
||||||
|
const od = loaded?.account.overdraft;
|
||||||
|
if (!loaded || loaded.account.type !== "bank" || !od?.enabled) return null;
|
||||||
|
const { ctx, account } = loaded;
|
||||||
|
const hh = ctx.m.householdId;
|
||||||
|
const S = od.statementDay ?? 1;
|
||||||
|
const rate = od.monthlyRateBp ?? 0;
|
||||||
|
const { current, statement } = cardPeriods(today, S);
|
||||||
|
|
||||||
|
const [balanceMinor, stStart, curStart, stDeltas, curDeltas, faiz] = await Promise.all([
|
||||||
|
balanceBefore(hh, account, null),
|
||||||
|
balanceBefore(hh, account, dayStart(statement.start)),
|
||||||
|
balanceBefore(hh, account, dayStart(current.start)),
|
||||||
|
dailyDeltas(hh, account._id, statement),
|
||||||
|
dailyDeltas(hh, account._id, current),
|
||||||
|
CategoryModel.findOne({ householdId: hh, defaultKey: "banka.faiz", archivedAt: null })
|
||||||
|
.select({ _id: 1 })
|
||||||
|
.lean(),
|
||||||
|
]);
|
||||||
|
const st = estimateOverdraftInterest(statement, stStart, stDeltas, rate);
|
||||||
|
const cur = estimateOverdraftInterest(current, curStart, curDeltas, rate, today);
|
||||||
|
const interestBooked = faiz
|
||||||
|
? Boolean(
|
||||||
|
await TransactionModel.exists({
|
||||||
|
householdId: hh,
|
||||||
|
accountId: account._id,
|
||||||
|
type: "expense",
|
||||||
|
effectiveCategoryIds: faiz._id,
|
||||||
|
deletedAt: null,
|
||||||
|
status: { $ne: "void" },
|
||||||
|
date: { $gte: dayStart(statement.end), $lt: endExclusive(addDays(today, 0)) },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
await CardStatementModel.updateOne(
|
||||||
|
{ accountId: account._id, periodEnd: statement.end },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
householdId: hh,
|
||||||
|
kind: "overdraft",
|
||||||
|
periodStart: statement.start,
|
||||||
|
statementDebtMinor: Math.max(
|
||||||
|
0,
|
||||||
|
-(await balanceBefore(hh, account, endExclusive(statement.end))),
|
||||||
|
),
|
||||||
|
interestMinor: st.interestMinor,
|
||||||
|
computedAt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
limitMinor: od.limitMinor,
|
||||||
|
balanceMinor,
|
||||||
|
usedMinor: Math.max(0, -balanceMinor),
|
||||||
|
// Kullanılabilir KMH: limit − kullanılan (pozitif bakiye KMH'yi artırmaz).
|
||||||
|
availableMinor: Math.max(0, od.limitMinor - Math.max(0, -balanceMinor)),
|
||||||
|
monthlyRateBp: rate,
|
||||||
|
current: { ...current, interestSoFarMinor: cur.interestMinor },
|
||||||
|
statement: {
|
||||||
|
...statement,
|
||||||
|
interestMinor: st.interestMinor,
|
||||||
|
averageUsedMinor: st.averageUsedMinor,
|
||||||
|
},
|
||||||
|
interestCategoryId: faiz ? String(faiz._id) : null,
|
||||||
|
interestBooked,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown, CreditCard, Landmark, Percent, Wallet } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuickAdd } from "@/components/layout/quick-add";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import type { CardSummary, OverdraftSummary } from "../card";
|
||||||
|
import { Amount, rowTitle } from "./transaction-parts";
|
||||||
|
|
||||||
|
const c = tr.card;
|
||||||
|
const o = tr.overdraft;
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
const pct = (bp: number) => formatMoneyInput(bp).replace(/,00$/, "");
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
sub,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: string;
|
||||||
|
sub?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className={cn("text-base font-semibold tabular-nums", tone)}>{value}</p>
|
||||||
|
{sub ? <p className="text-xs text-muted-foreground">{sub}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Limit kullanım çubuğu: borç + gelecek taksitler / limit. */
|
||||||
|
function UsageBar({ used, pending, limit }: { used: number; pending: number; limit: number }) {
|
||||||
|
if (limit <= 0) return null;
|
||||||
|
const u = Math.min(100, (used / limit) * 100);
|
||||||
|
const p = Math.min(100 - u, (pending / limit) * 100);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-2 w-full overflow-hidden rounded-full bg-muted"
|
||||||
|
role="img"
|
||||||
|
aria-label={`${Math.round(u + p)}%`}
|
||||||
|
>
|
||||||
|
<div className="bg-expense" style={{ width: `${u}%` }} />
|
||||||
|
<div className="bg-expense/40" style={{ width: `${p}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardSummaryPanel({
|
||||||
|
summary: s,
|
||||||
|
accountId,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
summary: CardSummary;
|
||||||
|
accountId: string;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const { openTransactionForm } = useQuickAdd();
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
|
const st = s.statement;
|
||||||
|
|
||||||
|
function pay() {
|
||||||
|
openTransactionForm({
|
||||||
|
kind: "create",
|
||||||
|
type: "transfer",
|
||||||
|
prefill: {
|
||||||
|
accountId: s.paymentAccount?.id,
|
||||||
|
toAccountId: accountId,
|
||||||
|
amountMinor: st.remainingMinor || undefined,
|
||||||
|
description: c.payDescription,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<CreditCard className="size-4" aria-hidden />
|
||||||
|
{c.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Stat label={c.debt} value={formatMoney(s.debtMinor)} tone="text-expense" />
|
||||||
|
<Stat
|
||||||
|
label={c.available}
|
||||||
|
value={formatMoney(s.availableMinor)}
|
||||||
|
sub={c.limitOf(formatMoney(s.limitMinor))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<UsageBar used={s.debtMinor} pending={s.pendingInstallmentsMinor} limit={s.limitMinor} />
|
||||||
|
{s.pendingInstallmentsMinor ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{c.pendingInstallments(formatMoney(s.pendingInstallmentsMinor))}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3 rounded-xl border p-3">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{c.statementPeriod}</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{c.periodRange(d(st.period.start), d(st.period.end))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label={c.statementDebt} value={formatMoney(st.debtMinor)} />
|
||||||
|
<Stat label={c.minimum(pct(s.minimumRateBp))} value={formatMoney(st.minimumMinor)} />
|
||||||
|
<Stat label={c.paid} value={formatMoney(st.paidMinor)} tone="text-income" />
|
||||||
|
<Stat
|
||||||
|
label={c.remaining}
|
||||||
|
value={formatMoney(st.remainingMinor)}
|
||||||
|
tone={st.remainingMinor ? "text-expense" : "text-income"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-sm">
|
||||||
|
{c.dueDate}: <span className="font-medium">{d(st.dueDate)}</span>{" "}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
st.remainingMinor && s.daysToDue < 0 ? "text-expense" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{st.remainingMinor ? `· ${c.daysLeft(s.daysToDue)}` : `· ${c.paidInFull}`}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{canWrite ? (
|
||||||
|
<Button size="sm" onClick={pay}>
|
||||||
|
<Wallet />
|
||||||
|
{c.pay}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">{c.currentPeriod}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{c.periodRange(d(s.current.start), d(s.current.end))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Stat label={c.spent} value={formatMoney(s.current.spentMinor)} tone="text-expense" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{c.upcomingInstallments}</h3>
|
||||||
|
{s.upcomingInstallments.length ? (
|
||||||
|
<ul className="divide-y rounded-xl border text-sm">
|
||||||
|
{s.upcomingInstallments.map((x) => (
|
||||||
|
<li key={x.periodEnd} className="flex items-center justify-between gap-3 p-2.5">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{c.installmentPeriod(d(x.periodEnd), d(x.dueDate), x.count)}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tabular-nums">{formatMoney(x.totalMinor)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{c.noInstallments}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{s.planned.length ? (
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{c.planned}</h3>
|
||||||
|
<ul className="divide-y rounded-xl border text-sm">
|
||||||
|
{s.planned.map((p) => (
|
||||||
|
<li key={p.id} className="flex items-center justify-between gap-3 p-2.5">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{d(p.date)} · {rowTitle(p)}
|
||||||
|
</span>
|
||||||
|
<Amount type={p.type} amountMinor={p.amountMinor} className="font-medium" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Collapsible open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="-ml-2 w-fit">
|
||||||
|
<ChevronDown className={cn("transition-transform", historyOpen && "rotate-180")} />
|
||||||
|
{c.history}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="pt-2">
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-muted-foreground">
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="p-2 text-left font-normal">{c.historyHeaders.period}</th>
|
||||||
|
<th className="p-2 text-right font-normal">{c.historyHeaders.debt}</th>
|
||||||
|
<th className="p-2 text-right font-normal">{c.historyHeaders.minimum}</th>
|
||||||
|
<th className="p-2 text-right font-normal">{c.historyHeaders.paid}</th>
|
||||||
|
<th className="p-2 text-right font-normal">{c.historyHeaders.due}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y tabular-nums">
|
||||||
|
{s.history.map((h) => (
|
||||||
|
<tr key={h.period.end}>
|
||||||
|
<td className="p-2 whitespace-nowrap">
|
||||||
|
{c.periodRange(d(h.period.start), d(h.period.end))}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-right">{formatMoney(h.debtMinor)}</td>
|
||||||
|
<td className="p-2 text-right">{formatMoney(h.minimumMinor)}</td>
|
||||||
|
<td className="p-2 text-right">{formatMoney(h.paidMinor)}</td>
|
||||||
|
<td className="p-2 text-right whitespace-nowrap">{d(h.dueDate)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverdraftPanel({
|
||||||
|
summary: s,
|
||||||
|
accountId,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
summary: OverdraftSummary;
|
||||||
|
accountId: string;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const { openTransactionForm } = useQuickAdd();
|
||||||
|
const range = c.periodRange(d(s.statement.start), d(s.statement.end));
|
||||||
|
|
||||||
|
function bookInterest() {
|
||||||
|
openTransactionForm({
|
||||||
|
kind: "create",
|
||||||
|
type: "expense",
|
||||||
|
prefill: {
|
||||||
|
accountId,
|
||||||
|
amountMinor: s.statement.interestMinor || undefined,
|
||||||
|
categoryId: s.interestCategoryId ?? undefined,
|
||||||
|
date: s.statement.end,
|
||||||
|
description: o.interestDescription(range),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Landmark className="size-4" aria-hidden />
|
||||||
|
{o.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Stat
|
||||||
|
label={o.limit}
|
||||||
|
value={formatMoney(s.limitMinor)}
|
||||||
|
sub={o.rate(pct(s.monthlyRateBp))}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={o.used}
|
||||||
|
value={formatMoney(s.usedMinor)}
|
||||||
|
tone={s.usedMinor ? "text-expense" : undefined}
|
||||||
|
/>
|
||||||
|
<Stat label={o.available} value={formatMoney(Math.max(0, s.availableMinor))} />
|
||||||
|
</div>
|
||||||
|
<UsageBar used={s.usedMinor} pending={0} limit={s.limitMinor} />
|
||||||
|
<section className="flex flex-col gap-2 rounded-xl border p-3">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{o.lastPeriod}</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">{range}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<Stat
|
||||||
|
label={o.estimatedInterest}
|
||||||
|
value={formatMoney(s.statement.interestMinor)}
|
||||||
|
tone={s.statement.interestMinor ? "text-expense" : undefined}
|
||||||
|
sub={o.averageUsed(formatMoney(s.statement.averageUsedMinor))}
|
||||||
|
/>
|
||||||
|
{s.interestBooked ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{o.booked}</p>
|
||||||
|
) : canWrite && s.statement.interestMinor > 0 ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={bookInterest}>
|
||||||
|
<Percent />
|
||||||
|
{o.bookInterest}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{o.taxNote}</p>
|
||||||
|
</section>
|
||||||
|
<p className="text-sm">
|
||||||
|
{o.currentSoFar}:{" "}
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{formatMoney(s.current.interestSoFarMinor)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCheck,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
List,
|
||||||
|
Loader2,
|
||||||
|
Scale,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MoneyInput } from "@/components/money-input";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { previewReconciliationAction, reconcileAccountAction } from "../actions";
|
||||||
|
import type { AccountStatement, ReconcilePreview, StatementRow } from "../statement";
|
||||||
|
import type { CardSummary, OverdraftSummary } from "../card";
|
||||||
|
import { CardSummaryPanel, OverdraftPanel } from "./card-summary";
|
||||||
|
import { RowIcon, rowSubtitle, rowTitle, useTransactionDetail } from "./transaction-parts";
|
||||||
|
|
||||||
|
const t = tr.statement;
|
||||||
|
const r = t.reconcile;
|
||||||
|
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
});
|
||||||
|
const monthLabel = (ym: string) => monthFmt.format(new Date(`${ym}-15T12:00:00+03:00`));
|
||||||
|
const dateLabel = (ymd: string) => formatDate(new Date(`${ymd}T12:00:00+03:00`));
|
||||||
|
|
||||||
|
function SignedMoney({ minor, className }: { minor: number; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-semibold whitespace-nowrap tabular-nums",
|
||||||
|
minor > 0 && "text-income",
|
||||||
|
minor < 0 && "text-expense",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{minor > 0 ? "+" : minor < 0 ? "−" : ""}
|
||||||
|
{formatMoney(Math.abs(minor))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatementView({
|
||||||
|
statement: s,
|
||||||
|
card,
|
||||||
|
overdraft,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
statement: AccountStatement;
|
||||||
|
card?: CardSummary | null;
|
||||||
|
overdraft?: OverdraftSummary | null;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const { openDetail, element: detailElement } = useTransactionDetail(canWrite);
|
||||||
|
const [reconcileOpen, setReconcileOpen] = useState(false);
|
||||||
|
const base = `/hesaplar/${s.account.id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href="/hesaplar">
|
||||||
|
<ArrowLeft />
|
||||||
|
{t.back}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<PageHeader
|
||||||
|
title={s.account.name}
|
||||||
|
description={`${tr.accounts.types[s.account.type]} · ${t.current}: ${formatMoney(s.currentBalanceMinor)}`}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/islemler?hesap=${s.account.id}`}>
|
||||||
|
<List />
|
||||||
|
<span className="hidden sm:inline">{t.allTransactions}</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{canWrite && s.canReconcile ? (
|
||||||
|
<Button onClick={() => setReconcileOpen(true)}>
|
||||||
|
<Scale />
|
||||||
|
{r.open}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{card ? (
|
||||||
|
<CardSummaryPanel summary={card} accountId={s.account.id} canWrite={canWrite} />
|
||||||
|
) : null}
|
||||||
|
{overdraft ? (
|
||||||
|
<OverdraftPanel summary={overdraft} accountId={s.account.id} canWrite={canWrite} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Button variant="outline" size="icon-sm" asChild aria-label={t.prevMonth}>
|
||||||
|
<Link href={`${base}?ay=${s.prevMonth}`}>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-base font-medium capitalize">{monthLabel(s.month)}</h2>
|
||||||
|
{s.nextMonth ? (
|
||||||
|
<Button variant="outline" size="icon-sm" asChild aria-label={t.nextMonth}>
|
||||||
|
<Link href={`${base}?ay=${s.nextMonth}`}>
|
||||||
|
<ChevronRight />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="icon-sm" disabled aria-label={t.nextMonth}>
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
[t.opening, s.openingMinor, false],
|
||||||
|
[t.inflow, s.inflowMinor, true],
|
||||||
|
[t.outflow, -s.outflowMinor, true],
|
||||||
|
[t.closing, s.closingMinor, false],
|
||||||
|
] as const
|
||||||
|
).map(([label, value, colored]) => (
|
||||||
|
<Card key={label} size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>{label}</CardDescription>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{colored ? (
|
||||||
|
<SignedMoney minor={value} />
|
||||||
|
) : (
|
||||||
|
<span className="tabular-nums">{formatMoney(value)}</span>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<CheckCheck className="size-3.5" aria-hidden />
|
||||||
|
{s.lastReconciliation
|
||||||
|
? t.lastReconciliation(
|
||||||
|
dateLabel(s.lastReconciliation.date),
|
||||||
|
formatMoney(s.lastReconciliation.statementBalanceMinor),
|
||||||
|
)
|
||||||
|
: t.noReconciliation}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{s.rows.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>{t.empty}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{s.rows.map((row) => (
|
||||||
|
<li key={row.item.id}>
|
||||||
|
<StatementRowView row={row} onOpen={() => openDetail(row.item.id)} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{s.truncated ? <p className="text-xs text-muted-foreground">{t.truncated}</p> : null}
|
||||||
|
|
||||||
|
{detailElement}
|
||||||
|
<ReconcileDialog
|
||||||
|
open={reconcileOpen}
|
||||||
|
onOpenChange={setReconcileOpen}
|
||||||
|
accountId={s.account.id}
|
||||||
|
isLiability={s.account.isLiability}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatementRowView({ row, onOpen }: { row: StatementRow; onOpen: () => void }) {
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<RowIcon item={row.item} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="truncate font-medium">{rowTitle(row.item)}</span>
|
||||||
|
{row.reconciled ? (
|
||||||
|
<CheckCheck className="size-3.5 shrink-0 text-income" aria-label={t.reconciledHint} />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{dateLabel(row.item.date)} · {rowSubtitle(row.item)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-col items-end">
|
||||||
|
<SignedMoney minor={row.deltaMinor} />
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums" title={t.balanceAfter}>
|
||||||
|
{formatMoney(row.balanceMinor)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const cls = "flex w-full items-center gap-3 p-3 text-left";
|
||||||
|
// Gizli karşı hesaba dokunan işlemin ayrıntısı açılmaz.
|
||||||
|
return row.restricted ? (
|
||||||
|
<div className={cls}>{content}</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className={cn(cls, "outline-none hover:bg-muted/50 focus-visible:bg-muted")}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReconcileDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
accountId,
|
||||||
|
isLiability,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
accountId: string;
|
||||||
|
isLiability: boolean;
|
||||||
|
}) {
|
||||||
|
const [date, setDate] = useState(todayIstanbul);
|
||||||
|
const [balance, setBalance] = useState("");
|
||||||
|
const [preview, setPreview] = useState<ReconcilePreview | null>(null);
|
||||||
|
const [accept, setAccept] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
function reset(next: boolean) {
|
||||||
|
if (!next) {
|
||||||
|
setPreview(null);
|
||||||
|
setAccept(false);
|
||||||
|
setErrors({});
|
||||||
|
setBalance("");
|
||||||
|
setDate(todayIstanbul());
|
||||||
|
}
|
||||||
|
onOpenChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = () => ({
|
||||||
|
statementDate: date,
|
||||||
|
statementBalance: balance,
|
||||||
|
acceptAdjustment: accept,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
setPending(true);
|
||||||
|
const res = await previewReconciliationAction(accountId, input());
|
||||||
|
setPending(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
setErrors({ ...res.fieldErrors, root: res.fieldErrors ? undefined : res.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
setAccept(false);
|
||||||
|
setPreview(res.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
setPending(true);
|
||||||
|
const res = await reconcileAccountAction(accountId, input());
|
||||||
|
setPending(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
setErrors({ root: res.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
res.data.adjustmentId
|
||||||
|
? r.doneWithAdjustment(res.data.markedCount)
|
||||||
|
: r.done(res.data.markedCount),
|
||||||
|
);
|
||||||
|
reset(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = preview?.differenceMinor ?? 0;
|
||||||
|
const display = (minor: number) => formatMoney(isLiability ? -minor : minor);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={reset}
|
||||||
|
title={r.title}
|
||||||
|
description={r.description}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={() => reset(false)}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
{preview ? (
|
||||||
|
<Button onClick={confirm} disabled={pending || (diff !== 0 && !accept)}>
|
||||||
|
{pending ? <Loader2 className="animate-spin" /> : null}
|
||||||
|
{diff !== 0 ? r.confirmWithAdjustment : r.confirm}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={check} disabled={pending || !balance.trim()}>
|
||||||
|
{pending ? <Loader2 className="animate-spin" /> : null}
|
||||||
|
{r.check}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={Boolean(errors.statementDate)}>
|
||||||
|
<FieldLabel htmlFor="rec-date">{r.date}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="rec-date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
max={todayIstanbul()}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDate(e.target.value);
|
||||||
|
setPreview(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{errors.statementDate ? <FieldError>{errors.statementDate}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={Boolean(errors.statementBalance)}>
|
||||||
|
<FieldLabel htmlFor="rec-balance">{isLiability ? r.debt : r.balance}</FieldLabel>
|
||||||
|
<MoneyInput
|
||||||
|
id="rec-balance"
|
||||||
|
value={balance}
|
||||||
|
allowNegative
|
||||||
|
onChange={(v) => {
|
||||||
|
setBalance(v);
|
||||||
|
setPreview(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FieldDescription>{isLiability ? r.debtHint : r.balanceHint}</FieldDescription>
|
||||||
|
{errors.statementBalance ? <FieldError>{errors.statementBalance}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{preview ? (
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border p-3" aria-live="polite">
|
||||||
|
<dl className="grid grid-cols-3 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground">{r.statementBalance}</dt>
|
||||||
|
<dd className="tabular-nums">{display(preview.statementBalanceMinor)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground">{r.appBalance}</dt>
|
||||||
|
<dd className="tabular-nums">{display(preview.appBalanceMinor)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground">{r.difference}</dt>
|
||||||
|
<dd>
|
||||||
|
<SignedMoney minor={diff} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{diff === 0 ? (
|
||||||
|
<p className="text-sm text-income">{r.matches}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-expense">{r.mismatch}</p>
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={accept}
|
||||||
|
onCheckedChange={(v) => setAccept(v === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{r.adjustmentOffer(
|
||||||
|
formatMoney(Math.abs(diff)),
|
||||||
|
diff > 0 ? tr.transactions.types.income : tr.transactions.types.expense,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Badge variant="secondary" className="w-fit">
|
||||||
|
{r.unreconciled(preview.unreconciledCount)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,918 @@
|
|||||||
|
"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 { allowedAttachmentKinds } from "@/shared/attachments";
|
||||||
|
import { AttachmentPicker } from "@/features/attachments/components/attachment-picker";
|
||||||
|
import type { AttachmentInfo } from "@/features/attachments/service";
|
||||||
|
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;
|
||||||
|
const INSTALLMENT_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 15, 18, 24, 36];
|
||||||
|
|
||||||
|
export type TransactionFormMode =
|
||||||
|
| {
|
||||||
|
kind: "create";
|
||||||
|
type: TransactionType;
|
||||||
|
/** Ön doldurma (ör. kart borcu ödemesi, KMH faiz kaydı). */
|
||||||
|
prefill?: {
|
||||||
|
accountId?: string;
|
||||||
|
toAccountId?: string;
|
||||||
|
amountMinor?: number;
|
||||||
|
categoryId?: string;
|
||||||
|
date?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { 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;
|
||||||
|
/** Önizleme bilgisiyle ekler; sunucuya yalnızca kimlikleri gider. */
|
||||||
|
attachments: AttachmentInfo[];
|
||||||
|
/** Taksit sayısı (yalnızca yeni kredi kartı gideri). */
|
||||||
|
installments: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toValues(mode: TransactionFormMode, options: TransactionFormOptions | null): FormValues {
|
||||||
|
if (mode.kind === "create") {
|
||||||
|
const p = mode.prefill ?? {};
|
||||||
|
return {
|
||||||
|
type: mode.type,
|
||||||
|
amount: p.amountMinor ? formatMoneyInput(p.amountMinor) : "",
|
||||||
|
date: p.date ?? todayIstanbul(),
|
||||||
|
accountId: p.accountId ?? options?.accounts[0]?.id ?? "",
|
||||||
|
toAccountId: p.toAccountId ?? "",
|
||||||
|
categoryId: p.categoryId ?? "",
|
||||||
|
splits: [],
|
||||||
|
payee: "",
|
||||||
|
description: p.description ?? "",
|
||||||
|
tags: "",
|
||||||
|
attachments: [],
|
||||||
|
installments: "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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(", "),
|
||||||
|
// Kopyada ekler taşınmaz (fiş o işleme aittir).
|
||||||
|
attachments: mode.kind === "edit" ? x.attachments : [],
|
||||||
|
installments: "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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. */
|
||||||
|
export 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 [uploadBusy, setUploadBusy] = 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 attachmentCount = useWatch({ control: form.control, name: "attachments" })?.length ?? 0;
|
||||||
|
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 selectedAccountType = options?.accounts.find((a) => a.id === accountId)?.type;
|
||||||
|
// Taksit: yeni (veya kopya) kredi kartı gideri, bölünmemiş.
|
||||||
|
const showInstallments =
|
||||||
|
mode?.kind !== "edit" &&
|
||||||
|
type === "expense" &&
|
||||||
|
selectedAccountType === "credit_card" &&
|
||||||
|
!splitMode;
|
||||||
|
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;
|
||||||
|
if (uploadBusy) {
|
||||||
|
form.setError("root", { message: tr.attachments.waitForUploads });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { attachments, ...values } = form.getValues();
|
||||||
|
const payload = {
|
||||||
|
...values,
|
||||||
|
installments: showInstallments ? values.installments : "1",
|
||||||
|
attachmentIds: attachments.map((a) => a.id),
|
||||||
|
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 || uploadBusy}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{showInstallments ? (
|
||||||
|
<Controller
|
||||||
|
name="installments"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="tx-installments">{f.installments}</FieldLabel>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger id="tx-installments" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{field.value === "1"
|
||||||
|
? f.singlePayment
|
||||||
|
: f.installmentCount(Number(field.value))}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{INSTALLMENT_OPTIONS.map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)}>
|
||||||
|
{n === 1 ? f.singlePayment : f.installmentCount(n)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{Number(field.value) > 1 && totalMinor > 0 ? (
|
||||||
|
<FieldDescription>
|
||||||
|
{f.installmentHint(
|
||||||
|
Number(field.value),
|
||||||
|
formatMoney(Math.ceil(totalMinor / Number(field.value))),
|
||||||
|
)}
|
||||||
|
</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{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}
|
||||||
|
{attachmentCount ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
· {tr.attachments.count(attachmentCount)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="pt-3">
|
||||||
|
<FieldGroup>
|
||||||
|
<Controller
|
||||||
|
name="attachments"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{tr.attachments.title}</FieldLabel>
|
||||||
|
<AttachmentPicker
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
allowedKinds={allowedAttachmentKinds(type)}
|
||||||
|
onBusyChange={setUploadBusy}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<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,385 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeftRight, CheckCheck, Copy, Pencil, Scale, Split, Trash2 } from "lucide-react";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { IconBadge } from "@/components/appearance/swatch";
|
||||||
|
import { useQuickAdd } from "@/components/layout/quick-add";
|
||||||
|
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 { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { AttachmentStrip } from "@/features/attachments/components/attachment-strip";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatDateTime, formatMoney } from "@/shared/format";
|
||||||
|
import type { TransactionType } from "@/shared/transactions";
|
||||||
|
import {
|
||||||
|
cancelRemainingInstallmentsAction,
|
||||||
|
getTransactionAction,
|
||||||
|
setTransactionDeletedAction,
|
||||||
|
} from "../actions";
|
||||||
|
import type { TransactionDetail, TransactionListItem } from "../service";
|
||||||
|
|
||||||
|
/** İşlem listesi, ayrıntı ve hesap ekstresinin paylaştığı parçalar. */
|
||||||
|
|
||||||
|
const t = tr.transactions;
|
||||||
|
const weekday = new Intl.DateTimeFormat("tr-TR", { weekday: "long", timeZone: "Europe/Istanbul" });
|
||||||
|
|
||||||
|
/** "2026-11-05" → "05.11.2026 · Perşembe" */
|
||||||
|
export function dayLabel(ymd: string) {
|
||||||
|
const d = new Date(`${ymd}T12:00:00+03:00`);
|
||||||
|
return `${formatDate(d)} · ${weekday.format(d)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signed(type: TransactionType, amountMinor: number) {
|
||||||
|
return type === "income" ? amountMinor : type === "expense" ? -amountMinor : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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.isAdjustment) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||||
|
<Scale 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" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowTitle(item: TransactionListItem) {
|
||||||
|
const base = baseTitle(item);
|
||||||
|
// Taksit: "Hepsiburada 4/9"
|
||||||
|
return item.installment ? `${base} ${item.installment.index}/${item.installment.count}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseTitle(item: TransactionListItem) {
|
||||||
|
if (item.isAdjustment) return t.adjustment;
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowSubtitle(item: TransactionListItem) {
|
||||||
|
if (item.type === "transfer")
|
||||||
|
return t.list.transfer(item.account.name, item.toAccount?.name ?? "—");
|
||||||
|
const what = item.isAdjustment
|
||||||
|
? item.description
|
||||||
|
: item.splitCount
|
||||||
|
? t.list.split(item.splitCount)
|
||||||
|
: (item.category?.path ?? "—");
|
||||||
|
return `${what} · ${item.account.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailDialog({
|
||||||
|
detail,
|
||||||
|
loading,
|
||||||
|
canWrite,
|
||||||
|
onOpenChange,
|
||||||
|
onEdit,
|
||||||
|
onCopy,
|
||||||
|
onDelete,
|
||||||
|
onCancelInstallments,
|
||||||
|
}: {
|
||||||
|
detail: TransactionDetail | null;
|
||||||
|
loading: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
|
onOpenChange: (o: boolean) => void;
|
||||||
|
onEdit: (d: TransactionDetail) => void;
|
||||||
|
onCopy: (d: TransactionDetail) => void;
|
||||||
|
onDelete: (d: TransactionDetail) => void;
|
||||||
|
onCancelInstallments: (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>
|
||||||
|
{/* Mutabakat düzeltmesi düzenlenemez/kopyalanamaz; yalnızca silinebilir. */}
|
||||||
|
{detail.isAdjustment ? null : (
|
||||||
|
<>
|
||||||
|
<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.isAdjustment ? row(t.fields.category, t.adjustment) : 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}
|
||||||
|
{detail.attachments.length
|
||||||
|
? row(
|
||||||
|
tr.attachments.title,
|
||||||
|
<span className="flex justify-end">
|
||||||
|
<AttachmentStrip items={detail.attachments} />
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{detail.installment
|
||||||
|
? row(
|
||||||
|
t.fields.installments,
|
||||||
|
<span className="flex flex-col items-end gap-1">
|
||||||
|
<span>
|
||||||
|
{detail.installment.index}/{detail.installment.count}
|
||||||
|
</span>
|
||||||
|
{canWrite &&
|
||||||
|
(detail.status === "planned" ||
|
||||||
|
detail.installment.index < detail.installment.count) ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onCancelInstallments(detail)}
|
||||||
|
>
|
||||||
|
{t.cancelInstallments}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{detail.reconciledAccountIds.length
|
||||||
|
? row(
|
||||||
|
tr.statement.reconcile.title,
|
||||||
|
<span className="inline-flex items-center gap-1 text-income">
|
||||||
|
<CheckCheck className="size-4" aria-hidden />
|
||||||
|
{t.reconciled}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ayrıntı penceresi + silme onayı + geri al bildirimi. Liste ve ekstre aynı davranışı kullanır;
|
||||||
|
* düzenle/kopyala uygulama genelindeki işlem formunu açar.
|
||||||
|
*/
|
||||||
|
export function useTransactionDetail(canWrite: boolean) {
|
||||||
|
const { openTransactionForm } = useQuickAdd();
|
||||||
|
const [detail, setDetail] = useState<TransactionDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<TransactionDetail | null>(null);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
setLoading(true);
|
||||||
|
setDetail(null);
|
||||||
|
const res = await getTransactionAction(id);
|
||||||
|
setLoading(false);
|
||||||
|
if (res.ok) setDetail(res.data);
|
||||||
|
else toast.error(res.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setDetail(null);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(d: TransactionDetail) {
|
||||||
|
setDeleting(null);
|
||||||
|
close();
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = (
|
||||||
|
<>
|
||||||
|
<DetailDialog
|
||||||
|
detail={detail}
|
||||||
|
loading={loading}
|
||||||
|
canWrite={canWrite}
|
||||||
|
onOpenChange={(o) => !o && close()}
|
||||||
|
onEdit={(d) => {
|
||||||
|
close();
|
||||||
|
openTransactionForm({ kind: "edit", transaction: d });
|
||||||
|
}}
|
||||||
|
onCopy={(d) => {
|
||||||
|
close();
|
||||||
|
openTransactionForm({ kind: "copy", transaction: d });
|
||||||
|
}}
|
||||||
|
onDelete={setDeleting}
|
||||||
|
onCancelInstallments={(d) => {
|
||||||
|
close();
|
||||||
|
startTransition(async () => {
|
||||||
|
const r = await cancelRemainingInstallmentsAction(d.id);
|
||||||
|
if (r.ok) toast.success(t.toast.installmentsCancelled(r.data.cancelled));
|
||||||
|
else toast.error(r.error);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return { openDetail, element };
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronLeft, ChevronRight, Download, Filter, Paperclip, Plus, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuickAdd } from "@/components/layout/quick-add";
|
||||||
|
import { MoneyInput } from "@/components/money-input";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, 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 { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import type { TransactionFilters } from "@/shared/schemas/transaction";
|
||||||
|
import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions";
|
||||||
|
import type { TransactionFormOptions, TransactionListItem, TransactionPage } from "../service";
|
||||||
|
import {
|
||||||
|
Amount,
|
||||||
|
dayLabel,
|
||||||
|
RowIcon,
|
||||||
|
rowSubtitle,
|
||||||
|
rowTitle,
|
||||||
|
signed,
|
||||||
|
useTransactionDetail,
|
||||||
|
} from "./transaction-parts";
|
||||||
|
|
||||||
|
const t = tr.transactions;
|
||||||
|
const ALL = "__all__";
|
||||||
|
|
||||||
|
// --- 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ı ---
|
||||||
|
|
||||||
|
// --- 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 } = useQuickAdd();
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
const { openDetail, element: detailElement } = useTransactionDetail(canWrite);
|
||||||
|
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}` : ""}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
/>
|
||||||
|
{detailElement}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
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: [],
|
||||||
|
attachmentIds: [],
|
||||||
|
installments: 1,
|
||||||
|
...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,
|
||||||
|
cardMinimumRate: 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,913 @@
|
|||||||
|
import "server-only";
|
||||||
|
import mongoose, { Types, type ClientSession, type PipelineStage } from "mongoose";
|
||||||
|
import {
|
||||||
|
listOwnerAttachments,
|
||||||
|
syncOwnerAttachments,
|
||||||
|
type AttachmentInfo,
|
||||||
|
} from "@/features/attachments/service";
|
||||||
|
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 { allowedAttachmentKinds } from "@/shared/attachments";
|
||||||
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
|
import { installmentSchedule } from "@/shared/card-periods";
|
||||||
|
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||||
|
import 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[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hane bağlamı (hesap/kategori haritaları, gizli hesaplar). İşlemler modülü içinde paylaşılır. */
|
||||||
|
export async function loadContext(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
minRole: "viewer" | "member",
|
||||||
|
) {
|
||||||
|
const m = await resolveMembership(userId, householdId, minRole);
|
||||||
|
if (!m) return null;
|
||||||
|
await postDueInstallments(m.householdId);
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
|
||||||
|
|
||||||
|
/** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */
|
||||||
|
export function baseFilter(ctx: Ctx): Record<string, unknown> {
|
||||||
|
const f: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null };
|
||||||
|
if (ctx.hiddenAccountIds.length) {
|
||||||
|
f.accountId = { $nin: ctx.hiddenAccountIds };
|
||||||
|
f.toAccountId = { $nin: ctx.hiddenAccountIds };
|
||||||
|
}
|
||||||
|
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 +.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Vadesi gelen taksitleri gerçekleştirir (planlı → posted). Okuma yollarında çağrılır; zamanlanmış
|
||||||
|
* görev gerekmez. Taksitler onay beklemez (Faz 7 kararı: kendi döneminde otomatik).
|
||||||
|
*/
|
||||||
|
export async function postDueInstallments(householdId: Types.ObjectId, today = todayIstanbul()) {
|
||||||
|
const tomorrow = istanbulDayStart(today)!.getTime() + DAY_MS;
|
||||||
|
await TransactionModel.updateMany(
|
||||||
|
{
|
||||||
|
householdId,
|
||||||
|
status: "planned",
|
||||||
|
deletedAt: null,
|
||||||
|
"installment.planId": { $type: "objectId" },
|
||||||
|
date: { $lt: new Date(tomorrow) },
|
||||||
|
},
|
||||||
|
{ $set: { status: "posted" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeAccountDeltas(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
await postDueInstallments(householdId);
|
||||||
|
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 }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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. */
|
||||||
|
export 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 };
|
||||||
|
if (input.installments > 1 && ctx.accountMap.get(String(d.accountId))?.type !== "credit_card") {
|
||||||
|
return fail(t.validation.installmentsCardOnly, {
|
||||||
|
installments: t.validation.installmentsCardOnly,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
try {
|
||||||
|
let id = "";
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
const base = {
|
||||||
|
...built.data,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
status: "posted",
|
||||||
|
payeeId,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
};
|
||||||
|
// Taksitli: her taksit ayrı işlem; vadesi gelmeyenler planlı (kendi döneminde gerçekleşir).
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const planId = new Types.ObjectId();
|
||||||
|
const docs =
|
||||||
|
input.installments > 1
|
||||||
|
? installmentSchedule(input.date, input.amount, input.installments).map((x) => ({
|
||||||
|
...base,
|
||||||
|
amountMinor: x.amountMinor,
|
||||||
|
date: istanbulDayStart(x.date)!,
|
||||||
|
status: x.date <= today ? "posted" : "planned",
|
||||||
|
installment: { planId, index: x.index + 1, count: input.installments },
|
||||||
|
}))
|
||||||
|
: [base];
|
||||||
|
const created = await TransactionModel.insertMany(docs as Record<string, unknown>[], {
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
const first = created[0]!;
|
||||||
|
id = String(first._id);
|
||||||
|
await attach(ctx, actorId, first._id, input, session);
|
||||||
|
});
|
||||||
|
return ok({ id });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AttachmentError) return error.result;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ek senkronizasyonu başarısızsa transaction'ı geri almak için fırlatılır. */
|
||||||
|
class AttachmentError extends Error {
|
||||||
|
constructor(readonly result: ActionResult<never>) {
|
||||||
|
super(result.ok ? "" : result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attach(
|
||||||
|
ctx: Ctx,
|
||||||
|
actorId: string,
|
||||||
|
transactionId: Types.ObjectId,
|
||||||
|
input: TransactionForm,
|
||||||
|
session: ClientSession,
|
||||||
|
) {
|
||||||
|
const synced = await syncOwnerAttachments({
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
actorId,
|
||||||
|
ownerType: "transaction",
|
||||||
|
ownerId: transactionId,
|
||||||
|
ids: input.attachmentIds,
|
||||||
|
allowedKinds: allowedAttachmentKinds(input.type),
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
if (!synced.ok) throw new AttachmentError(synced);
|
||||||
|
await TransactionModel.updateOne(
|
||||||
|
{ _id: transactionId, householdId: ctx.m.householdId },
|
||||||
|
{ $set: { attachmentCount: synced.data } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
if (current.isAdjustment) return fail(t.adjustmentReadOnly);
|
||||||
|
const built = buildDoc(ctx, input, current);
|
||||||
|
if (!built.ok) return built;
|
||||||
|
const d = built.data as {
|
||||||
|
type: TransactionType;
|
||||||
|
date: Date;
|
||||||
|
amountMinor: number;
|
||||||
|
accountId: Types.ObjectId;
|
||||||
|
toAccountId: Types.ObjectId | null;
|
||||||
|
categoryId: Types.ObjectId | null;
|
||||||
|
};
|
||||||
|
const payeeId = await touchPayee(
|
||||||
|
ctx.m.householdId,
|
||||||
|
input.payee,
|
||||||
|
input.type,
|
||||||
|
d.accountId,
|
||||||
|
d.categoryId,
|
||||||
|
);
|
||||||
|
// Bakiyeyi etkileyen bir alan değiştiyse mutabakat işareti geçersizleşir.
|
||||||
|
const balanceChanged =
|
||||||
|
d.type !== current.type ||
|
||||||
|
d.amountMinor !== current.amountMinor ||
|
||||||
|
d.date.getTime() !== new Date(current.date).getTime() ||
|
||||||
|
!d.accountId.equals(current.accountId) ||
|
||||||
|
String(d.toAccountId ?? "") !== String(current.toAccountId ?? "");
|
||||||
|
try {
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
await TransactionModel.updateOne(
|
||||||
|
{ _id: current._id, householdId: ctx.m.householdId },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
...built.data,
|
||||||
|
payeeId,
|
||||||
|
updatedBy: new Types.ObjectId(actorId),
|
||||||
|
...(balanceChanged ? { reconciledAccountIds: [] } : {}),
|
||||||
|
// Serinin planlı tekrarı tek başına düzenlendi ("yalnızca bu").
|
||||||
|
...(current.recurringRuleId && current.status === "planned" ? { detached: true } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
await attach(ctx, actorId, current._id, input, session);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AttachmentError) return error.result;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kalan (henüz gerçekleşmemiş) taksitleri iptal eder — ör. ürün iadesi veya erken kapama.
|
||||||
|
* Gerçekleşmiş taksitlere dokunulmaz.
|
||||||
|
*/
|
||||||
|
export async function cancelRemainingInstallments(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
transactionId: string,
|
||||||
|
): Promise<ActionResult<{ cancelled: number }>> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const tx = await loadOwnTransaction(ctx, transactionId);
|
||||||
|
const planId = tx?.installment?.planId;
|
||||||
|
if (!tx || !planId) return fail(tr.common.notFound);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const res = await TransactionModel.updateMany(
|
||||||
|
{
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
"installment.planId": planId,
|
||||||
|
status: "planned",
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{ $set: { deletedAt: new Date(), updatedBy: actor } },
|
||||||
|
);
|
||||||
|
await writeAudit({
|
||||||
|
action: "transaction.delete",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
targetType: "installmentPlan",
|
||||||
|
targetId: String(planId),
|
||||||
|
diff: { cancelled: res.modifiedCount, count: tx.installment?.count ?? null },
|
||||||
|
});
|
||||||
|
return ok({ cancelled: res.modifiedCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
/** Mutabakat düzeltmesi (kategorisiz, düzenlenemez). */
|
||||||
|
isAdjustment: boolean;
|
||||||
|
/** Mutabakatı yapılmış hesaplar. */
|
||||||
|
reconciledAccountIds: string[];
|
||||||
|
/** Taksit: "4/9" */
|
||||||
|
installment: { planId: string; index: number; count: number } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
attachments: AttachmentInfo[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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 ?? "—")]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LeanTx = Awaited<ReturnType<typeof TransactionModel.find>>[number] & {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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,
|
||||||
|
isAdjustment: Boolean(tx.isAdjustment),
|
||||||
|
reconciledAccountIds: (tx.reconciledAccountIds ?? []).map(String),
|
||||||
|
installment: tx.installment?.planId
|
||||||
|
? {
|
||||||
|
planId: String(tx.installment.planId),
|
||||||
|
index: tx.installment.index ?? 0,
|
||||||
|
count: tx.installment.count ?? 0,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
// Planlı kayıtlar (12 ay ileriye kadar) yalnızca açıkça istenince listelenir (/planli sayfası).
|
||||||
|
q.status = f.durum ?? { $ne: "planned" };
|
||||||
|
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: filters.durum ?? "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, attachments] = await Promise.all([
|
||||||
|
userNames([tx.createdBy as Types.ObjectId, tx.updatedBy as Types.ObjectId]),
|
||||||
|
tx.attachmentCount
|
||||||
|
? listOwnerAttachments(ctx.m.householdId, "transaction", tx._id)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
attachments,
|
||||||
|
...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: r.isAdjustment ? t.adjustment : 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { listAccounts } from "@/features/accounts/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { AuditLogModel } from "@/models/audit-log";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { ReconciliationModel } from "@/models/reconciliation";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { reconcileSchema } from "@/shared/schemas/reconciliation";
|
||||||
|
import type { TransactionForm } from "@/shared/schemas/transaction";
|
||||||
|
import { shiftMonth } from "@/shared/statement";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
createTransaction,
|
||||||
|
getTransaction,
|
||||||
|
setTransactionDeleted,
|
||||||
|
updateTransaction,
|
||||||
|
} from "./service";
|
||||||
|
import {
|
||||||
|
currentMonth,
|
||||||
|
getAccountStatement,
|
||||||
|
previewReconciliation,
|
||||||
|
reconcileAccount,
|
||||||
|
} from "./statement";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
emre = ids.user("emre");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
const acc = (name: string) =>
|
||||||
|
AccountModel.findOne({ householdId: cakir, name })
|
||||||
|
.lean()
|
||||||
|
.then((a) => String(a!._id));
|
||||||
|
const cat = (key: string) =>
|
||||||
|
CategoryModel.findOne({ householdId: cakir, defaultKey: key })
|
||||||
|
.lean()
|
||||||
|
.then((c) => String(c!._id));
|
||||||
|
const input = (statementDate: string, statementBalance: string, acceptAdjustment = false) =>
|
||||||
|
reconcileSchema.parse({ statementDate, statementBalance, acceptAdjustment });
|
||||||
|
const tl = (minor: number) => (minor / 100).toFixed(2).replace(".", ",");
|
||||||
|
|
||||||
|
async function balanceOf(userId: string, accountId: string) {
|
||||||
|
const list = await listAccounts(userId, cakir);
|
||||||
|
return list!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("hesap ekstresi", () => {
|
||||||
|
it("yürüyen bakiye tutarlı; ay sonu = sonraki ay başı; güncel bakiye hesap listesiyle aynı", async () => {
|
||||||
|
const ziraat = await acc("Ziraat Maaş");
|
||||||
|
const month = shiftMonth(currentMonth(), -1);
|
||||||
|
const s = (await getAccountStatement(ayse, cakir, ziraat, month))!;
|
||||||
|
expect(s.rows.length).toBeGreaterThan(0);
|
||||||
|
// Yeniden eskiye: ilk satırın bakiyesi ay sonu bakiyesi.
|
||||||
|
expect(s.rows[0]!.balanceMinor).toBe(s.closingMinor);
|
||||||
|
expect(s.closingMinor).toBe(s.openingMinor + s.inflowMinor - s.outflowMinor);
|
||||||
|
const asc = [...s.rows].reverse();
|
||||||
|
let running = s.openingMinor;
|
||||||
|
for (const r of asc) {
|
||||||
|
running += r.deltaMinor;
|
||||||
|
expect(r.balanceMinor).toBe(running);
|
||||||
|
}
|
||||||
|
const next = (await getAccountStatement(ayse, cakir, ziraat, currentMonth()))!;
|
||||||
|
expect(next.openingMinor).toBe(s.closingMinor);
|
||||||
|
expect(next.nextMonth).toBeNull();
|
||||||
|
expect(s.currentBalanceMinor).toBe(await balanceOf(ayse, ziraat));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gizli hesabın ekstresi yalnızca sahibine; hane dışına kapalı", async () => {
|
||||||
|
const pluxee = await acc("Ayşe — Pluxee");
|
||||||
|
expect(await getAccountStatement(ayse, cakir, pluxee, currentMonth())).not.toBeNull();
|
||||||
|
expect(await getAccountStatement(emre, cakir, pluxee, currentMonth())).toBeNull();
|
||||||
|
expect(await getAccountStatement(fatma, cakir, await acc("Cüzdan"), currentMonth())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gizli hesaba transfer ekstrede görünür ama karşı hesap adı gizlenir", async () => {
|
||||||
|
const ziraat = await acc("Ziraat Maaş");
|
||||||
|
const pluxee = await acc("Ayşe — Pluxee");
|
||||||
|
const r = await createTransaction(ayse, cakir, {
|
||||||
|
type: "transfer",
|
||||||
|
amount: 123_45,
|
||||||
|
date: todayIstanbul(),
|
||||||
|
accountId: ziraat,
|
||||||
|
toAccountId: pluxee,
|
||||||
|
categoryId: null,
|
||||||
|
splits: [],
|
||||||
|
payee: "",
|
||||||
|
description: "",
|
||||||
|
tags: [],
|
||||||
|
attachmentIds: [],
|
||||||
|
installments: 1,
|
||||||
|
} satisfies TransactionForm);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const s = (await getAccountStatement(emre, cakir, ziraat, currentMonth()))!;
|
||||||
|
const row = s.rows.find((x) => x.item.id === (r.ok ? r.data.id : ""))!;
|
||||||
|
expect(row.restricted).toBe(true);
|
||||||
|
expect(row.deltaMinor).toBe(-123_45);
|
||||||
|
expect(row.item.toAccount!.name).not.toContain("Pluxee");
|
||||||
|
expect(s.currentBalanceMinor).toBe(await balanceOf(emre, ziraat));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mutabakat", () => {
|
||||||
|
it("bakiye tutuyorsa işlemler işaretlenir, düzeltme oluşmaz", async () => {
|
||||||
|
const cuzdan = await acc("Cüzdan");
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const balance = await balanceOf(ayse, cuzdan);
|
||||||
|
const preview = await previewReconciliation(ayse, cakir, cuzdan, input(today, tl(balance)));
|
||||||
|
expect(preview).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { differenceMinor: 0, appBalanceMinor: balance },
|
||||||
|
});
|
||||||
|
const unreconciled = preview.ok ? preview.data.unreconciledCount : 0;
|
||||||
|
expect(unreconciled).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const r = await reconcileAccount(ayse, cakir, cuzdan, input(today, tl(balance)));
|
||||||
|
expect(r).toMatchObject({ ok: true, data: { adjustmentId: null, markedCount: unreconciled } });
|
||||||
|
const again = await reconcileAccount(ayse, cakir, cuzdan, input(today, tl(balance)));
|
||||||
|
expect(again).toMatchObject({ ok: true, data: { markedCount: 0 } });
|
||||||
|
|
||||||
|
const s = (await getAccountStatement(ayse, cakir, cuzdan, currentMonth()))!;
|
||||||
|
expect(s.rows.every((row) => row.reconciled)).toBe(true);
|
||||||
|
expect(s.lastReconciliation).toMatchObject({ date: today, differenceMinor: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fark varsa onay ister; onaylanınca düzeltme işlemi oluşur ve bakiye ekstreyle eşitlenir", async () => {
|
||||||
|
const ziraat = await acc("Ziraat Maaş");
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const balance = await balanceOf(ayse, ziraat);
|
||||||
|
const target = balance - 250_00;
|
||||||
|
|
||||||
|
const refused = await reconcileAccount(ayse, cakir, ziraat, input(today, tl(target)));
|
||||||
|
expect(refused.ok).toBe(false);
|
||||||
|
expect(await ReconciliationModel.countDocuments({ accountId: ziraat })).toBe(0);
|
||||||
|
|
||||||
|
const r = await reconcileAccount(ayse, cakir, ziraat, input(today, tl(target), true));
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const adjustmentId = r.ok ? r.data.adjustmentId! : "";
|
||||||
|
const adj = (await TransactionModel.findById(adjustmentId).lean())!;
|
||||||
|
expect(adj).toMatchObject({ type: "expense", amountMinor: 250_00, isAdjustment: true });
|
||||||
|
expect(await balanceOf(ayse, ziraat)).toBe(target);
|
||||||
|
|
||||||
|
const audit = await AuditLogModel.findOne({
|
||||||
|
action: "account.reconcile",
|
||||||
|
targetId: ziraat,
|
||||||
|
}).lean();
|
||||||
|
expect(audit!.diff).toMatchObject({ differenceMinor: -250_00 });
|
||||||
|
|
||||||
|
// Düzeltme işlemi düzenlenemez, silinebilir.
|
||||||
|
const detail = (await getTransaction(ayse, cakir, adjustmentId))!;
|
||||||
|
expect(detail.isAdjustment).toBe(true);
|
||||||
|
const edit = await updateTransaction(ayse, cakir, adjustmentId, {
|
||||||
|
type: "expense",
|
||||||
|
amount: 1_00,
|
||||||
|
date: today,
|
||||||
|
accountId: ziraat,
|
||||||
|
toAccountId: null,
|
||||||
|
categoryId: await cat("market"),
|
||||||
|
splits: [],
|
||||||
|
payee: "",
|
||||||
|
description: "",
|
||||||
|
tags: [],
|
||||||
|
attachmentIds: [],
|
||||||
|
installments: 1,
|
||||||
|
});
|
||||||
|
expect(edit.ok).toBe(false);
|
||||||
|
expect((await setTransactionDeleted(ayse, cakir, adjustmentId, true)).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kredi kartında borç pozitif girilir, uygulamada eksi bakiyeyle karşılaştırılır", async () => {
|
||||||
|
const kart = await acc("Garanti Bonus");
|
||||||
|
const balance = await balanceOf(ayse, kart);
|
||||||
|
expect(balance).toBeLessThan(0);
|
||||||
|
const preview = await previewReconciliation(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
kart,
|
||||||
|
input(todayIstanbul(), tl(-balance)),
|
||||||
|
);
|
||||||
|
expect(preview).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { statementBalanceMinor: balance, differenceMinor: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("işaretli işlemin tutarı değişince işaret kalkar; yalnızca açıklama değişince kalır", async () => {
|
||||||
|
const cuzdan = await acc("Cüzdan");
|
||||||
|
const tx = await TransactionModel.findOne({
|
||||||
|
accountId: cuzdan,
|
||||||
|
type: "expense",
|
||||||
|
reconciledAccountIds: cuzdan,
|
||||||
|
splits: { $size: 0 },
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean();
|
||||||
|
const d = (await getTransaction(ayse, cakir, String(tx!._id)))!;
|
||||||
|
const base: TransactionForm = {
|
||||||
|
type: "expense",
|
||||||
|
amount: d.amountMinor,
|
||||||
|
date: d.date,
|
||||||
|
accountId: cuzdan,
|
||||||
|
toAccountId: null,
|
||||||
|
categoryId: d.categoryId,
|
||||||
|
splits: [],
|
||||||
|
payee: d.payeeName,
|
||||||
|
description: "yeni açıklama",
|
||||||
|
tags: d.tags,
|
||||||
|
attachmentIds: [],
|
||||||
|
installments: 1,
|
||||||
|
};
|
||||||
|
expect((await updateTransaction(ayse, cakir, d.id, base)).ok).toBe(true);
|
||||||
|
expect(
|
||||||
|
(await TransactionModel.findById(d.id).lean())!.reconciledAccountIds.map(String),
|
||||||
|
).toEqual([cuzdan]);
|
||||||
|
expect(
|
||||||
|
(await updateTransaction(ayse, cakir, d.id, { ...base, amount: d.amountMinor + 1 })).ok,
|
||||||
|
).toBe(true);
|
||||||
|
expect((await TransactionModel.findById(d.id).lean())!.reconciledAccountIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yetki ve doğrulama: izleyici, gizli hesap, gelecek tarih", async () => {
|
||||||
|
const cuzdan = await acc("Cüzdan");
|
||||||
|
expect((await reconcileAccount(zeynep, cakir, cuzdan, input(todayIstanbul(), "0"))).ok).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await reconcileAccount(emre, cakir, await acc("Ayşe — Pluxee"), input(todayIstanbul(), "0")))
|
||||||
|
.ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect((await previewReconciliation(ayse, cakir, cuzdan, input("2999-01-01", "0"))).ok).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import "server-only";
|
||||||
|
import mongoose, { Types, type ClientSession, type PipelineStage } from "mongoose";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { writeAudit } from "@/lib/audit";
|
||||||
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { ReconciliationModel } from "@/models/reconciliation";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { isLiabilityType, type AccountType } from "@/shared/accounts";
|
||||||
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
|
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import type { ReconcileForm } from "@/shared/schemas/reconciliation";
|
||||||
|
import { foldTr } from "@/shared/search";
|
||||||
|
import { accountEffect, monthRange, shiftMonth, withRunningBalance } from "@/shared/statement";
|
||||||
|
import {
|
||||||
|
loadContext,
|
||||||
|
toItem,
|
||||||
|
userNames,
|
||||||
|
type Ctx,
|
||||||
|
type LeanTx,
|
||||||
|
type TransactionListItem,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hesap ekstresi (aylık, yürüyen bakiye) ve mutabakat (Bölüm 6.1).
|
||||||
|
* Ekstre yalnızca bakiyesini görebildiği hesaplar için açılır (gizli hesap → yalnızca sahibi).
|
||||||
|
* Başka birinin gizli hesabına yapılan transferler bakiyeyi etkilediği için ekstrede görünür,
|
||||||
|
* ama karşı hesap adı gizlenir ve ayrıntı açılmaz.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.statement;
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const MAX_ROWS = 2000;
|
||||||
|
|
||||||
|
/** Hesap bazında bakiyeyi etkileyen işlemler (posted, silinmemiş). */
|
||||||
|
export function accountMatch(householdId: Types.ObjectId, accountId: Types.ObjectId) {
|
||||||
|
return {
|
||||||
|
householdId,
|
||||||
|
status: "posted" as const,
|
||||||
|
deletedAt: null,
|
||||||
|
$or: [{ accountId }, { toAccountId: accountId }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Açılış bakiyesi + (isteğe bağlı tarihten önceki) işlem etkileri. */
|
||||||
|
export async function balanceBefore(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
account: { _id: Types.ObjectId; openingBalanceMinor: number },
|
||||||
|
before: Date | null,
|
||||||
|
session?: ClientSession,
|
||||||
|
): Promise<number> {
|
||||||
|
const pipeline: PipelineStage[] = [
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
...accountMatch(householdId, account._id),
|
||||||
|
...(before ? { date: { $lt: before } } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: null,
|
||||||
|
total: {
|
||||||
|
$sum: {
|
||||||
|
$cond: [
|
||||||
|
{ $eq: ["$type", "transfer"] },
|
||||||
|
{
|
||||||
|
$cond: [
|
||||||
|
{ $eq: ["$accountId", account._id] },
|
||||||
|
{ $multiply: ["$amountMinor", -1] },
|
||||||
|
"$amountMinor",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$cond: [
|
||||||
|
{ $eq: ["$type", "income"] },
|
||||||
|
"$amountMinor",
|
||||||
|
{ $multiply: ["$amountMinor", -1] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const agg = TransactionModel.aggregate<{ total: number }>(pipeline);
|
||||||
|
if (session) agg.session(session);
|
||||||
|
const [row] = await agg;
|
||||||
|
return account.openingBalanceMinor + (row?.total ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
type VisibleAccount = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
type: AccountType;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
openingBalanceMinor: number;
|
||||||
|
archivedAt?: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadVisibleAccount(
|
||||||
|
ctx: Ctx,
|
||||||
|
accountId: string,
|
||||||
|
session?: ClientSession,
|
||||||
|
): Promise<VisibleAccount | null> {
|
||||||
|
const id = toObjectId(accountId);
|
||||||
|
const info = id ? ctx.accountMap.get(String(id)) : undefined;
|
||||||
|
if (!id || !info || info.hidden) return null;
|
||||||
|
return AccountModel.findOne({ _id: id, householdId: ctx.m.householdId }, null, { session })
|
||||||
|
.select({ name: 1, type: 1, icon: 1, color: 1, openingBalanceMinor: 1, archivedAt: 1 })
|
||||||
|
.lean<VisibleAccount>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ekstre ---
|
||||||
|
|
||||||
|
export type StatementRow = {
|
||||||
|
item: TransactionListItem;
|
||||||
|
deltaMinor: number;
|
||||||
|
balanceMinor: number;
|
||||||
|
reconciled: boolean;
|
||||||
|
/** Karşı hesap başkasının gizli hesabıysa ayrıntı açılmaz. */
|
||||||
|
restricted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LastReconciliation = {
|
||||||
|
date: string;
|
||||||
|
statementBalanceMinor: number;
|
||||||
|
differenceMinor: number;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountStatement = {
|
||||||
|
account: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: AccountType;
|
||||||
|
icon: IconKey;
|
||||||
|
color: Swatch;
|
||||||
|
archived: boolean;
|
||||||
|
isLiability: boolean;
|
||||||
|
};
|
||||||
|
month: string;
|
||||||
|
prevMonth: string;
|
||||||
|
nextMonth: string | null;
|
||||||
|
openingMinor: number;
|
||||||
|
closingMinor: number;
|
||||||
|
inflowMinor: number;
|
||||||
|
outflowMinor: number;
|
||||||
|
currentBalanceMinor: number;
|
||||||
|
/** Yeniden eskiye. */
|
||||||
|
rows: StatementRow[];
|
||||||
|
truncated: boolean;
|
||||||
|
lastReconciliation: LastReconciliation | null;
|
||||||
|
canReconcile: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function currentMonth(now = new Date()): string {
|
||||||
|
return todayIstanbul(now).slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountStatement(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
accountId: string,
|
||||||
|
month: string,
|
||||||
|
): Promise<AccountStatement | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const account = await loadVisibleAccount(ctx, accountId);
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
const range = monthRange(month);
|
||||||
|
const start = istanbulDayStart(range.start)!;
|
||||||
|
const end = istanbulDayStart(range.end)!;
|
||||||
|
const hh = ctx.m.householdId;
|
||||||
|
|
||||||
|
const [openingMinor, currentBalanceMinor, txs, last] = await Promise.all([
|
||||||
|
balanceBefore(hh, account, start),
|
||||||
|
balanceBefore(hh, account, null),
|
||||||
|
TransactionModel.find({ ...accountMatch(hh, account._id), date: { $gte: start, $lt: end } })
|
||||||
|
.sort({ date: 1, createdAt: 1 })
|
||||||
|
.limit(MAX_ROWS + 1)
|
||||||
|
.lean() as unknown as Promise<LeanTx[]>,
|
||||||
|
ReconciliationModel.findOne({ householdId: hh, accountId: account._id })
|
||||||
|
.sort({ statementDate: -1, createdAt: -1 })
|
||||||
|
.lean(),
|
||||||
|
]);
|
||||||
|
const truncated = txs.length > MAX_ROWS;
|
||||||
|
const rowsAsc = txs.slice(0, MAX_ROWS);
|
||||||
|
const names = await userNames([
|
||||||
|
...rowsAsc.map((r) => r.createdBy as Types.ObjectId),
|
||||||
|
...(last ? [last.createdBy] : []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const accountKey = String(account._id);
|
||||||
|
const effects = rowsAsc.map((tx) => {
|
||||||
|
const item = toItem(ctx, tx, names);
|
||||||
|
const counterpart = tx.type === "transfer" ? [item.account, item.toAccount] : [];
|
||||||
|
const restricted = counterpart.some((a) => a && ctx.accountMap.get(a.id)?.hidden);
|
||||||
|
if (restricted) {
|
||||||
|
// Gizli karşı hesabın adı sızmasın.
|
||||||
|
for (const a of counterpart) {
|
||||||
|
if (a && ctx.accountMap.get(a.id)?.hidden) a.name = tr.transactions.list.hiddenAccount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
restricted,
|
||||||
|
reconciled: item.reconciledAccountIds.includes(accountKey),
|
||||||
|
deltaMinor: accountEffect(
|
||||||
|
{
|
||||||
|
type: tx.type,
|
||||||
|
amountMinor: tx.amountMinor,
|
||||||
|
accountId: String(tx.accountId),
|
||||||
|
toAccountId: tx.toAccountId ? String(tx.toAccountId) : null,
|
||||||
|
},
|
||||||
|
accountKey,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const running = withRunningBalance(openingMinor, effects);
|
||||||
|
const inflowMinor = effects.filter((e) => e.deltaMinor > 0).reduce((a, e) => a + e.deltaMinor, 0);
|
||||||
|
const outflowMinor = effects
|
||||||
|
.filter((e) => e.deltaMinor < 0)
|
||||||
|
.reduce((a, e) => a - e.deltaMinor, 0);
|
||||||
|
const now = currentMonth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
account: {
|
||||||
|
id: accountKey,
|
||||||
|
name: account.name,
|
||||||
|
type: account.type,
|
||||||
|
icon: account.icon as IconKey,
|
||||||
|
color: account.color as Swatch,
|
||||||
|
archived: Boolean(account.archivedAt),
|
||||||
|
isLiability: isLiabilityType(account.type),
|
||||||
|
},
|
||||||
|
month,
|
||||||
|
prevMonth: shiftMonth(month, -1),
|
||||||
|
nextMonth: month < now ? shiftMonth(month, 1) : null,
|
||||||
|
openingMinor,
|
||||||
|
closingMinor: running.at(-1)?.balanceMinor ?? openingMinor,
|
||||||
|
inflowMinor,
|
||||||
|
outflowMinor,
|
||||||
|
currentBalanceMinor,
|
||||||
|
rows: running.reverse(),
|
||||||
|
truncated,
|
||||||
|
lastReconciliation: last
|
||||||
|
? {
|
||||||
|
date: toIstanbulYmd(last.statementDate),
|
||||||
|
statementBalanceMinor: last.statementBalanceMinor,
|
||||||
|
differenceMinor: last.differenceMinor,
|
||||||
|
createdAt: new Date(last.createdAt).toISOString(),
|
||||||
|
createdBy: names.get(String(last.createdBy)) ?? "—",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
canReconcile: hasHouseholdRole(ctx.m.role, "member") && !account.archivedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mutabakat ---
|
||||||
|
|
||||||
|
export type ReconcilePreview = {
|
||||||
|
statementDate: string;
|
||||||
|
/** Uygulamadaki işaretle (kredi kartında eksi). */
|
||||||
|
statementBalanceMinor: number;
|
||||||
|
appBalanceMinor: number;
|
||||||
|
/** ekstre − uygulama: + ise uygulamada eksik gelir, − ise eksik gider. */
|
||||||
|
differenceMinor: number;
|
||||||
|
/** O tarihe kadar henüz işaretlenmemiş işlem sayısı. */
|
||||||
|
unreconciledCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateDate(ymd: string): Date | null {
|
||||||
|
const start = istanbulDayStart(ymd);
|
||||||
|
if (!start || ymd > todayIstanbul()) return null;
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computePreview(
|
||||||
|
ctx: Ctx,
|
||||||
|
account: VisibleAccount,
|
||||||
|
input: ReconcileForm,
|
||||||
|
session?: ClientSession,
|
||||||
|
): Promise<ActionResult<ReconcilePreview & { endExclusive: Date; dayStart: Date }>> {
|
||||||
|
const dayStart = validateDate(input.statementDate);
|
||||||
|
if (!dayStart) return fail(t.errors.date, { statementDate: t.errors.date });
|
||||||
|
const endExclusive = new Date(dayStart.getTime() + DAY_MS);
|
||||||
|
// Kredi kartında kullanıcı borcu pozitif girer; uygulamada borç eksi bakiyedir.
|
||||||
|
const statementBalanceMinor = isLiabilityType(account.type)
|
||||||
|
? -input.statementBalance
|
||||||
|
: input.statementBalance;
|
||||||
|
const appBalanceMinor = await balanceBefore(ctx.m.householdId, account, endExclusive, session);
|
||||||
|
const unreconciledCount = await TransactionModel.countDocuments(
|
||||||
|
{
|
||||||
|
...accountMatch(ctx.m.householdId, account._id),
|
||||||
|
date: { $lt: endExclusive },
|
||||||
|
reconciledAccountIds: { $ne: account._id },
|
||||||
|
},
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
return ok({
|
||||||
|
statementDate: input.statementDate,
|
||||||
|
statementBalanceMinor,
|
||||||
|
appBalanceMinor,
|
||||||
|
differenceMinor: statementBalanceMinor - appBalanceMinor,
|
||||||
|
unreconciledCount,
|
||||||
|
endExclusive,
|
||||||
|
dayStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewReconciliation(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
accountId: string,
|
||||||
|
input: ReconcileForm,
|
||||||
|
): Promise<ActionResult<ReconcilePreview>> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const account = await loadVisibleAccount(ctx, accountId);
|
||||||
|
if (!account) return fail(tr.common.notFound);
|
||||||
|
const result = await computePreview(ctx, account, input);
|
||||||
|
if (!result.ok) return result;
|
||||||
|
const { endExclusive: _e, dayStart: _d, ...preview } = result.data;
|
||||||
|
return ok(preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReconcileAbort extends Error {
|
||||||
|
constructor(readonly result: ActionResult<never>) {
|
||||||
|
super("abort");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutabakatı uygular (tek transaction): fark varsa (ve onaylandıysa) düzeltme işlemi oluşturur,
|
||||||
|
* ekstre tarihine kadarki işlemleri bu hesap için işaretler, kaydı ve denetim kaydını yazar.
|
||||||
|
*/
|
||||||
|
export async function reconcileAccount(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
accountId: string,
|
||||||
|
input: ReconcileForm,
|
||||||
|
): Promise<
|
||||||
|
ActionResult<{ adjustmentId: string | null; markedCount: number; differenceMinor: number }>
|
||||||
|
> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const pre = await loadVisibleAccount(ctx, accountId);
|
||||||
|
if (!pre) return fail(tr.common.notFound);
|
||||||
|
if (pre.archivedAt) return fail(t.errors.archived);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const hh = ctx.m.householdId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let out = { adjustmentId: null as string | null, markedCount: 0, differenceMinor: 0 };
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
const account = (await loadVisibleAccount(ctx, accountId, session))!;
|
||||||
|
const preview = await computePreview(ctx, account, input, session);
|
||||||
|
if (!preview.ok) throw new ReconcileAbort(preview);
|
||||||
|
const p = preview.data;
|
||||||
|
if (p.differenceMinor !== 0 && !input.acceptAdjustment) {
|
||||||
|
throw new ReconcileAbort(fail(t.errors.needsAdjustment));
|
||||||
|
}
|
||||||
|
|
||||||
|
let adjustmentId: Types.ObjectId | null = null;
|
||||||
|
if (p.differenceMinor !== 0) {
|
||||||
|
const description = t.adjustmentDescription(formatMoney(p.statementBalanceMinor));
|
||||||
|
const [adj] = await TransactionModel.create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
householdId: hh,
|
||||||
|
type: p.differenceMinor > 0 ? "income" : "expense",
|
||||||
|
status: "posted",
|
||||||
|
date: p.dayStart,
|
||||||
|
accountId: account._id,
|
||||||
|
amountMinor: Math.abs(p.differenceMinor),
|
||||||
|
categoryId: null,
|
||||||
|
splits: [],
|
||||||
|
effectiveCategoryIds: [],
|
||||||
|
payeeName: "",
|
||||||
|
description,
|
||||||
|
searchText: foldTr(`${tr.transactions.adjustment} ${description}`),
|
||||||
|
isAdjustment: true,
|
||||||
|
reconciledAccountIds: [account._id],
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
adjustmentId = adj!._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const marked = await TransactionModel.updateMany(
|
||||||
|
{
|
||||||
|
...accountMatch(hh, account._id),
|
||||||
|
date: { $lt: p.endExclusive },
|
||||||
|
reconciledAccountIds: { $ne: account._id },
|
||||||
|
},
|
||||||
|
{ $addToSet: { reconciledAccountIds: account._id } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
|
||||||
|
await ReconciliationModel.create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
householdId: hh,
|
||||||
|
accountId: account._id,
|
||||||
|
statementDate: p.dayStart,
|
||||||
|
statementBalanceMinor: p.statementBalanceMinor,
|
||||||
|
appBalanceMinor: p.appBalanceMinor,
|
||||||
|
differenceMinor: p.differenceMinor,
|
||||||
|
adjustmentTransactionId: adjustmentId,
|
||||||
|
markedCount: marked.modifiedCount,
|
||||||
|
createdBy: actor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
await writeAudit(
|
||||||
|
{
|
||||||
|
action: "account.reconcile",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: hh,
|
||||||
|
targetType: "account",
|
||||||
|
targetId: String(account._id),
|
||||||
|
diff: {
|
||||||
|
statementDate: p.statementDate,
|
||||||
|
statementBalanceMinor: p.statementBalanceMinor,
|
||||||
|
appBalanceMinor: p.appBalanceMinor,
|
||||||
|
differenceMinor: p.differenceMinor,
|
||||||
|
adjustmentTransactionId: adjustmentId ? String(adjustmentId) : null,
|
||||||
|
markedCount: marked.modifiedCount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
out = {
|
||||||
|
adjustmentId: adjustmentId ? String(adjustmentId) : null,
|
||||||
|
markedCount: marked.modifiedCount,
|
||||||
|
differenceMinor: p.differenceMinor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return ok(out);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ReconcileAbort) return error.result;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
+587
-1
@@ -34,6 +34,21 @@ export const tr = {
|
|||||||
unauthorized: "Bu işlem için yetkiniz yok.",
|
unauthorized: "Bu işlem için yetkiniz yok.",
|
||||||
notFound: "Kayıt bulunamadı.",
|
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",
|
||||||
light: "Açık",
|
light: "Açık",
|
||||||
@@ -45,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",
|
||||||
@@ -63,6 +79,565 @@ 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 —",
|
||||||
|
cardMinimumRate: "Asgari ödeme oranı (%)",
|
||||||
|
cardMinimumRateHint:
|
||||||
|
"Boş bırakılırsa limit 50.000 TL'nin altındaysa %20, üstündeyse %40 kullanılır.",
|
||||||
|
},
|
||||||
|
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ş: ek, 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.",
|
||||||
|
installments: "Taksit",
|
||||||
|
singlePayment: "Peşin",
|
||||||
|
installmentCount: (n: number) => `${n} taksit`,
|
||||||
|
installmentHint: (n: number, each: string) =>
|
||||||
|
`${n} × yaklaşık ${each}; vadesi gelen taksit kendi döneminde otomatik gerçekleşir.`,
|
||||||
|
},
|
||||||
|
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.",
|
||||||
|
installmentsMax: "En fazla 36 taksit",
|
||||||
|
installmentsExpenseOnly: "Taksit yalnızca bölünmemiş kredi kartı giderinde kullanılabilir.",
|
||||||
|
installmentsCardOnly: "Taksit yalnızca kredi kartı hesabında kullanılabilir.",
|
||||||
|
},
|
||||||
|
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ı.",
|
||||||
|
installmentsCancelled: (n: number) => `${n} kalan taksit iptal edildi.`,
|
||||||
|
},
|
||||||
|
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`,
|
||||||
|
},
|
||||||
|
adjustment: "Mutabakat düzeltmesi",
|
||||||
|
adjustmentReadOnly:
|
||||||
|
"Mutabakat düzeltmesi düzenlenemez; gerekirse silip mutabakatı yeniden yapın.",
|
||||||
|
reconciled: "Mutabakatı yapıldı",
|
||||||
|
cancelInstallments: "Kalan taksitleri iptal et",
|
||||||
|
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
|
||||||
|
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
|
||||||
|
noAccountsLink: "Hesaplara git",
|
||||||
|
},
|
||||||
|
recurring: {
|
||||||
|
title: "Planlı işlemler",
|
||||||
|
description:
|
||||||
|
"Düzenli ve planlı gelir/giderler. Planlı kayıtlar bakiyeyi etkilemez; onaylanınca gerçekleşir.",
|
||||||
|
newRule: "Yeni düzenli işlem",
|
||||||
|
createTitle: "Yeni düzenli işlem",
|
||||||
|
editTitle: "Düzenli işlemi düzenle",
|
||||||
|
tabs: { upcoming: "Yaklaşan", overdue: "Vadesi geçmiş", rules: "Kurallar" },
|
||||||
|
fields: {
|
||||||
|
estimate: "Tutar tahmini (ör. fatura); onaylarken düzeltilir",
|
||||||
|
startDate: "İlk tarih",
|
||||||
|
frequency: "Sıklık",
|
||||||
|
interval: "Kaç birimde bir",
|
||||||
|
monthlyMode: "Ayın hangi günü",
|
||||||
|
end: "Bitiş",
|
||||||
|
count: "Tekrar sayısı",
|
||||||
|
endDate: "Bitiş tarihi",
|
||||||
|
preview: "İlk tarihler",
|
||||||
|
},
|
||||||
|
frequencies: { daily: "Günlük", weekly: "Haftalık", monthly: "Aylık", yearly: "Yıllık" },
|
||||||
|
units: { daily: "gün", weekly: "hafta", monthly: "ay", yearly: "yıl" },
|
||||||
|
monthlyModes: { day: "İlk tarihin günü", last: "Ayın son günü" },
|
||||||
|
endKinds: { never: "Bitiş yok", count: "Tekrar sayısı", date: "Bitiş tarihi" },
|
||||||
|
actions: {
|
||||||
|
confirm: "Onayla",
|
||||||
|
skip: "Atla",
|
||||||
|
postpone: "Ertele",
|
||||||
|
edit: "Düzenle",
|
||||||
|
delete: "Sil",
|
||||||
|
more: "Diğer işlemler",
|
||||||
|
},
|
||||||
|
confirmTitle: "İşlemi onayla",
|
||||||
|
confirmDescription: "Gerçekleşen tutarı ve tarihi kontrol edin; onaylanınca bakiyeye yansır.",
|
||||||
|
postponeTitle: "Ertele",
|
||||||
|
postponeDate: "Yeni tarih",
|
||||||
|
scopeTitle: "Hangi kayıtlar değişsin?",
|
||||||
|
scopes: { one: "Yalnızca bu", following: "Bu ve sonrakiler", all: "Tümü" },
|
||||||
|
scopeHints: {
|
||||||
|
one: "Yalnızca bu planlı kayıt değişir; seri aynı kalır.",
|
||||||
|
following: "Seri bu tarihten itibaren yeni değerlerle devam eder.",
|
||||||
|
all: "Onaylanmamış tüm planlı kayıtlar yeni değerlerle yeniden oluşturulur. Gerçekleşmiş kayıtlar değişmez.",
|
||||||
|
},
|
||||||
|
deleteTitle: "Düzenli işlem silinsin mi?",
|
||||||
|
deleteDescription:
|
||||||
|
"Gerçekleşmiş kayıtlar korunur; yalnızca onaylanmamış planlı kayıtlar silinir.",
|
||||||
|
deleteScopes: { following: "Bu ve sonrakileri sil", all: "Tüm seriyi sil" },
|
||||||
|
estimate: "Tahmini",
|
||||||
|
next: (date: string) => `Sıradaki: ${date}`,
|
||||||
|
remaining: (n: number) => `${n} tekrar kaldı`,
|
||||||
|
endless: "Süresiz",
|
||||||
|
series: (pos: string) => `${pos}`,
|
||||||
|
empty: {
|
||||||
|
upcoming: "Önümüzdeki 60 günde planlı işlem yok.",
|
||||||
|
overdue: "Vadesi geçmiş planlı işlem yok.",
|
||||||
|
rules: "Henüz düzenli işlem yok. Kira, faturalar ve abonelikler için ekleyin.",
|
||||||
|
},
|
||||||
|
overdueBanner: (n: number) =>
|
||||||
|
`${n} planlı işlemin tarihi geçti; onaylayın, erteleyin veya atlayın.`,
|
||||||
|
overdueLink: "Planlı işlemlere git",
|
||||||
|
upcomingTitle: "Yaklaşan planlı işlemler",
|
||||||
|
toast: {
|
||||||
|
created: "Düzenli işlem kaydedildi.",
|
||||||
|
updated: "Düzenli işlem güncellendi.",
|
||||||
|
deleted: "Düzenli işlem silindi.",
|
||||||
|
confirmed: "İşlem onaylandı.",
|
||||||
|
skipped: "Bu tekrar atlandı.",
|
||||||
|
postponed: "İşlem ertelendi.",
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
interval: "1 ile 12 arasında olmalı",
|
||||||
|
count: "1 ile 600 arasında bir sayı girin",
|
||||||
|
endBeforeStart: "Bitiş tarihi ilk tarihten önce olamaz",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
splitAmount: "Bölünmüş planlı işlemin tutarı değiştirilemez.",
|
||||||
|
postponeDate: "Yeni tarih mevcut tarihten sonra olmalı.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
attachments: {
|
||||||
|
title: "Ekler",
|
||||||
|
add: "Ek ekle",
|
||||||
|
addHint: { image: "Fiş veya fatura fotoğrafı", imagePdf: "Görsel veya PDF (ör. bordro)" },
|
||||||
|
count: (n: number) => `${n} ek`,
|
||||||
|
uploading: "Yükleniyor…",
|
||||||
|
remove: "Eki kaldır",
|
||||||
|
open: "Aç",
|
||||||
|
download: "İndir",
|
||||||
|
fullscreen: "Tam ekran",
|
||||||
|
exitFullscreen: "Tam ekrandan çık",
|
||||||
|
previous: "Önceki",
|
||||||
|
next: "Sonraki",
|
||||||
|
position: (i: number, n: number) => `${i} / ${n}`,
|
||||||
|
viewerTitle: "Ek dosyalar",
|
||||||
|
pdf: "PDF belgesi",
|
||||||
|
moreCount: (n: number) => `+${n}`,
|
||||||
|
waitForUploads: "Yüklemeler bitmeden kaydedilemez.",
|
||||||
|
errors: {
|
||||||
|
empty: "Dosya seçilmedi veya dosya boş.",
|
||||||
|
tooLarge: (mb: number) => `Dosya çok büyük (en fazla ${mb} MB).`,
|
||||||
|
tooMany: (n: number) => `Bir kayda en fazla ${n} dosya eklenebilir.`,
|
||||||
|
tooManyPending: "Kaydedilmemiş çok fazla ek var; önce formu kaydedin.",
|
||||||
|
unsupported: "Desteklenmeyen dosya türü. JPEG, PNG, WebP, HEIC veya PDF yükleyin.",
|
||||||
|
unreadable: "Dosya okunamadı veya bozuk.",
|
||||||
|
kindNotAllowed: "Gider işlemlerine yalnızca görsel eklenebilir (PDF gelir ve transferlerde).",
|
||||||
|
invalid: "Ek dosya bulunamadı veya bu kayda eklenemez.",
|
||||||
|
upload: "Dosya yüklenemedi.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
title: "Kart özeti",
|
||||||
|
debt: "Toplam borç",
|
||||||
|
available: "Kullanılabilir limit",
|
||||||
|
limitOf: (limit: string) => `Limit ${limit}`,
|
||||||
|
pendingInstallments: (amount: string) => `Gelecek taksitler limitten düşer: ${amount}`,
|
||||||
|
statementPeriod: "Hesap kesim dönemi",
|
||||||
|
currentPeriod: "Güncel dönem",
|
||||||
|
periodRange: (start: string, end: string) => `${start} – ${end}`,
|
||||||
|
statementDebt: "Dönem borcu",
|
||||||
|
minimum: (rate: string) => `Asgari ödeme (%${rate})`,
|
||||||
|
paid: "Ödenen",
|
||||||
|
remaining: "Kalan",
|
||||||
|
dueDate: "Son ödeme",
|
||||||
|
daysLeft: (n: number) =>
|
||||||
|
n > 0 ? `${n} gün kaldı` : n === 0 ? "Bugün son gün" : `${-n} gün geçti`,
|
||||||
|
paidInFull: "Dönem borcu ödendi.",
|
||||||
|
spent: "Harcama",
|
||||||
|
pay: "Borcu öde",
|
||||||
|
payDescription: "Kart borcu ödemesi",
|
||||||
|
upcomingInstallments: "Yaklaşan taksitler",
|
||||||
|
installmentPeriod: (end: string, due: string, n: number) =>
|
||||||
|
`${end} kesim · son ödeme ${due} · ${n} taksit`,
|
||||||
|
noInstallments: "Bekleyen taksit yok.",
|
||||||
|
planned: "Planlı harcamalar",
|
||||||
|
history: "Geçmiş ekstreler",
|
||||||
|
historyHeaders: {
|
||||||
|
period: "Dönem",
|
||||||
|
debt: "Borç",
|
||||||
|
minimum: "Asgari",
|
||||||
|
paid: "Ödenen",
|
||||||
|
due: "Son ödeme",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overdraft: {
|
||||||
|
title: "Esnek hesap (KMH)",
|
||||||
|
limit: "KMH limiti",
|
||||||
|
used: "Kullanılan",
|
||||||
|
available: "Kullanılabilir KMH",
|
||||||
|
rate: (rate: string) => `Aylık faiz %${rate}`,
|
||||||
|
lastPeriod: "Geçen dönem",
|
||||||
|
currentSoFar: "Bu dönem (şimdiye kadar)",
|
||||||
|
estimatedInterest: "Tahmini faiz",
|
||||||
|
averageUsed: (amount: string) => `Ortalama kullanım ${amount}`,
|
||||||
|
bookInterest: "Faiz kaydı ekle",
|
||||||
|
booked: "Bu dönemin faiz kaydı girilmiş.",
|
||||||
|
interestDescription: (range: string) => `KMH faizi (${range})`,
|
||||||
|
taxNote: "Tahmin vergiler (KKDF/BSMV) hariçtir; banka ekstresindeki tutarı girin.",
|
||||||
|
},
|
||||||
|
statement: {
|
||||||
|
title: "Hesap ekstresi",
|
||||||
|
back: "Hesaplar",
|
||||||
|
allTransactions: "Tüm işlemler",
|
||||||
|
prevMonth: "Önceki ay",
|
||||||
|
nextMonth: "Sonraki ay",
|
||||||
|
opening: "Ay başı bakiye",
|
||||||
|
inflow: "Giriş",
|
||||||
|
outflow: "Çıkış",
|
||||||
|
closing: "Ay sonu bakiye",
|
||||||
|
current: "Güncel bakiye",
|
||||||
|
balanceAfter: "Bakiye",
|
||||||
|
empty: "Bu ayda işlem yok.",
|
||||||
|
truncated: "Çok fazla işlem var; yalnızca ilk 2.000 işlem gösteriliyor.",
|
||||||
|
lastReconciliation: (date: string, balance: string) =>
|
||||||
|
`Son mutabakat: ${date} · ekstre bakiyesi ${balance}`,
|
||||||
|
noReconciliation: "Henüz mutabakat yapılmadı.",
|
||||||
|
reconciledHint: "Mutabakatı yapılmış işlem",
|
||||||
|
adjustmentDescription: (statementBalance: string) =>
|
||||||
|
`Ekstre bakiyesi ${statementBalance} ile fark`,
|
||||||
|
reconcile: {
|
||||||
|
open: "Mutabakat yap",
|
||||||
|
title: "Mutabakat",
|
||||||
|
description:
|
||||||
|
"Banka veya kart ekstrenizdeki tarihi ve bakiyeyi girin; uygulamadaki bakiye ile karşılaştırılır.",
|
||||||
|
date: "Ekstre tarihi",
|
||||||
|
balance: "Ekstredeki bakiye (TL)",
|
||||||
|
debt: "Ekstredeki borç (TL)",
|
||||||
|
balanceHint: "Eksi bakiye için başına - koyun (ör. KMH kullanımı).",
|
||||||
|
debtHint: "Karttaki toplam borcu pozitif girin.",
|
||||||
|
check: "Karşılaştır",
|
||||||
|
statementBalance: "Ekstre",
|
||||||
|
appBalance: "Uygulama",
|
||||||
|
difference: "Fark",
|
||||||
|
matches: "Bakiyeler tutuyor.",
|
||||||
|
mismatch: "Bakiyeler tutmuyor. Eksik veya hatalı işlem olabilir.",
|
||||||
|
adjustmentOffer: (amount: string, kind: string) =>
|
||||||
|
`Farkı kapatmak için ${amount} tutarında bir “${kind}” düzeltme işlemi oluşturulsun.`,
|
||||||
|
acceptAdjustment: "Düzeltme işlemi oluştur",
|
||||||
|
unreconciled: (n: number) => `${n} işlem işaretlenecek.`,
|
||||||
|
confirm: "Mutabakatı tamamla",
|
||||||
|
confirmWithAdjustment: "Düzelt ve tamamla",
|
||||||
|
done: (n: number) => `Mutabakat tamamlandı; ${n} işlem işaretlendi.`,
|
||||||
|
doneWithAdjustment: (n: number) => `Düzeltme işlemi oluşturuldu ve ${n} işlem işaretlendi.`,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
date: "Geçerli ve bugünden ileri olmayan bir tarih girin.",
|
||||||
|
archived: "Arşivlenmiş hesapta mutabakat yapılamaz.",
|
||||||
|
needsAdjustment:
|
||||||
|
"Bakiyeler tutmuyor; düzeltme işlemini onaylayın veya işlemleri kontrol edin.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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ü",
|
||||||
@@ -81,7 +656,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",
|
||||||
@@ -196,6 +771,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ı",
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user