Compare commits
46 Commits
2de5f0cfe1
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d3a9e3a68 | |||
| 43053898b4 | |||
| 8a9f5ddc7d | |||
| 2e2e33233f | |||
| 2e1f4d7929 | |||
| 99fc9dfea6 | |||
| 00f8809823 | |||
| 3acd71fd59 | |||
| dcfa2f8c8d | |||
| 1208e2d1eb | |||
| d18c8a0d76 | |||
| a00c953dc0 | |||
| 904a969b18 | |||
| 0558d92203 | |||
| d2aa9d8ae2 | |||
| 8dd5fd42e3 | |||
| 380ddaf303 | |||
| eaf80c6bb4 | |||
| 7fda139199 | |||
| be9f2a5c3a | |||
| b79951601c | |||
| 3644e081b1 | |||
| de91f2505a | |||
| 291a117b4f | |||
| cd98694f0d | |||
| 2b648975ae | |||
| 38e2a7a26d | |||
| 3e11a34235 | |||
| cdbd7a0446 | |||
| fc3e0ae21b | |||
| 97e7ebe9aa | |||
| 2035f73e7d | |||
| 5191a5cce1 | |||
| 381214c2cb | |||
| e57e7b5517 | |||
| 9ae3eaf030 | |||
| 3b94bceaf0 | |||
| 1cf9fd9d8c | |||
| 4dbd2eb441 | |||
| c875847b65 | |||
| 4f41c0f668 | |||
| 1941b72cc6 | |||
| 708e4cf5c6 | |||
| c45e58c732 | |||
| 54d83cc6b9 | |||
| 7f69f4019d |
@@ -0,0 +1,24 @@
|
||||
# Bağımlılıklar ve build çıktıları imaj içinde yeniden üretilir
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Sırlar ve kullanıcı verisi asla imaja girmez
|
||||
.env*
|
||||
!.env.example
|
||||
uploads
|
||||
|
||||
# Geliştirme araçları
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.claude
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
*.md
|
||||
!README.md
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,24 @@
|
||||
# BÜTÇEM ortam değişkenleri — kopyala: cp .env.example .env.local
|
||||
# Gerçek değerler yalnızca .env.local (lokal) ve Dokploy ortam değişkenlerinde (canlı) tutulur.
|
||||
|
||||
# Lokal: docker compose ile tek düğümlü replica set (transaction için zorunlu)
|
||||
MONGODB_URI="mongodb://localhost:27019/butcem?replicaSet=rs0&directConnection=true"
|
||||
|
||||
# Üret: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET=""
|
||||
# Lokal: http://localhost:3000 — Canlı: https://butcem.ta1sph.website
|
||||
BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
# Faz 4 seed'i için ilk admin hesabı
|
||||
SEED_ADMIN_EMAIL="admin@butcem.local"
|
||||
SEED_ADMIN_PASSWORD=""
|
||||
# Örnek aile kullanıcılarının ortak şifresi (yalnızca geliştirme; production'da örnek kullanıcı oluşturulmaz)
|
||||
SEED_USER_PASSWORD=""
|
||||
|
||||
# Ek dosyalar (canlıda Docker volume: /app/uploads)
|
||||
UPLOAD_DIR="./uploads"
|
||||
MAX_UPLOAD_MB="10"
|
||||
|
||||
# Build sırasında Dockerfile tarafından doldurulur; lokalde boş bırakılabilir
|
||||
APP_VERSION=""
|
||||
APP_COMMIT=""
|
||||
@@ -30,8 +30,9 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
# env files (only the example is committed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -39,3 +40,9 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# user uploads (never committed; backed up separately)
|
||||
/uploads/
|
||||
|
||||
# local tooling
|
||||
/scratch/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.next
|
||||
node_modules
|
||||
package-lock.json
|
||||
coverage
|
||||
uploads
|
||||
next-env.d.ts
|
||||
docs/PROMPT.md
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindStylesheet": "./src/app/globals.css",
|
||||
"tailwindFunctions": ["cn", "cva"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"vitest.explorer",
|
||||
"mongodb.mongodb-vscode",
|
||||
"editorconfig.editorconfig"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"files.eol": "\n",
|
||||
"tailwindCSS.experimental.classRegex": [["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]],
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
# Değişiklik Günlüğü
|
||||
|
||||
Bu projedeki tüm önemli değişiklikler bu dosyada belgelenir.
|
||||
|
||||
Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ve proje [Semantic Versioning](https://semver.org/lang/tr/) kullanır.
|
||||
|
||||
## [Yayınlanmamış]
|
||||
|
||||
## [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
|
||||
|
||||
Faz 4: Örnek test verisi.
|
||||
|
||||
### Eklendi
|
||||
|
||||
- Seed (`npm run seed`): admin + 7 örnek kullanıcı (biri askıda, biri hanesi olmayan ikinci admin, ikisi e-postasız ve kullanıcı adıyla giriş yapan), 2 hane ("Çakır Ailesi", "Annemler") ve hane içi roller (owner / member / viewer; bir kullanıcı iki hanede farklı rollerle).
|
||||
- Seed idempotenttir (kullanıcı adı, hane adı ve üyelik çiftine göre); kullanıcılar Better Auth API'siyle oluşturulur. Geliştirme hesapları konsolda tablo olarak gösterilir.
|
||||
- `npm run seed:reset`: kimlik doğrulama ve hane koleksiyonlarını temizleyip yeniden doldurur; production'da reddedilir. Production'da seed yalnızca admini oluşturur.
|
||||
- Mongoose modelleri: `households` (ayarlar: bütçe ayı başlangıcı, taksit ve günlük plan tercihleri, özel hesaplar, ek mağaza eşiği) ve `householdMembers` (benzersiz `{householdId, userId}` index'i).
|
||||
- `SEED_USER_PASSWORD` ortam değişkeni (örnek kullanıcıların ortak geliştirme şifresi).
|
||||
- Seed entegrasyon testleri (idempotentlik, roller, askı, e-postasız giriş, reset).
|
||||
|
||||
## [0.3.0] - 2026-11-05
|
||||
|
||||
Faz 3: Backend — kimlik doğrulama, yetki katmanı ve güvenlik.
|
||||
|
||||
### Eklendi
|
||||
|
||||
- Better Auth (MongoDB adaptörü, replica set transaction): e-posta veya **kullanıcı adı** ile giriş, kayıt (sign-up) kapalı, admin plugin, 30 günlük oturum, `HttpOnly`/`Secure`/`SameSite=Lax` cookie.
|
||||
- Rate limit (veritabanında): genel 100 istek/dk, giriş denemeleri 5/dk.
|
||||
- Data Access Layer (`lib/dal.ts`): `getCurrentUser` (istek başına tek okuma), `requireSession`, `requireRole`, `getActionUser`. Askıdaki kullanıcının oturumu reddedilir.
|
||||
- `proxy.ts`: iyimser oturum cookie kontrolü (oturumsuz → `/login`); asıl kontrol DAL'da.
|
||||
- Admin kullanıcı yönetimi Server Action'ları: oluşturma, düzenleme, rol, şifre sıfırlama (diğer oturumları kapatır), askıya alma/kaldırma, silme. Kendini silememe/askıya alamama ve son admini düşürememe sunucuda da uygulanır; Better Auth hataları Türkçe alan hatalarına çevrilir.
|
||||
- E-postası olmayan kullanıcılar için `.invalid` yer tutucu adres (arayüzde gizlenir).
|
||||
- MongoDB (`lib/db/mongo.ts`) ve Mongoose (`lib/db/mongoose.ts`) bağlantıları (HMR güvenli tekil örnek, tembel oluşturma).
|
||||
- Güvenlik başlıkları: CSP (nonce'suz, Cache Components uyumlu), `X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`; production'da HSTS.
|
||||
- `GET /api/health` veritabanı ping'i; erişilemezse 503.
|
||||
- `npm run seed`: ilk admini (`SEED_ADMIN_*`, kullanıcı adı `admin`) idempotent olarak oluşturur.
|
||||
- `npm run test:integration`: gerçek MongoDB'de (`butcem_test`) kimlik doğrulama sözleşmesi testleri (kayıt kapalı, giriş, askı, admin yetkisi, rate limit).
|
||||
- BÜTÇEM logosu ve ikon seti (`logo.svg`, `logo-dark.svg`, `icon.svg`, `icon-maskable.svg`); `npm run icons` SVG kaynaktan üretir.
|
||||
|
||||
### Değişti
|
||||
|
||||
- Giriş, çıkış, dashboard ve admin sayfaları mock yerine gerçek oturum ve verilerle çalışır; oturuma bağlı kabuk Suspense içinde akış olarak gelir.
|
||||
|
||||
### Düzeltildi
|
||||
|
||||
- Kullanıcı adı normalizasyonu: `tr-TR` küçük harf dönüşümü "ADMIN"i "admın" yapıyordu; artık İ/I/ı → i ve ASCII küçük harf.
|
||||
|
||||
### Kaldırıldı
|
||||
|
||||
- Oturum ve admin kullanıcı listesi mock verileri (hane mock'u Faz 5'e kadar duruyor).
|
||||
|
||||
## [0.2.0] - 2026-11-05
|
||||
|
||||
Faz 2: Frontend iskeleti (veriler Faz 3'e kadar mock).
|
||||
|
||||
### Eklendi
|
||||
|
||||
- Marka paleti: mavi/beyaz oklch tema (açık), lacivert/açık mavi (koyu); anlamsal `--income`, `--expense`, `--budget`, `--warning` değişkenleri ve grafik renkleri.
|
||||
- Açık / Koyu / Sistem tema (next-themes) ve her ekranda tema seçici.
|
||||
- Giriş ekranı (`/login`): kullanıcı adı veya e-posta + şifre, zod doğrulama, şifre göster/gizle, yükleniyor durumu; kayıt bağlantısı yok. `/` her zaman `/login`'e yönlenir.
|
||||
- Uygulama kabuğu: masaüstünde daraltılabilir yan menü, mobilde alt gezinme çubuğu (ortada Hızlı ekle, "Daha fazla" ile menü), üst bar (hane seçici, Hızlı ekle, tema, kullanıcı menüsü: ad, rol, sürüm, Çıkış yap). Henüz gelmeyen sayfalar "Yakında" olarak pasif.
|
||||
- Hızlı ekle (gider / gelir / transfer) seçimi; mobilde alttan açılan Drawer, masaüstünde Dialog (`ResponsiveDialog`).
|
||||
- Dashboard: karşılama ve özet kartları (Suspense + Skeleton).
|
||||
- Admin kullanıcı yönetimi (`/admin/kullanicilar`): TanStack Table v9 ile Türkçe duyarlı arama, sıralama, sayfalama; oluşturma, düzenleme, rol değiştirme, şifre sıfırlama, askıya alma/kaldırma, silme (onaylı). Kendini silememe ve son admini düşürememe kuralları (`checkAdminAction`).
|
||||
- Paylaşılan zod şemaları (`src/shared/schemas`: giriş, kullanıcı, şifre, askı), Türkçe arama/sıralama (`foldTr`, `compareTr`), TL ve İstanbul saatli tarih biçimleri (`formatMoney`, `formatDate`).
|
||||
- PWA temeli: `manifest.webmanifest`, tema rengi, Apple web app meta, ikonlar.
|
||||
- `npm run icons`: `public/brand/icon.png`'den favicon.ico, uygulama, Apple ve maskable PWA ikonları üretir (sharp).
|
||||
- Yer tutucu marka dosyaları (`public/brand/logo.svg`, `logo-dark.svg`, `icon.png`).
|
||||
- 404, hata (`retry`) ve global hata sayfaları; yükleniyor ekranları.
|
||||
- `ActionResult` sonuç tipi (Faz 3 Server Action sözleşmesi).
|
||||
|
||||
### Değişti
|
||||
|
||||
- `use-mobile` hook'u `useSyncExternalStore` ile yeniden yazıldı (effect içinde setState yok, hydration güvenli).
|
||||
- ESLint: `_` önekli bilinçli kullanılmayan değişkenlere izin.
|
||||
|
||||
## [0.1.0] - 2026-11-05
|
||||
|
||||
Faz 1: Framework, klasör yapısı ve altyapı.
|
||||
|
||||
### Eklendi
|
||||
|
||||
- Next.js 16.4 (App Router, Turbopack, Cache Components), React 19.3, TypeScript strict (`noUncheckedIndexedAccess`), Tailwind CSS 4.
|
||||
- shadcn/ui (Radix, Nova preset) başlangıç yapılandırması.
|
||||
- Bağımlılıklar: Better Auth, MongoDB sürücüsü 7.7 (Mongoose ile ortak), Mongoose 9, zod 4, react-hook-form, next-themes, sonner, TanStack Table.
|
||||
- `src/` tabanlı klasör yapısı (`features`, `shared`, `models`, `lib/db`, `lib/storage`, `i18n`, `scripts/migrations`).
|
||||
- zod ile doğrulanan `env.ts` (tembel doğrulama, build sırasında sır gerektirmez) ve birim testleri.
|
||||
- `src/i18n/tr.ts` arayüz metinleri dosyası.
|
||||
- Lokal MongoDB 9 tek düğümlü replica set (`docker-compose.yml`, `rs0`, otomatik başlatma, PRIMARY healthcheck, host portu 27019).
|
||||
- Çok aşamalı `Dockerfile` (standalone çıktı, root olmayan kullanıcı, `/app/uploads` volume, HEALTHCHECK) ve `.dockerignore`.
|
||||
- Geçici `GET /api/health` (sürüm ve commit bilgisi).
|
||||
- Vitest, Prettier (Tailwind eklentisi), EditorConfig, VSCode önerilen eklentiler.
|
||||
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
|
||||
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
|
||||
|
||||
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.5.0...HEAD
|
||||
[0.5.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0
|
||||
[0.2.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://gitea.ta1sph.info/suphicakir/butcem/releases/tag/v0.1.0
|
||||
@@ -0,0 +1,54 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# BÜTÇEM — çok aşamalı üretim imajı (Next.js standalone çıktı).
|
||||
# Build: docker build --build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
|
||||
# Çalıştır: docker run -p 3000:3000 --env-file .env.production -v butcem-uploads:/app/uploads butcem
|
||||
|
||||
ARG NODE_VERSION=26
|
||||
|
||||
# 1) Bağımlılıklar — yalnızca lock dosyası değişince yeniden çalışır.
|
||||
FROM node:${NODE_VERSION}-alpine AS deps
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
# 2) Build
|
||||
FROM node:${NODE_VERSION}-alpine AS builder
|
||||
WORKDIR /app
|
||||
ARG APP_VERSION=""
|
||||
ARG APP_COMMIT="unknown"
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
APP_VERSION=${APP_VERSION} \
|
||||
APP_COMMIT=${APP_COMMIT}
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# 3) Çalışma zamanı — yalnızca standalone çıktı, root olmayan kullanıcı.
|
||||
FROM node:${NODE_VERSION}-alpine AS runner
|
||||
WORKDIR /app
|
||||
ARG APP_VERSION=""
|
||||
ARG APP_COMMIT="unknown"
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
UPLOAD_DIR=/app/uploads \
|
||||
APP_VERSION=${APP_VERSION} \
|
||||
APP_COMMIT=${APP_COMMIT}
|
||||
|
||||
RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs \
|
||||
&& mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
VOLUME ["/app/uploads"]
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,36 +1,86 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# BÜTÇEM
|
||||
|
||||
## Getting Started
|
||||
Aile bütçesi, banka/kart/cari hesapları, birikimler (altın, döviz, ziynet), aylık bütçe, ev stoğu ve akıllı alışveriş listelerini tek yerde yöneten, çok kullanıcılı web uygulaması.
|
||||
|
||||
First, run the development server:
|
||||
- Ürün tanımı, mimari kurallar ve faz planı: [docs/PROMPT.md](docs/PROMPT.md)
|
||||
- Sürüm geçmişi: [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## Teknoloji
|
||||
|
||||
Next.js 16 (App Router, Turbopack, Cache Components) · React 19 · TypeScript (strict) · Tailwind CSS 4 · shadcn/ui (Radix) · Better Auth · MongoDB 9 (native driver + Mongoose) · zod · Vitest
|
||||
|
||||
## Gereksinimler
|
||||
|
||||
- Node.js **26** (Active LTS) — `nvm use` (`.nvmrc`)
|
||||
- Docker + Docker Compose (lokal MongoDB replica set)
|
||||
|
||||
## Lokal geliştirme
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
# 1) Bağımlılıklar
|
||||
npm install
|
||||
|
||||
# 2) Ortam değişkenleri — BETTER_AUTH_SECRET için: openssl rand -base64 32
|
||||
cp .env.example .env.local
|
||||
|
||||
# 3) MongoDB (tek düğümlü replica set rs0, host portu 27019) — sağlıklı olana kadar bekler
|
||||
npm run db:up
|
||||
|
||||
# 4) Örnek veri: admin + aile kullanıcıları + 2 hane (tekrar çalıştırmak güvenli)
|
||||
npm run seed
|
||||
|
||||
# 5) Uygulama
|
||||
npm run dev # http://localhost:3000 → /login
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
### Geliştirme hesapları (`npm run seed`)
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
| Kullanıcı adı | Rol | Hane(ler) | Not |
|
||||
| ------------- | ----- | --------------------------------------- | ------------------------------- |
|
||||
| `admin` | admin | Çakır Ailesi (owner) | şifre `SEED_ADMIN_PASSWORD` |
|
||||
| `ayse` | user | Çakır Ailesi (owner), Annemler (viewer) | |
|
||||
| `emre` | user | Çakır Ailesi (member) | e-postasız |
|
||||
| `zeynep` | user | Çakır Ailesi (viewer) | e-postasız |
|
||||
| `fatma` | user | Annemler (owner) | |
|
||||
| `mehmet` | user | Annemler (member) | |
|
||||
| `burak` | admin | — | hanesi yok → hane verisi görmez |
|
||||
| `ozan` | user | Çakır Ailesi (member) | askıda, giriş yapamaz |
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
Admin dışındaki hesapların şifresi `SEED_USER_PASSWORD` (`.env.local`). Production'da yalnızca admin oluşturulur.
|
||||
|
||||
## Learn More
|
||||
Giriş kullanıcı adı **veya** e-posta ile yapılır. Kayıt kapalıdır; kullanıcıları admin `/admin/kullanicilar` sayfasından oluşturur.
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
## Scriptler
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
| Komut | Açıklama |
|
||||
| ----------------------------- | --------------------------------- |
|
||||
| `npm run dev` | Geliştirme sunucusu |
|
||||
| `npm run build` / `start` | Üretim build'i / sunucusu |
|
||||
| `npm run lint` | ESLint |
|
||||
| `npm run typecheck` | `tsc --noEmit` |
|
||||
| `npm run test` | Vitest birim testleri |
|
||||
| `npm run format` | Prettier |
|
||||
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
|
||||
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
|
||||
| `npm run test:integration` | Entegrasyon testleri (MongoDB) |
|
||||
| `npm run migrate` | Bekleyen migration'lar |
|
||||
| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil |
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
## Haneler ve yetki
|
||||
|
||||
## Deploy on Vercel
|
||||
- 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.
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
## Marka dosyaları
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
`public/brand/`: `logo.svg` (açık tema), `logo-dark.svg` (koyu tema), `icon.svg` (uygulama ikonu), `icon-maskable.svg` (Android/iOS, köşesiz). Yazı Noto Sans Bold (OFL) harf yollarıdır. İkonları değiştirdikten sonra `npm run icons` çalıştırın; favicon ve PWA ikonları bu kaynaklardan üretilir.
|
||||
|
||||
## Docker imajı
|
||||
|
||||
```bash
|
||||
docker build --build-arg APP_VERSION=$(node -p "require('./package.json').version") \
|
||||
--build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
|
||||
```
|
||||
|
||||
Canlı ortam (Dokploy, `butcem.ta1sph.website`) kurulumu Faz 15'te `docs/DEPLOY.md` içinde anlatılacak.
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,26 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert h-5 w-[100px]"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the{" "}
|
||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
||||
page.tsx
|
||||
</code>{" "}
|
||||
file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert h-[14px] w-4"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={14}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# Lokal geliştirme: MongoDB tek düğümlü replica set (rs0) — transaction için zorunlu.
|
||||
# Başlat: npm run db:up Durdur: npm run db:down Veriyi sil: docker compose down -v
|
||||
name: butcem
|
||||
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:9
|
||||
container_name: butcem-mongo
|
||||
command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--port", "27017"]
|
||||
ports:
|
||||
# Yalnızca bu makineden erişilebilir. Host portu 27019 (27017/27018 bu makinede dolu);
|
||||
# değiştirmek için: BUTCEM_MONGO_PORT=xxxxx npm run db:up
|
||||
- "127.0.0.1:${BUTCEM_MONGO_PORT:-27019}:27017"
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
- mongo-config:/data/configdb
|
||||
restart: unless-stopped
|
||||
# Healthcheck replica set'i ilk çalıştırmada başlatır; düğüm PRIMARY olana kadar "unhealthy" kalır.
|
||||
# Üye adresi konteyner içinden "localhost:27017"; uygulama host'tan directConnection=true ile
|
||||
# bağlandığı için üye listesini kullanmaz, port eşlemesi sorun olmaz.
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- mongosh
|
||||
- --quiet
|
||||
- --eval
|
||||
- >-
|
||||
try { rs.status() } catch (e) {
|
||||
rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] }) }
|
||||
if (!db.hello().isWritablePrimary) { quit(1) }
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
start_period: 20s
|
||||
start_interval: 2s
|
||||
retries: 30
|
||||
|
||||
volumes:
|
||||
mongo-data:
|
||||
mongo-config:
|
||||
@@ -258,8 +258,8 @@ Hesap türleri:
|
||||
|
||||
### 8.1 Lokal geliştirme
|
||||
- MongoDB, `docker-compose.yml` ile **tek düğümlü replica set** (`rs0`) olarak çalışır (transaction için zorunlu); kalıcı volume, healthcheck, replica set'i otomatik başlatan init.
|
||||
- Lokal bağlantı: `mongodb://localhost:27017/butcem?replicaSet=rs0&directConnection=true`.
|
||||
- `env.ts` zod ile doğrular: `MONGODB_URI`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD`, `UPLOAD_DIR`, `MAX_UPLOAD_MB`, `APP_VERSION` (build sırasında).
|
||||
- Lokal bağlantı: `mongodb://localhost:27019/butcem?replicaSet=rs0&directConnection=true` (bkz. 10.1).
|
||||
- `env.ts` zod ile doğrular: `MONGODB_URI`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD`, `UPLOAD_DIR`, `MAX_UPLOAD_MB`, `APP_VERSION` ve `APP_COMMIT` (build sırasında).
|
||||
|
||||
### 8.2 Canlı ortam (homeserver: "Atlas")
|
||||
- Kaynak kod: Atlas üzerindeki **Gitea** reposu (`origin`).
|
||||
@@ -309,6 +309,17 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
||||
- Stok eşiği hedef stoğun %20'si; ürün tipi bazında değiştirilebilir.
|
||||
- Ek dosya sınırı: dosya başına 10 MB, kayıt başına 10 dosya.
|
||||
|
||||
### 10.1 Faz 0 kararları (2026-11-05, onaylandı)
|
||||
- Yukarıdaki varsayılanların tümü kabul edildi.
|
||||
- **Giriş:** kullanıcı adı **veya** e-posta + şifre (Better Auth `username` plugin). Kayıt yine kapalı.
|
||||
- **Admin veri erişimi:** sistem admini kullanıcıları, haneleri ve üyelikleri yönetir; üyesi olmadığı hanenin finansal verisini **göremez**.
|
||||
- **Bütçe ayı başlangıcı:** hane ayarı `budgetMonthStartDay` (1–28); varsayılan 1 (takvim ayı). Ay sınırı hesapları bu ayara göre yapı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.
|
||||
- **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı).
|
||||
- **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır.
|
||||
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
||||
|
||||
---
|
||||
|
||||
## 11. Ek İstekler
|
||||
@@ -5,6 +5,15 @@ import nextTs from "eslint-config-next/typescript";
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
rules: {
|
||||
// "_" ile başlayan adlar bilinçli olarak kullanılmayan değişkenlerdir.
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
|
||||
@@ -1,7 +1,65 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
/**
|
||||
* Content-Security-Policy (nonce'suz): Cache Components'in statik kabuğu ile uyumlu.
|
||||
* 'unsafe-inline' script: Next.js'in satır içi hydration verisi ve next-themes'in tema betiği için.
|
||||
* Grafikler (Recharts, SVG) ve carousel (Embla) ek kaynak gerektirmez; ek dosyalar
|
||||
* kendi origin'imizden (/api/files/...) ve blob: önizlemelerle servis edilir.
|
||||
*/
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' blob: data:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"media-src 'self' blob:",
|
||||
"worker-src 'self' blob:",
|
||||
"manifest-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
...(isDev ? [] : ["upgrade-insecure-requests"]),
|
||||
].join("; ");
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: "Content-Security-Policy", value: csp },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
|
||||
// Kamera: Faz 6'da fiş fotoğrafı için yalnızca kendi origin'imiz.
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(self), microphone=(), geolocation=(), payment=(), usb=()",
|
||||
},
|
||||
...(isDev
|
||||
? []
|
||||
: // includeSubDomains/preload bilinçli olarak yok: ta1sph.website'in diğer alt alanlarını bağlamasın.
|
||||
[{ key: "Strict-Transport-Security", value: "max-age=31536000" }]),
|
||||
];
|
||||
|
||||
/**
|
||||
* /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 = {
|
||||
/* config options here */
|
||||
// Docker imajı için yalnızca gerekli dosyaları .next/standalone altına kopyala.
|
||||
output: "standalone",
|
||||
poweredByHeader: false,
|
||||
// HEIC çözücü (libheif WASM) bundle'a girmesin; Node require ile yüklensin.
|
||||
serverExternalPackages: ["heic-decode", "libheif-js"],
|
||||
experimental: {
|
||||
agentFeedback: true,
|
||||
},
|
||||
@@ -15,6 +73,18 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
async headers() {
|
||||
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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,26 +1,68 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||
"engines": {
|
||||
"node": ">=26 <27"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"db:up": "docker compose up -d --wait",
|
||||
"db:down": "docker compose down",
|
||||
"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",
|
||||
"icons": "tsx scripts/generate-icons.ts",
|
||||
"test:integration": "vitest run --config vitest.integration.config.mts",
|
||||
"migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts",
|
||||
"cleanup": "tsx --conditions=react-server --env-file=.env.local scripts/cleanup.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^7.0.2",
|
||||
"@tanstack/react-table": "^9.3.1",
|
||||
"better-auth": "^1.7.17",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cn": "^0.5.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"heic-decode": "^2.1.0",
|
||||
"lucide-react": "^1.68.0",
|
||||
"mongodb": "~7.7",
|
||||
"mongoose": "^9.11.4",
|
||||
"next": "16.4.5",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0"
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.8.1",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "^4.23.0",
|
||||
"sharp": "^0.35.5",
|
||||
"sonner": "^2.0.8",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^10.6.0",
|
||||
"@tailwindcss/turbopack": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^26.6.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.4.5",
|
||||
"prettier": "^3.9.10",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5",
|
||||
"vitest": "^5.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="BÜTÇEM">
|
||||
<!-- BÜTÇEM maskable/Apple ikonu (köşesiz, tam zemin). Kaynak: npm run icons -->
|
||||
<defs><linearGradient id="mg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
|
||||
<rect width="512" height="512" fill="url(#mg)"/>
|
||||
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
|
||||
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
|
||||
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 785 B |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="BÜTÇEM">
|
||||
<!-- BÜTÇEM uygulama ikonu: hane (çatı) + yükselen bütçe/birikim sütunları. Kaynak: npm run icons -->
|
||||
<defs><linearGradient id="ig" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#ig)"/>
|
||||
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
|
||||
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
|
||||
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 820 B |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 227 64" width="227" height="64" role="img" aria-label="BÜTÇEM">
|
||||
<!-- BÜTÇEM logosu (koyu tema). Yazı Noto Sans Bold (OFL) yollarıdır; font gerektirmez. -->
|
||||
<g transform="scale(0.125)">
|
||||
<defs><linearGradient id="dg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#dg)"/>
|
||||
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
|
||||
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
|
||||
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
|
||||
</g>
|
||||
<path fill="#edf2f8" d="M88.744 19.863999999999997Q93.742 19.863999999999997 96.02000000000001 21.342999999999996Q98.298 22.822 98.298 25.916Q98.298 28.194 97.09100000000001 29.537Q95.884 30.88 94.184 31.253999999999998V31.424Q95.374 31.662 96.46199999999999 32.274Q97.55 32.885999999999996 98.22999999999999 34.042Q98.91 35.198 98.91 37.102000000000004Q98.91 40.434 96.513 42.287Q94.116 44.14 89.968 44.14H80.89V19.863999999999997ZM89.016 29.588Q91.226 29.588 92.093 28.772Q92.96000000000001 27.956 92.96000000000001 26.63Q92.96000000000001 25.236 91.94 24.573Q90.92 23.91 88.778 23.91H86.092V29.588ZM86.092 33.498V40.026H89.39Q91.702 40.026 92.60300000000001 39.091Q93.504 38.156 93.504 36.694Q93.504 35.368 92.58600000000001 34.433Q91.668 33.498 89.22 33.498Z M124.10400000000001 35.572Q124.10400000000001 38.088 122.99900000000001 40.111000000000004Q121.894 42.134 119.667 43.307Q117.44000000000001 44.480000000000004 114.04 44.480000000000004Q109.212 44.480000000000004 106.696 42.015Q104.18 39.55 104.18 35.504V19.863999999999997H109.31400000000001V34.722Q109.31400000000001 37.714 110.53800000000001 38.938Q111.762 40.162 114.176 40.162Q116.69200000000001 40.162 117.831 38.836Q118.97 37.51 118.97 34.688V19.863999999999997H124.10400000000001ZM108.05600000000001 15.103999999999996Q108.05600000000001 13.913999999999994 108.71900000000001 13.369999999999996Q109.382 12.825999999999997 110.40200000000002 12.825999999999997Q111.32000000000001 12.825999999999997 112.03400000000002 13.369999999999996Q112.74800000000002 13.913999999999994 112.74800000000002 15.103999999999996Q112.74800000000002 16.225999999999996 112.03400000000002 16.769999999999996Q111.32000000000001 17.313999999999997 110.40200000000002 17.313999999999997Q109.382 17.313999999999997 108.71900000000001 16.769999999999996Q108.05600000000001 16.225999999999996 108.05600000000001 15.103999999999996ZM115.4 15.103999999999996Q115.4 13.913999999999994 116.08000000000001 13.369999999999996Q116.76000000000002 12.825999999999997 117.74600000000001 12.825999999999997Q118.69800000000001 12.825999999999997 119.39500000000001 13.369999999999996Q120.09200000000001 13.913999999999994 120.09200000000001 15.103999999999996Q120.09200000000001 16.225999999999996 119.39500000000001 16.769999999999996Q118.69800000000001 17.313999999999997 117.74600000000001 17.313999999999997Q116.76000000000002 17.313999999999997 116.08000000000001 16.769999999999996Q115.4 16.225999999999996 115.4 15.103999999999996Z M140.084 44.14H134.882V24.182H128.32V19.863999999999997H146.64600000000002V24.182H140.084Z M161.50400000000002 23.842Q158.58 23.842 156.897 26.035Q155.214 28.228 155.214 32.07Q155.214 35.912 156.829 38.037Q158.44400000000002 40.162 161.708 40.162Q163.306 40.162 164.78500000000003 39.788Q166.264 39.414 167.76000000000002 38.836V43.256Q164.87 44.480000000000004 161.13 44.480000000000004Q157.25400000000002 44.480000000000004 154.755 42.93300000000001Q152.256 41.386 151.049 38.581Q149.842 35.775999999999996 149.842 32.036Q149.842 28.363999999999997 151.202 25.541999999999998Q152.562 22.72 155.18 21.122Q157.798 19.523999999999997 161.572 19.523999999999997Q163.37400000000002 19.523999999999997 165.22700000000003 19.915Q167.08 20.305999999999997 168.746 21.122L167.04600000000002 25.27Q165.822 24.692 164.445 24.267Q163.068 23.842 161.50400000000002 23.842ZM164.496 48.526Q164.496 50.26 163.238 51.28Q161.98000000000002 52.3 159.73600000000002 52.3Q158.818 52.3 158.07000000000002 52.181Q157.32200000000003 52.062 156.846 51.892V49.206Q157.83200000000002 49.512 158.88600000000002 49.512Q160.68800000000002 49.512 160.68800000000002 48.322Q160.68800000000002 47.642 160.00800000000004 47.268Q159.32800000000003 46.894 158.03600000000003 46.69L159.22600000000003 44.14H161.94600000000003L161.436 45.466Q162.15 45.636 162.86400000000003 45.976Q163.57800000000003 46.316 164.03700000000003 46.928Q164.496 47.54 164.496 48.526Z"/>
|
||||
<path fill="#6bb9f8" d="M187.514 44.14H173.54000000000002V19.863999999999997H187.514V24.08H178.674V29.418H186.90200000000002V33.634H178.674V39.89H187.514Z M203.52800000000002 44.14 197.68 25.099999999999998H197.544Q197.57800000000003 25.779999999999998 197.64600000000002 27.14Q197.71400000000003 28.5 197.78200000000004 30.047Q197.85000000000002 31.594 197.85000000000002 32.852000000000004V44.14H193.26000000000002V19.863999999999997H200.264L206.01000000000002 38.428H206.11200000000002L212.198 19.863999999999997H219.20200000000003V44.14H214.40800000000002V32.647999999999996Q214.40800000000002 31.491999999999997 214.459 29.996Q214.51000000000002 28.5 214.56100000000004 27.157Q214.61200000000002 25.814 214.64600000000002 25.134H214.51000000000002L208.25400000000002 44.14Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 227 64" width="227" height="64" role="img" aria-label="BÜTÇEM">
|
||||
<!-- BÜTÇEM logosu (açık tema). Yazı Noto Sans Bold (OFL) yollarıdır; font gerektirmez. -->
|
||||
<g transform="scale(0.125)">
|
||||
<defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#lg)"/>
|
||||
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
|
||||
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
|
||||
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
|
||||
</g>
|
||||
<path fill="#101a2d" d="M88.744 19.863999999999997Q93.742 19.863999999999997 96.02000000000001 21.342999999999996Q98.298 22.822 98.298 25.916Q98.298 28.194 97.09100000000001 29.537Q95.884 30.88 94.184 31.253999999999998V31.424Q95.374 31.662 96.46199999999999 32.274Q97.55 32.885999999999996 98.22999999999999 34.042Q98.91 35.198 98.91 37.102000000000004Q98.91 40.434 96.513 42.287Q94.116 44.14 89.968 44.14H80.89V19.863999999999997ZM89.016 29.588Q91.226 29.588 92.093 28.772Q92.96000000000001 27.956 92.96000000000001 26.63Q92.96000000000001 25.236 91.94 24.573Q90.92 23.91 88.778 23.91H86.092V29.588ZM86.092 33.498V40.026H89.39Q91.702 40.026 92.60300000000001 39.091Q93.504 38.156 93.504 36.694Q93.504 35.368 92.58600000000001 34.433Q91.668 33.498 89.22 33.498Z M124.10400000000001 35.572Q124.10400000000001 38.088 122.99900000000001 40.111000000000004Q121.894 42.134 119.667 43.307Q117.44000000000001 44.480000000000004 114.04 44.480000000000004Q109.212 44.480000000000004 106.696 42.015Q104.18 39.55 104.18 35.504V19.863999999999997H109.31400000000001V34.722Q109.31400000000001 37.714 110.53800000000001 38.938Q111.762 40.162 114.176 40.162Q116.69200000000001 40.162 117.831 38.836Q118.97 37.51 118.97 34.688V19.863999999999997H124.10400000000001ZM108.05600000000001 15.103999999999996Q108.05600000000001 13.913999999999994 108.71900000000001 13.369999999999996Q109.382 12.825999999999997 110.40200000000002 12.825999999999997Q111.32000000000001 12.825999999999997 112.03400000000002 13.369999999999996Q112.74800000000002 13.913999999999994 112.74800000000002 15.103999999999996Q112.74800000000002 16.225999999999996 112.03400000000002 16.769999999999996Q111.32000000000001 17.313999999999997 110.40200000000002 17.313999999999997Q109.382 17.313999999999997 108.71900000000001 16.769999999999996Q108.05600000000001 16.225999999999996 108.05600000000001 15.103999999999996ZM115.4 15.103999999999996Q115.4 13.913999999999994 116.08000000000001 13.369999999999996Q116.76000000000002 12.825999999999997 117.74600000000001 12.825999999999997Q118.69800000000001 12.825999999999997 119.39500000000001 13.369999999999996Q120.09200000000001 13.913999999999994 120.09200000000001 15.103999999999996Q120.09200000000001 16.225999999999996 119.39500000000001 16.769999999999996Q118.69800000000001 17.313999999999997 117.74600000000001 17.313999999999997Q116.76000000000002 17.313999999999997 116.08000000000001 16.769999999999996Q115.4 16.225999999999996 115.4 15.103999999999996Z M140.084 44.14H134.882V24.182H128.32V19.863999999999997H146.64600000000002V24.182H140.084Z M161.50400000000002 23.842Q158.58 23.842 156.897 26.035Q155.214 28.228 155.214 32.07Q155.214 35.912 156.829 38.037Q158.44400000000002 40.162 161.708 40.162Q163.306 40.162 164.78500000000003 39.788Q166.264 39.414 167.76000000000002 38.836V43.256Q164.87 44.480000000000004 161.13 44.480000000000004Q157.25400000000002 44.480000000000004 154.755 42.93300000000001Q152.256 41.386 151.049 38.581Q149.842 35.775999999999996 149.842 32.036Q149.842 28.363999999999997 151.202 25.541999999999998Q152.562 22.72 155.18 21.122Q157.798 19.523999999999997 161.572 19.523999999999997Q163.37400000000002 19.523999999999997 165.22700000000003 19.915Q167.08 20.305999999999997 168.746 21.122L167.04600000000002 25.27Q165.822 24.692 164.445 24.267Q163.068 23.842 161.50400000000002 23.842ZM164.496 48.526Q164.496 50.26 163.238 51.28Q161.98000000000002 52.3 159.73600000000002 52.3Q158.818 52.3 158.07000000000002 52.181Q157.32200000000003 52.062 156.846 51.892V49.206Q157.83200000000002 49.512 158.88600000000002 49.512Q160.68800000000002 49.512 160.68800000000002 48.322Q160.68800000000002 47.642 160.00800000000004 47.268Q159.32800000000003 46.894 158.03600000000003 46.69L159.22600000000003 44.14H161.94600000000003L161.436 45.466Q162.15 45.636 162.86400000000003 45.976Q163.57800000000003 46.316 164.03700000000003 46.928Q164.496 47.54 164.496 48.526Z"/>
|
||||
<path fill="#105bbe" d="M187.514 44.14H173.54000000000002V19.863999999999997H187.514V24.08H178.674V29.418H186.90200000000002V33.634H178.674V39.89H187.514Z M203.52800000000002 44.14 197.68 25.099999999999998H197.544Q197.57800000000003 25.779999999999998 197.64600000000002 27.14Q197.71400000000003 28.5 197.78200000000004 30.047Q197.85000000000002 31.594 197.85000000000002 32.852000000000004V44.14H193.26000000000002V19.863999999999997H200.264L206.01000000000002 38.428H206.11200000000002L212.198 19.863999999999997H219.20200000000003V44.14H214.40800000000002V32.647999999999996Q214.40800000000002 31.491999999999997 214.459 29.996Q214.51000000000002 28.5 214.56100000000004 27.157Q214.61200000000002 25.814 214.64600000000002 25.134H214.51000000000002L208.25400000000002 44.14Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
@@ -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,125 @@
|
||||
/**
|
||||
* Uygulama ikonlarını logodan üretir: npm run icons
|
||||
*
|
||||
* Kaynak: public/brand/icon.svg (varsa; önce 512×512 icon.png olarak render edilir)
|
||||
* veya doğrudan public/brand/icon.png (kare, en az 512×512).
|
||||
* Çıktılar (commit edilir):
|
||||
* public/brand/icon.png 512 px (icon.svg varsa ondan üretilir)
|
||||
* src/app/favicon.ico 16/32/48 px (PNG gömülü ICO)
|
||||
* src/app/icon.png 192 px → <link rel="icon">
|
||||
* src/app/apple-icon.png 180 px, saydamsız → iOS ana ekran
|
||||
* public/icons/icon-192.png PWA manifest
|
||||
* public/icons/icon-512.png PWA manifest
|
||||
* public/icons/maskable-512.png PWA manifest (purpose: maskable)
|
||||
*
|
||||
* public/brand/icon-maskable.svg (köşesiz, tam zemin) varsa maskable ve Apple ikonları ondan
|
||||
* üretilir; yoksa icon.png marka mavisi zemine %80 güvenli alanla yerleştirilir.
|
||||
*
|
||||
* Logoyu değiştirdikten sonra bu betiği yeniden çalıştırın.
|
||||
*/
|
||||
import { access, mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SOURCE = path.join(ROOT, "public/brand/icon.png");
|
||||
const SOURCE_SVG = path.join(ROOT, "public/brand/icon.svg");
|
||||
const MASKABLE_SVG = path.join(ROOT, "public/brand/icon-maskable.svg");
|
||||
/** Saydamlık kabul etmeyen ikonlarda arka plan: marka mavisi (--primary, açık tema). */
|
||||
const BRAND_BG = "#105bbe";
|
||||
|
||||
async function png(size: number, opts: { flatten?: boolean; padding?: number } = {}) {
|
||||
const inner = Math.round(size * (1 - (opts.padding ?? 0) * 2));
|
||||
const resized = await sharp(SOURCE).resize(inner, inner, { fit: "contain" }).png().toBuffer();
|
||||
let img = sharp({
|
||||
create: {
|
||||
width: size,
|
||||
height: size,
|
||||
channels: 4,
|
||||
background: opts.flatten || opts.padding ? BRAND_BG : { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
},
|
||||
}).composite([{ input: resized, gravity: "center" }]);
|
||||
if (opts.flatten) img = img.flatten({ background: BRAND_BG });
|
||||
return img.png({ compressionLevel: 9 }).toBuffer();
|
||||
}
|
||||
|
||||
/** PNG görüntülerinden ICO dosyası oluşturur (Vista+ tüm tarayıcılar PNG gömülü ICO destekler). */
|
||||
function toIco(images: { size: number; data: Buffer }[]): Buffer {
|
||||
const header = Buffer.alloc(6);
|
||||
header.writeUInt16LE(0, 0); // reserved
|
||||
header.writeUInt16LE(1, 2); // type: icon
|
||||
header.writeUInt16LE(images.length, 4);
|
||||
const entries: Buffer[] = [];
|
||||
let offset = 6 + images.length * 16;
|
||||
for (const { size, data } of images) {
|
||||
const e = Buffer.alloc(16);
|
||||
e.writeUInt8(size >= 256 ? 0 : size, 0);
|
||||
e.writeUInt8(size >= 256 ? 0 : size, 1);
|
||||
e.writeUInt8(0, 2); // palette
|
||||
e.writeUInt8(0, 3); // reserved
|
||||
e.writeUInt16LE(1, 4); // color planes
|
||||
e.writeUInt16LE(32, 6); // bpp
|
||||
e.writeUInt32LE(data.length, 8);
|
||||
e.writeUInt32LE(offset, 12);
|
||||
offset += data.length;
|
||||
entries.push(e);
|
||||
}
|
||||
return Buffer.concat([header, ...entries, ...images.map((i) => i.data)]);
|
||||
}
|
||||
|
||||
async function exists(file: string) {
|
||||
return access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (await exists(SOURCE_SVG)) {
|
||||
// Vektörden yüksek yoğunlukta render: küçük boyutlarda da keskin kenarlar.
|
||||
const png512 = await sharp(SOURCE_SVG, { density: 288 }).resize(512, 512).png().toBuffer();
|
||||
await writeFile(SOURCE, png512);
|
||||
console.log(`✓ public/brand/icon.png ← icon.svg (${png512.length} bayt)`);
|
||||
}
|
||||
const meta = await sharp(SOURCE).metadata();
|
||||
if (!meta.width || !meta.height || meta.width !== meta.height || meta.width < 512) {
|
||||
throw new Error(`${SOURCE} kare ve en az 512×512 olmalı (şu an ${meta.width}×${meta.height}).`);
|
||||
}
|
||||
await mkdir(path.join(ROOT, "public/icons"), { recursive: true });
|
||||
|
||||
const fullBleed = (await exists(MASKABLE_SVG))
|
||||
? (size: number) =>
|
||||
sharp(MASKABLE_SVG, { density: 288 })
|
||||
.resize(size, size)
|
||||
.flatten({ background: BRAND_BG })
|
||||
.png()
|
||||
.toBuffer()
|
||||
: null;
|
||||
|
||||
const out: [string, Buffer][] = [
|
||||
["src/app/icon.png", await png(192)],
|
||||
[
|
||||
"src/app/apple-icon.png",
|
||||
fullBleed ? await fullBleed(180) : await png(180, { flatten: true }),
|
||||
],
|
||||
["public/icons/icon-192.png", await png(192)],
|
||||
["public/icons/icon-512.png", await png(512)],
|
||||
[
|
||||
"public/icons/maskable-512.png",
|
||||
fullBleed ? await fullBleed(512) : await png(512, { padding: 0.1 }),
|
||||
],
|
||||
[
|
||||
"src/app/favicon.ico",
|
||||
toIco(await Promise.all([16, 32, 48].map(async (size) => ({ size, data: await png(size) })))),
|
||||
],
|
||||
];
|
||||
for (const [file, data] of out) {
|
||||
await writeFile(path.join(ROOT, file), data);
|
||||
console.log(`✓ ${file} (${data.length} bayt)`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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,38 @@
|
||||
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 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];
|
||||
|
||||
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>;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Seed: npm run seed | Sıfırlayıp doldurma: npm run seed:reset (production'da reddedilir)
|
||||
*
|
||||
* Oluşturulanlar: admin (SEED_ADMIN_*), 7 örnek kullanıcı (biri askıda, biri ikinci admin),
|
||||
* 2 hane ve hane içi rolleri. Ayrıntı: scripts/seed/fixtures.ts
|
||||
* `server-only` modülleri Next dışında yükleyebilmek için --conditions=react-server ile çalışır.
|
||||
*/
|
||||
import { getMongoClient } from "@/lib/db/mongo";
|
||||
import { env } from "@/lib/env";
|
||||
import { closeSeedConnections, runSeed } from "./seed/run";
|
||||
|
||||
async function main() {
|
||||
const summary = await runSeed({ reset: process.argv.includes("--reset") });
|
||||
const { users, households, memberships, categories, accounts, transactions } = summary.created;
|
||||
console.log(
|
||||
`\nOluşturulan: ${users} kullanıcı, ${households} hane, ${memberships} üyelik, ${categories} kategori, ${accounts} hesap, ${transactions} işlem.`,
|
||||
);
|
||||
|
||||
// Hesap tablosu yalnızca geliştirme ortamında gösterilir.
|
||||
if (env().NODE_ENV !== "production" && summary.accounts.length) {
|
||||
console.log(
|
||||
"\nGeliştirme hesapları (şifreler: admin → SEED_ADMIN_PASSWORD, diğerleri → SEED_USER_PASSWORD):",
|
||||
);
|
||||
console.table(summary.accounts);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await closeSeedConnections();
|
||||
await getMongoClient().close();
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { HouseholdRole } from "@/features/households/types";
|
||||
import type { AccountType, MealCardProvider } from "@/shared/accounts";
|
||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||
import type { SystemRole } from "@/shared/schemas/common";
|
||||
|
||||
/** Örnek (geliştirme) kullanıcıları. E-postası olmayanlar kullanıcı adıyla giriş yapar. */
|
||||
export type SeedUser = {
|
||||
username: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
role: SystemRole;
|
||||
banReason?: string;
|
||||
};
|
||||
|
||||
export const SEED_USERS: SeedUser[] = [
|
||||
{ username: "ayse", name: "Ayşe Çakır", email: "ayse@butcem.local", role: "user" },
|
||||
{ username: "emre", name: "Emre Çakır", role: "user" },
|
||||
{ username: "zeynep", name: "Zeynep Çakır", role: "user" },
|
||||
{ username: "fatma", name: "Fatma Demir", email: "fatma@butcem.local", role: "user" },
|
||||
{ username: "mehmet", name: "Mehmet Demir", email: "mehmet@butcem.local", role: "user" },
|
||||
// İkinci admin: hiçbir haneye üye değil → hane verisini göremez (Faz 0 kararı).
|
||||
{ username: "burak", name: "Burak Öztürk", email: "burak@butcem.local", role: "admin" },
|
||||
{
|
||||
username: "ozan",
|
||||
name: "Ozan Kaya",
|
||||
email: "ozan@butcem.local",
|
||||
role: "user",
|
||||
banReason: "Ev arkadaşlığı sona erdi; erişim kaldırıldı.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Seed admin'in kullanıcı adı (SEED_ADMIN_EMAIL ile oluşturulur). */
|
||||
export const ADMIN_USERNAME = "admin";
|
||||
|
||||
export type SeedHousehold = {
|
||||
name: string;
|
||||
members: { username: string; role: HouseholdRole }[];
|
||||
};
|
||||
|
||||
export const SEED_HOUSEHOLDS: SeedHousehold[] = [
|
||||
{
|
||||
name: "Çakır Ailesi",
|
||||
members: [
|
||||
{ username: ADMIN_USERNAME, role: "owner" },
|
||||
{ username: "ayse", role: "owner" },
|
||||
{ username: "emre", role: "member" },
|
||||
{ username: "zeynep", role: "viewer" },
|
||||
{ username: "ozan", role: "member" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Annemler",
|
||||
members: [
|
||||
{ username: "fatma", role: "owner" },
|
||||
{ username: "mehmet", role: "member" },
|
||||
// Bir kullanıcı birden çok haneye üye olabilir.
|
||||
{ username: "ayse", role: "viewer" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Örnek hesaplar (tutarlar kuruş; kart borcu negatif). Açılış: 6 ay önce. */
|
||||
export type SeedAccount = {
|
||||
household: string;
|
||||
name: string;
|
||||
type: AccountType;
|
||||
institution?: string;
|
||||
openingBalanceMinor: number;
|
||||
color: Swatch;
|
||||
icon: IconKey;
|
||||
owner?: string;
|
||||
isPrivate?: boolean;
|
||||
mealCardProvider?: MealCardProvider;
|
||||
overdraft?: { limitMinor: number; monthlyRateBp: number; statementDay: number };
|
||||
creditCard?: {
|
||||
limitMinor: number;
|
||||
statementDay: number;
|
||||
dueDays: number;
|
||||
paymentAccount: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const SEED_ACCOUNTS: SeedAccount[] = [
|
||||
{
|
||||
household: "Çakır Ailesi",
|
||||
name: "Cüzdan",
|
||||
type: "cash",
|
||||
openingBalanceMinor: 2_350_00,
|
||||
color: "green",
|
||||
icon: "wallet",
|
||||
},
|
||||
{
|
||||
household: "Çakır Ailesi",
|
||||
name: "Ziraat Maaş",
|
||||
type: "bank",
|
||||
institution: "Ziraat Bankası",
|
||||
openingBalanceMinor: 18_420_50,
|
||||
color: "red",
|
||||
icon: "landmark",
|
||||
overdraft: { limitMinor: 10_000_00, monthlyRateBp: 425, statementDay: 1 },
|
||||
},
|
||||
{
|
||||
household: "Çakır Ailesi",
|
||||
name: "Garanti Bonus",
|
||||
type: "credit_card",
|
||||
institution: "Garanti BBVA",
|
||||
openingBalanceMinor: -6_480_75,
|
||||
color: "teal",
|
||||
icon: "credit-card",
|
||||
creditCard: {
|
||||
limitMinor: 60_000_00,
|
||||
statementDay: 29,
|
||||
dueDays: 10,
|
||||
paymentAccount: "Ziraat Maaş",
|
||||
},
|
||||
},
|
||||
{
|
||||
household: "Çakır Ailesi",
|
||||
name: "Birikim (Vadeli)",
|
||||
type: "savings",
|
||||
institution: "Ziraat Bankası",
|
||||
openingBalanceMinor: 45_000_00,
|
||||
color: "indigo",
|
||||
icon: "piggy-bank",
|
||||
},
|
||||
{
|
||||
household: "Çakır Ailesi",
|
||||
name: "Ayşe — Pluxee",
|
||||
type: "meal_card",
|
||||
mealCardProvider: "pluxee",
|
||||
openingBalanceMinor: 3_120_00,
|
||||
color: "lime",
|
||||
icon: "utensils",
|
||||
owner: "ayse",
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
household: "Annemler",
|
||||
name: "Nakit",
|
||||
type: "cash",
|
||||
openingBalanceMinor: 1_500_00,
|
||||
color: "green",
|
||||
icon: "wallet",
|
||||
},
|
||||
{
|
||||
household: "Annemler",
|
||||
name: "Vakıfbank Emekli",
|
||||
type: "bank",
|
||||
institution: "VakıfBank",
|
||||
openingBalanceMinor: 27_800_00,
|
||||
color: "amber",
|
||||
icon: "landmark",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getAuth } from "@/lib/auth";
|
||||
import { authErrorCode } from "@/lib/auth-errors";
|
||||
import { getDb, getMongoClient } from "@/lib/db/mongo";
|
||||
import { HouseholdModel } from "@/models/household";
|
||||
import { HouseholdMemberModel } from "@/models/household-member";
|
||||
import { closeSeedConnections, runSeed } from "./run";
|
||||
import { SEED_HOUSEHOLDS, SEED_USERS } from "./fixtures";
|
||||
|
||||
const silent = () => {};
|
||||
|
||||
beforeAll(async () => {
|
||||
expect(getDb().databaseName).toBe("butcem_test");
|
||||
await getDb().dropDatabase();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await getDb().dropDatabase();
|
||||
await closeSeedConnections();
|
||||
await getMongoClient().close();
|
||||
});
|
||||
|
||||
describe("seed", () => {
|
||||
it("ilk çalıştırmada admin, örnek kullanıcılar, haneler ve üyelikleri oluşturur", async () => {
|
||||
const summary = await runSeed({ log: silent });
|
||||
const memberCount = SEED_HOUSEHOLDS.reduce((n, h) => n + h.members.length, 0);
|
||||
expect(summary.created).toMatchObject({
|
||||
users: SEED_USERS.length + 1,
|
||||
households: SEED_HOUSEHOLDS.length,
|
||||
memberships: memberCount,
|
||||
});
|
||||
expect(summary.created.categories).toBeGreaterThan(40);
|
||||
expect(summary.created.accounts).toBe(7);
|
||||
expect(summary.created.transactions).toBeGreaterThan(150);
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
||||
});
|
||||
|
||||
it("idempotenttir: ikinci çalıştırma kopya oluşturmaz", async () => {
|
||||
const summary = await runSeed({ log: silent });
|
||||
expect(summary.created).toEqual({
|
||||
users: 0,
|
||||
households: 0,
|
||||
memberships: 0,
|
||||
categories: 0,
|
||||
accounts: 0,
|
||||
transactions: 0,
|
||||
});
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||
});
|
||||
|
||||
it("roller ve askı durumu doğru", async () => {
|
||||
const users = await getDb()
|
||||
.collection("user")
|
||||
.find({}, { projection: { username: 1, role: 1, banned: 1 } })
|
||||
.toArray();
|
||||
const by = new Map(users.map((u) => [u.username as string, u]));
|
||||
expect(by.get("admin")?.role).toBe("admin");
|
||||
expect(by.get("burak")?.role).toBe("admin");
|
||||
expect(by.get("ozan")?.banned).toBe(true);
|
||||
expect(users.filter((u) => u.banned)).toHaveLength(1);
|
||||
|
||||
// Bir kullanıcı birden çok hanede, farklı rollerle.
|
||||
const ayse = by.get("ayse")!;
|
||||
const roles = await HouseholdMemberModel.find({ userId: ayse._id }).lean();
|
||||
expect(roles.map((m) => m.role).sort()).toEqual(["owner", "viewer"]);
|
||||
|
||||
// Hanesi olmayan admin.
|
||||
expect(await HouseholdMemberModel.countDocuments({ userId: by.get("burak")!._id })).toBe(0);
|
||||
});
|
||||
|
||||
it("e-postası olmayan kullanıcı kullanıcı adıyla girer; askıdaki kullanıcı giremez", async () => {
|
||||
const api = getAuth().api;
|
||||
const emre = await api.signInUsername({
|
||||
body: { username: "emre", password: "Test-User-12345" },
|
||||
});
|
||||
expect(emre.user.name).toBe("Emre Çakır");
|
||||
|
||||
const error = await api
|
||||
.signInUsername({ body: { username: "ozan", password: "Test-User-12345" } })
|
||||
.then(
|
||||
() => null,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(authErrorCode(error)).toBe("BANNED_USER");
|
||||
});
|
||||
|
||||
it("reset koleksiyonları temizleyip yeniden doldurur", async () => {
|
||||
const summary = await runSeed({ reset: true, log: silent });
|
||||
expect(summary.created.users).toBe(SEED_USERS.length + 1);
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import mongoose, { Types } from "mongoose";
|
||||
import { getAuth } from "@/lib/auth";
|
||||
import { getDb } from "@/lib/db/mongo";
|
||||
import { connectMongoose } from "@/lib/db/mongoose";
|
||||
import { env } from "@/lib/env";
|
||||
import { getStorage } from "@/lib/storage";
|
||||
import { installDefaultCategories } from "@/features/categories/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { HouseholdModel } from "@/models/household";
|
||||
import { HouseholdMemberModel } from "@/models/household-member";
|
||||
import { storedEmail } from "@/shared/account-email";
|
||||
import { runMigrations } from "../migrations";
|
||||
import { seedTransactions } from "./transactions";
|
||||
import {
|
||||
ADMIN_USERNAME,
|
||||
SEED_ACCOUNTS,
|
||||
SEED_HOUSEHOLDS,
|
||||
SEED_USERS,
|
||||
type SeedUser,
|
||||
} from "./fixtures";
|
||||
|
||||
/**
|
||||
* İdempotent seed. Kullanıcılar Better Auth API'siyle oluşturulur (şifreler doğru hashlenir);
|
||||
* tekrar çalıştırınca kopya oluşturmaz (kullanıcı adı / hane adı / üyelik çiftine göre).
|
||||
* Production'da yalnızca admin oluşturulur; örnek kullanıcı ve hane oluşturulmaz.
|
||||
*/
|
||||
|
||||
export type SeedOptions = { reset?: boolean; log?: (line: string) => void };
|
||||
|
||||
export type SeedAccountRow = {
|
||||
rol: string;
|
||||
kullaniciAdi: string;
|
||||
eposta: string;
|
||||
durum: string;
|
||||
haneler: string;
|
||||
};
|
||||
|
||||
export type SeedSummary = {
|
||||
created: {
|
||||
users: number;
|
||||
households: number;
|
||||
memberships: number;
|
||||
categories: number;
|
||||
accounts: number;
|
||||
transactions: number;
|
||||
};
|
||||
accounts: SeedAccountRow[];
|
||||
};
|
||||
|
||||
const AUTH_COLLECTIONS = ["user", "account", "session", "verification", "rateLimit"];
|
||||
const DOMAIN_COLLECTIONS = [
|
||||
"households",
|
||||
"householdMembers",
|
||||
"auditLogs",
|
||||
"categories",
|
||||
"accounts",
|
||||
"transactions",
|
||||
"payees",
|
||||
"attachments",
|
||||
"reconciliations",
|
||||
];
|
||||
|
||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||
|
||||
async function findUserIdByUsername(username: string): Promise<string | null> {
|
||||
const doc = await getDb()
|
||||
.collection("user")
|
||||
.findOne({ username }, { projection: { _id: 1 } });
|
||||
return doc ? String(doc._id) : null;
|
||||
}
|
||||
|
||||
/** Admin olarak giriş yapıp oturum başlıklarını döner (ban gibi oturum isteyen admin API'leri için). */
|
||||
async function adminHeaders(api: AuthApi, password: string): Promise<Headers> {
|
||||
const { headers } = await api.signInUsername({
|
||||
body: { username: ADMIN_USERNAME, password },
|
||||
returnHeaders: true,
|
||||
});
|
||||
const cookie = headers
|
||||
.getSetCookie()
|
||||
.map((c) => c.split(";")[0])
|
||||
.join("; ");
|
||||
return new Headers({ cookie });
|
||||
}
|
||||
|
||||
export async function runSeed({
|
||||
reset = false,
|
||||
log = console.log,
|
||||
}: SeedOptions = {}): Promise<SeedSummary> {
|
||||
const e = env();
|
||||
const isProd = e.NODE_ENV === "production";
|
||||
if (reset && isProd) throw new Error("seed:reset production ortamında çalıştırılamaz.");
|
||||
if (!e.SEED_ADMIN_EMAIL || !e.SEED_ADMIN_PASSWORD) {
|
||||
throw new Error("SEED_ADMIN_EMAIL ve SEED_ADMIN_PASSWORD tanımlı olmalı.");
|
||||
}
|
||||
if (!isProd && !e.SEED_USER_PASSWORD) {
|
||||
throw new Error("SEED_USER_PASSWORD tanımlı olmalı (örnek kullanıcıların ortak şifresi).");
|
||||
}
|
||||
|
||||
const api = getAuth().api;
|
||||
const db = getDb();
|
||||
await connectMongoose();
|
||||
const summary: SeedSummary = {
|
||||
created: {
|
||||
users: 0,
|
||||
households: 0,
|
||||
memberships: 0,
|
||||
categories: 0,
|
||||
accounts: 0,
|
||||
transactions: 0,
|
||||
},
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
if (reset) {
|
||||
// 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]) {
|
||||
await db.collection(name).deleteMany({});
|
||||
}
|
||||
log("• Kimlik doğrulama ve hane koleksiyonları temizlendi.");
|
||||
}
|
||||
|
||||
// Index'ler ve şema değişiklikleri (production'da Mongoose autoIndex kapalı).
|
||||
await runMigrations(log);
|
||||
|
||||
// 1) Admin
|
||||
const adminEmail = e.SEED_ADMIN_EMAIL.toLowerCase();
|
||||
let adminId = await findUserIdByUsername(ADMIN_USERNAME);
|
||||
if (!adminId) {
|
||||
const res = await api.createUser({
|
||||
body: {
|
||||
email: adminEmail,
|
||||
password: e.SEED_ADMIN_PASSWORD,
|
||||
name: "Yönetici",
|
||||
role: "admin",
|
||||
data: { username: ADMIN_USERNAME },
|
||||
},
|
||||
});
|
||||
adminId = res.user.id;
|
||||
summary.created.users++;
|
||||
log(`✓ Admin oluşturuldu: ${adminEmail} (kullanıcı adı: ${ADMIN_USERNAME})`);
|
||||
} else {
|
||||
log(`• Admin zaten var: ${ADMIN_USERNAME}`);
|
||||
}
|
||||
|
||||
if (isProd) {
|
||||
log("• Production: örnek kullanıcı ve hane oluşturulmadı.");
|
||||
return summary;
|
||||
}
|
||||
|
||||
// 2) Örnek kullanıcılar
|
||||
const ids = new Map<string, string>([[ADMIN_USERNAME, adminId]]);
|
||||
const toBan: SeedUser[] = [];
|
||||
for (const u of SEED_USERS) {
|
||||
let id = await findUserIdByUsername(u.username);
|
||||
if (!id) {
|
||||
const res = await api.createUser({
|
||||
body: {
|
||||
email: storedEmail(u.username, u.email),
|
||||
password: e.SEED_USER_PASSWORD!,
|
||||
name: u.name,
|
||||
role: u.role,
|
||||
data: { username: u.username },
|
||||
},
|
||||
});
|
||||
id = res.user.id;
|
||||
summary.created.users++;
|
||||
log(`✓ Kullanıcı: ${u.name} (@${u.username})`);
|
||||
}
|
||||
ids.set(u.username, id);
|
||||
if (u.banReason) toBan.push(u);
|
||||
}
|
||||
|
||||
// 3) Askıdaki kullanıcı(lar) — admin API'si oturum ister.
|
||||
const pendingBans: { user: SeedUser; id: string }[] = [];
|
||||
for (const u of toBan) {
|
||||
const id = ids.get(u.username)!;
|
||||
const doc = await db
|
||||
.collection("user")
|
||||
.findOne({ _id: new Types.ObjectId(id) }, { projection: { banned: 1 } });
|
||||
if (!doc?.banned) pendingBans.push({ user: u, id });
|
||||
}
|
||||
if (pendingBans.length) {
|
||||
const headers = await adminHeaders(api, e.SEED_ADMIN_PASSWORD);
|
||||
for (const { user, id } of pendingBans) {
|
||||
await api.banUser({ headers, body: { userId: id, banReason: user.banReason } });
|
||||
log(`✓ Askıya alındı: @${user.username}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Haneler ve üyelikler
|
||||
const memberships = new Map<string, string[]>();
|
||||
const actor = new Types.ObjectId(adminId);
|
||||
for (const h of SEED_HOUSEHOLDS) {
|
||||
let household = await HouseholdModel.findOne({ name: h.name });
|
||||
if (!household) {
|
||||
household = await HouseholdModel.create({ name: h.name, createdBy: actor, updatedBy: actor });
|
||||
summary.created.households++;
|
||||
log(`✓ Hane: ${h.name}`);
|
||||
}
|
||||
for (const m of h.members) {
|
||||
const userId = new Types.ObjectId(ids.get(m.username)!);
|
||||
const result = await HouseholdMemberModel.updateOne(
|
||||
{ householdId: household._id, userId },
|
||||
{
|
||||
$setOnInsert: { householdId: household._id, userId, createdBy: actor },
|
||||
$set: { role: m.role, updatedBy: actor },
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
if (result.upsertedCount) summary.created.memberships++;
|
||||
memberships.set(m.username, [
|
||||
...(memberships.get(m.username) ?? []),
|
||||
`${h.name} (${m.role})`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Varsayılan kategoriler ve örnek hesaplar
|
||||
const householdIds = new Map<string, Types.ObjectId>();
|
||||
for (const h of SEED_HOUSEHOLDS) {
|
||||
const doc = await HouseholdModel.findOne({ name: h.name }).select({ _id: 1 }).lean();
|
||||
householdIds.set(h.name, doc!._id);
|
||||
summary.created.categories += await installDefaultCategories(doc!._id, actor);
|
||||
}
|
||||
const openingDate = new Date(Date.now() - 183 * 24 * 60 * 60 * 1000);
|
||||
for (const [order, a] of SEED_ACCOUNTS.entries()) {
|
||||
const householdId = householdIds.get(a.household)!;
|
||||
if (await AccountModel.exists({ householdId, name: a.name })) continue;
|
||||
const payer = a.creditCard
|
||||
? await AccountModel.findOne({ householdId, name: a.creditCard.paymentAccount })
|
||||
.select({ _id: 1 })
|
||||
.lean()
|
||||
: null;
|
||||
await AccountModel.create({
|
||||
householdId,
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
institution: a.institution ?? "",
|
||||
mealCardProvider: a.mealCardProvider ?? null,
|
||||
openingBalanceMinor: a.openingBalanceMinor,
|
||||
openingDate,
|
||||
color: a.color,
|
||||
icon: a.icon,
|
||||
order,
|
||||
ownerUserId: a.owner ? new Types.ObjectId(ids.get(a.owner)!) : null,
|
||||
isPrivate: a.isPrivate ?? false,
|
||||
overdraft: a.overdraft ? { enabled: true, ...a.overdraft } : null,
|
||||
creditCard: a.creditCard
|
||||
? {
|
||||
limitMinor: a.creditCard.limitMinor,
|
||||
statementDay: a.creditCard.statementDay,
|
||||
dueRule: { kind: "daysAfter", value: a.creditCard.dueDays },
|
||||
paymentAccountId: payer?._id ?? null,
|
||||
}
|
||||
: null,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
summary.created.accounts++;
|
||||
}
|
||||
if (summary.created.categories) log(`✓ Varsayılan kategoriler: ${summary.created.categories}`);
|
||||
if (summary.created.accounts) log(`✓ Örnek hesaplar: ${summary.created.accounts}`);
|
||||
|
||||
// 6) Son 6 ayın örnek işlemleri (faker.seed(42), idempotent)
|
||||
const userIdMap = new Map([...ids.entries()]);
|
||||
summary.created.transactions = await seedTransactions(householdIds, userIdMap);
|
||||
if (summary.created.transactions) log(`✓ Örnek işlemler: ${summary.created.transactions}`);
|
||||
|
||||
// 7) Geliştirme hesapları tablosu
|
||||
const all = [
|
||||
{ username: ADMIN_USERNAME, name: "Yönetici", email: adminEmail, role: "admin" as const },
|
||||
...SEED_USERS,
|
||||
];
|
||||
summary.accounts = all.map((u) => ({
|
||||
rol: u.role,
|
||||
kullaniciAdi: u.username,
|
||||
eposta: u.email ?? "—",
|
||||
durum: "banReason" in u && u.banReason ? "askıda" : "aktif",
|
||||
haneler: (memberships.get(u.username) ?? ["—"]).join(", "),
|
||||
}));
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Seed sonrası bağlantıları kapatır (CLI ve testler). */
|
||||
export async function closeSeedConnections() {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { Types } from "mongoose";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { PayeeModel } from "@/models/payee";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||
import { allocate } from "@/shared/money";
|
||||
import { foldTr } from "@/shared/search";
|
||||
import type { TransactionType } from "@/shared/transactions";
|
||||
|
||||
/**
|
||||
* Son 6 ayın gerçekçi işlemleri (faker.seed(42) ile tekrarlanabilir).
|
||||
* Her işlemin sabit `seedRef`'i vardır: tekrar çalıştırmada kopya oluşmaz.
|
||||
*/
|
||||
|
||||
type Ctx = {
|
||||
householdId: Types.ObjectId;
|
||||
account: (name: string) => Types.ObjectId;
|
||||
category: (key: string) => Types.ObjectId;
|
||||
user: (username: string) => Types.ObjectId;
|
||||
};
|
||||
|
||||
type SeedTx = {
|
||||
ref: string;
|
||||
type: TransactionType;
|
||||
ymd: string;
|
||||
amountMinor: number;
|
||||
account: string;
|
||||
toAccount?: string;
|
||||
category?: string;
|
||||
splits?: { category: string; weight: number; note?: string }[];
|
||||
payee?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
by: string;
|
||||
};
|
||||
|
||||
const tl = (min: number, max: number) => faker.number.int({ min: min * 100, max: max * 100 });
|
||||
const pick = <T>(xs: readonly T[]) => faker.helpers.arrayElement(xs);
|
||||
|
||||
/** Ayın gün sayısına göre güvenli gün (31 → ayın son günü). */
|
||||
function ymdOf(year: number, month: number, day: number) {
|
||||
const last = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(Math.min(day, last)).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function months(count: number): { y: number; m: number; key: string }[] {
|
||||
const [ty, tm] = todayIstanbul().split("-").map(Number) as [number, number];
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const d = new Date(Date.UTC(ty, tm - 1 - (count - 1 - i), 1));
|
||||
const y = d.getUTCFullYear();
|
||||
const m = d.getUTCMonth() + 1;
|
||||
return { y, m, key: `${y}-${String(m).padStart(2, "0")}` };
|
||||
});
|
||||
}
|
||||
|
||||
function cakirAiles(): SeedTx[] {
|
||||
const out: SeedTx[] = [];
|
||||
const marketPayees = ["Migros", "BİM", "A101", "Şok", "CarrefourSA"] as const;
|
||||
for (const { y, m, key } of months(6)) {
|
||||
const winter = [11, 12, 1, 2, 3].includes(m);
|
||||
const d = (day: number) => ymdOf(y, m, day);
|
||||
const add = (x: Omit<SeedTx, "ref">, n = 0) =>
|
||||
out.push({ ...x, ref: `cakir:${key}:${x.payee ?? x.category ?? x.type}:${n}` });
|
||||
|
||||
add({
|
||||
type: "income",
|
||||
ymd: d(15),
|
||||
amountMinor: 52_000_00,
|
||||
account: "Ziraat Maaş",
|
||||
category: "maas",
|
||||
payee: "Anadolu Yazılım A.Ş.",
|
||||
by: "admin",
|
||||
});
|
||||
add({
|
||||
type: "income",
|
||||
ymd: d(1),
|
||||
amountMinor: 4_500_00,
|
||||
account: "Ayşe — Pluxee",
|
||||
category: "yemek-karti",
|
||||
payee: "Pluxee",
|
||||
by: "ayse",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(3),
|
||||
amountMinor: 17_500_00,
|
||||
account: "Ziraat Maaş",
|
||||
category: "kira",
|
||||
payee: "Ev sahibi",
|
||||
description: "Kira",
|
||||
by: "admin",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(5),
|
||||
amountMinor: 1_250_00,
|
||||
account: "Ziraat Maaş",
|
||||
category: "aidat",
|
||||
payee: "Site yönetimi",
|
||||
by: "admin",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(10),
|
||||
amountMinor: tl(750, 1_600),
|
||||
account: "Ziraat Maaş",
|
||||
category: "faturalar.elektrik",
|
||||
payee: "Enerjisa",
|
||||
by: "ayse",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(12),
|
||||
amountMinor: tl(300, 620),
|
||||
account: "Ziraat Maaş",
|
||||
category: "faturalar.su",
|
||||
payee: "İSKİ",
|
||||
by: "ayse",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(14),
|
||||
amountMinor: winter ? tl(1_800, 3_400) : tl(350, 700),
|
||||
account: "Ziraat Maaş",
|
||||
category: "faturalar.dogalgaz",
|
||||
payee: "İGDAŞ",
|
||||
by: "ayse",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(20),
|
||||
amountMinor: 499_90,
|
||||
account: "Garanti Bonus",
|
||||
category: "faturalar.internet",
|
||||
payee: "Türk Telekom",
|
||||
by: "admin",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(22),
|
||||
amountMinor: 714_00,
|
||||
account: "Garanti Bonus",
|
||||
category: "faturalar.telefon",
|
||||
payee: "Turkcell",
|
||||
description: "2 hat",
|
||||
by: "admin",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(8),
|
||||
amountMinor: 229_99,
|
||||
account: "Garanti Bonus",
|
||||
category: "abonelikler",
|
||||
payee: "Netflix",
|
||||
by: "admin",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(9),
|
||||
amountMinor: 99_99,
|
||||
account: "Garanti Bonus",
|
||||
category: "abonelikler",
|
||||
payee: "Spotify",
|
||||
by: "emre",
|
||||
});
|
||||
|
||||
// Transferler: nakit çekme, kart ödemesi, birikim.
|
||||
add({
|
||||
type: "transfer",
|
||||
ymd: d(2),
|
||||
amountMinor: 2_000_00,
|
||||
account: "Ziraat Maaş",
|
||||
toAccount: "Cüzdan",
|
||||
description: "ATM",
|
||||
by: "admin",
|
||||
});
|
||||
add({
|
||||
type: "transfer",
|
||||
ymd: d(9),
|
||||
amountMinor: tl(11_000, 15_000),
|
||||
account: "Ziraat Maaş",
|
||||
toAccount: "Garanti Bonus",
|
||||
description: "Kart borcu ödemesi",
|
||||
by: "admin",
|
||||
});
|
||||
add({
|
||||
type: "transfer",
|
||||
ymd: d(16),
|
||||
amountMinor: 5_000_00,
|
||||
account: "Ziraat Maaş",
|
||||
toAccount: "Birikim (Vadeli)",
|
||||
description: "Aylık birikim",
|
||||
by: "ayse",
|
||||
});
|
||||
|
||||
// Market: haftada 1–2; bir kısmı bölünmüş.
|
||||
const marketCount = faker.number.int({ min: 6, max: 8 });
|
||||
for (let i = 0; i < marketCount; i++) {
|
||||
const payee = pick(marketPayees);
|
||||
const amountMinor = tl(280, 2_600);
|
||||
const split = amountMinor > 1_200_00 && faker.datatype.boolean({ probability: 0.5 });
|
||||
add(
|
||||
{
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor,
|
||||
account: pick(["Garanti Bonus", "Garanti Bonus", "Cüzdan"]),
|
||||
...(split
|
||||
? {
|
||||
splits: [
|
||||
{ category: "market.gida", weight: faker.number.int({ min: 55, max: 75 }) },
|
||||
{
|
||||
category: "market.temizlik",
|
||||
weight: faker.number.int({ min: 10, max: 25 }),
|
||||
note: "Deterjan",
|
||||
},
|
||||
{
|
||||
category: "market.kisisel-bakim",
|
||||
weight: faker.number.int({ min: 5, max: 15 }),
|
||||
},
|
||||
],
|
||||
}
|
||||
: { category: "market.gida" }),
|
||||
payee,
|
||||
tags: faker.datatype.boolean({ probability: 0.15 }) ? ["indirim"] : [],
|
||||
by: pick(["ayse", "ayse", "admin", "emre"]),
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < faker.number.int({ min: 2, max: 3 }); i++) {
|
||||
add(
|
||||
{
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor: tl(1_200, 2_300),
|
||||
account: "Garanti Bonus",
|
||||
category: "ulasim.yakit",
|
||||
payee: pick(["Shell", "Opet", "BP"]),
|
||||
by: "admin",
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < faker.number.int({ min: 4, max: 6 }); i++) {
|
||||
add(
|
||||
{
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor: tl(110, 340),
|
||||
account: "Ayşe — Pluxee",
|
||||
category: "eglence.kafe",
|
||||
payee: pick(["Starbucks", "Kahve Dünyası", "Simit Sarayı"]),
|
||||
by: "ayse",
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < faker.number.int({ min: 2, max: 4 }); i++) {
|
||||
add(
|
||||
{
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor: tl(450, 1_600),
|
||||
account: pick(["Garanti Bonus", "Ayşe — Pluxee"]),
|
||||
category: "eglence.disarida-yemek",
|
||||
payee: pick(["Köfteci Yusuf", "Nusr-Et", "Big Chefs", "Yemeksepeti"]),
|
||||
by: pick(["ayse", "admin"]),
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
add(
|
||||
{
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor: tl(60, 220),
|
||||
account: "Cüzdan",
|
||||
category: "ulasim.toplu-tasima",
|
||||
payee: "İstanbulkart",
|
||||
by: "emre",
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
if (faker.datatype.boolean({ probability: 0.6 })) {
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor: tl(140, 650),
|
||||
account: "Cüzdan",
|
||||
category: "saglik.ilac",
|
||||
payee: "Eczane",
|
||||
by: "ayse",
|
||||
});
|
||||
}
|
||||
if (faker.datatype.boolean({ probability: 0.5 })) {
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor: tl(600, 3_200),
|
||||
account: "Garanti Bonus",
|
||||
category: "giyim",
|
||||
payee: pick(["LC Waikiki", "Trendyol", "Koton"]),
|
||||
by: "ayse",
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function annemler(): SeedTx[] {
|
||||
const out: SeedTx[] = [];
|
||||
for (const { y, m, key } of months(6)) {
|
||||
const winter = [11, 12, 1, 2, 3].includes(m);
|
||||
const d = (day: number) => ymdOf(y, m, day);
|
||||
const add = (x: Omit<SeedTx, "ref">, n = 0) =>
|
||||
out.push({ ...x, ref: `annemler:${key}:${x.payee ?? x.category ?? x.type}:${n}` });
|
||||
add({
|
||||
type: "income",
|
||||
ymd: d(17),
|
||||
amountMinor: 24_350_00,
|
||||
account: "Vakıfbank Emekli",
|
||||
category: "maas",
|
||||
payee: "SGK",
|
||||
description: "Emekli maaşı",
|
||||
by: "fatma",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(11),
|
||||
amountMinor: tl(450, 900),
|
||||
account: "Vakıfbank Emekli",
|
||||
category: "faturalar.elektrik",
|
||||
payee: "Enerjisa",
|
||||
by: "mehmet",
|
||||
});
|
||||
add({
|
||||
type: "expense",
|
||||
ymd: d(13),
|
||||
amountMinor: winter ? tl(1_400, 2_600) : tl(250, 500),
|
||||
account: "Vakıfbank Emekli",
|
||||
category: "faturalar.dogalgaz",
|
||||
payee: "İGDAŞ",
|
||||
by: "mehmet",
|
||||
});
|
||||
add({
|
||||
type: "transfer",
|
||||
ymd: d(18),
|
||||
amountMinor: 4_000_00,
|
||||
account: "Vakıfbank Emekli",
|
||||
toAccount: "Nakit",
|
||||
description: "ATM",
|
||||
by: "fatma",
|
||||
});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
add(
|
||||
{
|
||||
type: "expense",
|
||||
ymd: d(faker.number.int({ min: 1, max: 28 })),
|
||||
amountMinor: tl(200, 1_100),
|
||||
account: pick(["Nakit", "Vakıfbank Emekli"]),
|
||||
category: "market.gida",
|
||||
payee: pick(["BİM", "Şok", "Pazar"]),
|
||||
by: pick(["fatma", "mehmet"]),
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function seedTransactions(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
faker.seed(42);
|
||||
let inserted = 0;
|
||||
const plan: [string, SeedTx[]][] = [
|
||||
["Çakır Ailesi", cakirAiles()],
|
||||
["Annemler", annemler()],
|
||||
];
|
||||
const today = todayIstanbul();
|
||||
|
||||
for (const [householdName, txs] of plan) {
|
||||
const householdId = householdIds.get(householdName)!;
|
||||
const [accounts, categories] = await Promise.all([
|
||||
AccountModel.find({ householdId }).select({ name: 1 }).lean(),
|
||||
CategoryModel.find({ householdId, defaultKey: { $ne: null } })
|
||||
.select({ defaultKey: 1 })
|
||||
.lean(),
|
||||
]);
|
||||
const ctx: Ctx = {
|
||||
householdId,
|
||||
account: (name) => accounts.find((a) => a.name === name)!._id,
|
||||
category: (key) => categories.find((c) => c.defaultKey === key)!._id,
|
||||
user: (username) => new Types.ObjectId(userIds.get(username)!),
|
||||
};
|
||||
const payeeStats = new Map<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
type: TransactionType;
|
||||
account: Types.ObjectId;
|
||||
category: Types.ObjectId | null;
|
||||
count: number;
|
||||
last: Date;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const x of txs) {
|
||||
if (x.ymd > today) continue; // Gelecek tarihli seed işlemi yok.
|
||||
const date = istanbulDayStart(x.ymd)!;
|
||||
const splits = x.splits
|
||||
? allocate(
|
||||
x.amountMinor,
|
||||
x.splits.map((s) => s.weight),
|
||||
).map((amountMinor, i) => ({
|
||||
categoryId: ctx.category(x.splits![i]!.category),
|
||||
amountMinor,
|
||||
note: x.splits![i]!.note ?? "",
|
||||
}))
|
||||
: [];
|
||||
const categoryId = x.category ? ctx.category(x.category) : null;
|
||||
const accountId = ctx.account(x.account);
|
||||
const by = ctx.user(x.by);
|
||||
const doc = {
|
||||
householdId,
|
||||
type: x.type,
|
||||
status: "posted",
|
||||
date,
|
||||
accountId,
|
||||
toAccountId: x.toAccount ? ctx.account(x.toAccount) : null,
|
||||
amountMinor: x.amountMinor,
|
||||
categoryId: splits.length ? null : categoryId,
|
||||
splits,
|
||||
effectiveCategoryIds: splits.length
|
||||
? [...new Set(splits.map((s) => String(s.categoryId)))].map(
|
||||
(id) => new Types.ObjectId(id),
|
||||
)
|
||||
: categoryId
|
||||
? [categoryId]
|
||||
: [],
|
||||
payeeName: x.payee ?? "",
|
||||
description: x.description ?? "",
|
||||
searchText: foldTr(
|
||||
[x.payee ?? "", x.description ?? "", ...splits.map((s) => s.note)].join(" "),
|
||||
),
|
||||
tags: x.tags ?? [],
|
||||
seedRef: x.ref,
|
||||
createdBy: by,
|
||||
updatedBy: by,
|
||||
};
|
||||
const res = await TransactionModel.updateOne(
|
||||
{ householdId, seedRef: x.ref },
|
||||
{ $setOnInsert: doc },
|
||||
{ upsert: true },
|
||||
);
|
||||
if (res.upsertedCount) inserted++;
|
||||
if (x.payee) {
|
||||
const key = foldTr(x.payee);
|
||||
const prev = payeeStats.get(key);
|
||||
const latest = !prev || date >= prev.last;
|
||||
payeeStats.set(key, {
|
||||
name: x.payee,
|
||||
type: latest ? x.type : prev!.type,
|
||||
account: latest ? accountId : prev!.account,
|
||||
category: latest ? (splits.length ? null : categoryId) : prev!.category,
|
||||
count: (prev?.count ?? 0) + 1,
|
||||
last: latest ? date : prev!.last,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [nameKey, p] of payeeStats) {
|
||||
await PayeeModel.updateOne(
|
||||
{ householdId, nameKey },
|
||||
{
|
||||
$setOnInsert: { householdId, nameKey, name: p.name },
|
||||
$set: {
|
||||
lastType: p.type,
|
||||
lastAccountId: p.account,
|
||||
lastCategoryId: p.category,
|
||||
useCount: p.count,
|
||||
lastUsedAt: p.last,
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
@@ -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,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { UsersTable } from "@/features/admin-users/components/users-table";
|
||||
import { listAdminUsers } from "@/features/admin-users/queries";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireRole } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.adminUsers.title };
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const [admin, users] = await Promise.all([requireRole("admin"), listAdminUsers()]);
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.adminUsers.title} description={tr.adminUsers.description} />
|
||||
<UsersTable users={users} currentUserId={admin.id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireRole } from "@/lib/dal";
|
||||
|
||||
/** Yalnızca sistem admini; diğerlerine 404. Sayfalar ayrıca kendi kontrolünü yapar. */
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireRole("admin");
|
||||
return children;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<Skeleton className="h-4 w-80 max-w-full" />
|
||||
</div>
|
||||
<SummaryCardsSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
|
||||
export const metadata: Metadata = { title: tr.dashboard.title };
|
||||
|
||||
type SummaryCard = {
|
||||
key: keyof typeof tr.dashboard.cards;
|
||||
tone?: string;
|
||||
amountMinor: number | null;
|
||||
};
|
||||
|
||||
/** FAZ 14: gerçek özet verisi (gelir, gider, bakiye, kart borcu, net varlık). */
|
||||
async function getSummary(): Promise<SummaryCard[]> {
|
||||
return [
|
||||
{ key: "monthIncome", tone: "text-income", amountMinor: null },
|
||||
{ key: "monthExpense", tone: "text-expense", amountMinor: null },
|
||||
{ key: "monthRemaining", tone: "text-budget", amountMinor: null },
|
||||
{ key: "totalBalance", amountMinor: null },
|
||||
{ key: "cardDebt", tone: "text-expense", amountMinor: null },
|
||||
{ key: "netWorth", amountMinor: null },
|
||||
];
|
||||
}
|
||||
|
||||
async function SummaryCards() {
|
||||
const cards = await getSummary();
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{cards.map((c) => (
|
||||
<Card key={c.key} size="sm">
|
||||
<CardHeader>
|
||||
<CardDescription>{tr.dashboard.cards[c.key]}</CardDescription>
|
||||
<CardTitle className={cn("text-lg tabular-nums", c.tone)}>
|
||||
{c.amountMinor === null ? "—" : formatMoney(c.amountMinor)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{c.amountMinor === null ? (
|
||||
<CardContent className="text-xs text-muted-foreground">
|
||||
{tr.dashboard.noData}
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)}
|
||||
description={`${household.name} · ${tr.dashboard.welcomeDescription}`}
|
||||
/>
|
||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||
<SummaryCards />
|
||||
</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,31 @@
|
||||
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 { 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();
|
||||
return (
|
||||
<StatementView
|
||||
key={`${id}-${month}`}
|
||||
statement={statement}
|
||||
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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { AppHeader } from "@/components/layout/app-header";
|
||||
import { AppShellFallback } from "@/components/layout/app-shell-fallback";
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { BottomNav } from "@/components/layout/bottom-nav";
|
||||
import { QuickAddProvider } from "@/components/layout/quick-add";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { getHouseholdContext } from "@/lib/dal";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
|
||||
/**
|
||||
* Oturum gerektiren alan. Oturum okuması istek anında yapılır (Cache Components), bu yüzden
|
||||
* kullanıcıya bağlı kabuk Suspense içinde render edilir. Her sayfa ayrıca kendi yetkisini
|
||||
* DAL üzerinden kontrol eder (layout istemci gezinmesinde yeniden çalışmaz).
|
||||
*/
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense fallback={<AppShellFallback />}>
|
||||
<AuthenticatedShell>{children}</AuthenticatedShell>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
|
||||
const ctx = await getHouseholdContext();
|
||||
if (!ctx) redirect("/login");
|
||||
const { user, household, memberships } = ctx;
|
||||
const canWrite = household ? hasHouseholdRole(household.role, "member") : false;
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<QuickAddProvider>
|
||||
<AppSidebar isAdmin={user.role === "admin"} version={APP_VERSION} />
|
||||
<SidebarInset>
|
||||
<AppHeader
|
||||
user={user}
|
||||
households={memberships}
|
||||
activeHouseholdId={household?.id}
|
||||
canWrite={canWrite}
|
||||
version={APP_VERSION}
|
||||
/>
|
||||
{/* 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>
|
||||
</SidebarInset>
|
||||
<BottomNav canWrite={canWrite} />
|
||||
</QuickAddProvider>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
||||
|
||||
/** Oturum gerektirmeyen ekranlar (yalnızca giriş). */
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-1 flex-col items-center justify-center bg-gradient-to-b from-primary/5 via-background to-background px-4 py-10">
|
||||
<div className="absolute top-3 right-3">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { looksLikeEmail, normalizeUsername } from "@/shared/schemas/common";
|
||||
import { loginSchema, type LoginInput } from "@/shared/schemas/auth";
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const form = useForm<LoginInput>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: { identifier: "", password: "" },
|
||||
});
|
||||
|
||||
async function onSubmit({ identifier, password }: LoginInput) {
|
||||
setFormError(null);
|
||||
const value = identifier.trim();
|
||||
const { error } = looksLikeEmail(value)
|
||||
? await authClient.signIn.email({ email: value.toLowerCase(), password })
|
||||
: await authClient.signIn.username({ username: normalizeUsername(value), password });
|
||||
|
||||
if (error) {
|
||||
// Hangi bilginin yanlış olduğu söylenmez (hesap tahmini zorlaşır).
|
||||
if (error.status === 429) setFormError(tr.auth.tooManyAttempts);
|
||||
else if (error.code === "BANNED_USER") setFormError(tr.auth.banned);
|
||||
else setFormError(tr.auth.invalidCredentials);
|
||||
form.setValue("password", "");
|
||||
return;
|
||||
}
|
||||
router.replace("/dashboard");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const submitting = form.formState.isSubmitting;
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} noValidate aria-busy={submitting}>
|
||||
<FieldGroup>
|
||||
<Controller
|
||||
name="identifier"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="identifier">{tr.auth.identifier}</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="identifier"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
inputMode="email"
|
||||
placeholder={tr.auth.identifierPlaceholder}
|
||||
aria-invalid={fieldState.invalid}
|
||||
autoFocus
|
||||
/>
|
||||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="password">{tr.auth.password}</FieldLabel>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
{formError ? <FieldError>{formError}</FieldError> : null}
|
||||
<Button type="submit" size="lg" disabled={submitting} className="w-full">
|
||||
{submitting ? <Loader2 className="animate-spin" /> : null}
|
||||
{submitting ? tr.auth.submitting : tr.auth.submit}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { Logo } from "@/components/brand/logo";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { getCurrentUser } from "@/lib/dal";
|
||||
import { LoginForm } from "./login-form";
|
||||
|
||||
export const metadata: Metadata = { title: tr.auth.loginTitle };
|
||||
|
||||
/** Oturum açıksa dashboard'a yönlendirir (istek anında, Suspense içinde). */
|
||||
async function RedirectIfSignedIn() {
|
||||
const user = await getCurrentUser();
|
||||
if (user) redirect("/dashboard");
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="flex w-full max-w-sm flex-col items-center gap-6">
|
||||
<Suspense fallback={null}>
|
||||
<RedirectIfSignedIn />
|
||||
</Suspense>
|
||||
<Logo priority className="scale-125" />
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">{tr.auth.loginTitle}</CardTitle>
|
||||
<CardDescription>{tr.auth.loginDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LoginForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="text-center text-xs text-muted-foreground">{tr.auth.noSignUp}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
import { getAuth } from "@/lib/auth";
|
||||
|
||||
/** Better Auth uç noktaları (/api/auth/*). Kayıt (sign-up) yapılandırmada kapalıdır. */
|
||||
export function GET(request: Request) {
|
||||
return toNextJsHandler(getAuth()).GET(request);
|
||||
}
|
||||
|
||||
export function POST(request: Request) {
|
||||
return toNextJsHandler(getAuth()).POST(request);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { connection } from "next/server";
|
||||
import { pingDb } from "@/lib/db/mongo";
|
||||
import { APP_COMMIT, APP_VERSION } from "@/lib/version";
|
||||
|
||||
/**
|
||||
* Sağlık kontrolü (Dokploy / Docker HEALTHCHECK). Veritabanına erişilemezse 503.
|
||||
* İç hata detayı döndürülmez.
|
||||
*/
|
||||
export async function GET() {
|
||||
// Cache Components açıkken GET handler'ı build'de prerender edilmesin.
|
||||
await connection();
|
||||
const dbOk = await pingDb();
|
||||
return Response.json(
|
||||
{
|
||||
status: dbOk ? "ok" : "degraded",
|
||||
db: dbOk ? "ok" : "unreachable",
|
||||
version: APP_VERSION,
|
||||
commit: APP_COMMIT,
|
||||
time: new Date().toISOString(),
|
||||
},
|
||||
{ status: dbOk ? 200 : 503, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { tr } from "@/i18n/tr";
|
||||
|
||||
/** Rota hata sınırı. İç detay gösterilmez; yalnızca sunucu loglarıyla eşleşen hata kodu (digest). */
|
||||
export default function ErrorPage({ error, retry }: { error: unknown; retry: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
const digest = error instanceof Error && "digest" in error ? String(error.digest) : undefined;
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{tr.errors.errorTitle}</h1>
|
||||
<p className="max-w-md text-muted-foreground">{tr.errors.errorDescription}</p>
|
||||
{digest ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tr.errors.reference}: <code>{digest}</code>
|
||||
</p>
|
||||
) : null}
|
||||
<Button onClick={() => retry()}>{tr.errors.retry}</Button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { tr } from "@/i18n/tr";
|
||||
import "./globals.css";
|
||||
|
||||
/** Kök layout hataları için son çare sınırı (kendi <html>/<body> etiketini render eder). */
|
||||
export default function GlobalError({ retry }: { error: unknown; retry: () => void }) {
|
||||
return (
|
||||
<html lang="tr">
|
||||
<body className="flex min-h-dvh flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{tr.errors.errorTitle}</h1>
|
||||
<p className="max-w-md text-muted-foreground">{tr.errors.errorDescription}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retry()}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
||||
>
|
||||
{tr.errors.retry}
|
||||
</button>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-income: var(--income);
|
||||
--color-income-foreground: var(--income-foreground);
|
||||
--color-expense: var(--expense);
|
||||
--color-expense-foreground: var(--expense-foreground);
|
||||
--color-budget: var(--budget);
|
||||
--color-budget-foreground: var(--budget-foreground);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
/*
|
||||
* BÜTÇEM marka paleti — mavi ve beyaz tonları (oklch). Sabit renk kodu yalnızca burada.
|
||||
* Anlamsal renkler: --income (gelir/yeşil), --expense (gider/kırmızı),
|
||||
* --budget (bütçe-plan/mavi), --warning (uyarı/turuncu). Grafikler de bunları kullanır.
|
||||
*/
|
||||
:root {
|
||||
--background: oklch(0.99 0.004 250);
|
||||
--foreground: oklch(0.22 0.04 262);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.22 0.04 262);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.22 0.04 262);
|
||||
--primary: oklch(0.49 0.17 258);
|
||||
--primary-foreground: oklch(0.99 0.004 250);
|
||||
--secondary: oklch(0.955 0.016 250);
|
||||
--secondary-foreground: oklch(0.3 0.06 260);
|
||||
--muted: oklch(0.955 0.012 250);
|
||||
--muted-foreground: oklch(0.5 0.03 258);
|
||||
--accent: oklch(0.935 0.03 250);
|
||||
--accent-foreground: oklch(0.3 0.08 260);
|
||||
--destructive: oklch(0.56 0.21 25);
|
||||
--border: oklch(0.905 0.016 252);
|
||||
--input: oklch(0.89 0.018 252);
|
||||
--ring: oklch(0.6 0.15 258);
|
||||
|
||||
--income: oklch(0.58 0.15 152);
|
||||
--income-foreground: oklch(0.99 0.004 250);
|
||||
--expense: oklch(0.56 0.21 25);
|
||||
--expense-foreground: oklch(0.99 0.004 250);
|
||||
--budget: oklch(0.55 0.17 258);
|
||||
--budget-foreground: oklch(0.99 0.004 250);
|
||||
--warning: oklch(0.68 0.17 55);
|
||||
--warning-foreground: oklch(0.22 0.04 262);
|
||||
|
||||
--chart-1: var(--income);
|
||||
--chart-2: var(--budget);
|
||||
--chart-3: var(--expense);
|
||||
--chart-4: var(--warning);
|
||||
--chart-5: oklch(0.65 0.08 230);
|
||||
--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-foreground: oklch(0.22 0.04 262);
|
||||
--sidebar-primary: oklch(0.49 0.17 258);
|
||||
--sidebar-primary-foreground: oklch(0.99 0.004 250);
|
||||
--sidebar-accent: oklch(0.935 0.03 250);
|
||||
--sidebar-accent-foreground: oklch(0.3 0.08 260);
|
||||
--sidebar-border: oklch(0.905 0.016 252);
|
||||
--sidebar-ring: oklch(0.6 0.15 258);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.18 0.04 264);
|
||||
--foreground: oklch(0.96 0.01 250);
|
||||
--card: oklch(0.22 0.045 264);
|
||||
--card-foreground: oklch(0.96 0.01 250);
|
||||
--popover: oklch(0.22 0.045 264);
|
||||
--popover-foreground: oklch(0.96 0.01 250);
|
||||
--primary: oklch(0.76 0.12 245);
|
||||
--primary-foreground: oklch(0.18 0.04 264);
|
||||
--secondary: oklch(0.27 0.045 264);
|
||||
--secondary-foreground: oklch(0.96 0.01 250);
|
||||
--muted: oklch(0.26 0.04 264);
|
||||
--muted-foreground: oklch(0.73 0.03 250);
|
||||
--accent: oklch(0.3 0.06 262);
|
||||
--accent-foreground: oklch(0.96 0.01 250);
|
||||
--destructive: oklch(0.7 0.18 22);
|
||||
--border: oklch(1 0 0 / 11%);
|
||||
--input: oklch(1 0 0 / 16%);
|
||||
--ring: oklch(0.7 0.12 245);
|
||||
|
||||
--income: oklch(0.75 0.16 152);
|
||||
--income-foreground: oklch(0.18 0.04 264);
|
||||
--expense: oklch(0.7 0.18 22);
|
||||
--expense-foreground: oklch(0.18 0.04 264);
|
||||
--budget: oklch(0.73 0.13 245);
|
||||
--budget-foreground: oklch(0.18 0.04 264);
|
||||
--warning: oklch(0.79 0.15 62);
|
||||
--warning-foreground: oklch(0.18 0.04 264);
|
||||
|
||||
--chart-1: var(--income);
|
||||
--chart-2: var(--budget);
|
||||
--chart-3: var(--expense);
|
||||
--chart-4: var(--warning);
|
||||
--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-foreground: oklch(0.96 0.01 250);
|
||||
--sidebar-primary: oklch(0.76 0.12 245);
|
||||
--sidebar-primary-foreground: oklch(0.18 0.04 264);
|
||||
--sidebar-accent: oklch(0.28 0.055 262);
|
||||
--sidebar-accent-foreground: oklch(0.96 0.01 250);
|
||||
--sidebar-border: oklch(1 0 0 / 11%);
|
||||
--sidebar-ring: oklch(0.7 0.12 245);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobil alt menü + iOS ana ekran çentiği için güvenli alan */
|
||||
@utility pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,49 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import "./globals.css";
|
||||
|
||||
// Türkçe karakterler (ğ, ş, ı, İ) latin-ext alt kümesinde.
|
||||
const geistSans = Geist({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin", "latin-ext"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin", "latin-ext"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: tr.app.name, template: `%s · ${tr.app.name}` },
|
||||
description: tr.app.tagline,
|
||||
applicationName: tr.app.name,
|
||||
appleWebApp: { capable: true, title: tr.app.name, statusBarStyle: "default" },
|
||||
formatDetection: { telephone: false },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
// Manifest/tarayıcı çubuğu rengi CSS değişkeni okuyamaz; --background değerlerinin hex karşılığı.
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#fafcfe" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#081123" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html
|
||||
lang="tr"
|
||||
suppressHydrationWarning
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="flex min-h-full flex-col">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { tr } from "@/i18n/tr";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-1 items-center justify-center gap-2 p-8 text-muted-foreground"
|
||||
>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<span>{tr.common.loading}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { tr } from "@/i18n/tr";
|
||||
|
||||
/** PWA manifest: "Ana ekrana ekle" ile kurulabilir. İkonlar: npm run icons. */
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
id: "/",
|
||||
name: tr.app.name,
|
||||
short_name: tr.app.name,
|
||||
description: tr.app.tagline,
|
||||
lang: "tr-TR",
|
||||
dir: "ltr",
|
||||
start_url: "/dashboard",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
orientation: "portrait",
|
||||
background_color: "#fafcfe",
|
||||
theme_color: "#105bbe",
|
||||
categories: ["finance", "productivity"],
|
||||
icons: [
|
||||
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { tr } from "@/i18n/tr";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<p className="text-6xl font-semibold text-primary tabular-nums">404</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{tr.errors.notFoundTitle}</h1>
|
||||
<p className="max-w-md text-muted-foreground">{tr.errors.notFoundDescription}</p>
|
||||
<Button asChild>
|
||||
<Link href="/">{tr.errors.backHome}</Link>
|
||||
</Button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/** Oturum yoksa proxy zaten /login'e yönlendirir; varsa dashboard. */
|
||||
export default function Home() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { tr } from "@/i18n/tr";
|
||||
|
||||
/**
|
||||
* Marka logosu: açık temada logo.svg, koyu temada logo-dark.svg (CSS ile seçilir,
|
||||
* hydration uyumsuzluğu olmaz). Dosyalar public/brand/ altında.
|
||||
*/
|
||||
export function Logo({ className, priority }: { className?: string; priority?: boolean }) {
|
||||
const common = { width: 114, height: 32, priority, unoptimized: true } as const;
|
||||
return (
|
||||
<span className={cn("inline-flex items-center", className)}>
|
||||
<Image
|
||||
src="/brand/logo.svg"
|
||||
alt={tr.app.name}
|
||||
className="h-8 w-auto dark:hidden"
|
||||
{...common}
|
||||
/>
|
||||
<Image
|
||||
src="/brand/logo-dark.svg"
|
||||
alt=""
|
||||
className="hidden h-8 w-auto dark:block"
|
||||
{...common}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import type { SessionUser } from "@/features/auth/types";
|
||||
import type { HouseholdSummary } from "@/features/households/types";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { HouseholdSwitcher } from "./household-switcher";
|
||||
import { QuickAddButton } from "./quick-add";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { UserMenu } from "./user-menu";
|
||||
|
||||
/** Üst bar: menü düğmesi (masaüstü), hane seçici, hızlı ekle, tema ve kullanıcı menüsü. */
|
||||
export function AppHeader({
|
||||
user,
|
||||
households,
|
||||
activeHouseholdId,
|
||||
canWrite,
|
||||
version,
|
||||
}: {
|
||||
user: SessionUser;
|
||||
households: HouseholdSummary[];
|
||||
activeHouseholdId: string | undefined;
|
||||
/** Aktif hanede en az "member" mi? (İzleyici veri giremez.) */
|
||||
canWrite: boolean;
|
||||
version: string;
|
||||
}) {
|
||||
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">
|
||||
<SidebarTrigger className="hidden md:inline-flex" aria-label={tr.nav.toggleSidebar} />
|
||||
<HouseholdSwitcher households={households} activeId={activeHouseholdId} />
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{canWrite ? <QuickAddButton className="hidden md:inline-flex" /> : null}
|
||||
<ThemeToggle />
|
||||
<UserMenu user={user} version={version} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { tr } from "@/i18n/tr";
|
||||
|
||||
/** Oturum okunurken gösterilen kabuk iskeleti (yan menü + üst bar + içerik). */
|
||||
export function AppShellFallback() {
|
||||
return (
|
||||
<div role="status" aria-label={tr.common.loading} className="flex min-h-dvh w-full">
|
||||
<div className="hidden w-64 shrink-0 flex-col gap-3 border-r bg-sidebar p-3 md:flex">
|
||||
<Skeleton className="h-8 w-28" />
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-7 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex h-14 items-center gap-2 border-b px-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="ml-auto size-8 rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 p-4 md:p-6">
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Logo } from "@/components/brand/logo";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { ADMIN_NAV, MAIN_NAV, isActivePath, isAvailable, type NavItem } from "./nav-config";
|
||||
|
||||
function NavList({ items, pathname }: { items: NavItem[]; pathname: string }) {
|
||||
const { setOpenMobile } = useSidebar();
|
||||
return (
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
if (!isAvailable(item)) {
|
||||
return (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
aria-disabled
|
||||
disabled
|
||||
tooltip={`${item.label} · ${tr.common.comingSoon}`}
|
||||
>
|
||||
<Icon />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuBadge className="text-[0.65rem] opacity-70">
|
||||
{tr.common.comingSoon}
|
||||
</SidebarMenuBadge>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
const active = isActivePath(pathname, item.href);
|
||||
return (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton asChild isActive={active} tooltip={item.label}>
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => setOpenMobile(false)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/** Masaüstünde sabit yan menü; mobilde "Daha fazla" ile açılan Sheet. */
|
||||
export function AppSidebar({ isAdmin, version }: { isAdmin: boolean; version: string }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="h-14 justify-center px-3">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
aria-label={tr.nav.dashboard}
|
||||
className="group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
<Logo />
|
||||
</Link>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{tr.nav.main}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<NavList items={MAIN_NAV} pathname={pathname} />
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
{isAdmin ? (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{tr.nav.admin}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<NavList items={ADMIN_NAV} pathname={pathname} />
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="px-4 pb-3 text-xs text-muted-foreground group-data-[collapsible=icon]:hidden">
|
||||
{tr.app.name} · {tr.app.version} {version}
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { Menu, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BOTTOM_NAV, isActivePath, isAvailable, type NavItem } from "./nav-config";
|
||||
import { useQuickAdd } from "./quick-add";
|
||||
|
||||
const itemClass =
|
||||
"flex min-h-14 flex-1 flex-col items-center justify-center gap-0.5 text-[0.7rem] font-medium outline-none focus-visible:bg-muted";
|
||||
|
||||
function BottomNavLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
||||
const Icon = item.icon;
|
||||
if (!isAvailable(item)) {
|
||||
return (
|
||||
<span
|
||||
aria-disabled
|
||||
className={cn(itemClass, "text-muted-foreground/50")}
|
||||
title={tr.common.comingSoon}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const active = isActivePath(pathname, item.href);
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(itemClass, active ? "text-primary" : "text-muted-foreground")}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mobil alt gezinme çubuğu (md altı). */
|
||||
export function BottomNav({ canWrite }: { canWrite: boolean }) {
|
||||
const pathname = usePathname();
|
||||
const { openQuickAdd } = useQuickAdd();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
return (
|
||||
<nav
|
||||
aria-label={tr.nav.main}
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t bg-background/95 pb-safe backdrop-blur supports-backdrop-filter:bg-background/80 md:hidden"
|
||||
>
|
||||
<div className="flex items-stretch">
|
||||
{BOTTOM_NAV.left.map((item) => (
|
||||
<BottomNavLink key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openQuickAdd()}
|
||||
disabled={!canWrite}
|
||||
title={canWrite ? undefined : tr.households.viewerReadOnly}
|
||||
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 disabled:opacity-40"
|
||||
>
|
||||
<Plus className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
{BOTTOM_NAV.right.map((item) => (
|
||||
<BottomNavLink key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenMobile(true)}
|
||||
className={cn(itemClass, "text-muted-foreground")}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
{tr.nav.more}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { House } from "lucide-react";
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { switchHouseholdAction } from "@/features/households/actions";
|
||||
import type { HouseholdSummary } from "@/features/households/types";
|
||||
import { tr } from "@/i18n/tr";
|
||||
|
||||
/** Üst bar aktif hane seçici. Seçim sunucuda doğrulanır ve cookie'ye yazılır. */
|
||||
export function HouseholdSwitcher({
|
||||
households,
|
||||
activeId,
|
||||
}: {
|
||||
households: HouseholdSummary[];
|
||||
activeId: string | undefined;
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const current = households.find((h) => h.id === activeId);
|
||||
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 (
|
||||
<Select value={activeId} onValueChange={onChange} disabled={pending || households.length < 2}>
|
||||
<SelectTrigger
|
||||
aria-label={tr.household.label}
|
||||
className="w-[min(46vw,13rem)]"
|
||||
aria-busy={pending}
|
||||
>
|
||||
<House className="text-muted-foreground" />
|
||||
{/* Etiket children ile verilir: Radix aksi halde hydration'a kadar boş gösterir. */}
|
||||
<SelectValue placeholder={tr.household.select}>{current?.name}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{households.map((h) => (
|
||||
<SelectItem key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
<span className="text-xs text-muted-foreground">· {tr.household.roles[h.role]}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
CalendarClock,
|
||||
ChartColumn,
|
||||
Coins,
|
||||
House,
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
Tags,
|
||||
Target,
|
||||
UserCog,
|
||||
Users,
|
||||
Wallet,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { tr } from "@/i18n/tr";
|
||||
|
||||
export type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
/** Sayfanın ekleneceği faz; mevcut fazdan büyükse "Yakında" olarak pasif gösterilir. */
|
||||
phase: number;
|
||||
};
|
||||
|
||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||
export const CURRENT_PHASE = 6.3;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
export const MAIN_NAV: NavItem[] = [
|
||||
{ href: "/dashboard", label: tr.nav.dashboard, icon: LayoutDashboard, phase: 2 },
|
||||
{ href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6.2 },
|
||||
{ href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 },
|
||||
{ href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 },
|
||||
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7 },
|
||||
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 },
|
||||
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 },
|
||||
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
|
||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
|
||||
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
|
||||
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
|
||||
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
|
||||
];
|
||||
|
||||
export const ADMIN_NAV: NavItem[] = [
|
||||
{ href: "/admin/kullanicilar", label: tr.nav.adminUsers, icon: UserCog, phase: 2 },
|
||||
{ href: "/admin/haneler", label: tr.nav.adminHouseholds, icon: House, phase: 5 },
|
||||
];
|
||||
|
||||
/** 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 = {
|
||||
left: [navByHref("/dashboard"), navByHref("/islemler")],
|
||||
right: [navByHref("/butce")],
|
||||
};
|
||||
|
||||
export function isActivePath(pathname: string, href: string) {
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { createContext, use, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
TransactionFormDialog,
|
||||
type TransactionFormMode,
|
||||
} from "@/features/transactions/components/transaction-form";
|
||||
import { tr } from "@/i18n/tr";
|
||||
|
||||
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<QuickAddApi | null>(null);
|
||||
|
||||
export function useQuickAdd() {
|
||||
const ctx = use(QuickAddContext);
|
||||
if (!ctx) throw new Error("useQuickAdd, QuickAddProvider içinde kullanılmalı");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Uygulama genelinde tek işlem formu; her ekrandan açılabilir (Bölüm 4). */
|
||||
export function QuickAddProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mode, setMode] = useState<TransactionFormMode | null>(null);
|
||||
const api: QuickAddApi = {
|
||||
openQuickAdd: (type = "expense") => setMode({ kind: "create", type }),
|
||||
openTransactionForm: setMode,
|
||||
};
|
||||
return (
|
||||
<QuickAddContext value={api}>
|
||||
{children}
|
||||
<TransactionFormDialog mode={mode} onOpenChange={(open) => !open && setMode(null)} />
|
||||
</QuickAddContext>
|
||||
);
|
||||
}
|
||||
|
||||
/** Masaüstü üst bar düğmesi. */
|
||||
export function QuickAddButton({ className }: { className?: string }) {
|
||||
const { openQuickAdd } = useQuickAdd();
|
||||
return (
|
||||
<Button onClick={() => openQuickAdd()} className={className}>
|
||||
<Plus />
|
||||
{tr.quickAdd.button}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { tr } from "@/i18n/tr";
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "light", label: tr.theme.light, icon: Sun },
|
||||
{ value: "dark", label: tr.theme.dark, icon: Moon },
|
||||
{ value: "system", label: tr.theme.system, icon: Monitor },
|
||||
] as const;
|
||||
|
||||
/** Açık / Koyu / Sistem tema seçici. İkon CSS ile değişir (hydration güvenli). */
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label={tr.theme.label}>
|
||||
<Sun className="scale-100 rotate-0 transition-transform dark:scale-0 dark:-rotate-90" />
|
||||
<Moon className="absolute scale-0 rotate-90 transition-transform dark:scale-100 dark:rotate-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-36">
|
||||
<DropdownMenuLabel>{tr.theme.label}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={theme ?? "system"} onValueChange={setTheme}>
|
||||
{OPTIONS.map(({ value, label, icon: Icon }) => (
|
||||
<DropdownMenuRadioItem key={value} value={value}>
|
||||
<Icon />
|
||||
{label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { LogOut } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { SessionUser } from "@/features/auth/types";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { tr } from "@/i18n/tr";
|
||||
|
||||
export function initials(name: string) {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p.charAt(0).toLocaleUpperCase("tr-TR"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Kullanıcı menüsü: ad, rol, sürüm ve Çıkış yap. */
|
||||
export function UserMenu({ user, version }: { user: SessionUser; version: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
async function logout() {
|
||||
const { error } = await authClient.signOut();
|
||||
if (error) {
|
||||
toast.error(tr.common.genericError);
|
||||
return;
|
||||
}
|
||||
toast.success(tr.userMenu.loggedOut);
|
||||
router.replace("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
className="rounded-full"
|
||||
aria-label={tr.userMenu.open}
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarFallback className="bg-primary/10 font-medium text-primary">
|
||||
{initials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuLabel className="flex flex-col gap-1 font-normal">
|
||||
<span className="truncate font-medium text-foreground">{user.name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">@{user.username}</span>
|
||||
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="mt-1">
|
||||
{tr.roles[user.role]}
|
||||
</Badge>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => void logout()}>
|
||||
<LogOut />
|
||||
{tr.userMenu.logout}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{tr.app.name} · {tr.app.version} {version}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -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,20 @@
|
||||
/** Sayfa başlığı + açıklama + sağda isteğe bağlı aksiyonlar. */
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group";
|
||||
import { tr } from "@/i18n/tr";
|
||||
|
||||
/** Göster/gizle düğmeli şifre alanı. */
|
||||
export function PasswordInput(props: Omit<React.ComponentProps<typeof InputGroupInput>, "type">) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const label = visible ? tr.common.hidePassword : tr.common.showPassword;
|
||||
return (
|
||||
<InputGroup>
|
||||
<InputGroupInput type={visible ? "text" : "password"} {...props} />
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
aria-pressed={visible}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
>
|
||||
{visible ? <EyeOff /> : <Eye />}
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
/** Kök istemci sağlayıcıları: tema (açık/koyu/sistem), tooltip ve bildirimler. */
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
{children}
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Masaüstünde Dialog, mobilde (neredeyse) tam ekran alttan açılan Drawer.
|
||||
* Formlar bu bileşenle açılır (Bölüm 4: "formlar mobilde tam ekran Sheet/Drawer").
|
||||
*/
|
||||
export function ResponsiveDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
}: Props) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="max-h-[96dvh]">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
||||
</DrawerHeader>
|
||||
<div className={cn("overflow-y-auto px-4 pb-2", className)}>{children}</div>
|
||||
{footer ? <DrawerFooter className="pb-safe">{footer}</DrawerFooter> : null}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
<div className={className}>{children}</div>
|
||||
{footer ? <DialogFooter>{footer}</DialogFooter> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "cn";
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm";
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
};
|
||||