Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8ad362f8b | |||
| 5793490cb4 | |||
| ebcdf3f741 | |||
| c52e8f9976 | |||
| b11972fda3 | |||
| 19536f45de | |||
| c0b114efeb | |||
| afc6da1606 | |||
| 18c892bdcd | |||
| add845684c | |||
| 092db5a8c2 | |||
| 6ce8ffa4a5 | |||
| 1bdcedefdc | |||
| 7d7dd61c60 |
@@ -2,6 +2,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
out
|
out
|
||||||
|
dist
|
||||||
coverage
|
coverage
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
@@ -10,6 +11,7 @@ next-env.d.ts
|
|||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
uploads
|
uploads
|
||||||
|
import
|
||||||
|
|
||||||
# Geliştirme araçları
|
# Geliştirme araçları
|
||||||
.git
|
.git
|
||||||
|
|||||||
@@ -46,3 +46,7 @@ next-env.d.ts
|
|||||||
|
|
||||||
# local tooling
|
# local tooling
|
||||||
/scratch/
|
/scratch/
|
||||||
|
/dist
|
||||||
|
|
||||||
|
# Kişisel ekstre dosyaları (içe aktarma için) — asla repoya girmez
|
||||||
|
/import/
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ coverage
|
|||||||
uploads
|
uploads
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
docs/PROMPT.md
|
docs/PROMPT.md
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -6,6 +6,60 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
|||||||
|
|
||||||
## [Yayınlanmamış]
|
## [Yayınlanmamış]
|
||||||
|
|
||||||
|
Faz 17a: Kakeibo K2 — ekstre içe aktarma altyapısı.
|
||||||
|
|
||||||
|
### Eklendi
|
||||||
|
|
||||||
|
- **Ekstre içe aktarma** (`/islemler/ice-aktar`, İşlemler sayfasında "İçe aktar"): Kakeibo çalışma kitabı (.xlsx; "Kredi Kartı" ve "Maaş Hesabı" sayfaları) yüklenir, önce taslak olarak önizlenir, sonra tek seferde işlenir.
|
||||||
|
- Kurulum adımı: dosyanın ihtiyaç duyduğu hesaplar (kredi kartı, maaş hesabı, nakit, ek hesap) mevcut hesaplardan seçilir ya da önerilen açılış bakiyesiyle tek tıkla oluşturulur (açılış bakiyesi dosyadaki devreden bakiyeden, kart borcu ilk kart ödemesinden).
|
||||||
|
- Kontroller: her ekstre için **ekstre toplamı = satır toplamı**, maaş hesabı bakiye zinciri ve son bakiye, daha önce aktarılmış satırlar, bekletilen altın satırları ve oluşturulacak kategoriler.
|
||||||
|
- Önizleme: satır başına tür (gider / gelir / atla), kategori ve Kakeibo grubu düzenlenebilir; "aynı açıklamalı tüm satırlara uygula"; İnceleme gereken / Grupsuz / Transfer / Atlanacak süzgeçleri.
|
||||||
|
- Muhasebe kuralları: kart ödemesi, ATM çekimi ve hesaplar arası havale transfer (çift sayma yok); kampanya iadesi ve iadeler gelir ("İade"); KMH faizi, KKDF/BSMV ve banka ücretleri banka maliyeti; taksitler ekstreye yansıdığı kesim tarihinde, ortak taksit planıyla; aynı gün çıkan promosyon çifti atlanır.
|
||||||
|
- Tekrar engeli: her satırın parmak izi saklanır; aynı dosya yeniden yüklendiğinde aktarılmış satırlar atlanır. **Toplu geri alma** aktarmanın tüm işlemlerini siler ve dosya yeniden aktarılabilir hale gelir.
|
||||||
|
- Eksik Kakeibo kategorileri (Sigorta, Online alışveriş, Elektronik ve hobi, Kitap ve kültür; Prim, Yol parası, Avans, Eş katkısı, Aile desteği) işlenirken oluşturulur.
|
||||||
|
- Migration `017-imports`: aktarma partileri ve işlem parmak izi dizinleri (geri alınabilir).
|
||||||
|
- Testler: çalışma kitabı ayrıştırma, taksit metni, ekstre ve bakiye kontrolleri (birim); taslak, hesap oluşturma, toplu düzenleme, işleme, bakiyeler, tekrar yükleme ve geri alma (entegrasyon).
|
||||||
|
|
||||||
|
### Bilinen sınırlamalar
|
||||||
|
|
||||||
|
- Altın alım/satım satırları Faz 17b'de altın hesabı dosyasıyla eşleştirilene dek bekletilir; o zamana dek maaş hesabı bakiyesi bu satırların neti kadar farklı görünür.
|
||||||
|
- Yapı Kredi "Güncel Dönem" (.xls) ve Ek Hesap / Altın Hesabı (PDF) dosyaları Faz 17b'de.
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-11-07
|
||||||
|
|
||||||
|
Faz 16: Kakeibo K1 — dört harcama grubu.
|
||||||
|
|
||||||
|
### Eklendi
|
||||||
|
|
||||||
|
- Kakeibo grupları: gider kategorilerine **Zorunlu / İstek / Kültür / Beklenmeyen** grubu; alt kategori grubu üst kategoriden devralır. Kategori formunda grup seçimi ve açıklaması; kategori listesinde grup rozeti (devralınan soluk) ve grupsuz kategori uyarısı.
|
||||||
|
- **Banka maliyeti:** KMH faizi, KKDF/BSMV, kart ve hesap ücretleri Kakeibo harcamasına ve dört gruba girmez, ayrı gösterilir (kategori düzeyinde işaret, alt ağaç devralır).
|
||||||
|
- İşlem formunda **Kakeibo grubu**: varsayılan kategoriden ("Kategoriden: Zorunlu"), bu kayıt için değiştirilebilir; bölünmüş işlemde kalem başına.
|
||||||
|
- Kakeibo raporu (`/raporlar/kakeibo`): Kakeibo harcaması ve grup payları (yığılmış çubuk), grup başına tutar, pay, önceki aya göre değişim ve kategori kırılımı; banka maliyeti ve grupsuz harcamalar ayrı; CSV. Bütçe sayfasında seçili ayın Kakeibo özeti.
|
||||||
|
- Migration `016-kakeibo-groups`: varsayılan kategoriler beceri eşlemesine göre doldurulur (kullanıcı kategorileri grupsuz kalır, mevcut seçimlere dokunulmaz, kategori silinmez). Migration'lar geri alınabilir: `npm run migrate -- --down <ad>`.
|
||||||
|
- Testler: grup devralma, banka maliyeti, kayıt bazında grup, grup özeti ve sıfıra bölme (birim); varsayılan gruplar, rapor toplamı = bütçenin gerçekleşeni, grup değiştirme, kategori güncelleme, migration geri alma/yeniden uygulama (entegrasyon).
|
||||||
|
|
||||||
|
### Güvenlik
|
||||||
|
|
||||||
|
- `import/` klasörü (kişisel ekstre dosyaları) git'e ve Docker imajına girmez.
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-11-07
|
||||||
|
|
||||||
|
Faz 15: canlıya alma (Dokploy + Gitea, `butcem.ta1sph.website`).
|
||||||
|
|
||||||
|
### Eklendi
|
||||||
|
|
||||||
|
- Konteyner başlatma adımı (`docker-entrypoint.sh` → `scripts/bootstrap.mjs`): MongoDB replica set ve PRIMARY kontrolü (yoksa açık hata mesajıyla durur), bekleyen migration'lar, `SEED_ADMIN_*` tanımlıysa ilk admin hesabı (varsa dokunulmaz). esbuild ile tek dosyaya derlenir; imajda `tsx` gerekmez. Acil durumda `SKIP_BOOTSTRAP=1`.
|
||||||
|
- `/api/health` yanıtında `replicaSet` alanı (transaction desteğinin canlıda doğrulanması için).
|
||||||
|
- `docs/DEPLOY.md`: Dokploy uygulama kurulumu, ortam değişkenleri, volume, domain/HTTPS, Gitea webhook ile otomatik deploy, güncelleme/geri alma, yedekleme, geri yükleme ve sorun giderme; her komut açıklamalı.
|
||||||
|
- `docs/MONGODB-KURULUM.md`: mongo:7 servisinde replica set kontrolü, keyfile ile tek düğümlü replica set'e geçiş, uygulama kullanıcısı yetkileri, bağlantı dizesi, transaction denemesi.
|
||||||
|
- `scripts/smoke-test.sh`: sağlık (veritabanı, replica set, sürüm), güvenlik başlıkları, HTTP→HTTPS, oturumsuz erişim engeli ve isteğe bağlı giriş kontrolü; veri değiştirmez.
|
||||||
|
- `scripts/backup/`: günlük yedek betiği (mongodump + uploads arşivi, SHA-256 özetleri, 7 gün saklama, parola komut satırında görünmez), onaylı geri yükleme betiği ve ayar örneği.
|
||||||
|
|
||||||
|
### Değişti
|
||||||
|
|
||||||
|
- Docker imajı: `ENTRYPOINT` başlatma adımıyla; sağlık kontrolü başlangıç payı 60 sn.
|
||||||
|
- Uygulama mongo:7.0 ile uyumlu (tüm entegrasyon testleri mongo:7 replica set'e karşı geçti).
|
||||||
|
|
||||||
## [0.14.0] - 2026-11-07
|
## [0.14.0] - 2026-11-07
|
||||||
|
|
||||||
Faz 14: dashboard ana grafiği ve raporlar (14a ve 14b alt fazları).
|
Faz 14: dashboard ana grafiği ve raporlar (14a ve 14b alt fazları).
|
||||||
|
|||||||
+10
-3
@@ -2,6 +2,7 @@
|
|||||||
# BÜTÇEM — çok aşamalı üretim imajı (Next.js standalone çıktı).
|
# 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 .
|
# 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
|
# Çalıştır: docker run -p 3000:3000 --env-file .env.production -v butcem-uploads:/app/uploads butcem
|
||||||
|
# Başlarken: replica set kontrolü, bekleyen migration'lar, ilk admin (docker-entrypoint.sh).
|
||||||
|
|
||||||
ARG NODE_VERSION=26
|
ARG NODE_VERSION=26
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ FROM node:${NODE_VERSION}-alpine AS deps
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
# SheetJS resmi dağıtımdan vendor/ altında (npm uzak tarball indirmez).
|
||||||
|
COPY vendor ./vendor
|
||||||
RUN npm ci --no-audit --no-fund
|
RUN npm ci --no-audit --no-fund
|
||||||
|
|
||||||
# 2) Build
|
# 2) Build
|
||||||
@@ -22,7 +25,8 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
|
|||||||
APP_COMMIT=${APP_COMMIT}
|
APP_COMMIT=${APP_COMMIT}
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
# Uygulama + başlatma betiği (migration ve ilk admin; tek dosya, tsx gerekmez).
|
||||||
|
RUN npm run build && npm run build:scripts
|
||||||
|
|
||||||
# 3) Çalışma zamanı — yalnızca standalone çıktı, root olmayan kullanıcı.
|
# 3) Çalışma zamanı — yalnızca standalone çıktı, root olmayan kullanıcı.
|
||||||
FROM node:${NODE_VERSION}-alpine AS runner
|
FROM node:${NODE_VERSION}-alpine AS runner
|
||||||
@@ -43,12 +47,15 @@ RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs \
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/dist/bootstrap.mjs ./scripts/bootstrap.mjs
|
||||||
|
COPY --chown=nextjs:nodejs --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
VOLUME ["/app/uploads"]
|
VOLUME ["/app/uploads"]
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
# start-period: ilk deploy'da migration'lar için pay.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -52,19 +52,20 @@ Giriş kullanıcı adı **veya** e-posta ile yapılır. Kayıt kapalıdır; kull
|
|||||||
|
|
||||||
## Scriptler
|
## Scriptler
|
||||||
|
|
||||||
| Komut | Açıklama |
|
| Komut | Açıklama |
|
||||||
| ----------------------------- | --------------------------------- |
|
| ----------------------------- | -------------------------------------------------------------------------------- |
|
||||||
| `npm run dev` | Geliştirme sunucusu |
|
| `npm run dev` | Geliştirme sunucusu |
|
||||||
| `npm run build` / `start` | Üretim build'i / sunucusu |
|
| `npm run build` / `start` | Üretim build'i / sunucusu |
|
||||||
| `npm run lint` | ESLint |
|
| `npm run lint` | ESLint |
|
||||||
| `npm run typecheck` | `tsc --noEmit` |
|
| `npm run typecheck` | `tsc --noEmit` |
|
||||||
| `npm run test` | Vitest birim testleri |
|
| `npm run test` | Vitest birim testleri |
|
||||||
| `npm run format` | Prettier |
|
| `npm run format` | Prettier |
|
||||||
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
|
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
|
||||||
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
|
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
|
||||||
| `npm run test:integration` | Entegrasyon testleri (MongoDB) |
|
| `npm run test:integration` | Entegrasyon testleri (MongoDB) |
|
||||||
| `npm run migrate` | Bekleyen migration'lar |
|
| `npm run migrate` | Bekleyen migration'lar |
|
||||||
| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil |
|
| `npm run bootstrap` | Canlı başlatma adımı (replica set kontrolü + migration; production'da ilk admin) |
|
||||||
|
| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil |
|
||||||
|
|
||||||
## Haneler ve yetki
|
## Haneler ve yetki
|
||||||
|
|
||||||
@@ -83,4 +84,13 @@ docker build --build-arg APP_VERSION=$(node -p "require('./package.json').versio
|
|||||||
--build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
|
--build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem .
|
||||||
```
|
```
|
||||||
|
|
||||||
Canlı ortam (Dokploy, `butcem.ta1sph.website`) kurulumu Faz 15'te `docs/DEPLOY.md` içinde anlatılacak.
|
Konteyner başlarken `docker-entrypoint.sh` önce başlatma adımını (`scripts/bootstrap.mjs`: replica
|
||||||
|
set kontrolü, bekleyen migration'lar, `SEED_ADMIN_*` tanımlıysa ilk admin) sonra uygulamayı çalıştırır.
|
||||||
|
|
||||||
|
## Canlı ortam
|
||||||
|
|
||||||
|
- Kurulum, ortam değişkenleri, domain, volume, otomatik deploy, yedekleme ve geri yükleme:
|
||||||
|
[`docs/DEPLOY.md`](docs/DEPLOY.md)
|
||||||
|
- MongoDB replica set ve uygulama kullanıcısı: [`docs/MONGODB-KURULUM.md`](docs/MONGODB-KURULUM.md)
|
||||||
|
- Duman testi: `scripts/smoke-test.sh https://butcem.ta1sph.website`
|
||||||
|
- Yedek / geri yükleme betikleri: `scripts/backup/`
|
||||||
|
|||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# BÜTÇEM konteyner girişi: önce başlatma (replica set kontrolü, migration'lar, ilk admin),
|
||||||
|
# başarılıysa uygulama. Başlatma başarısız olursa konteyner durur; Dokploy günlüğünde sebep görünür.
|
||||||
|
# Acil durumda atlamak için: SKIP_BOOTSTRAP=1
|
||||||
|
set -e
|
||||||
|
if [ "${SKIP_BOOTSTRAP:-0}" != "1" ]; then
|
||||||
|
node /app/scripts/bootstrap.mjs
|
||||||
|
fi
|
||||||
|
exec node /app/server.js
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
# Canlıya alma (Dokploy + Gitea)
|
||||||
|
|
||||||
|
BÜTÇEM'i Atlas sunucusunda Dokploy ile `https://butcem.ta1sph.website` adresinde yayınlamak,
|
||||||
|
güncellemek, yedeklemek ve geri yüklemek için adım adım kılavuz.
|
||||||
|
|
||||||
|
**Mimari:** Gitea (`main` dalı) → webhook → Dokploy, Dockerfile ile imaj build eder → konteyner
|
||||||
|
başlarken veritabanını hazırlar (replica set kontrolü, migration'lar, ilk admin) → Next.js
|
||||||
|
uygulaması 3000 portunda → Traefik HTTPS ile dışarı açar. MongoDB aynı Dokploy ağında, dışa portsuz.
|
||||||
|
Yüklenen dosyalar `/app/uploads` volume'unda.
|
||||||
|
|
||||||
|
> Sunucu komutları Atlas'ta çalıştırılır. Her komutun altında ne yaptığı yazıyor. `<…>` ile
|
||||||
|
> gösterilen yerleri kendi değerlerinizle değiştirin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ön koşullar
|
||||||
|
|
||||||
|
- [ ] MongoDB servisi **replica set** olarak çalışıyor ve `butcem_app` kullanıcısı hazır →
|
||||||
|
[`MONGODB-KURULUM.md`](MONGODB-KURULUM.md) (1–5. adımlar).
|
||||||
|
- [ ] Gitea'da BÜTÇEM reposu (`main` dalı güncel; `git push origin main`).
|
||||||
|
- [ ] DNS: `butcem.ta1sph.website` için Atlas'ın genel IP'sine **A kaydı** (ve gerekiyorsa
|
||||||
|
modemde 80/443 yönlendirmesi Traefik'e).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig +short butcem.ta1sph.website
|
||||||
|
```
|
||||||
|
|
||||||
|
Alan adının çözüldüğü IP'yi gösterir; Atlas'ın genel IP'si olmalı.
|
||||||
|
|
||||||
|
## 2. Dokploy'da uygulama
|
||||||
|
|
||||||
|
Dokploy › proje › **Create Service › Application**, ad: `butcem`.
|
||||||
|
|
||||||
|
### 2.1 Kaynak (General)
|
||||||
|
|
||||||
|
- **Provider:** Gitea (Dokploy › Settings › Git'te Gitea bağlantısı tanımlıysa) ya da **Git**
|
||||||
|
(repo URL'si + Dokploy'un SSH anahtarı Gitea'da deploy key olarak ekli).
|
||||||
|
- **Repository / Branch:** BÜTÇEM reposu, `main`.
|
||||||
|
- **Build Type:** `Dockerfile` (yol: `Dockerfile`, context: `.`).
|
||||||
|
- **Build Args** (isteğe bağlı): `APP_COMMIT=<kısa-commit>` — sağlık kontrolünde görünür; boşsa
|
||||||
|
`unknown` yazar. Sürüm `package.json`'dan otomatik gelir.
|
||||||
|
|
||||||
|
### 2.2 Ortam değişkenleri (Environment)
|
||||||
|
|
||||||
|
| Değişken | Değer | Not |
|
||||||
|
| --------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||||
|
| `MONGODB_URI` | `mongodb://butcem_app:<parola>@<iç-host>:27017/butcem?authSource=<admin\|butcem>&replicaSet=rs0&directConnection=true` | Ayrıntı: MONGODB-KURULUM.md › 5 |
|
||||||
|
| `BETTER_AUTH_SECRET` | 32+ karakter rastgele | Aşağıdaki komutla üretin; **değiştirirseniz tüm oturumlar kapanır** |
|
||||||
|
| `BETTER_AUTH_URL` | `https://butcem.ta1sph.website` | Sonunda `/` yok |
|
||||||
|
| `SEED_ADMIN_EMAIL` | yönetici e-postası | İlk açılışta admin oluşturulur |
|
||||||
|
| `SEED_ADMIN_PASSWORD` | güçlü parola (8+) | İlk girişten sonra parolayı değiştirip bu değişkeni silebilirsiniz |
|
||||||
|
| `MAX_UPLOAD_MB` | `10` | İsteğe bağlı |
|
||||||
|
|
||||||
|
`NODE_ENV=production`, `PORT=3000` ve `UPLOAD_DIR=/app/uploads` imajda tanımlıdır; eklemeyin.
|
||||||
|
`SEED_USER_PASSWORD` canlıda kullanılmaz (örnek kullanıcı oluşturulmaz).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
`BETTER_AUTH_SECRET` için 32 baytlık rastgele bir değer üretir.
|
||||||
|
|
||||||
|
### 2.3 Volume (Advanced › Volumes / Mounts)
|
||||||
|
|
||||||
|
- **Volume Mount** · ad: `butcem-uploads` · bağlama yolu: **`/app/uploads`**.
|
||||||
|
Fiş, ürün görseli ve ek dosyalar burada tutulur; imaj yeniden oluşturulduğunda kaybolmaz.
|
||||||
|
(Bind mount yerine adlandırılmış volume kullanın; imajdaki dizin izinleri — uid 1001 — volume'a
|
||||||
|
ilk oluşturulurken aktarılır.)
|
||||||
|
|
||||||
|
### 2.4 Domain (Domains)
|
||||||
|
|
||||||
|
- **Host:** `butcem.ta1sph.website` · **Path:** `/` · **Container Port:** `3000`
|
||||||
|
- **HTTPS:** açık · **Certificate:** Let's Encrypt
|
||||||
|
|
||||||
|
### 2.5 İlk deploy
|
||||||
|
|
||||||
|
**Deploy**'a basın ve **Logs**'ta şu satırları bekleyin:
|
||||||
|
|
||||||
|
```
|
||||||
|
• MongoDB rs0 PRIMARY
|
||||||
|
✓ Migration: 001-initial-indexes — … (ilk kurulumda 15 migration)
|
||||||
|
✓ Admin oluşturuldu: <e-posta> (kullanıcı adı: admin)
|
||||||
|
✓ Ready in …
|
||||||
|
```
|
||||||
|
|
||||||
|
"MongoDB replica set olarak çalışmıyor" hatası görürseniz MONGODB-KURULUM.md › 3'ü uygulayın.
|
||||||
|
Konteynerin Docker sağlık durumu birkaç saniye içinde **healthy** olur (`/api/health`).
|
||||||
|
|
||||||
|
### 2.6 İlk giriş
|
||||||
|
|
||||||
|
`https://butcem.ta1sph.website` › kullanıcı adı **`admin`** ve `SEED_ADMIN_PASSWORD`.
|
||||||
|
|
||||||
|
1. **Yönetim › Kullanıcılar**'dan aile üyelerini oluşturun.
|
||||||
|
2. **Yönetim › Haneler**'den haneyi açıp üyeleri ve rollerini ekleyin.
|
||||||
|
3. (Önerilir) Admin parolasını **Yönetim › Kullanıcılar › kendi satırınız › Şifre sıfırla** ile
|
||||||
|
değiştirip Dokploy'da `SEED_ADMIN_PASSWORD`'u silin ve yeniden deploy edin; başlatma artık
|
||||||
|
yalnızca migration'ları çalıştırır. (Admin hesabı varken bu değişken parolayı değiştirmez.)
|
||||||
|
|
||||||
|
## 3. Duman testi (smoke test)
|
||||||
|
|
||||||
|
Kendi bilgisayarınızda, repo klasöründe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Sağlık (veritabanı, replica set, sürüm), güvenlik başlıkları, HTTP→HTTPS yönlendirmesi ve
|
||||||
|
oturumsuz erişimin engellendiğini kontrol eder; **veri değiştirmez**. Tümü ✓ olmalı.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SMOKE_USER=admin SMOKE_PASSWORD='<parola>' scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Ayrıca giriş yapıp ana sayfanın açıldığını dener (parola komut geçmişine düşer; sonra
|
||||||
|
`history -d` ile silebilir ya da satırı boşlukla başlatabilirsiniz).
|
||||||
|
|
||||||
|
Elle kontrol listesi (bir kez, tarayıcıdan):
|
||||||
|
|
||||||
|
- [ ] Giriş / çıkış, telefondan PWA olarak ana ekrana ekleme.
|
||||||
|
- [ ] Bir hesap ve bir gider işlemi oluşturma (transaction'lı yazma).
|
||||||
|
- [ ] İşleme fiş fotoğrafı ekleyip görüntüleme (uploads volume).
|
||||||
|
- [ ] Bir transfer (iki hesap birden; transaction).
|
||||||
|
- [ ] Dokploy'da uygulamayı **Restart** edip fişin hâlâ açıldığını görme (volume kalıcılığı).
|
||||||
|
|
||||||
|
## 4. Otomatik deploy (Gitea webhook)
|
||||||
|
|
||||||
|
Dokploy › `butcem` › **Deployments** › **Webhook URL**'yi kopyalayın (Auto Deploy açık olmalı).
|
||||||
|
Gitea › repo › **Settings › Webhooks › Add Webhook › Gitea**:
|
||||||
|
|
||||||
|
- **Target URL:** kopyaladığınız Dokploy URL'si · **HTTP Method:** POST · **Content type:** JSON
|
||||||
|
- **Trigger On:** Push Events · **Branch filter:** `main`
|
||||||
|
- Kaydedip **Test Delivery** ile deneyin; Dokploy'da yeni bir deployment başlamalı.
|
||||||
|
|
||||||
|
Bundan sonra `git push origin main` → otomatik build ve yayın. Her deploy'da bekleyen
|
||||||
|
migration'lar uygulama açılmadan önce çalışır.
|
||||||
|
|
||||||
|
## 5. Güncelleme ve geri alma
|
||||||
|
|
||||||
|
- **Normal güncelleme:** `main`'e push (webhook) ya da Dokploy'da **Deploy**.
|
||||||
|
- **Büyük güncellemeden önce** elle yedek alın: `sudo /opt/butcem/butcem-backup.sh` (bkz. 6).
|
||||||
|
- **Geri alma:** Dokploy › Deployments › önceki başarılı deployment › **Rollback** (ya da
|
||||||
|
Gitea'da önceki commit'e dönüp push). Migration'lar yalnızca ileri yönlüdür; yeni sürümün
|
||||||
|
eklediği index/alanlar eski sürümü bozmaz. Veri bozulduysa 7'deki geri yüklemeyi kullanın.
|
||||||
|
- **Acil durum:** başlatma adımı (migration) hata verip uygulama açılmıyorsa geçici olarak
|
||||||
|
`SKIP_BOOTSTRAP=1` ortam değişkeniyle açabilirsiniz; sorunu giderince kaldırın.
|
||||||
|
|
||||||
|
## 6. Yedekleme
|
||||||
|
|
||||||
|
Günlük: `butcem` veritabanı (`mongodump`) + uploads volume arşivi + SHA-256 özetleri,
|
||||||
|
**7 gün saklama**, `/var/backups/butcem` altında. Betik: `scripts/backup/butcem-backup.sh`.
|
||||||
|
|
||||||
|
> ⚠️ Şimdilik yedekler **yalnızca sunucuda**. Disk arızasında yedekler de gider; bir sonraki adım
|
||||||
|
> olarak sunucu dışına kopya (NAS'a `rsync` ya da S3 uyumlu depolama) eklenmeli.
|
||||||
|
|
||||||
|
### 6.1 Kurulum (bir kez)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -d -m 755 /opt/butcem
|
||||||
|
```
|
||||||
|
|
||||||
|
Betikler için `/opt/butcem` klasörünü oluşturur.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 750 scripts/backup/butcem-backup.sh scripts/backup/butcem-restore.sh /opt/butcem/
|
||||||
|
```
|
||||||
|
|
||||||
|
Yedek ve geri yükleme betiklerini (repo klasöründen; sunucuya `scp` ile de kopyalayabilirsiniz)
|
||||||
|
`/opt/butcem`'e yalnızca root'un çalıştırabileceği izinle kopyalar.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 600 scripts/backup/butcem-backup.env.example /etc/butcem-backup.env
|
||||||
|
sudo nano /etc/butcem-backup.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Ayar dosyasını yalnızca root'un okuyabileceği izinle oluşturur ve düzenlemek için açar. Doldurun:
|
||||||
|
|
||||||
|
- `MONGO_CONTAINER`: MongoDB konteyner adının ayırt edici kısmı (`docker ps --format '{{.Names}}'`).
|
||||||
|
- `MONGO_ROOT_USER` / `MONGO_ROOT_PASSWORD`: `butcem_root` bilgileri.
|
||||||
|
- `UPLOADS_VOLUME`: uygulamanın volume adı.
|
||||||
|
```bash
|
||||||
|
docker volume ls | grep -i uploads
|
||||||
|
```
|
||||||
|
Volume adlarını listeler (Dokploy'da verdiğiniz `butcem-uploads` adını doğrulayın).
|
||||||
|
|
||||||
|
### 6.2 İlk çalıştırma
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /opt/butcem/butcem-backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Yedeği hemen alır; sonunda `Tamam: … veritabanı, … dosya` yazmalı.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ls -lh /var/backups/butcem && sudo sh -c 'cd /var/backups/butcem && sha256sum -c SHA256SUMS-*'
|
||||||
|
```
|
||||||
|
|
||||||
|
Yedek dosyalarını listeler ve bütünlüklerini doğrular (`OK`).
|
||||||
|
|
||||||
|
### 6.3 Günlük zamanlama
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
root'un zamanlanmış görevlerini açar. Şu satırı ekleyin (her gece 03:30):
|
||||||
|
|
||||||
|
```
|
||||||
|
30 3 * * * /opt/butcem/butcem-backup.sh >> /var/log/butcem-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo tail -n 20 /var/log/butcem-backup.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Ertesi gün son yedeğin günlüğünü gösterir.
|
||||||
|
|
||||||
|
## 7. Geri yükleme
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ls -1 /var/backups/butcem
|
||||||
|
```
|
||||||
|
|
||||||
|
Mevcut yedekleri listeler; dönülecek tarih damgasını seçin.
|
||||||
|
|
||||||
|
1. Dokploy › `butcem` › **Stop** (yazma olmasın).
|
||||||
|
2. Geri yükleyin:
|
||||||
|
```bash
|
||||||
|
sudo /opt/butcem/butcem-restore.sh /var/backups/butcem/mongo-<damga>.archive.gz /var/backups/butcem/uploads-<damga>.tar.gz
|
||||||
|
```
|
||||||
|
Bütünlüğü kontrol eder, onay ister (`evet`), `butcem` veritabanını yedektekiyle **değiştirir**
|
||||||
|
ve uploads volume'unu arşivden yeniden yazar. Yalnızca veritabanı için ikinci dosyayı vermeyin.
|
||||||
|
3. Dokploy › **Start**, ardından duman testi (3).
|
||||||
|
|
||||||
|
**Tatbikat:** Ayda bir, en son yedeği bu adımlarla deneme amaçlı bir Dokploy kopyasına ya da
|
||||||
|
lokale (`docker compose` mongo'su) yükleyip açılıp açılmadığını kontrol edin. Yedeği hiç denenmemiş
|
||||||
|
bir yedek, yedek değildir.
|
||||||
|
|
||||||
|
## 8. Sorun giderme
|
||||||
|
|
||||||
|
| Belirti | Neden / çözüm |
|
||||||
|
| --------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
|
| Logs: `MongoDB replica set olarak çalışmıyor` | MONGODB-KURULUM.md › 3 |
|
||||||
|
| Logs: `Authentication failed` | `MONGODB_URI` kullanıcı/parola/`authSource`; parola URL kodlu mu? |
|
||||||
|
| Logs: `Geçersiz ortam değişkenleri` | Listelenen değişkeni düzeltin (ör. `BETTER_AUTH_SECRET` < 32 karakter) |
|
||||||
|
| Konteyner `unhealthy` | `curl -s https://butcem.ta1sph.website/api/health` → `db` alanı; MongoDB servisi çalışıyor mu? |
|
||||||
|
| Giriş sonrası tekrar giriş sayfası | `BETTER_AUTH_URL` alan adıyla birebir aynı mı (https, sonda `/` yok)? |
|
||||||
|
| Yüklenen dosya açılmıyor / yükleme hatası | Volume `/app/uploads`'a bağlı mı? `docker exec <app> ls -ln /app/uploads` sahibi 1001 olmalı |
|
||||||
|
| Sertifika alınamadı | DNS A kaydı ve 80 portu Traefik'e ulaşıyor mu? |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs --tail 100 $(docker ps -q --filter name=<butcem-uygulama-adı> | head -n1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Uygulama konteynerinin son 100 log satırını gösterir (Dokploy › Logs ile aynı).
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# MongoDB kurulumu (canlı, Dokploy)
|
||||||
|
|
||||||
|
BÜTÇEM çok adımlı yazmaları (transfer, "Alışverişi tamamla", kart ödemesi…) **MongoDB transaction**
|
||||||
|
ile yapar. Transaction yalnızca **replica set** üzerinde çalışır; tek sunucu için **tek düğümlü
|
||||||
|
replica set** (`rs0`) yeterlidir. Uygulama başlarken bunu kontrol eder; replica set yoksa açık bir
|
||||||
|
hata mesajıyla durur.
|
||||||
|
|
||||||
|
Bu belge mevcut kurulumu esas alır: Dokploy'da **`mongo:7`** veritabanı servisi (mongo:8 sunucu
|
||||||
|
çekirdeğiyle uyumsuz), kimlik doğrulama açık, `butcem_root` (yönetici) ve `butcem_app` (uygulama)
|
||||||
|
kullanıcıları oluşturulmuş. Uygulama mongo:7.0 ile test edilmiştir (tüm entegrasyon testleri).
|
||||||
|
|
||||||
|
> Komutlar Atlas sunucusunda çalıştırılır. `<…>` ile gösterilen yerleri kendi değerlerinizle
|
||||||
|
> değiştirin. Parolaları komut geçmişine yazmamak için `mongosh` parola sorduğunda girin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. MongoDB konteynerini bul
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --format '{{.Names}}' | grep -i mongo
|
||||||
|
```
|
||||||
|
|
||||||
|
Çalışan konteynerlerin adlarından MongoDB'ninkini listeler. Dokploy ada rastgele bir son ek ekler
|
||||||
|
(ör. `butcem-mongo-a1b2c3.1.xyz`). Aşağıda bu adı `<MONGO>` olarak kullanın; adın ayırt edici
|
||||||
|
başlangıcı (ör. `butcem-mongo-a1b2c3`) da yeterlidir.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MONGO=$(docker ps -q --filter name=<mongo-servis-adı> | head -n1); echo "$MONGO"
|
||||||
|
```
|
||||||
|
|
||||||
|
Konteyner kimliğini bir değişkene alır; sonraki komutlar `$MONGO` ile kısalır.
|
||||||
|
|
||||||
|
## 2. Replica set açık mı?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||||
|
--eval 'try { const s = rs.status(); print(s.set, s.members[0].stateStr) } catch (e) { print("REPLICA SET YOK: " + e.codeName) }'
|
||||||
|
```
|
||||||
|
|
||||||
|
Yönetici olarak bağlanıp replica set durumunu yazdırır (parolayı sorar).
|
||||||
|
|
||||||
|
- Çıktı **`rs0 PRIMARY`** ise replica set hazır; **4. adıma** geçin.
|
||||||
|
- Çıktı **`REPLICA SET YOK: NoReplicationEnabled`** ise **3. adımı** uygulayın.
|
||||||
|
|
||||||
|
## 3. Tek düğümlü replica set'i aç
|
||||||
|
|
||||||
|
Dokploy'un MongoDB servisinde bir **"Replica Set"** seçeneği varsa (sürüme göre değişir) onu açıp
|
||||||
|
servisi yeniden deploy etmeniz ve 3.4'e geçmeniz yeterlidir. Yoksa aşağıdaki elle yöntemi uygulayın.
|
||||||
|
Kimlik doğrulama açıkken replica set bir **anahtar dosyası (keyfile)** ister.
|
||||||
|
|
||||||
|
### 3.1 Yedek alın
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it "$MONGO" mongodump -u butcem_root --authenticationDatabase admin --gzip --archive=/data/db/oncesi.archive.gz
|
||||||
|
docker cp "$MONGO":/data/db/oncesi.archive.gz ~/mongo-oncesi.archive.gz && docker exec "$MONGO" rm /data/db/oncesi.archive.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Değişiklikten önce tüm veritabanlarının yedeğini konteyner içinde bir dosyaya alır (parolayı sorar),
|
||||||
|
sonra ev dizinine kopyalayıp konteynerdeki kopyayı siler. Yeni kurulumda veri yoksa atlanabilir.
|
||||||
|
|
||||||
|
### 3.2 Anahtar dosyasını oluştur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec "$MONGO" sh -c 'head -c 756 /dev/urandom | base64 > /data/db/rs-keyfile && chmod 400 /data/db/rs-keyfile && chown 999:999 /data/db/rs-keyfile && ls -l /data/db/rs-keyfile'
|
||||||
|
```
|
||||||
|
|
||||||
|
Kalıcı veri volume'u (`/data/db`) içinde rastgele bir anahtar dosyası üretir; yalnızca `mongodb`
|
||||||
|
kullanıcısı (uid 999) okuyabilir. Çıktıda `-r-------- … mongodb mongodb` görmelisiniz. Dosya
|
||||||
|
volume'da olduğu için yeniden başlatmalarda korunur.
|
||||||
|
|
||||||
|
### 3.3 Başlatma komutunu değiştir ve yeniden deploy et
|
||||||
|
|
||||||
|
Dokploy › MongoDB servisi › **Advanced** sekmesi › **Command** alanına:
|
||||||
|
|
||||||
|
```
|
||||||
|
mongod --replSet rs0 --bind_ip_all --keyFile /data/db/rs-keyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
yazıp kaydedin ve servisi **Redeploy** edin. (`MONGO_INITDB_ROOT_*` ortam değişkenleri yerinde
|
||||||
|
kaldığı için kimlik doğrulama açık kalır.) Konteyner yeniden oluşacağından 1. adımdaki `MONGO`
|
||||||
|
değişkenini yeniden alın.
|
||||||
|
|
||||||
|
### 3.4 Replica set'i başlat (tek seferlik)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||||
|
--eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })'
|
||||||
|
```
|
||||||
|
|
||||||
|
`rs0` adlı tek üyeli replica set'i başlatır; çıktıda `{ ok: 1 }` görmelisiniz. Uygulama
|
||||||
|
`directConnection=true` ile bağlandığı için üye adresinin `localhost` olması sorun değildir.
|
||||||
|
|
||||||
|
Birkaç saniye sonra 2. adımdaki kontrolü tekrarlayın: **`rs0 PRIMARY`**.
|
||||||
|
|
||||||
|
## 4. Uygulama kullanıcısını doğrula
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin --eval '
|
||||||
|
for (const d of ["admin", "butcem"]) {
|
||||||
|
const u = db.getSiblingDB(d).getUser("butcem_app");
|
||||||
|
if (u) print("tanımlı veritabanı:", d, "| roller:", JSON.stringify(u.roles));
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`butcem_app` kullanıcısının hangi veritabanında tanımlı olduğunu (bağlantı dizesindeki
|
||||||
|
`authSource`) ve rollerini yazdırır. Beklenen rol: `[{"role":"readWrite","db":"butcem"}]`.
|
||||||
|
|
||||||
|
Rol farklıysa düzeltin:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it "$MONGO" mongosh -u butcem_root --authenticationDatabase admin \
|
||||||
|
--eval 'db.getSiblingDB("<authSource>").updateUser("butcem_app", { roles: [{ role: "readWrite", db: "butcem" }] })'
|
||||||
|
```
|
||||||
|
|
||||||
|
Uygulama kullanıcısına yalnızca `butcem` veritabanında okuma-yazma yetkisi verir (migration'lar
|
||||||
|
index oluşturmak için bu yetkiyle yetinir).
|
||||||
|
|
||||||
|
## 5. Bağlantı dizesi (Dokploy uygulama ortam değişkeni)
|
||||||
|
|
||||||
|
```
|
||||||
|
MONGODB_URI=mongodb://butcem_app:<parola-url-kodlu>@<iç-host>:27017/butcem?authSource=<authSource>&replicaSet=rs0&directConnection=true
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<iç-host>`: Dokploy › MongoDB servisi › **Internal Host** (ör. `butcem-mongo-a1b2c3`). Uygulama
|
||||||
|
ve veritabanı aynı Dokploy ağında olduğundan **dışa port açmaya gerek yoktur**; açmayın.
|
||||||
|
- `<authSource>`: 4. adımın çıktısındaki veritabanı (`admin` veya `butcem`).
|
||||||
|
- Parolada `@ : / ? # %` gibi karakterler varsa URL kodlayın:
|
||||||
|
```bash
|
||||||
|
python3 -c 'import urllib.parse,getpass; print(urllib.parse.quote(getpass.getpass(), safe=""))'
|
||||||
|
```
|
||||||
|
Parolayı sorar ve bağlantı dizesine yazılacak kodlu hâlini basar (geçmişe düşmez).
|
||||||
|
|
||||||
|
## 6. Transaction denemesi (isteğe bağlı)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it "$MONGO" mongosh "mongodb://butcem_app@localhost:27017/butcem?authSource=<authSource>&replicaSet=rs0&directConnection=true" --eval '
|
||||||
|
const s = db.getMongo().startSession(); const c = s.getDatabase("butcem").getCollection("tx_probe");
|
||||||
|
s.startTransaction(); c.insertOne({ a: 1 }); s.commitTransaction();
|
||||||
|
print("transaction tamam:", db.getSiblingDB("butcem").getCollection("tx_probe").countDocuments({}));
|
||||||
|
db.getSiblingDB("butcem").getCollection("tx_probe").drop();'
|
||||||
|
```
|
||||||
|
|
||||||
|
Uygulama kullanıcısıyla bağlanıp küçük bir transaction yazar, sonucu basar ve deneme
|
||||||
|
koleksiyonunu siler. `transaction tamam: 1` görmelisiniz.
|
||||||
+10
-2
@@ -295,8 +295,13 @@ Skill fazları (0–4) uygulanır, ardından alan fazları gelir. Her faz kendi
|
|||||||
| 13 | Alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu, **alışveriş sonrası giriş ve karşılaştırma**, "alışverişi tamamla" transaction'ı | 0.13.0 |
|
| 13 | Alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu, **alışveriş sonrası giriş ve karşılaştırma**, "alışverişi tamamla" transaction'ı | 0.13.0 |
|
||||||
| 14 | Dashboard (çizgi grafik + periyotlar) ve raporlar, CSV dışa aktarma | 0.14.0 |
|
| 14 | Dashboard (çizgi grafik + periyotlar) ve raporlar, CSV dışa aktarma | 0.14.0 |
|
||||||
| 15 | Canlıya alma: `docs/DEPLOY.md`, Dokploy + Gitea, domain, volume, yedekleme, smoke test | **1.0.0** |
|
| 15 | Canlıya alma: `docs/DEPLOY.md`, Dokploy + Gitea, domain, volume, yedekleme, smoke test | **1.0.0** |
|
||||||
| 16 | `/api/v1` mobil API ve mobil kimlik doğrulama | 1.1.0 |
|
| 16 | **Kakeibo K1:** dört grup (Zorunlu/İstek/Kültür/Beklenmeyen), banka maliyeti ayrımı, kayıt bazında grup, Kakeibo raporu | 1.1.0 |
|
||||||
| 17 | Otomatik fiyat sağlayıcıları (market ve altın/döviz) araştırması ve izinliyse entegrasyonu | 1.2.0 |
|
| 17 | **Kakeibo K2:** ekstre içe aktarma (Yapı Kredi kart/hesap, Kakeibo çalışma kitabı; önizleme, tekrar engeli, ekstre toplamı kontrolü, geri alma) | 1.2.0 |
|
||||||
|
| 18 | **Kakeibo K3:** ay planı ve dört soru (ay başı sihirbazı, harcanabilir, grup bütçeleri, haftalık nakit limiti), birikim hedefleri | 1.3.0 |
|
||||||
|
| 19 | **Kakeibo K4:** haftalık kontrol, ay sonu değerlendirmesi (pişmanlık, iyileştirme hedefleri), Kakeibo panosu (KMH faizi/gün, taksit yükü, kayıtsız nakit) | 1.4.0 |
|
||||||
|
| 20 | **Kakeibo K5:** satın almadan önce kontrol ve 24 saat bekleme listesi | 1.5.0 |
|
||||||
|
| 21 | `/api/v1` mobil API ve mobil kimlik doğrulama | 1.6.0 |
|
||||||
|
| 22 | Otomatik fiyat sağlayıcıları (market ve altın/döviz) araştırması ve izinliyse entegrasyonu | 1.7.0 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -326,6 +331,9 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
|||||||
- **Faz 12 kararları (2026-11-06):** 12a mağazalar ve ürünler (marka/paket, görseller), 12b fiyat kayıtları, birim fiyat ve fiyat geçmişi; `v0.12.0` 12b sonunda. Mağazalar, ürünler ve fiyatlar hane bazlıdır (hane içinde ortak, başka hane görmez). Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa ürün kaydında dönüşüm zorunludur ("5 kg = 33 yıkama"); her fiyat ürün tipinin biriminde karşılaştırılır.
|
- **Faz 12 kararları (2026-11-06):** 12a mağazalar ve ürünler (marka/paket, görseller), 12b fiyat kayıtları, birim fiyat ve fiyat geçmişi; `v0.12.0` 12b sonunda. Mağazalar, ürünler ve fiyatlar hane bazlıdır (hane içinde ortak, başka hane görmez). Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa ürün kaydında dönüşüm zorunludur ("5 kg = 33 yıkama"); her fiyat ürün tipinin biriminde karşılaştırılır.
|
||||||
- **Faz 13 kararları (2026-11-06):** 13a alışveriş listeleri, stok eşiği önerileri (onayla/reddet), akıllı mağaza önerisi ve alışveriş modu; 13b alışveriş sonrası giriş, karşılaştırma raporu ve "Alışverişi tamamla"; `v0.13.0` 13b sonunda. Birden çok mağazadan alışverişte mağaza başına ayrı bölünmüş gider (her mağaza için ayrı hesap ve fiş). Tamamlanınca liste kapanır ve rapor sabitlenir; alınmayanlar başka aktif listeye taşınır, yeni listeye alınır ya da bırakılır. Reddedilen öneri yeni alım girilene kadar gösterilmez. Markası belirtilmeyen ürün tipi kalemi (ör. pazardan domates) tipin biriminde stoğa işlenir, fiyat kaydı oluşmaz.
|
- **Faz 13 kararları (2026-11-06):** 13a alışveriş listeleri, stok eşiği önerileri (onayla/reddet), akıllı mağaza önerisi ve alışveriş modu; 13b alışveriş sonrası giriş, karşılaştırma raporu ve "Alışverişi tamamla"; `v0.13.0` 13b sonunda. Birden çok mağazadan alışverişte mağaza başına ayrı bölünmüş gider (her mağaza için ayrı hesap ve fiş). Tamamlanınca liste kapanır ve rapor sabitlenir; alınmayanlar başka aktif listeye taşınır, yeni listeye alınır ya da bırakılır. Reddedilen öneri yeni alım girilene kadar gösterilmez. Markası belirtilmeyen ürün tipi kalemi (ör. pazardan domates) tipin biriminde stoğa işlenir, fiyat kaydı oluşmaz.
|
||||||
- **Faz 14 kararları (2026-11-07):** 14a dashboard ana grafiği (Haftalık/Aylık/Altı aylık/Yıllık, kümülatif/dönemsel) ve finans raporları (kategori dağılımı, aylık trend, kişi ve hesap bazlı, bütçe vs. gerçekleşen); 14b cari ve aynî borçlar, birikim kâr/zarar, alışveriş (liste vs. gerçekleşen, mağaza bazlı harcama), birim fiyat geçmişi, fire/zarar; `v0.14.0` 14b sonunda. Raporlar `/raporlar` altında ayrı sayfalar; dönem ve filtreler URL'de, her raporda CSV. "Ay" bütçe ayıdır; gider kuralları bütçeyle aynı (taksit modu, gizli hesap ayarı). Kişi bazlı harcama gideri kaydeden üyeye göre; mağaza bazlı harcama gider alıcısının mağaza adıyla başlamasına göre (şubeler birlikte).
|
- **Faz 14 kararları (2026-11-07):** 14a dashboard ana grafiği (Haftalık/Aylık/Altı aylık/Yıllık, kümülatif/dönemsel) ve finans raporları (kategori dağılımı, aylık trend, kişi ve hesap bazlı, bütçe vs. gerçekleşen); 14b cari ve aynî borçlar, birikim kâr/zarar, alışveriş (liste vs. gerçekleşen, mağaza bazlı harcama), birim fiyat geçmişi, fire/zarar; `v0.14.0` 14b sonunda. Raporlar `/raporlar` altında ayrı sayfalar; dönem ve filtreler URL'de, her raporda CSV. "Ay" bütçe ayıdır; gider kuralları bütçeyle aynı (taksit modu, gizli hesap ayarı). Kişi bazlı harcama gideri kaydeden üyeye göre; mağaza bazlı harcama gider alıcısının mağaza adıyla başlamasına göre (şubeler birlikte).
|
||||||
|
- **Faz 15 kararları (2026-11-07):** Canlı MongoDB Dokploy servisi **mongo:7** (mongo:8 sunucu çekirdeğiyle uyumsuz); uygulama mongo:7.0'a karşı tüm entegrasyon testleriyle doğrulandı. Replica set kimlik doğrulamalı tek düğüm (`rs0`, keyfile), uygulama `directConnection=true` ile bağlanır (`docs/MONGODB-KURULUM.md`). Migration'lar ve ilk admin konteyner başlarken otomatik (`scripts/bootstrap.mjs`; replica set yoksa uygulama açılmaz). Gitea `main` push → Dokploy webhook ile otomatik deploy. Yedek: günlük `mongodump` + uploads arşivi, 7 gün, şimdilik yalnızca sunucuda (sunucu dışı kopya sonraki adım).
|
||||||
|
- **Kakeibo kararları (2026-11-07):** Uygulama `.claude/skills/kakeibo` becerisine göre Kakeibo yöntemine uyarlanır (Faz 16–20); mobil API ve fiyat sağlayıcıları öncelik değil, 21–22'ye kaydı. KMH faizi, KKDF/BSMV ve banka ücretleri "banka maliyeti"dir: dört gruba ve Kakeibo harcama toplamına girmez, ayrı gösterilir. Günlük plan korunur, Kakeibo haftalık kontrolü yanına eklenir. Kişilere giden havale/EFT gider sayılır; kampanya iadeleri "İade" geliri. Ekstre dosyaları `import/` (git ve Docker dışı); gerçek veri önce dev veritabanında denenir, canlıya aktarmadan önce sorulur.
|
||||||
|
- **Faz 16 kararları (2026-11-07):** Kakeibo grubu gider kategorisinde tutulur ve alt kategoriye devredilir; işlem ya da bölünmüş kalem bazında değiştirilebilir ("kategoriyi ürün değil amaç belirler"). Banka maliyeti işareti kategori düzeyinde (varsayılan "Banka ve kart giderleri" ağacı); grup değiştirilse bile banka maliyeti kalır. Varsayılan kategoriler beceri eşlemesine göre doldurulur, kullanıcı kategorileri grupsuz kalır ve uyarı gösterilir. Migration'lar artık geri alınabilir (`npm run migrate -- --down <ad>`).
|
||||||
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const eslintConfig = defineConfig([
|
|||||||
".next/**",
|
".next/**",
|
||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
|
// Derlenmiş başlatma betiği (esbuild paketi).
|
||||||
|
"dist/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
Generated
+15
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.14.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.14.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^7.0.2",
|
"@hookform/resolvers": "^7.0.2",
|
||||||
"@tanstack/react-table": "^9.3.1",
|
"@tanstack/react-table": "^9.3.1",
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"sonner": "^2.0.8",
|
"sonner": "^2.0.8",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||||
"zod": "^4.6.7"
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -13158,6 +13159,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.20.3",
|
||||||
|
"resolved": "file:vendor/xlsx-0.20.3.tgz",
|
||||||
|
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.14.0",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
"icons": "tsx scripts/generate-icons.ts",
|
"icons": "tsx scripts/generate-icons.ts",
|
||||||
"test:integration": "vitest run --config vitest.integration.config.mts",
|
"test:integration": "vitest run --config vitest.integration.config.mts",
|
||||||
"migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts",
|
"migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts",
|
||||||
|
"bootstrap": "tsx --conditions=react-server --env-file=.env.local scripts/bootstrap.ts",
|
||||||
|
"build:scripts": "esbuild scripts/bootstrap.ts --bundle --platform=node --format=esm --target=node22 --conditions=react-server --outfile=dist/bootstrap.mjs --external:sharp --external:kerberos --external:snappy --external:socks --external:aws4 --external:@mongodb-js/zstd --external:mongodb-client-encryption --external:gcp-metadata --external:@aws-sdk/credential-providers --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --log-level=warning",
|
||||||
"cleanup": "tsx --conditions=react-server --env-file=.env.local scripts/cleanup.ts"
|
"cleanup": "tsx --conditions=react-server --env-file=.env.local scripts/cleanup.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"sonner": "^2.0.8",
|
"sonner": "^2.0.8",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||||
"zod": "^4.6.7"
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# /etc/butcem-backup.env — butcem-backup.sh ayarları. İzin: sudo chmod 600 /etc/butcem-backup.env
|
||||||
|
# Değerler için docs/DEPLOY.md › Yedekleme.
|
||||||
|
|
||||||
|
# MongoDB konteyner adı veya adının ayırt edici bir parçası (docker ps --format '{{.Names}}')
|
||||||
|
MONGO_CONTAINER="butcem-mongo"
|
||||||
|
MONGO_ROOT_USER="butcem_root"
|
||||||
|
MONGO_ROOT_PASSWORD=""
|
||||||
|
|
||||||
|
# Uygulamanın /app/uploads volume adı (docker volume ls)
|
||||||
|
UPLOADS_VOLUME="butcem-uploads"
|
||||||
|
|
||||||
|
BACKUP_DIR="/var/backups/butcem"
|
||||||
|
RETENTION_DAYS="7"
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# BÜTÇEM günlük yedek — sunucuda (Atlas) root olarak, cron ile çalışır. Ayrıntı: docs/DEPLOY.md › Yedekleme
|
||||||
|
#
|
||||||
|
# Üretir (BACKUP_DIR altında, tarih damgalı):
|
||||||
|
# mongo-YYYYmmdd-HHMMSS.archive.gz → butcem veritabanı (mongodump --archive --gzip)
|
||||||
|
# uploads-YYYYmmdd-HHMMSS.tar.gz → yüklenen dosyalar (uploads volume)
|
||||||
|
# SHA256SUMS-YYYYmmdd-HHMMSS → bütünlük özetleri
|
||||||
|
# RETENTION_DAYS günden eski yedekleri siler.
|
||||||
|
#
|
||||||
|
# Ayarlar /etc/butcem-backup.env dosyasından okunur (yalnızca root okuyabilmeli: chmod 600).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONFIG="${BUTCEM_BACKUP_CONFIG:-/etc/butcem-backup.env}"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||||
|
|
||||||
|
: "${MONGO_CONTAINER:?MONGO_CONTAINER tanımlı değil (MongoDB konteyner adı veya adının bir parçası)}"
|
||||||
|
: "${MONGO_ROOT_USER:?MONGO_ROOT_USER tanımlı değil}"
|
||||||
|
: "${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD tanımlı değil}"
|
||||||
|
: "${UPLOADS_VOLUME:?UPLOADS_VOLUME tanımlı değil (uygulamanın /app/uploads volume adı)}"
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/var/backups/butcem}"
|
||||||
|
RETENTION_DAYS="${RETENTION_DAYS:-7}"
|
||||||
|
DB_NAME="${DB_NAME:-butcem}"
|
||||||
|
HELPER_IMAGE="${HELPER_IMAGE:-alpine:3.20}"
|
||||||
|
|
||||||
|
TS="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
log() { echo "[$(date '+%F %T')] $*"; }
|
||||||
|
|
||||||
|
# Dokploy konteyner adlarına rastgele son ek ekler; adın bir parçasıyla eşleşen çalışan konteyner.
|
||||||
|
CID="$(docker ps -q --filter "name=${MONGO_CONTAINER}" | head -n1)"
|
||||||
|
[ -n "$CID" ] || { log "HATA: '${MONGO_CONTAINER}' adlı çalışan MongoDB konteyneri yok."; exit 1; }
|
||||||
|
docker volume inspect "$UPLOADS_VOLUME" >/dev/null 2>&1 || { log "HATA: '${UPLOADS_VOLUME}' volume'u yok."; exit 1; }
|
||||||
|
|
||||||
|
install -d -m 700 "$BACKUP_DIR"
|
||||||
|
MONGO_FILE="$BACKUP_DIR/mongo-$TS.archive.gz"
|
||||||
|
UPLOADS_FILE="$BACKUP_DIR/uploads-$TS.tar.gz"
|
||||||
|
|
||||||
|
# 1) MongoDB: parola komut satırında görünmesin diye geçici yapılandırma dosyasıyla.
|
||||||
|
log "MongoDB yedeği alınıyor ($DB_NAME)…"
|
||||||
|
CONF="/tmp/butcem-dump-$TS.yaml"
|
||||||
|
printf 'password: "%s"\n' "$MONGO_ROOT_PASSWORD" | docker exec -i "$CID" sh -c "umask 077 && cat > $CONF"
|
||||||
|
if ! docker exec "$CID" mongodump --quiet --config "$CONF" --username "$MONGO_ROOT_USER" \
|
||||||
|
--authenticationDatabase admin --db "$DB_NAME" --archive --gzip >"$MONGO_FILE"; then
|
||||||
|
docker exec "$CID" rm -f "$CONF"
|
||||||
|
rm -f "$MONGO_FILE"
|
||||||
|
log "HATA: mongodump başarısız."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker exec "$CID" rm -f "$CONF"
|
||||||
|
[ -s "$MONGO_FILE" ] || { log "HATA: MongoDB yedeği boş."; exit 1; }
|
||||||
|
|
||||||
|
# 2) Yüklenen dosyalar: volume salt okunur bağlanır.
|
||||||
|
log "Uploads yedeği alınıyor ($UPLOADS_VOLUME)…"
|
||||||
|
docker run --rm -v "$UPLOADS_VOLUME":/data:ro -v "$BACKUP_DIR":/backup "$HELPER_IMAGE" \
|
||||||
|
tar czf "/backup/$(basename "$UPLOADS_FILE")" -C /data .
|
||||||
|
[ -s "$UPLOADS_FILE" ] || { log "HATA: uploads yedeği boş."; exit 1; }
|
||||||
|
|
||||||
|
# 3) Bütünlük özeti ve izinler
|
||||||
|
( cd "$BACKUP_DIR" && sha256sum "$(basename "$MONGO_FILE")" "$(basename "$UPLOADS_FILE")" >"SHA256SUMS-$TS" )
|
||||||
|
chmod 600 "$MONGO_FILE" "$UPLOADS_FILE" "$BACKUP_DIR/SHA256SUMS-$TS"
|
||||||
|
|
||||||
|
# 4) Saklama süresi
|
||||||
|
find "$BACKUP_DIR" -maxdepth 1 -type f \( -name 'mongo-*.archive.gz' -o -name 'uploads-*.tar.gz' -o -name 'SHA256SUMS-*' \) \
|
||||||
|
-mtime +"$RETENTION_DAYS" -print -delete | sed 's/^/ silindi: /'
|
||||||
|
|
||||||
|
log "Tamam: $(du -h "$MONGO_FILE" | cut -f1) veritabanı, $(du -h "$UPLOADS_FILE" | cut -f1) dosya → $BACKUP_DIR"
|
||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# BÜTÇEM geri yükleme — sunucuda root olarak, ELLE çalıştırılır. Ayrıntı: docs/DEPLOY.md › Geri yükleme
|
||||||
|
#
|
||||||
|
# sudo scripts/backup/butcem-restore.sh /var/backups/butcem/mongo-….archive.gz [/var/backups/butcem/uploads-….tar.gz]
|
||||||
|
#
|
||||||
|
# DİKKAT: butcem veritabanındaki koleksiyonlar yedektekilerle DEĞİŞTİRİLİR (--drop); uploads arşivi
|
||||||
|
# verilirse volume içeriği silinip arşivden yazılır. Önce uygulamayı durdurun (Dokploy › Stop).
|
||||||
|
# Ayarlar butcem-backup.sh ile aynı dosyadan: /etc/butcem-backup.env
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONFIG="${BUTCEM_BACKUP_CONFIG:-/etc/butcem-backup.env}"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||||
|
: "${MONGO_CONTAINER:?}" "${MONGO_ROOT_USER:?}" "${MONGO_ROOT_PASSWORD:?}" "${UPLOADS_VOLUME:?}"
|
||||||
|
DB_NAME="${DB_NAME:-butcem}"
|
||||||
|
HELPER_IMAGE="${HELPER_IMAGE:-alpine:3.20}"
|
||||||
|
|
||||||
|
MONGO_FILE="${1:?Kullanım: butcem-restore.sh <mongo-arşivi> [uploads-arşivi]}"
|
||||||
|
UPLOADS_FILE="${2:-}"
|
||||||
|
[ -s "$MONGO_FILE" ] || { echo "Arşiv yok veya boş: $MONGO_FILE"; exit 1; }
|
||||||
|
[ -z "$UPLOADS_FILE" ] || [ -s "$UPLOADS_FILE" ] || { echo "Arşiv yok veya boş: $UPLOADS_FILE"; exit 1; }
|
||||||
|
|
||||||
|
# Bütünlük kontrolü (aynı klasörde SHA256SUMS varsa)
|
||||||
|
DIR="$(cd "$(dirname "$MONGO_FILE")" && pwd)"
|
||||||
|
STAMP="$(basename "$MONGO_FILE" | sed -E 's/^mongo-(.*)\.archive\.gz$/\1/')"
|
||||||
|
if [ -f "$DIR/SHA256SUMS-$STAMP" ]; then
|
||||||
|
(cd "$DIR" && sha256sum -c "SHA256SUMS-$STAMP") || { echo "Bütünlük kontrolü başarısız."; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
CID="$(docker ps -q --filter "name=${MONGO_CONTAINER}" | head -n1)"
|
||||||
|
[ -n "$CID" ] || { echo "Çalışan MongoDB konteyneri yok: $MONGO_CONTAINER"; exit 1; }
|
||||||
|
|
||||||
|
echo "Geri yüklenecek: $MONGO_FILE${UPLOADS_FILE:+ ve $UPLOADS_FILE}"
|
||||||
|
echo "'$DB_NAME' veritabanındaki mevcut veri yedektekiyle DEĞİŞTİRİLECEK."
|
||||||
|
if [ "${ASSUME_YES:-0}" != "1" ]; then
|
||||||
|
read -r -p "Devam etmek için 'evet' yazın: " ANSWER
|
||||||
|
[ "$ANSWER" = "evet" ] || { echo "Vazgeçildi."; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONF="/tmp/butcem-restore-$$.yaml"
|
||||||
|
printf 'password: "%s"\n' "$MONGO_ROOT_PASSWORD" | docker exec -i "$CID" sh -c "umask 077 && cat > $CONF"
|
||||||
|
trap 'docker exec "$CID" rm -f "$CONF" >/dev/null 2>&1 || true' EXIT
|
||||||
|
docker exec -i "$CID" mongorestore --quiet --config "$CONF" --username "$MONGO_ROOT_USER" \
|
||||||
|
--authenticationDatabase admin --archive --gzip --drop --nsInclude "${DB_NAME}.*" <"$MONGO_FILE"
|
||||||
|
echo "✓ Veritabanı geri yüklendi."
|
||||||
|
|
||||||
|
if [ -n "$UPLOADS_FILE" ]; then
|
||||||
|
docker run --rm -v "$UPLOADS_VOLUME":/data -v "$(cd "$(dirname "$UPLOADS_FILE")" && pwd)":/backup:ro "$HELPER_IMAGE" \
|
||||||
|
sh -c "find /data -mindepth 1 -delete && tar xzf /backup/$(basename "$UPLOADS_FILE") -C /data && chown -R 1001:1001 /data"
|
||||||
|
echo "✓ Yüklenen dosyalar geri yüklendi."
|
||||||
|
fi
|
||||||
|
echo "Uygulamayı yeniden başlatın (Dokploy › Start) ve duman testini çalıştırın."
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Canlı başlatma (Docker entrypoint, uygulamadan önce): replica set kontrolü, bekleyen migration'lar
|
||||||
|
* ve SEED_ADMIN_* tanımlıysa ilk admin hesabı (varsa dokunulmaz). İdempotent; her deploy'da çalışır.
|
||||||
|
* İmajda esbuild ile tek dosyaya derlenir (scripts/bootstrap.mjs). Lokal: npm run bootstrap
|
||||||
|
*/
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { getDb, getMongoClient } from "@/lib/db/mongo";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { runMigrations } from "./migrations";
|
||||||
|
import { closeSeedConnections, runSeed } from "./seed/run";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const e = env();
|
||||||
|
const hello = await getDb().admin().command({ hello: 1 });
|
||||||
|
if (!hello.setName) {
|
||||||
|
throw new Error(
|
||||||
|
"MongoDB replica set olarak çalışmıyor; transaction'lı yazmalar başarısız olur. " +
|
||||||
|
"docs/DEPLOY.md › 'MongoDB replica set' adımlarını uygulayın.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hello.isWritablePrimary) throw new Error("MongoDB düğümü PRIMARY değil (yazılamaz).");
|
||||||
|
console.log(`• MongoDB ${hello.setName} PRIMARY`);
|
||||||
|
|
||||||
|
if (e.NODE_ENV === "production" && e.SEED_ADMIN_EMAIL && e.SEED_ADMIN_PASSWORD) {
|
||||||
|
// Production'da seed yalnızca migration + admin yapar (örnek veri oluşturulmaz).
|
||||||
|
await runSeed();
|
||||||
|
} else {
|
||||||
|
await runMigrations();
|
||||||
|
if (e.NODE_ENV === "production")
|
||||||
|
console.log("• SEED_ADMIN_* tanımlı değil; admin kontrolü atlandı.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error(`✗ Başlatma hatası: ${err instanceof Error ? err.message : err}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await closeSeedConnections();
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await getMongoClient().close();
|
||||||
|
});
|
||||||
+10
-2
@@ -4,9 +4,17 @@
|
|||||||
*/
|
*/
|
||||||
import mongoose from "mongoose";
|
import mongoose from "mongoose";
|
||||||
import { getMongoClient } from "@/lib/db/mongo";
|
import { getMongoClient } from "@/lib/db/mongo";
|
||||||
import { runMigrations } from "./migrations";
|
import { revertMigration, runMigrations } from "./migrations";
|
||||||
|
|
||||||
runMigrations()
|
// npm run migrate → bekleyen migration'lar
|
||||||
|
// npm run migrate -- --down <ad> → bir migration'ı geri al
|
||||||
|
const downIndex = process.argv.indexOf("--down");
|
||||||
|
const task =
|
||||||
|
downIndex >= 0
|
||||||
|
? revertMigration(process.argv[downIndex + 1] ?? "")
|
||||||
|
: runMigrations().then(() => {});
|
||||||
|
|
||||||
|
task
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
console.error(err instanceof Error ? err.message : err);
|
console.error(err instanceof Error ? err.message : err);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { defaultKakeibo } from "@/features/categories/service";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kakeibo dört grup (Faz 16): gider kategorilerine `kakeiboGroup` ve `bankCost`. Varsayılan
|
||||||
|
* kategoriler beceri eşlemesine göre doldurulur; kullanıcının kategorileri grupsuz kalır (arayüz
|
||||||
|
* "grup seç" uyarısı gösterir). Alanı zaten olan kategoriye dokunulmaz. Hiçbir kategori silinmez.
|
||||||
|
* Geri alma: alanlar kaldırılır (npm run migrate -- --down 016-kakeibo-groups).
|
||||||
|
*/
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "016-kakeibo-groups",
|
||||||
|
description: "gider kategorilerine Kakeibo grubu ve banka maliyeti; varsayılanlar dolduruldu",
|
||||||
|
async up() {
|
||||||
|
const defaults = await CategoryModel.find({
|
||||||
|
kind: "expense",
|
||||||
|
defaultKey: { $type: "string" },
|
||||||
|
kakeiboGroup: { $exists: false },
|
||||||
|
})
|
||||||
|
.select({ defaultKey: 1 })
|
||||||
|
.lean<{ _id: unknown; defaultKey: string }[]>();
|
||||||
|
const ops = defaults.map((c) => ({
|
||||||
|
updateOne: { filter: { _id: c._id }, update: { $set: defaultKakeibo(c.defaultKey) } },
|
||||||
|
}));
|
||||||
|
if (ops.length) await CategoryModel.bulkWrite(ops);
|
||||||
|
await CategoryModel.updateMany(
|
||||||
|
{ kakeiboGroup: { $exists: false } },
|
||||||
|
{ $set: { kakeiboGroup: null, bankCost: false } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async down() {
|
||||||
|
await CategoryModel.updateMany({}, { $unset: { kakeiboGroup: "", bankCost: "" } });
|
||||||
|
await TransactionModel.updateMany(
|
||||||
|
{ kakeiboGroup: { $exists: true } },
|
||||||
|
{ $unset: { kakeiboGroup: "" } },
|
||||||
|
);
|
||||||
|
await TransactionModel.updateMany(
|
||||||
|
{ "splits.kakeiboGroup": { $exists: true } },
|
||||||
|
{ $unset: { "splits.$[].kakeiboGroup": "" } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ImportBatchModel } from "@/models/import-batch";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
/** Ekstre içe aktarma (Faz 17): importBatches index'i ve işlemlerde tekrar engeli index'i. */
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "017-imports",
|
||||||
|
description: "içe aktarma partileri ve işlemlerde tekrar engeli (importKey) index'leri",
|
||||||
|
async up() {
|
||||||
|
await ImportBatchModel.createIndexes();
|
||||||
|
await TransactionModel.createIndexes();
|
||||||
|
},
|
||||||
|
async down(db) {
|
||||||
|
await db
|
||||||
|
.collection("transactions")
|
||||||
|
.dropIndex("householdId_1_importKey_1")
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -15,6 +15,8 @@ import { migration as m012 } from "./012-stock-counts";
|
|||||||
import { migration as m013 } from "./013-stores-products";
|
import { migration as m013 } from "./013-stores-products";
|
||||||
import { migration as m014 } from "./014-product-prices";
|
import { migration as m014 } from "./014-product-prices";
|
||||||
import { migration as m015 } from "./015-shopping-lists";
|
import { migration as m015 } from "./015-shopping-lists";
|
||||||
|
import { migration as m016 } from "./016-kakeibo-groups";
|
||||||
|
import { migration as m017 } from "./017-imports";
|
||||||
import type { Migration } from "./types";
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||||
@@ -34,6 +36,8 @@ export const MIGRATIONS: Migration[] = [
|
|||||||
m013,
|
m013,
|
||||||
m014,
|
m014,
|
||||||
m015,
|
m015,
|
||||||
|
m016,
|
||||||
|
m017,
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
@@ -63,3 +67,23 @@ export async function runMigrations(log: (line: string) => void = console.log):
|
|||||||
if (!ran.length) log("• Bekleyen migration yok.");
|
if (!ran.length) log("• Bekleyen migration yok.");
|
||||||
return ran;
|
return ran;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bir migration'ı geri alır (yalnızca `down` tanımlıysa) ve kaydını siler; bir sonraki
|
||||||
|
* `runMigrations` onu yeniden uygular. Kullanım: npm run migrate -- --down 016-kakeibo-groups
|
||||||
|
*/
|
||||||
|
export async function revertMigration(
|
||||||
|
name: string,
|
||||||
|
log: (line: string) => void = console.log,
|
||||||
|
): Promise<void> {
|
||||||
|
const m = MIGRATIONS.find((x) => x.name === name);
|
||||||
|
if (!m) throw new Error(`Migration bulunamadı: ${name}`);
|
||||||
|
if (!m.down) throw new Error(`${name} geri alınamaz (down tanımlı değil).`);
|
||||||
|
const db = getDb();
|
||||||
|
await connectMongoose();
|
||||||
|
const col = db.collection<{ name: string }>(COLLECTION);
|
||||||
|
if (!(await col.findOne({ name }))) throw new Error(`${name} uygulanmamış.`);
|
||||||
|
await m.down(db);
|
||||||
|
await col.deleteOne({ name });
|
||||||
|
log(`↩ Geri alındı: ${name}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { Db } from "mongodb";
|
import type { Db } from "mongodb";
|
||||||
|
|
||||||
/** Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). Geri alma yok; ileri yönlü. */
|
/**
|
||||||
|
* Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). İleri yönlü; Faz 16'dan
|
||||||
|
* itibaren veri değiştirenler `down` ile geri alınabilir (npm run migrate -- --down <ad>).
|
||||||
|
*/
|
||||||
export type Migration = {
|
export type Migration = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
up: (db: Db) => Promise<void>;
|
up: (db: Db) => Promise<void>;
|
||||||
|
down?: (db: Db) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# BÜTÇEM duman testi (smoke test) — canlıya alma sonrası, yalnızca OKUMA yapar; veri değiştirmez.
|
||||||
|
#
|
||||||
|
# Kullanım:
|
||||||
|
# scripts/smoke-test.sh https://butcem.ta1sph.website [beklenen-sürüm]
|
||||||
|
# SMOKE_USER=admin SMOKE_PASSWORD='…' scripts/smoke-test.sh https://butcem.ta1sph.website 1.0.0
|
||||||
|
#
|
||||||
|
# Gerekenler: bash, curl. Çıkış kodu: 0 = hepsi geçti, 1 = en az bir kontrol başarısız.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
BASE="${1:?Kullanım: scripts/smoke-test.sh <adres> [beklenen-sürüm]}"
|
||||||
|
BASE="${BASE%/}"
|
||||||
|
EXPECT_VERSION="${2:-}"
|
||||||
|
FAIL=0
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
|
||||||
|
bad() { printf ' \033[31m✗\033[0m %s\n' "$1"; FAIL=1; }
|
||||||
|
check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; }
|
||||||
|
|
||||||
|
echo "BÜTÇEM duman testi: $BASE"
|
||||||
|
|
||||||
|
echo "1) Sağlık kontrolü"
|
||||||
|
HEALTH_CODE=$(curl -s -o "$TMP/health" -w '%{http_code}' "$BASE/api/health")
|
||||||
|
HEALTH=$(cat "$TMP/health")
|
||||||
|
echo " $HEALTH"
|
||||||
|
check "GET /api/health 200" '[ "$HEALTH_CODE" = 200 ]'
|
||||||
|
check "veritabanı erişilebilir" 'grep -q "\"db\":\"ok\"" <<<"$HEALTH"'
|
||||||
|
check "MongoDB replica set (transaction için)" '! grep -q "\"replicaSet\":null" <<<"$HEALTH" && grep -q "\"replicaSet\":\"" <<<"$HEALTH"'
|
||||||
|
if [ -n "$EXPECT_VERSION" ]; then
|
||||||
|
check "sürüm $EXPECT_VERSION" 'grep -q "\"version\":\"$EXPECT_VERSION\"" <<<"$HEALTH"'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "2) Sayfalar ve güvenlik başlıkları"
|
||||||
|
LOGIN_CODE=$(curl -s -D "$TMP/login-headers" -o /dev/null -w '%{http_code}' "$BASE/login")
|
||||||
|
H=$(tr -d '\r' <"$TMP/login-headers" | tr 'A-Z' 'a-z')
|
||||||
|
check "GET /login 200" '[ "$LOGIN_CODE" = 200 ]'
|
||||||
|
check "Content-Security-Policy var" 'grep -q "^content-security-policy:" <<<"$H"'
|
||||||
|
check "X-Frame-Options: DENY" 'grep -q "^x-frame-options: deny" <<<"$H"'
|
||||||
|
check "X-Content-Type-Options: nosniff" 'grep -q "^x-content-type-options: nosniff" <<<"$H"'
|
||||||
|
if [[ "$BASE" == https://* ]]; then
|
||||||
|
check "Strict-Transport-Security var" 'grep -q "^strict-transport-security:" <<<"$H"'
|
||||||
|
HTTP_URL="http://${BASE#https://}/login"
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$HTTP_URL")
|
||||||
|
check "HTTP → HTTPS yönlendirmesi ($HTTP_CODE)" '[[ "$HTTP_CODE" =~ ^30[1278]$ ]]'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "3) Oturumsuz erişim engelleniyor"
|
||||||
|
DASH=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$BASE/dashboard")
|
||||||
|
check "/dashboard → giriş sayfasına yönlendirme ($DASH)" '[[ "$DASH" =~ ^30[27]\ .*/login ]]'
|
||||||
|
FILE_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/files/000000000000000000000000")
|
||||||
|
check "/api/files oturumsuz 401 ($FILE_CODE)" '[ "$FILE_CODE" = 401 ]'
|
||||||
|
CSV=$(curl -s -o "$TMP/csv" -w '%{http_code}' "$BASE/raporlar/kategoriler/csv")
|
||||||
|
check "rapor CSV'si oturumsuz veri vermiyor ($CSV)" '! grep -q "Toplam gidere oran" "$TMP/csv"'
|
||||||
|
|
||||||
|
if [ -n "${SMOKE_USER:-}" ] && [ -n "${SMOKE_PASSWORD:-}" ]; then
|
||||||
|
echo "4) Giriş ($SMOKE_USER)"
|
||||||
|
SIGN=$(curl -s -c "$TMP/jar" -o "$TMP/signin" -w '%{http_code}' -X POST "$BASE/api/auth/sign-in/username" \
|
||||||
|
-H 'Content-Type: application/json' -H "Origin: $BASE" \
|
||||||
|
--data "$(printf '{"username":"%s","password":"%s"}' "$SMOKE_USER" "$SMOKE_PASSWORD")")
|
||||||
|
check "giriş 200 ($SIGN)" '[ "$SIGN" = 200 ]'
|
||||||
|
PAGE=$(curl -s -b "$TMP/jar" -L -o /dev/null -w '%{http_code} %{url_effective}' "$BASE/dashboard")
|
||||||
|
check "oturumla ana sayfa açılıyor ($PAGE)" '[[ "$PAGE" =~ ^200\ ]] && [[ ! "$PAGE" =~ /login ]]'
|
||||||
|
curl -s -b "$TMP/jar" -X POST "$BASE/api/auth/sign-out" -H "Origin: $BASE" -o /dev/null
|
||||||
|
else
|
||||||
|
echo "4) Giriş testi atlandı (SMOKE_USER ve SMOKE_PASSWORD tanımlı değil)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FAIL" = 0 ]; then echo "Sonuç: tüm kontroller geçti."; else echo "Sonuç: başarısız kontrol var."; fi
|
||||||
|
exit "$FAIL"
|
||||||
@@ -5,6 +5,7 @@ import { BudgetView } from "@/features/budgets/components/budget-view";
|
|||||||
import { IncomePlanSection } from "@/features/budgets/components/income-plan";
|
import { IncomePlanSection } from "@/features/budgets/components/income-plan";
|
||||||
import { getIncomePlan } from "@/features/budgets/plan";
|
import { getIncomePlan } from "@/features/budgets/plan";
|
||||||
import { getBudget } from "@/features/budgets/service";
|
import { getBudget } from "@/features/budgets/service";
|
||||||
|
import { KakeiboBudgetCard } from "@/features/reports/components/kakeibo-card";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||||
@@ -27,7 +28,13 @@ export default async function BudgetPage({ searchParams }: PageProps<"/butce">)
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={tr.budget.title} description={tr.budget.description} />
|
<PageHeader title={tr.budget.title} description={tr.budget.description} />
|
||||||
<BudgetView key={data.month} data={data} />
|
<BudgetView
|
||||||
|
key={data.month}
|
||||||
|
data={data}
|
||||||
|
summary={
|
||||||
|
<KakeiboBudgetCard userId={user.id} householdId={household.id} month={data.month} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
<IncomePlanSection
|
<IncomePlanSection
|
||||||
key={`income-${data.month}`}
|
key={`income-${data.month}`}
|
||||||
month={data.month}
|
month={data.month}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ImportWizard } from "@/features/imports/components/import-wizard";
|
||||||
|
import { getImportDraft } from "@/features/imports/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.imports.title };
|
||||||
|
|
||||||
|
/** /islemler/ice-aktar/:id — aktarma taslağı ya da işlenmiş aktarma. */
|
||||||
|
export default async function ImportPage({ params }: PageProps<"/islemler/ice-aktar/[id]">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("member");
|
||||||
|
const { id } = await params;
|
||||||
|
const draft = await getImportDraft(user.id, household.id, id);
|
||||||
|
if (!draft) notFound();
|
||||||
|
return <ImportWizard key={`${draft.id}-${draft.status}`} draft={draft} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ArrowLeft, ChevronRight } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { UploadCard } from "@/features/imports/components/upload-card";
|
||||||
|
import { listImports } from "@/features/imports/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
import { formatDateTime } from "@/shared/format";
|
||||||
|
|
||||||
|
const t = tr.imports;
|
||||||
|
export const metadata: Metadata = { title: t.title };
|
||||||
|
|
||||||
|
/** /islemler/ice-aktar — Kakeibo çalışma kitabı yükleme ve aktarma geçmişi. */
|
||||||
|
export default async function ImportsPage() {
|
||||||
|
const { user, household } = await requireActiveHousehold("member");
|
||||||
|
const list = await listImports(user.id, household.id);
|
||||||
|
if (!list) notFound();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href="/islemler">
|
||||||
|
<ArrowLeft />
|
||||||
|
{tr.nav.transactions}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<PageHeader title={t.title} description={t.description} />
|
||||||
|
<UploadCard />
|
||||||
|
<section aria-labelledby="imp-h" className="flex flex-col gap-2">
|
||||||
|
<h2 id="imp-h" className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t.history}
|
||||||
|
</h2>
|
||||||
|
{list.length ? (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{list.map((b) => (
|
||||||
|
<li key={b.id}>
|
||||||
|
<Link
|
||||||
|
href={`/islemler/ice-aktar/${b.id}`}
|
||||||
|
className="flex items-center gap-3 p-3 text-sm hover:bg-accent/40"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-medium">{b.fileName}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(b.createdAt)} · {t.rowsCount(b.rowCount)}
|
||||||
|
{b.committed ? ` · ${t.committed(b.committed.transactions)}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Badge variant={b.status === "committed" ? "default" : "secondary"}>
|
||||||
|
{t.statuses[b.status]}
|
||||||
|
</Badge>
|
||||||
|
<ChevronRight className="size-4 text-muted-foreground" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.empty}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||||
|
import { getKakeiboReport } from "@/features/reports/kakeibo";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { csvMoney, toCsv } from "@/shared/csv";
|
||||||
|
import { KAKEIBO_GROUPS, type SpendingClass } from "@/shared/kakeibo";
|
||||||
|
|
||||||
|
/** GET /raporlar/kakeibo/csv?ay=YYYY-MM — sınıf × kategori tutarları. */
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const c = await csvContext(request);
|
||||||
|
if (!c) return unauthorized();
|
||||||
|
const r = await getKakeiboReport(c.userId, c.householdId, c.month);
|
||||||
|
if (!r) return forbidden();
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
const label = (cls: SpendingClass) =>
|
||||||
|
cls === "BANK_COST" ? k.bankCost : cls === "UNGROUPED" ? k.ungrouped : k.groups[cls];
|
||||||
|
const order: SpendingClass[] = [...KAKEIBO_GROUPS, "UNGROUPED", "BANK_COST"];
|
||||||
|
const rows = order.flatMap((cls) =>
|
||||||
|
r.now.byClass[cls].map((x) => [label(cls), x.path, csvMoney(x.amountMinor)]),
|
||||||
|
);
|
||||||
|
return csvResponse(
|
||||||
|
toCsv(tr.reports.kakeiboReport.csvHeader, rows),
|
||||||
|
tr.reports.kakeiboReport.csvFile(r.month),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { KakeiboReportView } from "@/features/reports/components/kakeibo-view";
|
||||||
|
import {
|
||||||
|
CsvButton,
|
||||||
|
MonthNav,
|
||||||
|
ReportFrame,
|
||||||
|
ToolsEnd,
|
||||||
|
monthLabel,
|
||||||
|
} from "@/features/reports/components/report-frame";
|
||||||
|
import { getKakeiboReport } from "@/features/reports/kakeibo";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||||
|
|
||||||
|
const t = tr.reports.kakeiboReport;
|
||||||
|
export const metadata: Metadata = { title: t.title };
|
||||||
|
|
||||||
|
/** /raporlar/kakeibo?ay=YYYY-MM — Kakeibo dört grup, banka maliyeti, grupsuzlar. */
|
||||||
|
export default async function KakeiboReportPage({ searchParams }: PageProps<"/raporlar/kakeibo">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||||
|
const data = await getKakeiboReport(user.id, household.id, ay.success ? ay.data : undefined);
|
||||||
|
if (!data) notFound();
|
||||||
|
return (
|
||||||
|
<ReportFrame
|
||||||
|
title={t.title}
|
||||||
|
description={t.description(monthLabel(data.month))}
|
||||||
|
tools={
|
||||||
|
<>
|
||||||
|
<MonthNav month={data.month} currentMonth={data.currentMonth} path="/raporlar/kakeibo" />
|
||||||
|
<ToolsEnd>
|
||||||
|
<CsvButton href={`/raporlar/kakeibo/csv?ay=${data.month}`} />
|
||||||
|
</ToolsEnd>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{data.includesHidden ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{tr.reports.includesHidden}</p>
|
||||||
|
) : null}
|
||||||
|
<KakeiboReportView data={data} />
|
||||||
|
</ReportFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BookOpenCheck,
|
||||||
ChartPie,
|
ChartPie,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Coins,
|
Coins,
|
||||||
@@ -25,6 +26,7 @@ const t = tr.reports;
|
|||||||
export const metadata: Metadata = { title: t.title };
|
export const metadata: Metadata = { title: t.title };
|
||||||
|
|
||||||
const REPORTS = [
|
const REPORTS = [
|
||||||
|
{ href: "/raporlar/kakeibo", key: "kakeibo", icon: BookOpenCheck },
|
||||||
{ href: "/raporlar/kategoriler", key: "categories", icon: ChartPie },
|
{ href: "/raporlar/kategoriler", key: "categories", icon: ChartPie },
|
||||||
{ href: "/raporlar/trend", key: "trend", icon: TrendingUp },
|
{ href: "/raporlar/trend", key: "trend", icon: TrendingUp },
|
||||||
{ href: "/raporlar/kisi-hesap", key: "people", icon: Users },
|
{ href: "/raporlar/kisi-hesap", key: "people", icon: Users },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { connection } from "next/server";
|
import { connection } from "next/server";
|
||||||
import { pingDb } from "@/lib/db/mongo";
|
import { getDb, pingDb } from "@/lib/db/mongo";
|
||||||
import { APP_COMMIT, APP_VERSION } from "@/lib/version";
|
import { APP_COMMIT, APP_VERSION } from "@/lib/version";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,10 +10,21 @@ export async function GET() {
|
|||||||
// Cache Components açıkken GET handler'ı build'de prerender edilmesin.
|
// Cache Components açıkken GET handler'ı build'de prerender edilmesin.
|
||||||
await connection();
|
await connection();
|
||||||
const dbOk = await pingDb();
|
const dbOk = await pingDb();
|
||||||
|
// Transaction'lar için replica set şart; adı yoksa (standalone) null.
|
||||||
|
let replicaSet: string | null = null;
|
||||||
|
if (dbOk) {
|
||||||
|
try {
|
||||||
|
const hello = await getDb().admin().command({ hello: 1 }, { timeoutMS: 2_000 });
|
||||||
|
replicaSet = typeof hello.setName === "string" ? hello.setName : null;
|
||||||
|
} catch {
|
||||||
|
replicaSet = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
status: dbOk ? "ok" : "degraded",
|
status: dbOk ? "ok" : "degraded",
|
||||||
db: dbOk ? "ok" : "unreachable",
|
db: dbOk ? "ok" : "unreachable",
|
||||||
|
replicaSet,
|
||||||
version: APP_VERSION,
|
version: APP_VERSION,
|
||||||
commit: APP_COMMIT,
|
commit: APP_COMMIT,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export type NavItem = {
|
|||||||
|
|
||||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||||
export const CURRENT_PHASE = 14.2;
|
export const CURRENT_PHASE = 16;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: str
|
|||||||
|
|
||||||
type Pending = { kind: "edit" } | { kind: "replace"; source: "copy" | "actuals" } | null;
|
type Pending = { kind: "edit" } | { kind: "replace"; source: "copy" | "actuals" } | null;
|
||||||
|
|
||||||
export function BudgetView({ data }: { data: Data }) {
|
export function BudgetView({ data, summary }: { data: Data; summary?: React.ReactNode }) {
|
||||||
const [pending, setPending] = useState<Pending>(null);
|
const [pending, setPending] = useState<Pending>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const close = () => setPending(null);
|
const close = () => setPending(null);
|
||||||
@@ -306,6 +306,8 @@ export function BudgetView({ data }: { data: Data }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{summary}
|
||||||
|
|
||||||
{data.lines.length ? (
|
{data.lines.length ? (
|
||||||
<ul className="flex flex-col divide-y rounded-xl border bg-card">
|
<ul className="flex flex-col divide-y rounded-xl border bg-card">
|
||||||
{data.lines.map((l) => (
|
{data.lines.map((l) => (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { IconBadge } from "@/components/appearance/swatch";
|
import { IconBadge } from "@/components/appearance/swatch";
|
||||||
|
import { KakeiboBadge } from "./kakeibo-badge";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
@@ -61,6 +62,9 @@ export function CategoriesManager({
|
|||||||
[categories, kind, showArchived],
|
[categories, kind, showArchived],
|
||||||
);
|
);
|
||||||
const archivedCount = categories.filter((c) => c.kind === kind && c.archived).length;
|
const archivedCount = categories.filter((c) => c.kind === kind && c.archived).length;
|
||||||
|
const ungrouped = categories.filter(
|
||||||
|
(c) => c.kind === "expense" && !c.archived && !c.kakeibo.group && !c.kakeibo.bankCost,
|
||||||
|
).length;
|
||||||
|
|
||||||
function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) {
|
function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -90,6 +94,9 @@ export function CategoriesManager({
|
|||||||
<span className={cn("min-w-0 flex-1 truncate", node.depth === 0 && "font-medium")}>
|
<span className={cn("min-w-0 flex-1 truncate", node.depth === 0 && "font-medium")}>
|
||||||
{node.name}
|
{node.name}
|
||||||
</span>
|
</span>
|
||||||
|
{node.kind === "expense" && !node.archived ? (
|
||||||
|
<KakeiboBadge kakeibo={node.kakeibo} />
|
||||||
|
) : null}
|
||||||
{node.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
{node.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
@@ -191,6 +198,11 @@ export function CategoriesManager({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!canEdit ? <p className="text-sm text-muted-foreground">{t.readOnly}</p> : null}
|
{!canEdit ? <p className="text-sm text-muted-foreground">{t.readOnly}</p> : null}
|
||||||
|
{kind === "expense" && ungrouped ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||||
|
{tr.kakeibo.ungroupedCount(ungrouped)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{tree.length ? (
|
{tree.length ? (
|
||||||
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
|
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
|||||||
import { IconPicker, SwatchPicker } from "@/components/appearance/pickers";
|
import { IconPicker, SwatchPicker } from "@/components/appearance/pickers";
|
||||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||||
import {
|
import {
|
||||||
categoryFormSchema,
|
categoryFormSchema,
|
||||||
type CategoryForm,
|
type CategoryForm,
|
||||||
@@ -60,6 +62,8 @@ export function CategoryFormDialog({
|
|||||||
parentId: c.parentId ?? "",
|
parentId: c.parentId ?? "",
|
||||||
icon: c.icon,
|
icon: c.icon,
|
||||||
color: c.color,
|
color: c.color,
|
||||||
|
kakeiboGroup: c.kakeiboGroup ?? "",
|
||||||
|
bankCost: c.bankCost,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const parent = categories.find((c) => c.id === state.parentId);
|
const parent = categories.find((c) => c.id === state.parentId);
|
||||||
@@ -69,6 +73,9 @@ export function CategoryFormDialog({
|
|||||||
parentId: state.parentId ?? "",
|
parentId: state.parentId ?? "",
|
||||||
icon: parent?.icon ?? "tag",
|
icon: parent?.icon ?? "tag",
|
||||||
color: parent?.color ?? "blue",
|
color: parent?.color ?? "blue",
|
||||||
|
// Yeni alt kategori grubu üstten devralır.
|
||||||
|
kakeiboGroup: "",
|
||||||
|
bankCost: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [state, categories, form]);
|
}, [state, categories, form]);
|
||||||
@@ -105,6 +112,10 @@ export function CategoryFormDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const color = useWatch({ control: form.control, name: "color" }) ?? "blue";
|
const color = useWatch({ control: form.control, name: "color" }) ?? "blue";
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
const parentId = useWatch({ control: form.control, name: "parentId" });
|
||||||
|
const parentGroup = categories.find((c) => c.id === parentId)?.kakeibo.group ?? null;
|
||||||
|
const parentBank = categories.find((c) => c.id === parentId)?.kakeibo.bankCost ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveDialog
|
<ResponsiveDialog
|
||||||
@@ -177,6 +188,60 @@ export function CategoryFormDialog({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{kind === "expense" ? (
|
||||||
|
<>
|
||||||
|
<Controller
|
||||||
|
name="kakeiboGroup"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="cat-kakeibo">{k.field}</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value || ROOT}
|
||||||
|
onValueChange={(v) => field.onChange(v === ROOT ? "" : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="cat-kakeibo" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ROOT}>
|
||||||
|
{parentGroup ? k.inheritWith(k.groups[parentGroup]) : k.inherit}
|
||||||
|
</SelectItem>
|
||||||
|
{KAKEIBO_GROUPS.map((g) => (
|
||||||
|
<SelectItem key={g} value={g}>
|
||||||
|
{k.groups[g]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldDescription>
|
||||||
|
{field.value
|
||||||
|
? k.groupHints[field.value as keyof typeof k.groupHints]
|
||||||
|
: k.ungroupedHint}
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="bankCost"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
id="cat-bank"
|
||||||
|
checked={Boolean(field.value) || parentBank}
|
||||||
|
disabled={parentBank}
|
||||||
|
onCheckedChange={(x) => field.onChange(x === true)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<FieldLabel htmlFor="cat-bank">{k.bankCost}</FieldLabel>
|
||||||
|
<FieldDescription>{k.bankCostHint}</FieldDescription>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<Controller
|
<Controller
|
||||||
name="color"
|
name="color"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { KakeiboGroup, ResolvedKakeibo } from "@/shared/kakeibo";
|
||||||
|
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
|
||||||
|
/** Kakeibo grup renkleri: doğrulanmış kategorik palet (series-1…4); etiket her zaman yanında. */
|
||||||
|
export const KAKEIBO_COLOR: Record<KakeiboGroup, string> = {
|
||||||
|
ZORUNLU: "var(--series-1)",
|
||||||
|
ISTEK: "var(--series-2)",
|
||||||
|
KULTUR: "var(--series-3)",
|
||||||
|
BEKLENMEYEN: "var(--series-4)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KakeiboDot({
|
||||||
|
group,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
group: KakeiboGroup | null;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"inline-block size-2.5 shrink-0 rounded-full",
|
||||||
|
!group && "border border-dashed border-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={group ? { backgroundColor: KAKEIBO_COLOR[group] } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kategorinin etkin Kakeibo grubu; devralınan grup soluk, banka maliyeti ve grupsuz ayrı. */
|
||||||
|
export function KakeiboBadge({ kakeibo }: { kakeibo: ResolvedKakeibo }) {
|
||||||
|
if (kakeibo.bankCost)
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" title={k.bankCostHint}>
|
||||||
|
{k.bankCost}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
if (!kakeibo.group)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5 border-dashed text-muted-foreground"
|
||||||
|
title={k.ungroupedHint}
|
||||||
|
>
|
||||||
|
<KakeiboDot group={null} />
|
||||||
|
{k.ungrouped}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn("gap-1.5", kakeibo.inherited && "text-muted-foreground")}
|
||||||
|
title={
|
||||||
|
kakeibo.inherited
|
||||||
|
? `${k.groups[kakeibo.group]} (${k.inherited})`
|
||||||
|
: k.groupHints[kakeibo.group]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<KakeiboDot group={kakeibo.group} />
|
||||||
|
{k.groups[kakeibo.group]}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ const form = (over: Partial<Parameters<typeof createCategory>[2]> = {}) => ({
|
|||||||
parentId: null,
|
parentId: null,
|
||||||
icon: "tag" as const,
|
icon: "tag" as const,
|
||||||
color: "blue" as const,
|
color: "blue" as const,
|
||||||
|
kakeiboGroup: null,
|
||||||
|
bankCost: false,
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import { toObjectId } from "@/lib/object-id";
|
|||||||
import { CategoryModel } from "@/models/category";
|
import { CategoryModel } from "@/models/category";
|
||||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
import type { CategoryForm, CategoryKind } from "@/shared/schemas/category";
|
import type { CategoryForm, CategoryKind } from "@/shared/schemas/category";
|
||||||
|
import {
|
||||||
|
DEFAULT_BANK_COST_KEYS,
|
||||||
|
DEFAULT_KAKEIBO_GROUPS,
|
||||||
|
resolveKakeibo,
|
||||||
|
type KakeiboGroup,
|
||||||
|
type ResolvedKakeibo,
|
||||||
|
} from "@/shared/kakeibo";
|
||||||
import { foldTr } from "@/shared/search";
|
import { foldTr } from "@/shared/search";
|
||||||
import { DEFAULT_CATEGORIES } from "./defaults";
|
import { DEFAULT_CATEGORIES } from "./defaults";
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +33,16 @@ import {
|
|||||||
|
|
||||||
const t = tr.categories;
|
const t = tr.categories;
|
||||||
|
|
||||||
export type CategoryView = FlatCategory & { icon: IconKey; color: Swatch; isDefault: boolean };
|
export type CategoryView = FlatCategory & {
|
||||||
|
icon: IconKey;
|
||||||
|
color: Swatch;
|
||||||
|
isDefault: boolean;
|
||||||
|
/** Kendi Kakeibo grubu (null = üstten devral). */
|
||||||
|
kakeiboGroup: KakeiboGroup | null;
|
||||||
|
bankCost: boolean;
|
||||||
|
/** Etkin grup ve banka maliyeti (devralma dahil). */
|
||||||
|
kakeibo: ResolvedKakeibo;
|
||||||
|
};
|
||||||
|
|
||||||
type Lean = {
|
type Lean = {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
@@ -39,9 +55,26 @@ type Lean = {
|
|||||||
color: string;
|
color: string;
|
||||||
defaultKey: string | null;
|
defaultKey: string | null;
|
||||||
ancestors: Types.ObjectId[];
|
ancestors: Types.ObjectId[];
|
||||||
|
kakeiboGroup?: KakeiboGroup | null;
|
||||||
|
bankCost?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toView = (c: Lean): CategoryView => ({
|
const NONE: ResolvedKakeibo = { group: null, inherited: false, bankCost: false };
|
||||||
|
|
||||||
|
/** Görünümler: etkin Kakeibo grubu tüm kategoriler (arşivliler dahil) üzerinden çözülür. */
|
||||||
|
function toViews(rows: Lean[], all: Lean[] = rows): CategoryView[] {
|
||||||
|
const resolved = resolveKakeibo(
|
||||||
|
all.map((c) => ({
|
||||||
|
id: String(c._id),
|
||||||
|
parentId: c.parentId ? String(c.parentId) : null,
|
||||||
|
kakeiboGroup: c.kakeiboGroup ?? null,
|
||||||
|
bankCost: Boolean(c.bankCost),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return rows.map((c) => ({ ...toView(c), kakeibo: resolved.get(String(c._id)) ?? NONE }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const toView = (c: Lean): Omit<CategoryView, "kakeibo"> & { kakeibo?: ResolvedKakeibo } => ({
|
||||||
id: String(c._id),
|
id: String(c._id),
|
||||||
kind: c.kind,
|
kind: c.kind,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
@@ -51,8 +84,16 @@ const toView = (c: Lean): CategoryView => ({
|
|||||||
icon: c.icon as IconKey,
|
icon: c.icon as IconKey,
|
||||||
color: c.color as Swatch,
|
color: c.color as Swatch,
|
||||||
isDefault: Boolean(c.defaultKey),
|
isDefault: Boolean(c.defaultKey),
|
||||||
|
kakeiboGroup: c.kakeiboGroup ?? null,
|
||||||
|
bankCost: Boolean(c.bankCost),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Kakeibo alanları yalnızca gider kategorisinde; gelirde temizlenir. */
|
||||||
|
const kakeiboFields = (input: CategoryForm) =>
|
||||||
|
input.kind === "expense"
|
||||||
|
? { kakeiboGroup: input.kakeiboGroup, bankCost: input.bankCost }
|
||||||
|
: { kakeiboGroup: null, bankCost: false };
|
||||||
|
|
||||||
const violationMessage = (v: MoveViolation) => t.violations[v];
|
const violationMessage = (v: MoveViolation) => t.violations[v];
|
||||||
|
|
||||||
export async function listCategories(
|
export async function listCategories(
|
||||||
@@ -64,9 +105,9 @@ export async function listCategories(
|
|||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const filter: Record<string, unknown> = { householdId: m.householdId };
|
const filter: Record<string, unknown> = { householdId: m.householdId };
|
||||||
if (opts.kind) filter.kind = opts.kind;
|
if (opts.kind) filter.kind = opts.kind;
|
||||||
if (!opts.includeArchived) filter.archivedAt = null;
|
const all = await CategoryModel.find(filter).lean<Lean[]>();
|
||||||
const rows = await CategoryModel.find(filter).lean<Lean[]>();
|
const rows = opts.includeArchived ? all : all.filter((c) => !c.archivedAt);
|
||||||
return rows.map(toView);
|
return toViews(rows, all);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadKind(householdId: Types.ObjectId, kind: CategoryKind, session?: ClientSession) {
|
async function loadKind(householdId: Types.ObjectId, kind: CategoryKind, session?: ClientSession) {
|
||||||
@@ -86,6 +127,17 @@ function hasSiblingNamed(all: Lean[], parentId: string | null, name: string, exc
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Varsayılan kategorinin Kakeibo grubu ve banka maliyeti işareti (Faz 16). */
|
||||||
|
export function defaultKakeibo(key: string): {
|
||||||
|
kakeiboGroup: KakeiboGroup | null;
|
||||||
|
bankCost: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
kakeiboGroup: DEFAULT_KAKEIBO_GROUPS[key] ?? null,
|
||||||
|
bankCost: (DEFAULT_BANK_COST_KEYS as readonly string[]).includes(key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Varsayılan seti yükler (idempotent: defaultKey'e göre). Yeni hane oluşturulurken aynı
|
* Varsayılan seti yükler (idempotent: defaultKey'e göre). Yeni hane oluşturulurken aynı
|
||||||
* transaction içinde (session) ya da migration/seed'den çağrılır. Eklenen kategori sayısını döner.
|
* transaction içinde (session) ya da migration/seed'den çağrılır. Eklenen kategori sayısını döner.
|
||||||
@@ -117,6 +169,7 @@ export async function installDefaultCategories(
|
|||||||
color: def.color,
|
color: def.color,
|
||||||
order,
|
order,
|
||||||
defaultKey: def.key,
|
defaultKey: def.key,
|
||||||
|
...(kind === "expense" ? defaultKakeibo(def.key) : {}),
|
||||||
createdBy: actor,
|
createdBy: actor,
|
||||||
updatedBy: actor,
|
updatedBy: actor,
|
||||||
},
|
},
|
||||||
@@ -164,6 +217,7 @@ export async function createCategory(
|
|||||||
ancestors: parent ? [...parent.ancestors, parent._id] : [],
|
ancestors: parent ? [...parent.ancestors, parent._id] : [],
|
||||||
icon: input.icon,
|
icon: input.icon,
|
||||||
color: input.color,
|
color: input.color,
|
||||||
|
...kakeiboFields(input),
|
||||||
order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0,
|
order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0,
|
||||||
createdBy: actor,
|
createdBy: actor,
|
||||||
updatedBy: actor,
|
updatedBy: actor,
|
||||||
@@ -209,6 +263,7 @@ export async function updateCategory(
|
|||||||
nameKey: foldTr(input.name),
|
nameKey: foldTr(input.name),
|
||||||
icon: input.icon,
|
icon: input.icon,
|
||||||
color: input.color,
|
color: input.color,
|
||||||
|
...kakeiboFields(input),
|
||||||
updatedBy: actor,
|
updatedBy: actor,
|
||||||
};
|
};
|
||||||
if (moving) {
|
if (moving) {
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
|
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||||
|
import { parseMoney } from "@/shared/money";
|
||||||
|
import {
|
||||||
|
commitImport,
|
||||||
|
createImportAccount,
|
||||||
|
createImportDraft,
|
||||||
|
deleteImportDraft,
|
||||||
|
MAX_IMPORT_BYTES,
|
||||||
|
revertImport,
|
||||||
|
setImportAccount,
|
||||||
|
updateImportRow,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
const t = tr.imports;
|
||||||
|
const id = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||||
|
const role = z.enum(["card", "bank", "cash", "extra"]);
|
||||||
|
|
||||||
|
async function ctx() {
|
||||||
|
return getHouseholdActionContext("member");
|
||||||
|
}
|
||||||
|
|
||||||
|
function done<T>(r: ActionResult<T>) {
|
||||||
|
if (r.ok) revalidatePath("/", "layout");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dosya yükleme (FormData: file). Yalnızca .xlsx; boyut sınırı Server Action gövdesiyle uyumlu. */
|
||||||
|
export async function uploadImportAction(form: FormData) {
|
||||||
|
const c = await ctx();
|
||||||
|
if (!c) return fail(tr.common.unauthorized);
|
||||||
|
const file = form.get("file");
|
||||||
|
if (!(file instanceof File) || file.size === 0) return fail(t.errors.noFile);
|
||||||
|
if (file.size > MAX_IMPORT_BYTES) return fail(t.errors.tooLarge);
|
||||||
|
if (!/\.xlsx$/i.test(file.name)) return fail(t.errors.unreadable);
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
return done(await createImportDraft(c.user.id, c.household.id, { name: file.name, buffer }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setImportAccountAction(batchId: unknown, r: unknown, accountId: unknown) {
|
||||||
|
const c = await ctx();
|
||||||
|
const p = z
|
||||||
|
.object({ batchId: id, role, accountId: id })
|
||||||
|
.safeParse({ batchId, role: r, accountId });
|
||||||
|
if (!c || !p.success) return fail(tr.common.notFound);
|
||||||
|
return done(
|
||||||
|
await setImportAccount(
|
||||||
|
c.user.id,
|
||||||
|
c.household.id,
|
||||||
|
p.data.batchId,
|
||||||
|
p.data.role,
|
||||||
|
p.data.accountId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createImportAccountAction(batchId: unknown, r: unknown, input: unknown) {
|
||||||
|
const c = await ctx();
|
||||||
|
const p = z
|
||||||
|
.object({
|
||||||
|
batchId: id,
|
||||||
|
role,
|
||||||
|
name: z.string().trim().min(1, tr.common.required).max(80),
|
||||||
|
opening: z.string().trim(),
|
||||||
|
openingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
})
|
||||||
|
.safeParse({ batchId, role: r, ...(input as object) });
|
||||||
|
if (!c || !p.success) return fail(tr.adminUsers.errors.checkFields);
|
||||||
|
const minor = p.data.opening ? parseMoney(p.data.opening.replace("−", "-")) : 0;
|
||||||
|
if (minor === null) return fail(tr.validation.money);
|
||||||
|
return done(
|
||||||
|
await createImportAccount(c.user.id, c.household.id, p.data.batchId, p.data.role, {
|
||||||
|
name: p.data.name,
|
||||||
|
openingMinor: minor,
|
||||||
|
openingDate: p.data.openingDate,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateImportRowAction(batchId: unknown, edit: unknown) {
|
||||||
|
const c = await ctx();
|
||||||
|
const p = z
|
||||||
|
.object({
|
||||||
|
batchId: id,
|
||||||
|
rowId: id,
|
||||||
|
kind: z.enum(["expense", "income", "skip"]).optional(),
|
||||||
|
categoryId: z
|
||||||
|
.union([id, z.string().regex(/^new:[a-z.-]+$/)])
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
kakeiboGroup: z.enum(KAKEIBO_GROUPS).nullable().optional(),
|
||||||
|
applyToSame: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.safeParse({ batchId, ...(edit as object) });
|
||||||
|
if (!c || !p.success) return fail(tr.common.notFound);
|
||||||
|
const { batchId: b, ...rest } = p.data;
|
||||||
|
// Satır düzenlemesi sık; tüm sayfayı yenilemeye gerek yok.
|
||||||
|
return updateImportRow(c.user.id, c.household.id, b, rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitImportAction(batchId: unknown) {
|
||||||
|
const c = await ctx();
|
||||||
|
const p = id.safeParse(batchId);
|
||||||
|
if (!c || !p.success) return fail(tr.common.notFound);
|
||||||
|
return done(await commitImport(c.user.id, c.household.id, p.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revertImportAction(batchId: unknown) {
|
||||||
|
const c = await ctx();
|
||||||
|
const p = id.safeParse(batchId);
|
||||||
|
if (!c || !p.success) return fail(tr.common.notFound);
|
||||||
|
return done(await revertImport(c.user.id, c.household.id, p.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteImportDraftAction(batchId: unknown) {
|
||||||
|
const c = await ctx();
|
||||||
|
const p = id.safeParse(batchId);
|
||||||
|
if (!c || !p.success) return fail(tr.common.notFound);
|
||||||
|
return done(await deleteImportDraft(c.user.id, c.household.id, p.data));
|
||||||
|
}
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
Copy,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Undo2,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { KakeiboSelect } from "@/features/transactions/components/kakeibo-select";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { AccountType } from "@/shared/accounts";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import type { ImportRole } from "@/shared/import/kakeibo-workbook";
|
||||||
|
import type { KakeiboGroup } from "@/shared/kakeibo";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import {
|
||||||
|
commitImportAction,
|
||||||
|
createImportAccountAction,
|
||||||
|
deleteImportDraftAction,
|
||||||
|
revertImportAction,
|
||||||
|
setImportAccountAction,
|
||||||
|
updateImportRowAction,
|
||||||
|
} from "../actions";
|
||||||
|
import type { ImportDraft, ImportRowView } from "../service";
|
||||||
|
|
||||||
|
const t = tr.imports;
|
||||||
|
const PAGE = 50;
|
||||||
|
const ROLE_TYPES: Record<string, AccountType[]> = {
|
||||||
|
card: ["credit_card"],
|
||||||
|
bank: ["bank"],
|
||||||
|
cash: ["cash"],
|
||||||
|
extra: ["bank", "cash"],
|
||||||
|
};
|
||||||
|
type Filter = "all" | "review" | "ungrouped" | "transfer" | "skip";
|
||||||
|
|
||||||
|
const day = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
|
||||||
|
function Step({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3 rounded-xl border bg-card p-4">
|
||||||
|
<h2 className="font-medium">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleRow({ draft, role }: { draft: ImportDraft; role: ImportRole }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const preset = draft.presets[role];
|
||||||
|
const current = draft.accounts[role] ?? "";
|
||||||
|
const options = draft.accountOptions.filter((a) => ROLE_TYPES[role]?.includes(a.type));
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const isCard = role === "card";
|
||||||
|
const [v, setV] = useState({
|
||||||
|
name: preset?.name ?? "",
|
||||||
|
opening: preset ? formatMoneyInput(Math.abs(preset.openingMinor)) : "",
|
||||||
|
sign: (preset?.openingMinor ?? 0) < 0 ? "-" : "",
|
||||||
|
openingDate: preset?.openingDate ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function pick(accountId: string) {
|
||||||
|
const r = await setImportAccountAction(draft.id, role, accountId);
|
||||||
|
if (!r.ok) toast.error(r.error);
|
||||||
|
else router.refresh();
|
||||||
|
}
|
||||||
|
async function create() {
|
||||||
|
setBusy(true);
|
||||||
|
const r = await createImportAccountAction(draft.id, role, {
|
||||||
|
name: v.name,
|
||||||
|
opening: `${v.sign}${v.opening}`,
|
||||||
|
openingDate: v.openingDate,
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (!r.ok) toast.error(r.error);
|
||||||
|
else {
|
||||||
|
setCreating(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
{current ? (
|
||||||
|
<CheckCircle2 className="size-4 text-income" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="size-4 text-amber-600" aria-hidden />
|
||||||
|
)}
|
||||||
|
{t.roles[role]}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t.roleHints[role]}</p>
|
||||||
|
</div>
|
||||||
|
<Select value={current || undefined} onValueChange={pick}>
|
||||||
|
<SelectTrigger className="w-56" aria-label={t.roles[role]}>
|
||||||
|
<SelectValue placeholder={t.selectAccount} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{draft.canCreateAccounts && preset && !current ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setCreating((x) => !x)}>
|
||||||
|
<Plus />
|
||||||
|
{t.createSuggested}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{creating && preset ? (
|
||||||
|
<div className="grid gap-3 rounded-lg border bg-muted/30 p-3 sm:grid-cols-3">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={`acc-${role}-name`}>{t.accountName}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`acc-${role}-name`}
|
||||||
|
value={v.name}
|
||||||
|
onChange={(e) => setV((s) => ({ ...s, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={`acc-${role}-open`}>
|
||||||
|
{isCard ? t.openingDebt : t.openingBalance}
|
||||||
|
</FieldLabel>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{!isCard ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
aria-label="±"
|
||||||
|
onClick={() => setV((s) => ({ ...s, sign: s.sign ? "" : "-" }))}
|
||||||
|
>
|
||||||
|
{v.sign ? "−" : "+"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Input
|
||||||
|
id={`acc-${role}-open`}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={v.opening}
|
||||||
|
onChange={(e) => setV((s) => ({ ...s, opening: e.target.value }))}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={`acc-${role}-date`}>{t.openingDate}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`acc-${role}-date`}
|
||||||
|
type="date"
|
||||||
|
value={v.openingDate}
|
||||||
|
onChange={(e) => setV((s) => ({ ...s, openingDate: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<FieldDescription className="sm:col-span-2">
|
||||||
|
{isCard ? t.openingHintCard : role === "bank" ? t.openingHintBank : ""}
|
||||||
|
</FieldDescription>
|
||||||
|
<Button className="sm:justify-self-end" onClick={create} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="animate-spin" /> : null}
|
||||||
|
{tr.common.create}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowItem({
|
||||||
|
row,
|
||||||
|
draft,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
row: ImportRowView;
|
||||||
|
draft: ImportDraft;
|
||||||
|
onEdit: (
|
||||||
|
row: ImportRowView,
|
||||||
|
patch: Partial<Pick<ImportRowView, "kind" | "categoryId" | "kakeiboGroup">>,
|
||||||
|
) => void;
|
||||||
|
}) {
|
||||||
|
const cats = draft.categories.filter(
|
||||||
|
(c) => c.kind === (row.kind === "income" ? "income" : "expense"),
|
||||||
|
);
|
||||||
|
const cat = draft.categories.find((c) => c.id === row.categoryId);
|
||||||
|
const editable =
|
||||||
|
!row.duplicate && (row.kind === "expense" || row.kind === "income" || row.kind === "skip");
|
||||||
|
const muted = row.duplicate || row.kind === "skip" || row.kind === "deferred";
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-2 p-3 text-sm",
|
||||||
|
muted && "bg-muted/30 text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="w-20 shrink-0 tabular-nums">{day(row.date)}</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-medium">{row.description || "—"}</span>
|
||||||
|
<span className="mt-0.5 flex flex-wrap gap-1">
|
||||||
|
{row.installment ? (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t.installment(row.installment.index, row.installment.count)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{row.duplicate ? (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Copy className="size-3" aria-hidden />
|
||||||
|
{t.duplicate}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{row.review && !row.duplicate ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-3" aria-hidden />
|
||||||
|
{t.reviews[row.review] ?? row.review}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{row.source.category || row.source.type ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{row.source.category || row.source.type}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-right font-medium tabular-nums",
|
||||||
|
row.amountMinor < 0 ? "" : "text-income",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.amountMinor < 0 ? "−" : "+"}
|
||||||
|
{formatMoney(Math.abs(row.amountMinor))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{row.kind === "transfer" ? (
|
||||||
|
<p className="pl-23 text-xs">
|
||||||
|
{t.kinds.transfer}{" "}
|
||||||
|
{row.amountMinor < 0
|
||||||
|
? t.transferTo(t.roles[row.counterRole ?? ""] ?? "")
|
||||||
|
: t.transferFrom(t.roles[row.counterRole ?? ""] ?? "")}
|
||||||
|
</p>
|
||||||
|
) : row.kind === "deferred" ? (
|
||||||
|
<p className="pl-23 text-xs">
|
||||||
|
{t.kinds.deferred}: {t.roles.gold}
|
||||||
|
</p>
|
||||||
|
) : editable ? (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-[8rem_minmax(0,1fr)_12rem] sm:pl-23">
|
||||||
|
<Select
|
||||||
|
value={row.kind}
|
||||||
|
onValueChange={(k) => onEdit(row, { kind: k as ImportRowView["kind"] })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full text-sm" aria-label={t.columns.kind}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(["expense", "income", "skip"] as const).map((k) => (
|
||||||
|
<SelectItem key={k} value={k}>
|
||||||
|
{t.kinds[k]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{row.kind !== "skip" ? (
|
||||||
|
<Select
|
||||||
|
value={row.categoryId ?? undefined}
|
||||||
|
onValueChange={(c) => onEdit(row, { categoryId: c })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full text-sm" aria-label={t.columns.category}>
|
||||||
|
<SelectValue placeholder={tr.transactions.fields.selectCategory} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{cats.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.path}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
{row.kind === "expense" ? (
|
||||||
|
<KakeiboSelect
|
||||||
|
compact
|
||||||
|
value={row.kakeiboGroup && row.kakeiboGroup !== cat?.group ? row.kakeiboGroup : ""}
|
||||||
|
onChange={(g) =>
|
||||||
|
onEdit(row, { kakeiboGroup: (g || cat?.group || null) as KakeiboGroup | null })
|
||||||
|
}
|
||||||
|
suggested={cat?.group ?? null}
|
||||||
|
bankCost={cat?.bankCost ?? false}
|
||||||
|
ariaLabel={t.columns.group}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /islemler/ice-aktar/:id — hesaplar, kontroller, önizleme ve işleme. */
|
||||||
|
export function ImportWizard({ draft }: { draft: ImportDraft }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [rows, setRows] = useState(draft.rows);
|
||||||
|
const [sheet, setSheet] = useState<"card" | "bank">(
|
||||||
|
draft.rows.some((r) => r.sheet === "card") ? "card" : "bank",
|
||||||
|
);
|
||||||
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [applyToSame, setApplyToSame] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [confirmRevert, setConfirmRevert] = useState(false);
|
||||||
|
const isDraft = draft.status === "draft";
|
||||||
|
const catOf = (id: string | null) => draft.categories.find((c) => c.id === id);
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const ofSheet = rows.filter((r) => r.sheet === sheet);
|
||||||
|
switch (filter) {
|
||||||
|
case "review":
|
||||||
|
return ofSheet.filter((r) => r.review && !r.duplicate);
|
||||||
|
case "ungrouped":
|
||||||
|
return ofSheet.filter(
|
||||||
|
(r) =>
|
||||||
|
r.kind === "expense" &&
|
||||||
|
!r.duplicate &&
|
||||||
|
!(r.kakeiboGroup ?? catOf(r.categoryId)?.group) &&
|
||||||
|
!catOf(r.categoryId)?.bankCost,
|
||||||
|
);
|
||||||
|
case "transfer":
|
||||||
|
return ofSheet.filter((r) => r.kind === "transfer");
|
||||||
|
case "skip":
|
||||||
|
return ofSheet.filter((r) => r.kind === "skip" || r.kind === "deferred" || r.duplicate);
|
||||||
|
default:
|
||||||
|
return ofSheet;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [rows, sheet, filter]);
|
||||||
|
const pageRows = visible.slice(page * PAGE, page * PAGE + PAGE);
|
||||||
|
|
||||||
|
const toCreate = rows.filter((r) => !r.duplicate && r.kind !== "skip" && r.kind !== "deferred");
|
||||||
|
const missingCategory = toCreate.filter(
|
||||||
|
(r) => (r.kind === "expense" || r.kind === "income") && !r.categoryId,
|
||||||
|
);
|
||||||
|
const missingRoles = draft.neededRoles.filter((r) => !draft.accounts[r]);
|
||||||
|
const deferredRows = rows.filter((r) => r.kind === "deferred");
|
||||||
|
const deferred = deferredRows.length;
|
||||||
|
const deferredNet = deferredRows.reduce((sum, r) => sum + r.amountMinor, 0);
|
||||||
|
const duplicates = rows.filter((r) => r.duplicate).length;
|
||||||
|
const okStatements = draft.checks.statements.filter((s) => s.ok).length;
|
||||||
|
|
||||||
|
async function edit(
|
||||||
|
row: ImportRowView,
|
||||||
|
patch: Partial<Pick<ImportRowView, "kind" | "categoryId" | "kakeiboGroup">>,
|
||||||
|
) {
|
||||||
|
const same = (x: ImportRowView) =>
|
||||||
|
applyToSame
|
||||||
|
? x.sheet === row.sheet &&
|
||||||
|
x.description.toLocaleLowerCase("tr-TR") === row.description.toLocaleLowerCase("tr-TR") &&
|
||||||
|
x.kind === row.kind
|
||||||
|
: x.id === row.id;
|
||||||
|
const before = rows;
|
||||||
|
setRows((rs) => rs.map((x) => (same(x) ? { ...x, ...patch, edited: true } : x)));
|
||||||
|
const r = await updateImportRowAction(draft.id, { rowId: row.id, ...patch, applyToSame });
|
||||||
|
if (!r.ok) {
|
||||||
|
setRows(before);
|
||||||
|
toast.error(r.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
setBusy(true);
|
||||||
|
const r = await commitImportAction(draft.id);
|
||||||
|
setBusy(false);
|
||||||
|
if (!r.ok) toast.error(r.error);
|
||||||
|
else {
|
||||||
|
toast.success(t.committed(r.data.transactions));
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revert() {
|
||||||
|
setConfirmRevert(false);
|
||||||
|
setBusy(true);
|
||||||
|
const r = await revertImportAction(draft.id);
|
||||||
|
setBusy(false);
|
||||||
|
if (!r.ok) toast.error(r.error);
|
||||||
|
else {
|
||||||
|
toast.success(t.reverted(r.data.transactions));
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDraft() {
|
||||||
|
const r = await deleteImportDraftAction(draft.id);
|
||||||
|
if (!r.ok) toast.error(r.error);
|
||||||
|
else router.push("/islemler/ice-aktar");
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: Filter[] = ["all", "review", "ungrouped", "transfer", "skip"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href="/islemler/ice-aktar">
|
||||||
|
<ArrowLeft />
|
||||||
|
{t.back}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<PageHeader
|
||||||
|
title={draft.fileName}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{t.statuses[draft.status]} · {t.rowsCount(draft.rowCount)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isDraft ? (
|
||||||
|
<Step title={t.statuses[draft.status]!}>
|
||||||
|
{draft.committed ? (
|
||||||
|
<p className="text-sm">
|
||||||
|
{t.committedSummary(
|
||||||
|
draft.committed.transactions,
|
||||||
|
draft.committed.skipped,
|
||||||
|
draft.committed.categoriesCreated,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link href="/islemler">{t.goTransactions}</Link>
|
||||||
|
</Button>
|
||||||
|
{draft.status === "committed" ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setConfirmRevert(true)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Undo2 />
|
||||||
|
{t.revert}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Step>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isDraft && draft.neededRoles.length ? (
|
||||||
|
<Step title={t.steps.accounts}>
|
||||||
|
{!draft.canCreateAccounts ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t.ownerOnly}</p>
|
||||||
|
) : null}
|
||||||
|
<ul className="divide-y">
|
||||||
|
{draft.neededRoles.map((role) => (
|
||||||
|
<RoleRow key={role} draft={draft} role={role} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Step>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Step title={t.steps.checks}>
|
||||||
|
<ul className="flex flex-col gap-1.5 text-sm">
|
||||||
|
{draft.checks.statements.length ? (
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
{okStatements === draft.checks.statements.length ? (
|
||||||
|
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-income" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<XCircle className="mt-0.5 size-4 shrink-0 text-expense" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{t.statementsSummary(okStatements, draft.checks.statements.length)}
|
||||||
|
{draft.checks.statements
|
||||||
|
.filter((s) => !s.ok)
|
||||||
|
.map((s) => (
|
||||||
|
<span key={s.cut} className="block text-xs text-expense">
|
||||||
|
{t.statementBad(
|
||||||
|
day(s.cut),
|
||||||
|
formatMoney(s.rowsMinor),
|
||||||
|
s.statementMinor === null ? "—" : formatMoney(s.statementMinor),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
<li className="text-muted-foreground">{t.noStatements}</li>
|
||||||
|
)}
|
||||||
|
{draft.checks.balance.closingMinor !== null ? (
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
{draft.checks.balance.mismatchLine === null ? (
|
||||||
|
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-income" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<XCircle className="mt-0.5 size-4 shrink-0 text-expense" aria-hidden />
|
||||||
|
)}
|
||||||
|
{draft.checks.balance.mismatchLine === null
|
||||||
|
? t.balanceOk(formatMoney(draft.checks.balance.closingMinor))
|
||||||
|
: t.balanceBad(draft.checks.balance.mismatchLine)}
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
{duplicates ? (
|
||||||
|
<li className="text-muted-foreground">{t.duplicates(duplicates)}</li>
|
||||||
|
) : null}
|
||||||
|
{deferred ? (
|
||||||
|
<li className="text-muted-foreground">
|
||||||
|
{t.deferred(
|
||||||
|
deferred,
|
||||||
|
`${deferredNet < 0 ? "−" : "+"}${formatMoney(Math.abs(deferredNet))}`,
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
{isDraft && draft.newCategories.length ? (
|
||||||
|
<li className="text-muted-foreground">
|
||||||
|
{t.newCategories(draft.newCategories.join(", "))}
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step title={t.steps.rows}>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Tabs
|
||||||
|
value={sheet}
|
||||||
|
onValueChange={(x) => {
|
||||||
|
setSheet(x as "card" | "bank");
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
{(["card", "bank"] as const)
|
||||||
|
.filter((s) => rows.some((r) => r.sheet === s))
|
||||||
|
.map((s) => (
|
||||||
|
<TabsTrigger key={s} value={s}>
|
||||||
|
{t.sheets[s]} ({rows.filter((r) => r.sheet === s).length})
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{filters.map((f) => (
|
||||||
|
<Button
|
||||||
|
key={f}
|
||||||
|
size="sm"
|
||||||
|
variant={filter === f ? "secondary" : "ghost"}
|
||||||
|
onClick={() => {
|
||||||
|
setFilter(f);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.filters[f]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isDraft ? (
|
||||||
|
<label className="ml-auto flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked={applyToSame} onCheckedChange={(x) => setApplyToSame(x === true)} />
|
||||||
|
{t.applyToSame}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
className={cn("divide-y rounded-lg border", !isDraft && "pointer-events-none opacity-90")}
|
||||||
|
>
|
||||||
|
{pageRows.map((r) => (
|
||||||
|
<RowItem key={r.id} row={r} draft={draft} onEdit={edit} />
|
||||||
|
))}
|
||||||
|
{!pageRows.length ? (
|
||||||
|
<li className="p-6 text-center text-sm text-muted-foreground">{t.empty}</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
{visible.length > PAGE ? (
|
||||||
|
<div className="flex items-center justify-end gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{t.page(page * PAGE + 1, Math.min(visible.length, (page + 1) * PAGE), visible.length)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
{t.prev}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={(page + 1) * PAGE >= visible.length}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
{t.next}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
{isDraft ? (
|
||||||
|
<Step title={t.steps.commit}>
|
||||||
|
<p className="text-sm">
|
||||||
|
{t.commitSummary(toCreate.length, rows.length - toCreate.length)}
|
||||||
|
</p>
|
||||||
|
{missingRoles.map((r) => (
|
||||||
|
<p key={r} className="text-sm text-expense">
|
||||||
|
{t.errors.roleMissing(t.roles[r] ?? r)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{missingCategory.length ? (
|
||||||
|
<p className="text-sm text-expense">
|
||||||
|
{t.errors.category(String(missingCategory.length))}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="text-xs text-muted-foreground">{t.commitHint}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={commit}
|
||||||
|
disabled={busy || missingRoles.length > 0 || missingCategory.length > 0}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="animate-spin" /> : null}
|
||||||
|
{busy ? t.committing : t.commit}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={removeDraft} disabled={busy}>
|
||||||
|
{t.deleteDraft}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Step>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AlertDialog open={confirmRevert} onOpenChange={setConfirmRevert}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t.revertTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t.revertDescription}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||||
|
<Button variant="destructive" onClick={revert}>
|
||||||
|
{t.revert}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FileSpreadsheet, Loader2, Upload } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { uploadImportAction } from "../actions";
|
||||||
|
|
||||||
|
const t = tr.imports;
|
||||||
|
|
||||||
|
/** Kakeibo çalışma kitabı yükleme: dosya seçilince okunur ve taslağa gidilir. */
|
||||||
|
export function UploadCard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const input = useRef<HTMLInputElement>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function onPick(file: File | undefined) {
|
||||||
|
if (!file) return;
|
||||||
|
setBusy(true);
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", file);
|
||||||
|
const r = await uploadImportAction(form);
|
||||||
|
setBusy(false);
|
||||||
|
if (input.current) input.current.value = "";
|
||||||
|
if (!r.ok) {
|
||||||
|
toast.error(r.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/islemler/ice-aktar/${r.data.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col items-start gap-3 rounded-xl border border-dashed bg-card p-5 sm:flex-row sm:items-center">
|
||||||
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<FileSpreadsheet className="size-5 text-muted-foreground" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<p className="min-w-0 flex-1 text-sm text-muted-foreground">{t.uploadHint}</p>
|
||||||
|
<input
|
||||||
|
ref={input}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
className="sr-only"
|
||||||
|
id="import-file"
|
||||||
|
onChange={(e) => onPick(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => input.current?.click()} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="animate-spin" /> : <Upload />}
|
||||||
|
{busy ? t.uploading : t.upload}
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { listAccounts } from "@/features/accounts/service";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import {
|
||||||
|
commitImport,
|
||||||
|
createImportAccount,
|
||||||
|
createImportDraft,
|
||||||
|
getImportDraft,
|
||||||
|
revertImport,
|
||||||
|
updateImportRow,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
/** Yapay Kakeibo çalışma kitabı (gerçek veri değil; yapı gerçek dosyayla aynı). */
|
||||||
|
const serial = (ymd: string) =>
|
||||||
|
Date.UTC(+ymd.slice(0, 4), +ymd.slice(5, 7) - 1, +ymd.slice(8, 10)) / 86_400_000 + 25569;
|
||||||
|
|
||||||
|
function workbook(): Buffer {
|
||||||
|
const card = [
|
||||||
|
[
|
||||||
|
"İşlem tarihi",
|
||||||
|
"Ekstre kesim",
|
||||||
|
"İşyeri",
|
||||||
|
"Kategori",
|
||||||
|
"Kakeibo",
|
||||||
|
"Kart",
|
||||||
|
"Taksit",
|
||||||
|
"Tutar (TL)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
serial("2026-01-05"),
|
||||||
|
serial("2026-01-28"),
|
||||||
|
"Test Market",
|
||||||
|
"Market & gıda",
|
||||||
|
"Zorunlu",
|
||||||
|
"Fiziksel kart",
|
||||||
|
null,
|
||||||
|
100,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
serial("2026-01-06"),
|
||||||
|
serial("2026-01-28"),
|
||||||
|
"Test Market",
|
||||||
|
"Market & gıda",
|
||||||
|
"İstek",
|
||||||
|
"Fiziksel kart",
|
||||||
|
null,
|
||||||
|
40,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
serial("2025-12-10"),
|
||||||
|
serial("2026-01-28"),
|
||||||
|
"Test Yapı (taksit)",
|
||||||
|
"Ev / tadilat / yapı market",
|
||||||
|
"Beklenmeyen",
|
||||||
|
"Fiziksel kart",
|
||||||
|
"1/3 (300,00 TL)",
|
||||||
|
100,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
serial("2025-12-10"),
|
||||||
|
serial("2026-02-28"),
|
||||||
|
"Test Yapı (taksit)",
|
||||||
|
"Ev / tadilat / yapı market",
|
||||||
|
"Beklenmeyen",
|
||||||
|
"Fiziksel kart",
|
||||||
|
"2/3 (300,00 TL)",
|
||||||
|
100,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
serial("2026-01-07"),
|
||||||
|
serial("2026-01-28"),
|
||||||
|
"Test Esnaf",
|
||||||
|
"Tanımsız esnaf",
|
||||||
|
null,
|
||||||
|
"Fiziksel kart",
|
||||||
|
null,
|
||||||
|
20,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
serial("2026-01-08"),
|
||||||
|
serial("2026-01-28"),
|
||||||
|
"Kampanya iadesi",
|
||||||
|
"Kampanya iadesi",
|
||||||
|
"İstek",
|
||||||
|
"Fiziksel kart",
|
||||||
|
null,
|
||||||
|
-10,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
const bank = [
|
||||||
|
["Tarih", "Açıklama", "Tür", "Tutar (TL)", "Bakiye (TL)", "Ay"],
|
||||||
|
[serial("2025-12-31"), "Devreden bakiye", "Açılış", null, 1000, null],
|
||||||
|
[serial("2026-01-02"), "Yol parası", "Yol parası", 500, 1500, null],
|
||||||
|
[serial("2026-01-03"), "ATM nakit çekim", "Nakit çekim (ATM)", -200, 1300, null],
|
||||||
|
[serial("2026-01-04"), "Kredi kartı borç ödemesi", "Kredi kartı ödemesi", -250, 1050, null],
|
||||||
|
[serial("2026-01-05"), "Giden havale (kişi)", "Havale/EFT (kişi)", -50, 1000, null],
|
||||||
|
[serial("2026-01-06"), "KMH faiz tahakkuku", "KMH faiz ve vergi", -5, 995, null],
|
||||||
|
];
|
||||||
|
const summary = [
|
||||||
|
["Kakeibo özeti"],
|
||||||
|
[
|
||||||
|
"Ekstre kesim",
|
||||||
|
"Zorunlu",
|
||||||
|
"İstek",
|
||||||
|
"Kültür",
|
||||||
|
"Beklenmeyen",
|
||||||
|
"Toplam",
|
||||||
|
"Ekstredeki harcama",
|
||||||
|
"Fark",
|
||||||
|
],
|
||||||
|
[serial("2026-01-28"), 0, 0, 0, 0, 0, 250, 0],
|
||||||
|
[serial("2026-02-28"), 0, 0, 0, 0, 0, 100, 0],
|
||||||
|
];
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), "Özet");
|
||||||
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(card), "Kredi Kartı");
|
||||||
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(bank), "Maaş Hesabı");
|
||||||
|
return XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let household = "";
|
||||||
|
let ayse = "";
|
||||||
|
let zeynep = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
// Tertemiz bir hane: Annemler'de ayse izleyici; testte Çakır yerine yeni hesaplar açılır.
|
||||||
|
household = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
describe("Kakeibo çalışma kitabı içe aktarma", () => {
|
||||||
|
let batch = "";
|
||||||
|
const accounts: Record<string, string> = {};
|
||||||
|
|
||||||
|
it("taslak: öneriler, kontroller, ihtiyaç duyulan roller; izleyici aktaramaz", async () => {
|
||||||
|
expect(
|
||||||
|
(await createImportDraft(zeynep, household, { name: "x.xlsx", buffer: workbook() })).ok,
|
||||||
|
).toBe(false);
|
||||||
|
const r = await createImportDraft(ayse, household, {
|
||||||
|
name: "kakeibo.xlsx",
|
||||||
|
buffer: workbook(),
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
batch = r.ok ? r.data.id : "";
|
||||||
|
const d = (await getImportDraft(ayse, household, batch))!;
|
||||||
|
expect(d.rows).toHaveLength(11);
|
||||||
|
expect(d.checks.statements.every((s) => s.ok)).toBe(true);
|
||||||
|
expect(d.checks.balance).toEqual({ mismatchLine: null, closingMinor: 99_500 });
|
||||||
|
expect(d.neededRoles).toEqual(["card", "bank", "cash"]);
|
||||||
|
expect(d.presets.bank).toMatchObject({ openingMinor: 100_000, openingDate: "2026-01-01" });
|
||||||
|
expect(d.presets.card).toMatchObject({ openingMinor: 25_000 });
|
||||||
|
expect(d.newCategories).toContain("Yol parası");
|
||||||
|
// İşlenince oluşacak kategori önizlemede eksik sayılmaz; seçilebilir.
|
||||||
|
const yol = d.rows.find((r) => r.description === "Yol parası")!;
|
||||||
|
expect(yol.categoryId).toBe("new:kakeibo.yol");
|
||||||
|
expect(d.categories.find((c) => c.id === "new:kakeibo.yol")).toMatchObject({ kind: "income" });
|
||||||
|
expect(
|
||||||
|
d.rows.filter((r) => (r.kind === "expense" || r.kind === "income") && !r.categoryId),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("önerilen hesaplar oluşturulup rollere bağlanır", async () => {
|
||||||
|
const d = (await getImportDraft(ayse, household, batch))!;
|
||||||
|
for (const role of ["bank", "card", "cash"] as const) {
|
||||||
|
const p = d.presets[role]!;
|
||||||
|
const r = await createImportAccount(ayse, household, batch, role, {
|
||||||
|
name: `Test ${p.name}`,
|
||||||
|
openingMinor: p.openingMinor,
|
||||||
|
openingDate: p.openingDate,
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) accounts[role] = r.data.id;
|
||||||
|
}
|
||||||
|
expect((await getImportDraft(ayse, household, batch))!.accounts).toMatchObject(accounts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("satır düzenleme: grupsuz havaleye kategori ve grup; aynı işyerine toplu", async () => {
|
||||||
|
const d = (await getImportDraft(ayse, household, batch))!;
|
||||||
|
const havale = d.rows.find((r) => r.description === "Giden havale (kişi)")!;
|
||||||
|
expect(havale.review).toBe("ungrouped");
|
||||||
|
const dining = d.categories.find((c) => c.path === "Eğlence › Dışarıda yemek")!;
|
||||||
|
expect(
|
||||||
|
(await updateImportRow(ayse, household, batch, { rowId: havale.id, categoryId: dining.id }))
|
||||||
|
.ok,
|
||||||
|
).toBe(true);
|
||||||
|
const market = d.rows.find((r) => r.description === "Test Market")!;
|
||||||
|
const multi = await updateImportRow(ayse, household, batch, {
|
||||||
|
rowId: market.id,
|
||||||
|
kakeiboGroup: "ZORUNLU",
|
||||||
|
applyToSame: true,
|
||||||
|
});
|
||||||
|
expect(multi).toMatchObject({ ok: true, data: { updated: 2 } });
|
||||||
|
// Geri dön: ikinci market satırı İstek kalsın (kayıt bazında grup testi için).
|
||||||
|
const second = d.rows.filter((r) => r.description === "Test Market")[1]!;
|
||||||
|
await updateImportRow(ayse, household, batch, { rowId: second.id, kakeiboGroup: "ISTEK" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("işle: işlemler, transferler, taksit planı, grup, yeni kategoriler; bakiye dosyayla tutar", async () => {
|
||||||
|
const r = await commitImport(ayse, household, batch);
|
||||||
|
expect(r).toMatchObject({ ok: true, data: { transactions: 11, skipped: 0 } });
|
||||||
|
const txs = await TransactionModel.find({
|
||||||
|
importBatchId: { $ne: null },
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean();
|
||||||
|
expect(txs).toHaveLength(11);
|
||||||
|
const cardPay = txs.find((x) => x.payeeName === "Kredi kartı borç ödemesi")!;
|
||||||
|
expect(cardPay).toMatchObject({ type: "transfer", amountMinor: 25_000 });
|
||||||
|
expect(String(cardPay.accountId)).toBe(accounts.bank);
|
||||||
|
expect(String(cardPay.toAccountId)).toBe(accounts.card);
|
||||||
|
const inst = txs.filter((x) => x.installment);
|
||||||
|
expect(inst).toHaveLength(2);
|
||||||
|
expect(String(inst[0]!.installment!.planId)).toBe(String(inst[1]!.installment!.planId));
|
||||||
|
const markets = txs.filter((x) => x.payeeName === "Test Market");
|
||||||
|
expect(markets.map((x) => x.kakeiboGroup).sort()).toEqual(["ISTEK", null]);
|
||||||
|
const refund = txs.find((x) => x.payeeName === "Kampanya iadesi")!;
|
||||||
|
expect(refund.type).toBe("income");
|
||||||
|
expect(await CategoryModel.exists({ defaultKey: "kakeibo.yol" })).toBeTruthy();
|
||||||
|
|
||||||
|
const list = (await listAccounts(ayse, household))!.accounts;
|
||||||
|
const bal = (id: string) => list.find((a) => a.id === id)!.balanceMinor;
|
||||||
|
expect(bal(accounts.bank!)).toBe(99_500); // dosyadaki son bakiye
|
||||||
|
expect(bal(accounts.cash!)).toBe(20_000);
|
||||||
|
// Kart: −250 açılış + 250 ödeme − (100 + 40 + 100 + 100 + 20) + 10 iade.
|
||||||
|
expect(bal(accounts.card!)).toBe(-35_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aynı dosya tekrar yüklenince tüm satırlar daha önce aktarılmış sayılır", async () => {
|
||||||
|
const again = await createImportDraft(ayse, household, {
|
||||||
|
name: "kakeibo.xlsx",
|
||||||
|
buffer: workbook(),
|
||||||
|
});
|
||||||
|
const d = (await getImportDraft(ayse, household, again.ok ? again.data.id : ""))!;
|
||||||
|
expect(d.rows.every((r) => r.duplicate)).toBe(true);
|
||||||
|
expect(d.neededRoles).toEqual([]);
|
||||||
|
expect(await commitImport(ayse, household, d.id)).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { transactions: 0, skipped: 11 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("geri alma işlemleri siler; dosya yeniden aktarılabilir", async () => {
|
||||||
|
expect(await revertImport(ayse, household, batch)).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { transactions: 11 },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await TransactionModel.countDocuments({ importBatchId: { $ne: null }, deletedAt: null }),
|
||||||
|
).toBe(0);
|
||||||
|
expect((await revertImport(ayse, household, batch)).ok).toBe(false);
|
||||||
|
const fresh = await createImportDraft(ayse, household, {
|
||||||
|
name: "kakeibo.xlsx",
|
||||||
|
buffer: workbook(),
|
||||||
|
});
|
||||||
|
const d = (await getImportDraft(ayse, household, fresh.ok ? fresh.data.id : ""))!;
|
||||||
|
expect(d.rows.some((r) => r.duplicate)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import { createAccount } from "@/features/accounts/service";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { writeAudit } from "@/lib/audit";
|
||||||
|
import { connectMongoose } from "@/lib/db/mongoose";
|
||||||
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { ImportBatchModel } from "@/models/import-batch";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { AccountType } from "@/shared/accounts";
|
||||||
|
import { istanbulDayStart } from "@/shared/dates";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import {
|
||||||
|
checkStatements,
|
||||||
|
firstCardPayment,
|
||||||
|
IMPORT_CATEGORIES,
|
||||||
|
parseBankSheet,
|
||||||
|
parseCardSheet,
|
||||||
|
parseStatementTotals,
|
||||||
|
type ImportRole,
|
||||||
|
type ProposedRow,
|
||||||
|
type RowKind,
|
||||||
|
type StatementCheck,
|
||||||
|
} from "@/shared/import/kakeibo-workbook";
|
||||||
|
import { isKakeiboGroup, resolveKakeibo, type KakeiboGroup } from "@/shared/kakeibo";
|
||||||
|
import { accountFormSchema } from "@/shared/schemas/account";
|
||||||
|
import { compareTr, foldTr } from "@/shared/search";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ekstre içe aktarma (Faz 17a): Kakeibo çalışma kitabı. Okuma: member; hesap oluşturma owner
|
||||||
|
* (hesap servisinin kuralı). Akış: dosya → taslak (öneriler, tekrar işaretleri, kontroller) →
|
||||||
|
* hesap eşleme → satır düzenleme → tek transaction'da işle → gerekirse toplu geri al.
|
||||||
|
*
|
||||||
|
* Kişisel veri: dosyanın başlık alanları (müşteri/kart no) okunmaz; satır açıklamaları yalnızca
|
||||||
|
* hanenin kendi verisinde saklanır. Tekrar engeli için işlemde anahtarın SHA-256 özeti tutulur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.imports;
|
||||||
|
// Server Action gövde sınırı (Next varsayılanı 1 MB) ile uyumlu.
|
||||||
|
export const MAX_IMPORT_BYTES = 1024 * 1024 - 64 * 1024;
|
||||||
|
const ROLES: ImportRole[] = ["card", "bank", "cash", "extra"];
|
||||||
|
const ROLE_TYPES: Record<ImportRole, AccountType[]> = {
|
||||||
|
card: ["credit_card"],
|
||||||
|
bank: ["bank"],
|
||||||
|
cash: ["cash"],
|
||||||
|
extra: ["bank", "cash"],
|
||||||
|
gold: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
type RowDoc = ProposedRow & {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
categoryId: Types.ObjectId | null;
|
||||||
|
duplicate: boolean;
|
||||||
|
edited: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BatchDoc = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
householdId: Types.ObjectId;
|
||||||
|
fileName: string;
|
||||||
|
fileHash: string;
|
||||||
|
status: "draft" | "committed" | "reverted";
|
||||||
|
accounts: Partial<Record<ImportRole, string>>;
|
||||||
|
rows: RowDoc[];
|
||||||
|
checks: ImportChecks;
|
||||||
|
committed: { transactions: number; skipped: number; categoriesCreated: number } | null;
|
||||||
|
committedAt: Date | null;
|
||||||
|
revertedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportChecks = {
|
||||||
|
statements: StatementCheck[];
|
||||||
|
balance: { mismatchLine: number | null; closingMinor: number | null };
|
||||||
|
opening: {
|
||||||
|
bank: { date: string; balanceMinor: number } | null;
|
||||||
|
card: { date: string; amountMinor: number } | null;
|
||||||
|
};
|
||||||
|
/** Kart satırlarının en erken tarihi (kart açılış tarihi önerisi). */
|
||||||
|
firstCardDate: string | null;
|
||||||
|
firstBankDate: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hashKey = (key: string) => createHash("sha256").update(key).digest("hex");
|
||||||
|
|
||||||
|
// --- Dosya okuma ---
|
||||||
|
|
||||||
|
/** Kakeibo çalışma kitabını okur. Sayfalar yoksa açık hata döner. */
|
||||||
|
export function readWorkbook(
|
||||||
|
buffer: Buffer,
|
||||||
|
): ActionResult<{ rows: ProposedRow[]; checks: ImportChecks }> {
|
||||||
|
let wb: XLSX.WorkBook;
|
||||||
|
try {
|
||||||
|
wb = XLSX.read(buffer, { type: "buffer", cellDates: false, dense: true });
|
||||||
|
} catch {
|
||||||
|
return fail(t.errors.unreadable);
|
||||||
|
}
|
||||||
|
const sheet = (name: string) => {
|
||||||
|
const ws = wb.Sheets[name];
|
||||||
|
return ws
|
||||||
|
? (XLSX.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null }) as unknown[][])
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
const cardRows = sheet("Kredi Kartı");
|
||||||
|
const bankRows = sheet("Maaş Hesabı");
|
||||||
|
if (!cardRows && !bankRows) return fail(t.errors.noSheets);
|
||||||
|
const card = cardRows ? parseCardSheet(cardRows) : [];
|
||||||
|
const bank = bankRows ? parseBankSheet(bankRows) : null;
|
||||||
|
const summary = sheet("Özet");
|
||||||
|
const rows = [...card, ...(bank?.rows ?? [])];
|
||||||
|
const dates = (s: "card" | "bank") =>
|
||||||
|
rows
|
||||||
|
.filter((r) => r.sheet === s)
|
||||||
|
.map((r) => r.date)
|
||||||
|
.sort();
|
||||||
|
return ok({
|
||||||
|
rows,
|
||||||
|
checks: {
|
||||||
|
statements: summary ? checkStatements(card, parseStatementTotals(summary)) : [],
|
||||||
|
balance: {
|
||||||
|
mismatchLine: bank?.balanceMismatch?.line ?? null,
|
||||||
|
closingMinor: bank?.closingBalanceMinor ?? null,
|
||||||
|
},
|
||||||
|
opening: { bank: bank?.opening ?? null, card: firstCardPayment(rows) },
|
||||||
|
firstCardDate: dates("card")[0] ?? null,
|
||||||
|
firstBankDate: dates("bank")[0] ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Görünümler ---
|
||||||
|
|
||||||
|
export type ImportRowView = {
|
||||||
|
id: string;
|
||||||
|
sheet: "card" | "bank";
|
||||||
|
line: number;
|
||||||
|
date: string;
|
||||||
|
statementCut: string | null;
|
||||||
|
description: string;
|
||||||
|
amountMinor: number;
|
||||||
|
kind: RowKind;
|
||||||
|
categoryId: string | null;
|
||||||
|
kakeiboGroup: KakeiboGroup | null;
|
||||||
|
counterRole: ImportRole | null;
|
||||||
|
installment: { index: number; count: number; totalMinor: number } | null;
|
||||||
|
source: { category: string; type: string };
|
||||||
|
review: string | null;
|
||||||
|
duplicate: boolean;
|
||||||
|
edited: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportSummary = {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
status: BatchDoc["status"];
|
||||||
|
createdAt: string;
|
||||||
|
committedAt: string | null;
|
||||||
|
revertedAt: string | null;
|
||||||
|
rowCount: number;
|
||||||
|
committed: BatchDoc["committed"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountPreset = {
|
||||||
|
role: ImportRole;
|
||||||
|
name: string;
|
||||||
|
type: AccountType;
|
||||||
|
openingMinor: number;
|
||||||
|
openingDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportDraft = ImportSummary & {
|
||||||
|
rows: ImportRowView[];
|
||||||
|
checks: ImportChecks;
|
||||||
|
accounts: Partial<Record<ImportRole, string>>;
|
||||||
|
/** Satırların ihtiyaç duyduğu roller. */
|
||||||
|
neededRoles: ImportRole[];
|
||||||
|
accountOptions: { id: string; name: string; type: AccountType }[];
|
||||||
|
presets: Record<string, AccountPreset>;
|
||||||
|
categories: {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
kind: "expense" | "income";
|
||||||
|
group: KakeiboGroup | null;
|
||||||
|
bankCost: boolean;
|
||||||
|
}[];
|
||||||
|
/** Henüz hanede olmayan ve işlenince oluşturulacak kategoriler. */
|
||||||
|
newCategories: string[];
|
||||||
|
canCreateAccounts: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const summaryOf = (b: BatchDoc): ImportSummary => ({
|
||||||
|
id: String(b._id),
|
||||||
|
fileName: b.fileName,
|
||||||
|
status: b.status,
|
||||||
|
createdAt: b.createdAt.toISOString(),
|
||||||
|
committedAt: b.committedAt?.toISOString() ?? null,
|
||||||
|
revertedAt: b.revertedAt?.toISOString() ?? null,
|
||||||
|
rowCount: b.rows.length,
|
||||||
|
committed: b.committed,
|
||||||
|
});
|
||||||
|
|
||||||
|
type CatLean = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
kind: "expense" | "income";
|
||||||
|
name: string;
|
||||||
|
parentId: Types.ObjectId | null;
|
||||||
|
defaultKey: string | null;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
kakeiboGroup?: KakeiboGroup | null;
|
||||||
|
bankCost?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadCategories(householdId: Types.ObjectId) {
|
||||||
|
const cats = await CategoryModel.find({ householdId }).lean<CatLean[]>();
|
||||||
|
const byId = new Map(cats.map((c) => [String(c._id), c]));
|
||||||
|
const path = (c: CatLean): string => {
|
||||||
|
const p = c.parentId ? byId.get(String(c.parentId)) : undefined;
|
||||||
|
return p ? `${path(p)} › ${c.name}` : c.name;
|
||||||
|
};
|
||||||
|
const resolved = resolveKakeibo(
|
||||||
|
cats.map((c) => ({
|
||||||
|
id: String(c._id),
|
||||||
|
parentId: c.parentId ? String(c.parentId) : null,
|
||||||
|
kakeiboGroup: c.kakeiboGroup ?? null,
|
||||||
|
bankCost: Boolean(c.bankCost),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const byKey = new Map(cats.filter((c) => c.defaultKey).map((c) => [c.defaultKey!, c]));
|
||||||
|
return { cats, byId, byKey, path, resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowCategoryId = (r: RowDoc, byKey: Map<string, CatLean>) =>
|
||||||
|
r.categoryId
|
||||||
|
? String(r.categoryId)
|
||||||
|
: r.categoryKey
|
||||||
|
? byKey.get(r.categoryKey)
|
||||||
|
? String(byKey.get(r.categoryKey)!._id)
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/** İşlenirken oluşturulacak kategori için önizlemedeki sözde kimlik. */
|
||||||
|
export const NEW_CATEGORY_PREFIX = "new:";
|
||||||
|
const IMPORT_KEYS = new Set(IMPORT_CATEGORIES.map((x) => x.key));
|
||||||
|
const draftCategoryId = (r: RowDoc, byKey: Map<string, CatLean>) =>
|
||||||
|
rowCategoryId(r, byKey) ??
|
||||||
|
(r.categoryId === null && r.categoryKey && IMPORT_KEYS.has(r.categoryKey)
|
||||||
|
? NEW_CATEGORY_PREFIX + r.categoryKey
|
||||||
|
: null);
|
||||||
|
|
||||||
|
function neededRoles(rows: readonly RowDoc[]): ImportRole[] {
|
||||||
|
const need = new Set<ImportRole>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.kind === "skip" || r.kind === "deferred" || r.duplicate) continue;
|
||||||
|
need.add(r.sheet);
|
||||||
|
if (r.kind === "transfer" && r.counterRole) need.add(r.counterRole);
|
||||||
|
}
|
||||||
|
return ROLES.filter((x) => need.has(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBatch(m: Membership, batchId: string) {
|
||||||
|
const id = toObjectId(batchId);
|
||||||
|
if (!id) return null;
|
||||||
|
return ImportBatchModel.findOne({ _id: id, householdId: m.householdId }).lean<BatchDoc>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Okuma ---
|
||||||
|
|
||||||
|
export async function listImports(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<ImportSummary[] | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "member");
|
||||||
|
if (!m) return null;
|
||||||
|
const rows = await ImportBatchModel.find({ householdId: m.householdId })
|
||||||
|
.select({ rows: 0 })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(50)
|
||||||
|
.lean<(Omit<BatchDoc, "rows"> & { rows?: undefined })[]>();
|
||||||
|
const counts = await ImportBatchModel.aggregate<{ _id: Types.ObjectId; n: number }>([
|
||||||
|
{ $match: { householdId: m.householdId } },
|
||||||
|
{ $project: { n: { $size: "$rows" } } },
|
||||||
|
]);
|
||||||
|
const n = new Map(counts.map((c) => [String(c._id), c.n]));
|
||||||
|
return rows.map((b) => ({
|
||||||
|
...summaryOf({ ...b, rows: [] } as BatchDoc),
|
||||||
|
rowCount: n.get(String(b._id)) ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getImportDraft(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
batchId: string,
|
||||||
|
): Promise<ImportDraft | null> {
|
||||||
|
const m = await resolveMembership(userId, householdId, "member");
|
||||||
|
if (!m) return null;
|
||||||
|
const b = await loadBatch(m, batchId);
|
||||||
|
if (!b) return null;
|
||||||
|
const [cats, accounts] = await Promise.all([
|
||||||
|
loadCategories(m.householdId),
|
||||||
|
AccountModel.find({ householdId: m.householdId, archivedAt: null })
|
||||||
|
.select({ name: 1, type: 1, isPrivate: 1, ownerUserId: 1 })
|
||||||
|
.lean<
|
||||||
|
{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
type: AccountType;
|
||||||
|
isPrivate: boolean;
|
||||||
|
ownerUserId: Types.ObjectId | null;
|
||||||
|
}[]
|
||||||
|
>(),
|
||||||
|
]);
|
||||||
|
const c = b.checks;
|
||||||
|
const presets: Record<string, AccountPreset> = {
|
||||||
|
bank: {
|
||||||
|
role: "bank",
|
||||||
|
name: t.presets.bank,
|
||||||
|
type: "bank",
|
||||||
|
openingMinor: c.opening.bank?.balanceMinor ?? 0,
|
||||||
|
openingDate: c.opening.bank
|
||||||
|
? addDays(c.opening.bank.date, 1)
|
||||||
|
: (c.firstBankDate ?? "2026-01-01"),
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
role: "card",
|
||||||
|
name: t.presets.card,
|
||||||
|
type: "credit_card",
|
||||||
|
openingMinor: c.opening.card?.amountMinor ?? 0,
|
||||||
|
openingDate: c.firstCardDate ?? "2026-01-01",
|
||||||
|
},
|
||||||
|
cash: {
|
||||||
|
role: "cash",
|
||||||
|
name: t.presets.cash,
|
||||||
|
type: "cash",
|
||||||
|
openingMinor: 0,
|
||||||
|
openingDate: c.firstBankDate ?? "2026-01-01",
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
role: "extra",
|
||||||
|
name: t.presets.extra,
|
||||||
|
type: "bank",
|
||||||
|
openingMinor: 0,
|
||||||
|
openingDate: c.firstBankDate ?? "2026-01-01",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const visible = accounts.filter((a) => !a.isPrivate || String(a.ownerUserId) === userId);
|
||||||
|
const missingCats = IMPORT_CATEGORIES.filter((x) => !cats.byKey.has(x.key));
|
||||||
|
const missing = missingCats.map((x) => x.name);
|
||||||
|
return {
|
||||||
|
...summaryOf(b),
|
||||||
|
rows: b.rows.map((r) => ({
|
||||||
|
id: String(r._id),
|
||||||
|
sheet: r.sheet,
|
||||||
|
line: r.line,
|
||||||
|
date: r.date,
|
||||||
|
statementCut: r.statementCut,
|
||||||
|
description: r.description,
|
||||||
|
amountMinor: r.amountMinor,
|
||||||
|
kind: r.kind,
|
||||||
|
categoryId: draftCategoryId(r, cats.byKey),
|
||||||
|
kakeiboGroup: isKakeiboGroup(r.kakeiboGroup) ? r.kakeiboGroup : null,
|
||||||
|
counterRole: r.counterRole,
|
||||||
|
installment: r.installment
|
||||||
|
? {
|
||||||
|
index: r.installment.index,
|
||||||
|
count: r.installment.count,
|
||||||
|
totalMinor: r.installment.totalMinor,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
source: r.source,
|
||||||
|
review: r.review,
|
||||||
|
duplicate: r.duplicate,
|
||||||
|
edited: r.edited,
|
||||||
|
})),
|
||||||
|
checks: b.checks,
|
||||||
|
accounts: b.accounts ?? {},
|
||||||
|
neededRoles: neededRoles(b.rows),
|
||||||
|
accountOptions: visible
|
||||||
|
.map((a) => ({ id: String(a._id), name: a.name, type: a.type }))
|
||||||
|
.sort((x, y) => compareTr(x.name, y.name)),
|
||||||
|
presets,
|
||||||
|
categories: cats.cats
|
||||||
|
.filter((x) => !x.archivedAt)
|
||||||
|
.map((x) => {
|
||||||
|
const k = cats.resolved.get(String(x._id));
|
||||||
|
return {
|
||||||
|
id: String(x._id),
|
||||||
|
path: cats.path(x),
|
||||||
|
kind: x.kind,
|
||||||
|
group: k?.group ?? null,
|
||||||
|
bankCost: k?.bankCost ?? false,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.concat(
|
||||||
|
missingCats.map((x) => ({
|
||||||
|
id: NEW_CATEGORY_PREFIX + x.key,
|
||||||
|
path: t.newCategoryName(x.name),
|
||||||
|
kind: x.kind,
|
||||||
|
group: x.kind === "expense" ? x.kakeiboGroup : null,
|
||||||
|
bankCost: false,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.sort((x, y) => compareTr(x.path, y.path)),
|
||||||
|
newCategories: missing,
|
||||||
|
canCreateAccounts: hasHouseholdRole(m.role, "owner"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Yazma ---
|
||||||
|
|
||||||
|
export async function createImportDraft(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
file: { name: string; buffer: Buffer },
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
if (file.buffer.byteLength > MAX_IMPORT_BYTES) return fail(t.errors.tooLarge);
|
||||||
|
const parsed = readWorkbook(file.buffer);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
const { rows, checks } = parsed.data;
|
||||||
|
if (!rows.length) return fail(t.errors.empty);
|
||||||
|
// Hanede zaten içe aktarılmış satırlar (aynı anahtar) atlanmak üzere işaretlenir.
|
||||||
|
const hashes = rows.map((r) => hashKey(r.key));
|
||||||
|
const existing = new Set(
|
||||||
|
(
|
||||||
|
await TransactionModel.find({ householdId: m.householdId, importKey: { $in: hashes } })
|
||||||
|
.select({ importKey: 1 })
|
||||||
|
.lean<{ importKey: string }[]>()
|
||||||
|
).map((x) => x.importKey),
|
||||||
|
);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const doc = await ImportBatchModel.create({
|
||||||
|
householdId: m.householdId,
|
||||||
|
source: "kakeibo-workbook",
|
||||||
|
fileName: file.name.slice(0, 200),
|
||||||
|
fileHash: createHash("sha256").update(file.buffer).digest("hex"),
|
||||||
|
rows: rows.map((r, i) => ({ ...r, duplicate: existing.has(hashes[i]!) })),
|
||||||
|
checks,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
return ok({ id: String(doc._id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function draftFor(actorId: string, householdId: string, batchId: string) {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return { error: fail(tr.common.unauthorized) } as const;
|
||||||
|
const b = await loadBatch(m, batchId);
|
||||||
|
if (!b) return { error: fail(tr.common.notFound) } as const;
|
||||||
|
if (b.status !== "draft") return { error: fail(t.errors.notDraft) } as const;
|
||||||
|
return { error: null, m, b, actor: new Types.ObjectId(actorId) } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bir rolü mevcut bir hesaba bağlar. */
|
||||||
|
export async function setImportAccount(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
batchId: string,
|
||||||
|
role: ImportRole,
|
||||||
|
accountId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await draftFor(actorId, householdId, batchId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const id = toObjectId(accountId);
|
||||||
|
const acc = id
|
||||||
|
? await AccountModel.findOne({ _id: id, householdId: r.m.householdId, archivedAt: null })
|
||||||
|
.select({ type: 1 })
|
||||||
|
.lean<{ type: AccountType }>()
|
||||||
|
: null;
|
||||||
|
if (!acc || !ROLE_TYPES[role].includes(acc.type)) return fail(t.errors.accountType);
|
||||||
|
await ImportBatchModel.updateOne(
|
||||||
|
{ _id: r.b._id },
|
||||||
|
{ $set: { [`accounts.${role}`]: accountId, updatedBy: r.actor } },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Önerilen değerlerle hesap oluşturup role bağlar (hesap oluşturma: owner). */
|
||||||
|
export async function createImportAccount(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
batchId: string,
|
||||||
|
role: ImportRole,
|
||||||
|
input: { name: string; openingMinor: number; openingDate: string },
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const draft = await getImportDraft(actorId, householdId, batchId);
|
||||||
|
if (!draft || draft.status !== "draft") return fail(tr.common.notFound);
|
||||||
|
const preset = draft.presets[role];
|
||||||
|
if (!preset) return fail(tr.common.notFound);
|
||||||
|
const isCard = preset.type === "credit_card";
|
||||||
|
const form = accountFormSchema.safeParse({
|
||||||
|
name: input.name,
|
||||||
|
type: preset.type,
|
||||||
|
institution: "",
|
||||||
|
mealCardProvider: null,
|
||||||
|
// Kartta açılış borcu pozitif girilir; hesap servisi negatife çevirir. Bankada eksi bakiye
|
||||||
|
// (KMH) eksi işaretle girilir.
|
||||||
|
openingBalance: formatMoneyInput(isCard ? Math.abs(input.openingMinor) : input.openingMinor),
|
||||||
|
openingDate: input.openingDate,
|
||||||
|
color: isCard ? "purple" : preset.type === "cash" ? "green" : "blue",
|
||||||
|
icon: isCard ? "credit-card" : preset.type === "cash" ? "wallet" : "landmark",
|
||||||
|
ownerUserId: "",
|
||||||
|
isPrivate: false,
|
||||||
|
overdraftEnabled: role === "bank",
|
||||||
|
overdraftLimit: "0",
|
||||||
|
overdraftMonthlyRate: "",
|
||||||
|
overdraftStatementDay: "",
|
||||||
|
cardLimit: "0",
|
||||||
|
cardStatementDay: "28",
|
||||||
|
cardDueKind: "daysAfter",
|
||||||
|
cardDueValue: "12",
|
||||||
|
cardPaymentAccountId: "",
|
||||||
|
cardMinimumRate: "",
|
||||||
|
});
|
||||||
|
if (!form.success) return fail(form.error.issues[0]?.message ?? t.errors.accountType);
|
||||||
|
const created = await createAccount(actorId, householdId, form.data);
|
||||||
|
if (!created.ok) return created;
|
||||||
|
const set = await setImportAccount(actorId, householdId, batchId, role, created.data.id);
|
||||||
|
if (!set.ok) return set;
|
||||||
|
return ok({ id: created.data.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportRowEdit = {
|
||||||
|
rowId: string;
|
||||||
|
kind?: "expense" | "income" | "skip";
|
||||||
|
categoryId?: string | null;
|
||||||
|
kakeiboGroup?: KakeiboGroup | null;
|
||||||
|
/** Aynı açıklamalı (işyeri) tüm satırlara uygula. */
|
||||||
|
applyToSame?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function updateImportRow(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
batchId: string,
|
||||||
|
edit: ImportRowEdit,
|
||||||
|
): Promise<ActionResult<{ updated: number }>> {
|
||||||
|
const r = await draftFor(actorId, householdId, batchId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const row = r.b.rows.find((x) => String(x._id) === edit.rowId);
|
||||||
|
if (!row) return fail(tr.common.notFound);
|
||||||
|
const newKey = edit.categoryId?.startsWith(NEW_CATEGORY_PREFIX)
|
||||||
|
? edit.categoryId.slice(NEW_CATEGORY_PREFIX.length)
|
||||||
|
: null;
|
||||||
|
if (newKey !== null && !IMPORT_KEYS.has(newKey))
|
||||||
|
return fail(tr.transactions.validation.categoryInvalid);
|
||||||
|
if (edit.categoryId && newKey === null) {
|
||||||
|
const ok = await CategoryModel.exists({
|
||||||
|
_id: new Types.ObjectId(edit.categoryId),
|
||||||
|
householdId: r.m.householdId,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
if (!ok) return fail(tr.transactions.validation.categoryInvalid);
|
||||||
|
}
|
||||||
|
const targets = edit.applyToSame
|
||||||
|
? r.b.rows.filter(
|
||||||
|
(x) =>
|
||||||
|
x.sheet === row.sheet &&
|
||||||
|
foldTr(x.description) === foldTr(row.description) &&
|
||||||
|
x.kind === row.kind,
|
||||||
|
)
|
||||||
|
: [row];
|
||||||
|
const ops = targets.map((x) => {
|
||||||
|
const s: Record<string, unknown> = { "rows.$[r].edited": true };
|
||||||
|
if (edit.kind) s["rows.$[r].kind"] = edit.kind;
|
||||||
|
if (newKey !== null) {
|
||||||
|
s["rows.$[r].categoryId"] = null;
|
||||||
|
s["rows.$[r].categoryKey"] = newKey;
|
||||||
|
} else if (edit.categoryId !== undefined)
|
||||||
|
s["rows.$[r].categoryId"] = edit.categoryId ? new Types.ObjectId(edit.categoryId) : null;
|
||||||
|
if (edit.kakeiboGroup !== undefined) s["rows.$[r].kakeiboGroup"] = edit.kakeiboGroup;
|
||||||
|
return {
|
||||||
|
updateOne: {
|
||||||
|
filter: { _id: r.b._id },
|
||||||
|
update: { $set: { ...s, updatedBy: r.actor } },
|
||||||
|
arrayFilters: [{ "r._id": x._id }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (ops.length) await ImportBatchModel.bulkWrite(ops);
|
||||||
|
return ok({ updated: ops.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Partideki satırları tek transaction'da işler. */
|
||||||
|
export async function commitImport(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
batchId: string,
|
||||||
|
): Promise<ActionResult<{ transactions: number; skipped: number }>> {
|
||||||
|
const r = await draftFor(actorId, householdId, batchId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
const { m, b, actor } = r;
|
||||||
|
const need = neededRoles(b.rows);
|
||||||
|
const accountIds: Partial<Record<ImportRole, Types.ObjectId>> = {};
|
||||||
|
for (const role of need) {
|
||||||
|
const id = b.accounts?.[role] ? toObjectId(b.accounts[role]!) : null;
|
||||||
|
const acc = id
|
||||||
|
? await AccountModel.findOne({ _id: id, householdId: m.householdId, archivedAt: null })
|
||||||
|
.select({ type: 1 })
|
||||||
|
.lean<{ type: AccountType }>()
|
||||||
|
: null;
|
||||||
|
if (!acc || !ROLE_TYPES[role].includes(acc.type))
|
||||||
|
return fail(t.errors.roleMissing(t.roles[role] ?? role));
|
||||||
|
accountIds[role] = id!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mongoose = await connectMongoose();
|
||||||
|
let inserted = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let categoriesCreated = 0;
|
||||||
|
try {
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
inserted = 0;
|
||||||
|
skipped = 0;
|
||||||
|
categoriesCreated = 0;
|
||||||
|
// Eksik içe aktarma kategorileri (anahtar: kakeibo.*).
|
||||||
|
const existing = await CategoryModel.find({
|
||||||
|
householdId: m.householdId,
|
||||||
|
defaultKey: { $in: IMPORT_CATEGORIES.map((x) => x.key) },
|
||||||
|
})
|
||||||
|
.select({ defaultKey: 1 })
|
||||||
|
.session(session)
|
||||||
|
.lean<{ defaultKey: string }[]>();
|
||||||
|
const have = new Set(existing.map((x) => x.defaultKey));
|
||||||
|
const missing = IMPORT_CATEGORIES.filter((x) => !have.has(x.key));
|
||||||
|
if (missing.length) {
|
||||||
|
await CategoryModel.insertMany(
|
||||||
|
missing.map((x, i) => ({
|
||||||
|
householdId: m.householdId,
|
||||||
|
kind: x.kind,
|
||||||
|
name: x.name,
|
||||||
|
nameKey: foldTr(x.name),
|
||||||
|
parentId: null,
|
||||||
|
ancestors: [],
|
||||||
|
icon: x.icon,
|
||||||
|
color: x.color,
|
||||||
|
order: 100 + i,
|
||||||
|
defaultKey: x.key,
|
||||||
|
kakeiboGroup: x.kind === "expense" ? x.kakeiboGroup : null,
|
||||||
|
bankCost: false,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
})),
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
categoriesCreated = missing.length;
|
||||||
|
}
|
||||||
|
const cats = await CategoryModel.find({ householdId: m.householdId })
|
||||||
|
.session(session)
|
||||||
|
.lean<CatLean[]>();
|
||||||
|
const byKey = new Map(cats.filter((c) => c.defaultKey).map((c) => [c.defaultKey!, c]));
|
||||||
|
const byId = new Map(cats.map((c) => [String(c._id), c]));
|
||||||
|
const resolved = resolveKakeibo(
|
||||||
|
cats.map((c) => ({
|
||||||
|
id: String(c._id),
|
||||||
|
parentId: c.parentId ? String(c.parentId) : null,
|
||||||
|
kakeiboGroup: c.kakeiboGroup ?? null,
|
||||||
|
bankCost: Boolean(c.bankCost),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const plans = new Map<string, Types.ObjectId>();
|
||||||
|
const docs: Record<string, unknown>[] = [];
|
||||||
|
for (const row of b.rows) {
|
||||||
|
if (row.duplicate || row.kind === "skip" || row.kind === "deferred") {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const own = accountIds[row.sheet]!;
|
||||||
|
const base = {
|
||||||
|
householdId: m.householdId,
|
||||||
|
status: "posted",
|
||||||
|
date: istanbulDayStart(row.date)!,
|
||||||
|
amountMinor: Math.abs(row.amountMinor),
|
||||||
|
payeeName: row.description.slice(0, 80),
|
||||||
|
description: "",
|
||||||
|
searchText: foldTr(row.description),
|
||||||
|
tags: [t.tag],
|
||||||
|
importBatchId: b._id,
|
||||||
|
importKey: hashKey(row.key),
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
};
|
||||||
|
if (row.kind === "transfer") {
|
||||||
|
const other = accountIds[row.counterRole as ImportRole]!;
|
||||||
|
const out = row.amountMinor < 0;
|
||||||
|
docs.push({
|
||||||
|
...base,
|
||||||
|
type: "transfer",
|
||||||
|
accountId: out ? own : other,
|
||||||
|
toAccountId: out ? other : own,
|
||||||
|
categoryId: null,
|
||||||
|
splits: [],
|
||||||
|
effectiveCategoryIds: [],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const catId = rowCategoryId(row, byKey);
|
||||||
|
const cat = catId ? byId.get(catId) : undefined;
|
||||||
|
const kind = row.kind as "expense" | "income";
|
||||||
|
if (!cat || cat.kind !== kind || cat.archivedAt)
|
||||||
|
throw new ImportError(fail(t.errors.category(row.description || String(row.line))));
|
||||||
|
const group = isKakeiboGroup(row.kakeiboGroup) ? row.kakeiboGroup : null;
|
||||||
|
const categoryGroup = resolved.get(catId!)?.group ?? null;
|
||||||
|
let installment = null;
|
||||||
|
if (row.installment && kind === "expense") {
|
||||||
|
const planId = plans.get(row.installment.key) ?? new Types.ObjectId();
|
||||||
|
plans.set(row.installment.key, planId);
|
||||||
|
installment = { planId, index: row.installment.index, count: row.installment.count };
|
||||||
|
}
|
||||||
|
docs.push({
|
||||||
|
...base,
|
||||||
|
type: kind,
|
||||||
|
accountId: own,
|
||||||
|
toAccountId: null,
|
||||||
|
categoryId: cat._id,
|
||||||
|
splits: [],
|
||||||
|
effectiveCategoryIds: [cat._id],
|
||||||
|
kakeiboGroup: kind === "expense" && group && group !== categoryGroup ? group : null,
|
||||||
|
installment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (docs.length) await TransactionModel.insertMany(docs, { session });
|
||||||
|
inserted = docs.length;
|
||||||
|
await ImportBatchModel.updateOne(
|
||||||
|
{ _id: b._id, status: "draft" },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
status: "committed",
|
||||||
|
committedAt: new Date(),
|
||||||
|
committed: { transactions: inserted, skipped, categoriesCreated },
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ImportError) return error.result;
|
||||||
|
if ((error as { code?: number }).code === 11000) return fail(t.errors.duplicate);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await writeAudit({
|
||||||
|
action: "import.commit",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: m.householdId,
|
||||||
|
targetType: "importBatch",
|
||||||
|
targetId: String(b._id),
|
||||||
|
diff: { transactions: inserted, skipped, categoriesCreated, fileName: b.fileName },
|
||||||
|
});
|
||||||
|
return ok({ transactions: inserted, skipped });
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImportError extends Error {
|
||||||
|
constructor(readonly result: ActionResult<never>) {
|
||||||
|
super(result.ok ? "" : result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** İşlenmiş partinin işlemlerini siler (soft delete; anahtar boşaltılır, yeniden içe aktarılabilir). */
|
||||||
|
export async function revertImport(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
batchId: string,
|
||||||
|
): Promise<ActionResult<{ transactions: number }>> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const b = await loadBatch(m, batchId);
|
||||||
|
if (!b) return fail(tr.common.notFound);
|
||||||
|
if (b.status !== "committed") return fail(t.errors.notCommitted);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const mongoose = await connectMongoose();
|
||||||
|
let n = 0;
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
const res = await TransactionModel.updateMany(
|
||||||
|
{ householdId: m.householdId, importBatchId: b._id, deletedAt: null },
|
||||||
|
{ $set: { deletedAt: new Date(), importKey: null, updatedBy: actor } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
n = res.modifiedCount;
|
||||||
|
await ImportBatchModel.updateOne(
|
||||||
|
{ _id: b._id },
|
||||||
|
{ $set: { status: "reverted", revertedAt: new Date(), updatedBy: actor } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await writeAudit({
|
||||||
|
action: "import.revert",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: m.householdId,
|
||||||
|
targetType: "importBatch",
|
||||||
|
targetId: String(b._id),
|
||||||
|
diff: { transactions: n, fileName: b.fileName },
|
||||||
|
});
|
||||||
|
return ok({ transactions: n });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Taslağı siler (yalnızca işlenmemiş). */
|
||||||
|
export async function deleteImportDraft(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
batchId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const r = await draftFor(actorId, householdId, batchId);
|
||||||
|
if (r.error) return r.error;
|
||||||
|
await ImportBatchModel.deleteOne({ _id: r.b._id, status: "draft" });
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { KakeiboDot } from "@/features/categories/components/kakeibo-badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||||
|
import { getKakeiboReport } from "../kakeibo";
|
||||||
|
import { formatPct } from "./report-frame";
|
||||||
|
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
|
||||||
|
/** Bütçe sayfası: seçili bütçe ayının Kakeibo grup özeti (rapora bağlantılı). */
|
||||||
|
export async function KakeiboBudgetCard({
|
||||||
|
userId,
|
||||||
|
householdId,
|
||||||
|
month,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
householdId: string;
|
||||||
|
month: string;
|
||||||
|
}) {
|
||||||
|
const data = await getKakeiboReport(userId, householdId, month);
|
||||||
|
if (!data) return null;
|
||||||
|
const s = data.now.summary;
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby="kb-card"
|
||||||
|
className="flex flex-col gap-3 rounded-xl border bg-card p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 id="kb-card" className="font-medium">
|
||||||
|
{tr.reports.kakeiboReport.title}
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
href={`/raporlar/kakeibo?ay=${month}`}
|
||||||
|
className="ml-auto flex items-center gap-0.5 text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{tr.reports.hub.open}
|
||||||
|
<ChevronRight className="size-3.5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-2 gap-3 md:grid-cols-6">
|
||||||
|
{KAKEIBO_GROUPS.map((g) => (
|
||||||
|
<div key={g}>
|
||||||
|
<dt className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<KakeiboDot group={g} />
|
||||||
|
{k.groups[g]}
|
||||||
|
</dt>
|
||||||
|
<dd className="font-semibold tabular-nums">
|
||||||
|
{formatMoney(s.groups[g])}{" "}
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
|
{formatPct(s.shares[g])}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground">{k.ungrouped}</dt>
|
||||||
|
<dd className="font-semibold tabular-nums">{formatMoney(s.ungroupedMinor)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground">{k.bankCost}</dt>
|
||||||
|
<dd className="font-semibold tabular-nums">{formatMoney(s.bankCostMinor)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { AlertTriangle, Landmark } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { KAKEIBO_COLOR, KakeiboDot } from "@/features/categories/components/kakeibo-badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { KAKEIBO_GROUPS, type KakeiboGroup } from "@/shared/kakeibo";
|
||||||
|
import type { KakeiboReport } from "../kakeibo";
|
||||||
|
import { Change, formatPct } from "./report-frame";
|
||||||
|
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
const t = tr.reports.kakeiboReport;
|
||||||
|
|
||||||
|
/** Dört grubun (ve grupsuzun) harcamadaki payı: tek yığılmış çubuk + lejant + tablo. */
|
||||||
|
function Composition({ data }: { data: KakeiboReport }) {
|
||||||
|
const s = data.now.summary;
|
||||||
|
const parts = [
|
||||||
|
...KAKEIBO_GROUPS.map((g) => ({
|
||||||
|
key: g,
|
||||||
|
label: k.groups[g],
|
||||||
|
share: s.shares[g],
|
||||||
|
color: KAKEIBO_COLOR[g],
|
||||||
|
})),
|
||||||
|
...(s.ungroupedMinor
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "UNGROUPED",
|
||||||
|
label: k.ungrouped,
|
||||||
|
share: s.ungroupedShare,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
].filter((p) => p.share > 0);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
className="flex h-4 w-full gap-0.5 overflow-hidden rounded-md bg-muted"
|
||||||
|
role="img"
|
||||||
|
aria-label={t.chartLabel}
|
||||||
|
>
|
||||||
|
{parts.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.key}
|
||||||
|
className="h-full first:rounded-l-md last:rounded-r-md"
|
||||||
|
style={{ width: `${p.share}%`, backgroundColor: p.color }}
|
||||||
|
title={`${p.label}: ${formatPct(p.share)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
{parts.map((p) => (
|
||||||
|
<li key={p.key} className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="size-2.5 rounded-sm"
|
||||||
|
style={{ backgroundColor: p.color }}
|
||||||
|
/>
|
||||||
|
{p.label} <span className="tabular-nums">{formatPct(p.share)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupCard({ data, group }: { data: KakeiboReport; group: KakeiboGroup }) {
|
||||||
|
const s = data.now.summary;
|
||||||
|
const rows = data.now.byClass[group];
|
||||||
|
const ch = data.change[group];
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby={`kg-${group}`}
|
||||||
|
className="flex flex-col gap-2 rounded-xl border bg-card p-4"
|
||||||
|
>
|
||||||
|
<header className="flex items-start gap-2">
|
||||||
|
<KakeiboDot group={group} className="mt-1.5" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 id={`kg-${group}`} className="font-medium">
|
||||||
|
{k.groups[group]}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">{k.groupHints[group]}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-semibold tabular-nums">{formatMoney(s.groups[group])}</p>
|
||||||
|
<p className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{formatPct(s.shares[group])} ·{" "}
|
||||||
|
<Change minor={ch.minor} pct={ch.pct} format={formatMoney} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{rows.length ? (
|
||||||
|
<ul className="divide-y text-sm">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<li key={r.id} className="flex justify-between gap-3 py-1.5">
|
||||||
|
<span className="min-w-0 truncate">{r.path}</span>
|
||||||
|
<span className="shrink-0 tabular-nums">{formatMoney(r.amountMinor)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.noSpending}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /raporlar/kakeibo — dört grup, banka maliyeti ve grupsuz harcamalar. */
|
||||||
|
export function KakeiboReportView({ data }: { data: KakeiboReport }) {
|
||||||
|
const s = data.now.summary;
|
||||||
|
const bank = data.now.byClass.BANK_COST;
|
||||||
|
const ungrouped = data.now.byClass.UNGROUPED;
|
||||||
|
const bankChange = data.change.BANK_COST;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4">
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">{t.spending}</span>
|
||||||
|
<span className="text-xl font-semibold tabular-nums">
|
||||||
|
{formatMoney(s.spendingMinor)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Composition data={data} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-between gap-1 rounded-xl border bg-card p-4 md:w-64">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Landmark className="size-4" aria-hidden />
|
||||||
|
{k.bankCost}
|
||||||
|
</span>
|
||||||
|
<span className="text-xl font-semibold tabular-nums">{formatMoney(s.bankCostMinor)}</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
<Change minor={bankChange.minor} pct={bankChange.pct} format={formatMoney} />
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] leading-tight text-muted-foreground">{t.bankCostNote}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ungrouped.length ? (
|
||||||
|
<section
|
||||||
|
aria-labelledby="kg-ungrouped"
|
||||||
|
className="flex flex-col gap-2 rounded-xl border border-dashed border-amber-500/50 bg-amber-500/5 p-4"
|
||||||
|
>
|
||||||
|
<h2 id="kg-ungrouped" className="flex items-center gap-1.5 font-medium">
|
||||||
|
<AlertTriangle className="size-4 text-amber-600" aria-hidden />
|
||||||
|
{k.ungrouped}: {formatMoney(s.ungroupedMinor)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t.ungroupedHelp}{" "}
|
||||||
|
<Link href="/kategoriler" className="text-primary hover:underline">
|
||||||
|
{t.goCategories}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<ul className="divide-y text-sm">
|
||||||
|
{ungrouped.map((r) => (
|
||||||
|
<li key={r.id} className="flex justify-between gap-3 py-1.5">
|
||||||
|
<span className="min-w-0 truncate">{r.path}</span>
|
||||||
|
<span className="tabular-nums">{formatMoney(r.amountMinor)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{KAKEIBO_GROUPS.map((g) => (
|
||||||
|
<GroupCard key={g} data={data} group={g} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bank.length ? (
|
||||||
|
<section
|
||||||
|
aria-labelledby="kg-bank"
|
||||||
|
className={cn("flex flex-col gap-2 rounded-xl border bg-card p-4")}
|
||||||
|
>
|
||||||
|
<h2 id="kg-bank" className="font-medium">
|
||||||
|
{k.bankCost}
|
||||||
|
</h2>
|
||||||
|
<ul className="divide-y text-sm">
|
||||||
|
{bank.map((r) => (
|
||||||
|
<li key={r.id} className="flex justify-between gap-3 py-1.5">
|
||||||
|
<span className="min-w-0 truncate">{r.path}</span>
|
||||||
|
<span className="tabular-nums">{formatMoney(r.amountMinor)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { getBudget } from "@/features/budgets/service";
|
||||||
|
import { listCategories, updateCategory } from "@/features/categories/service";
|
||||||
|
import {
|
||||||
|
createTransaction,
|
||||||
|
getTransactionFormOptions,
|
||||||
|
setTransactionDeleted,
|
||||||
|
} from "@/features/transactions/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { transactionFormSchema } from "@/shared/schemas/transaction";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { getKakeiboReport } from "./kakeibo";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let fatma = "";
|
||||||
|
|
||||||
|
const catId = (defaultKey: string) =>
|
||||||
|
CategoryModel.findOne({ householdId: cakir, defaultKey })
|
||||||
|
.lean()
|
||||||
|
.then((c) => String(c!._id));
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
describe("varsayılan gruplar", () => {
|
||||||
|
it("varsayılan kategoriler gruplu; alt kategoriler devralır; banka maliyeti işaretli", async () => {
|
||||||
|
const cats = (await listCategories(ayse, cakir, { kind: "expense" }))!;
|
||||||
|
const by = (name: string) => cats.find((c) => c.name === name)!;
|
||||||
|
expect(by("Market").kakeibo).toEqual({ group: "ZORUNLU", inherited: false, bankCost: false });
|
||||||
|
expect(by("Gıda").kakeibo).toEqual({ group: "ZORUNLU", inherited: true, bankCost: false });
|
||||||
|
expect(by("Kişisel bakım").kakeibo.group).toBe("ISTEK");
|
||||||
|
expect(by("Dışarıda yemek").kakeibo.group).toBe("ISTEK");
|
||||||
|
expect(by("Eğitim").kakeibo.group).toBe("KULTUR");
|
||||||
|
expect(by("Faiz").kakeibo.bankCost).toBe(true);
|
||||||
|
expect(by("Diğer").kakeibo.group).toBeNull();
|
||||||
|
const opts = (await getTransactionFormOptions(ayse, cakir))!;
|
||||||
|
expect(opts.categoryKakeibo[by("Gıda").id]).toEqual({ group: "ZORUNLU", bankCost: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Kakeibo raporu", () => {
|
||||||
|
it("harcama + banka maliyeti = bütçenin gerçekleşeni; başka hane göremez", async () => {
|
||||||
|
const r = (await getKakeiboReport(ayse, cakir))!;
|
||||||
|
const budget = (await getBudget(ayse, cakir))!;
|
||||||
|
const s = r.now.summary;
|
||||||
|
expect(s.spendingMinor + s.bankCostMinor).toBe(budget.totals.actualMinor);
|
||||||
|
const classSum = Object.values(r.now.byClass)
|
||||||
|
.flat()
|
||||||
|
.reduce((a, x) => a + x.amountMinor, 0);
|
||||||
|
expect(classSum).toBe(budget.totals.actualMinor);
|
||||||
|
expect(await getKakeiboReport(fatma, cakir)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("işlem bazında grup değiştirme tutarı taşır; banka maliyeti harcamaya girmez", async () => {
|
||||||
|
const before = (await getKakeiboReport(ayse, cakir))!.now.summary;
|
||||||
|
const account = String((await AccountModel.findOne({ name: "Ziraat Maaş" }).lean())!._id);
|
||||||
|
const form = (over: Record<string, unknown>) =>
|
||||||
|
transactionFormSchema.parse({
|
||||||
|
type: "expense",
|
||||||
|
amount: "100",
|
||||||
|
date: todayIstanbul(),
|
||||||
|
accountId: account,
|
||||||
|
installments: 1,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const gida = await catId("market.gida");
|
||||||
|
const faiz = await catId("banka.faiz");
|
||||||
|
// Market alışverişi ama amaç "istek" (ör. doğum günü pastası).
|
||||||
|
const a = await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
form({ categoryId: gida, kakeiboGroup: "ISTEK" }),
|
||||||
|
);
|
||||||
|
// Bölünmüş: 60 ZORUNLU (kategoriden) + 40 KÜLTÜR (değiştirilmiş).
|
||||||
|
const b = await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
form({
|
||||||
|
categoryId: "",
|
||||||
|
splits: [
|
||||||
|
{ categoryId: gida, amount: "60", note: "" },
|
||||||
|
{ categoryId: gida, amount: "40", note: "", kakeiboGroup: "KULTUR" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const c = await createTransaction(ayse, cakir, form({ categoryId: faiz, amount: "25" }));
|
||||||
|
expect([a.ok, b.ok, c.ok]).toEqual([true, true, true]);
|
||||||
|
|
||||||
|
const after = (await getKakeiboReport(ayse, cakir))!.now.summary;
|
||||||
|
expect(after.groups.ISTEK - before.groups.ISTEK).toBe(100_00);
|
||||||
|
expect(after.groups.ZORUNLU - before.groups.ZORUNLU).toBe(60_00);
|
||||||
|
expect(after.groups.KULTUR - before.groups.KULTUR).toBe(40_00);
|
||||||
|
expect(after.bankCostMinor - before.bankCostMinor).toBe(25_00);
|
||||||
|
expect(after.spendingMinor - before.spendingMinor).toBe(200_00);
|
||||||
|
|
||||||
|
for (const r of [a, b, c]) if (r.ok) await setTransactionDeleted(ayse, cakir, r.data.id, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kategoriye grup atanınca grupsuzdan çıkar", async () => {
|
||||||
|
const diger = await catId("diger-gider");
|
||||||
|
const cats = (await listCategories(ayse, cakir, { kind: "expense" }))!;
|
||||||
|
const d = cats.find((c) => c.id === diger)!;
|
||||||
|
const r = await updateCategory(ayse, cakir, diger, {
|
||||||
|
kind: "expense",
|
||||||
|
name: d.name,
|
||||||
|
parentId: null,
|
||||||
|
icon: d.icon,
|
||||||
|
color: d.color,
|
||||||
|
kakeiboGroup: "BEKLENMEYEN",
|
||||||
|
bankCost: false,
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const after = (await listCategories(ayse, cakir, { kind: "expense" }))!.find(
|
||||||
|
(c) => c.id === diger,
|
||||||
|
)!;
|
||||||
|
expect(after.kakeibo.group).toBe("BEKLENMEYEN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("migration 016", () => {
|
||||||
|
it("geri alınır (alanlar kalkar) ve yeniden uygulanınca varsayılanlar dolar", async () => {
|
||||||
|
const { revertMigration, runMigrations } = await import("../../../scripts/migrations");
|
||||||
|
const log = () => {};
|
||||||
|
await revertMigration("016-kakeibo-groups", log);
|
||||||
|
expect(await CategoryModel.countDocuments({ kakeiboGroup: { $exists: true } })).toBe(0);
|
||||||
|
expect(await runMigrations(log)).toEqual(["016-kakeibo-groups"]);
|
||||||
|
const gida = await CategoryModel.findOne({ householdId: cakir, defaultKey: "market" }).lean();
|
||||||
|
expect(gida!.kakeiboGroup).toBe("ZORUNLU");
|
||||||
|
const banka = await CategoryModel.findOne({ householdId: cakir, defaultKey: "banka" }).lean();
|
||||||
|
expect(banka!.bankCost).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import "server-only";
|
||||||
|
import type { Types } from "mongoose";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { budgetPeriod, shiftMonth } from "@/shared/budget";
|
||||||
|
import { istanbulDayStart } from "@/shared/dates";
|
||||||
|
import {
|
||||||
|
classifyAmounts,
|
||||||
|
isKakeiboGroup,
|
||||||
|
KAKEIBO_GROUPS,
|
||||||
|
summarizeGroups,
|
||||||
|
type GroupSummary,
|
||||||
|
type KakeiboGroup,
|
||||||
|
type SpendingClass,
|
||||||
|
} from "@/shared/kakeibo";
|
||||||
|
import { changePct } from "@/shared/reports";
|
||||||
|
import { actualByCategory, base, monthInfo, ORPHAN, type Base, type MonthInfo } from "./service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kakeibo grup raporu (Faz 16). Kategori tutarları bütçeyle aynı kaynaktan (gerçekleşmiş
|
||||||
|
* giderler, bölünmüş kalemler, taksit modu, gizli hesap ayarı); işlem/kalem bazında grubu
|
||||||
|
* değiştirilen tutarlar seçilen gruba taşınır. Banka maliyeti ayrı, grupsuzlar listelenir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
export type KakeiboCategoryRow = { id: string; path: string; amountMinor: number };
|
||||||
|
|
||||||
|
export type KakeiboMonth = {
|
||||||
|
summary: GroupSummary;
|
||||||
|
/** Sınıf başına kategori kırılımı (tutara göre büyükten küçüğe). */
|
||||||
|
byClass: Record<SpendingClass, KakeiboCategoryRow[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KakeiboReport = MonthInfo & {
|
||||||
|
current: boolean;
|
||||||
|
previous: string;
|
||||||
|
now: KakeiboMonth;
|
||||||
|
prev: KakeiboMonth;
|
||||||
|
/** Grup başına önceki aya göre değişim (tutar ve yüzde). */
|
||||||
|
change: Record<KakeiboGroup | "BANK_COST", { minor: number; pct: number | null }>;
|
||||||
|
includesHidden: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dönemde grubu işlem/kalem bazında değiştirilmiş gider tutarları (kategori × grup). */
|
||||||
|
async function overrides(b: Base, month: string) {
|
||||||
|
const { from, to } = budgetPeriod(month, b.s.startDay);
|
||||||
|
const match: Record<string, unknown> = {
|
||||||
|
householdId: b.ctx.m.householdId,
|
||||||
|
type: "expense",
|
||||||
|
status: "posted",
|
||||||
|
deletedAt: null,
|
||||||
|
date: {
|
||||||
|
$gte: istanbulDayStart(from)!,
|
||||||
|
$lt: new Date(istanbulDayStart(to)!.getTime() + DAY_MS),
|
||||||
|
},
|
||||||
|
$or: [{ kakeiboGroup: { $type: "string" } }, { "splits.kakeiboGroup": { $type: "string" } }],
|
||||||
|
};
|
||||||
|
if (!b.s.includePrivate && b.ctx.hiddenAccountIds.length)
|
||||||
|
match.accountId = { $nin: b.ctx.hiddenAccountIds };
|
||||||
|
// Tamamı alış ayına modunda taksitler bütçede alış ayına toplanır; değiştirme yalnızca
|
||||||
|
// taksitsiz işlemlere uygulanır (tutarlar kategori toplamıyla tutarlı kalsın).
|
||||||
|
if (b.s.installmentMode === "purchase_month") match["installment.planId"] = { $in: [null] };
|
||||||
|
const docs = await TransactionModel.find(match)
|
||||||
|
.select({ categoryId: 1, amountMinor: 1, kakeiboGroup: 1, splits: 1 })
|
||||||
|
.lean<
|
||||||
|
{
|
||||||
|
categoryId: Types.ObjectId | null;
|
||||||
|
amountMinor: number;
|
||||||
|
kakeiboGroup: string | null;
|
||||||
|
splits: { categoryId: Types.ObjectId; amountMinor: number; kakeiboGroup?: string | null }[];
|
||||||
|
}[]
|
||||||
|
>();
|
||||||
|
const sums = new Map<string, { categoryId: string; group: KakeiboGroup; amountMinor: number }>();
|
||||||
|
const add = (categoryId: string, group: unknown, amount: number) => {
|
||||||
|
if (!isKakeiboGroup(group)) return;
|
||||||
|
const key = `${categoryId}:${group}`;
|
||||||
|
const cur = sums.get(key) ?? { categoryId, group, amountMinor: 0 };
|
||||||
|
cur.amountMinor += amount;
|
||||||
|
sums.set(key, cur);
|
||||||
|
};
|
||||||
|
for (const d of docs) {
|
||||||
|
if (d.splits?.length)
|
||||||
|
for (const s of d.splits) add(String(s.categoryId), s.kakeiboGroup, s.amountMinor);
|
||||||
|
else if (d.categoryId) add(String(d.categoryId), d.kakeiboGroup, d.amountMinor);
|
||||||
|
}
|
||||||
|
return [...sums.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kakeiboMonth(b: Base, month: string): Promise<KakeiboMonth> {
|
||||||
|
const [amounts, over] = await Promise.all([actualByCategory(b, month), overrides(b, month)]);
|
||||||
|
const items = classifyAmounts(amounts, over, b.ctx.kakeibo);
|
||||||
|
const byClass = Object.fromEntries(
|
||||||
|
[...KAKEIBO_GROUPS, "BANK_COST", "UNGROUPED"].map((c) => [c, [] as KakeiboCategoryRow[]]),
|
||||||
|
) as Record<SpendingClass, KakeiboCategoryRow[]>;
|
||||||
|
const merged = new Map<string, KakeiboCategoryRow & { cls: SpendingClass }>();
|
||||||
|
for (const it of items) {
|
||||||
|
const key = `${it.cls}:${it.categoryId}`;
|
||||||
|
const path =
|
||||||
|
it.categoryId === ORPHAN
|
||||||
|
? tr.reports.uncategorized
|
||||||
|
: (b.ctx.categoryMap.get(it.categoryId)?.path ?? "—");
|
||||||
|
const cur = merged.get(key) ?? { id: it.categoryId, path, amountMinor: 0, cls: it.cls };
|
||||||
|
cur.amountMinor += it.amountMinor;
|
||||||
|
merged.set(key, cur);
|
||||||
|
}
|
||||||
|
for (const r of merged.values())
|
||||||
|
byClass[r.cls].push({ id: r.id, path: r.path, amountMinor: r.amountMinor });
|
||||||
|
for (const list of Object.values(byClass)) list.sort((a, c) => c.amountMinor - a.amountMinor);
|
||||||
|
return { summary: summarizeGroups(items), byClass };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKakeiboReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
month?: string,
|
||||||
|
): Promise<KakeiboReport | null> {
|
||||||
|
const b = await base(userId, householdId);
|
||||||
|
if (!b) return null;
|
||||||
|
const m = month ?? b.currentMonth;
|
||||||
|
const previous = shiftMonth(m, -1);
|
||||||
|
const [now, prev] = await Promise.all([kakeiboMonth(b, m), kakeiboMonth(b, previous)]);
|
||||||
|
const change = Object.fromEntries(
|
||||||
|
[...KAKEIBO_GROUPS, "BANK_COST" as const].map((g) => {
|
||||||
|
const cur = g === "BANK_COST" ? now.summary.bankCostMinor : now.summary.groups[g];
|
||||||
|
const old = g === "BANK_COST" ? prev.summary.bankCostMinor : prev.summary.groups[g];
|
||||||
|
return [g, { minor: cur - old, pct: changePct(cur, old) }];
|
||||||
|
}),
|
||||||
|
) as KakeiboReport["change"];
|
||||||
|
return {
|
||||||
|
...monthInfo(b, m),
|
||||||
|
previous,
|
||||||
|
now,
|
||||||
|
prev,
|
||||||
|
change,
|
||||||
|
includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ import { categoryBreakdown, savingsRate, sharePct, type CategoryBreakdown } from
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const DAY_MS = 86_400_000;
|
const DAY_MS = 86_400_000;
|
||||||
const ORPHAN = "__none__";
|
export const ORPHAN = "__none__";
|
||||||
|
|
||||||
export type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string };
|
export type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string };
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export async function base(userId: string, householdId: string): Promise<Base |
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Kategori bazında gerçekleşen gider (kendi tutarı); kategorisiz/bilinmeyen tek satırda. */
|
/** Kategori bazında gerçekleşen gider (kendi tutarı); kategorisiz/bilinmeyen tek satırda. */
|
||||||
async function actualByCategory(b: Base, month: string) {
|
export async function actualByCategory(b: Base, month: string) {
|
||||||
const { from, to } = budgetPeriod(month, b.s.startDay);
|
const { from, to } = budgetPeriod(month, b.s.startDay);
|
||||||
const amounts = await expenseAmounts(b.ctx, b.s, from, to);
|
const amounts = await expenseAmounts(b.ctx, b.s, from, to);
|
||||||
const out = new Map<string, number>();
|
const out = new Map<string, number>();
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { KakeiboDot } from "@/features/categories/components/kakeibo-badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { KAKEIBO_GROUPS, type KakeiboGroup } from "@/shared/kakeibo";
|
||||||
|
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
const FROM_CATEGORY = "__category__";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kakeibo grubu seçimi: varsayılan "kategoriden" (önerilen grup gösterilir); kullanıcı bu kayıt
|
||||||
|
* için değiştirebilir. Banka maliyeti kategorisinde seçim kapalıdır (harcama değildir).
|
||||||
|
*/
|
||||||
|
export function KakeiboSelect({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
suggested,
|
||||||
|
bankCost,
|
||||||
|
compact = false,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/** Kategorinin etkin grubu (öneri). */
|
||||||
|
suggested: KakeiboGroup | null;
|
||||||
|
bankCost: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}) {
|
||||||
|
if (bankCost)
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn("text-xs text-muted-foreground", compact ? "self-center" : "")}
|
||||||
|
title={k.bankCostHint}
|
||||||
|
>
|
||||||
|
{k.bankCost}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
const effective = (value || suggested) as KakeiboGroup | null;
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value || FROM_CATEGORY}
|
||||||
|
onValueChange={(v) => onChange(v === FROM_CATEGORY ? "" : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id={id}
|
||||||
|
aria-label={ariaLabel ?? k.field}
|
||||||
|
className={cn("w-full", compact && "h-8 text-sm")}
|
||||||
|
>
|
||||||
|
<SelectValue>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<KakeiboDot group={effective} />
|
||||||
|
<span className="truncate">
|
||||||
|
{value
|
||||||
|
? k.groups[value as KakeiboGroup]
|
||||||
|
: suggested
|
||||||
|
? compact
|
||||||
|
? k.groups[suggested]
|
||||||
|
: k.fromCategory(k.groups[suggested])
|
||||||
|
: k.fromCategoryNone}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={FROM_CATEGORY}>
|
||||||
|
{suggested ? k.fromCategory(k.groups[suggested]) : k.fromCategoryNone}
|
||||||
|
</SelectItem>
|
||||||
|
{KAKEIBO_GROUPS.map((g) => (
|
||||||
|
<SelectItem key={g} value={g}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<KakeiboDot group={g} />
|
||||||
|
{k.groups[g]}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { KakeiboSelect } from "./kakeibo-select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { todayIstanbul } from "@/shared/dates";
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
import { formatMoney } from "@/shared/format";
|
import { formatMoney } from "@/shared/format";
|
||||||
@@ -61,7 +62,7 @@ export type TransactionFormMode =
|
|||||||
| { kind: "edit"; transaction: TransactionDetail }
|
| { kind: "edit"; transaction: TransactionDetail }
|
||||||
| { kind: "copy"; transaction: TransactionDetail };
|
| { kind: "copy"; transaction: TransactionDetail };
|
||||||
|
|
||||||
type SplitValue = { categoryId: string; amount: string; note: string };
|
type SplitValue = { categoryId: string; amount: string; note: string; kakeiboGroup: string };
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
type: TransactionType;
|
type: TransactionType;
|
||||||
amount: string;
|
amount: string;
|
||||||
@@ -70,6 +71,8 @@ type FormValues = {
|
|||||||
toAccountId: string;
|
toAccountId: string;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
splits: SplitValue[];
|
splits: SplitValue[];
|
||||||
|
/** Kakeibo grubu bu işlem için değiştirildiyse ("" = kategoriden). */
|
||||||
|
kakeiboGroup: string;
|
||||||
payee: string;
|
payee: string;
|
||||||
description: string;
|
description: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
@@ -90,6 +93,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
|||||||
toAccountId: p.toAccountId ?? "",
|
toAccountId: p.toAccountId ?? "",
|
||||||
categoryId: p.categoryId ?? "",
|
categoryId: p.categoryId ?? "",
|
||||||
splits: [],
|
splits: [],
|
||||||
|
kakeiboGroup: "",
|
||||||
payee: "",
|
payee: "",
|
||||||
description: p.description ?? "",
|
description: p.description ?? "",
|
||||||
tags: "",
|
tags: "",
|
||||||
@@ -109,7 +113,9 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
|||||||
categoryId: s.categoryId,
|
categoryId: s.categoryId,
|
||||||
amount: formatMoneyInput(s.amountMinor),
|
amount: formatMoneyInput(s.amountMinor),
|
||||||
note: s.note,
|
note: s.note,
|
||||||
|
kakeiboGroup: s.kakeiboGroup ?? "",
|
||||||
})),
|
})),
|
||||||
|
kakeiboGroup: x.kakeiboGroup ?? "",
|
||||||
payee: x.payeeName,
|
payee: x.payeeName,
|
||||||
description: x.description,
|
description: x.description,
|
||||||
tags: x.tags.join(", "),
|
tags: x.tags.join(", "),
|
||||||
@@ -359,6 +365,7 @@ export function TransactionFormDialog({
|
|||||||
const amount = useWatch({ control: form.control, name: "amount" });
|
const amount = useWatch({ control: form.control, name: "amount" });
|
||||||
const accountId = useWatch({ control: form.control, name: "accountId" });
|
const accountId = useWatch({ control: form.control, name: "accountId" });
|
||||||
const splitValues = useWatch({ control: form.control, name: "splits" });
|
const splitValues = useWatch({ control: form.control, name: "splits" });
|
||||||
|
const categoryIdValue = useWatch({ control: form.control, name: "categoryId" });
|
||||||
const attachmentCount = useWatch({ control: form.control, name: "attachments" })?.length ?? 0;
|
const attachmentCount = useWatch({ control: form.control, name: "attachments" })?.length ?? 0;
|
||||||
const splitMode = splits.fields.length > 0;
|
const splitMode = splits.fields.length > 0;
|
||||||
|
|
||||||
@@ -426,9 +433,15 @@ export function TransactionFormDialog({
|
|||||||
}
|
}
|
||||||
const first = form.getValues("categoryId");
|
const first = form.getValues("categoryId");
|
||||||
splits.replace([
|
splits.replace([
|
||||||
{ categoryId: first, amount: form.getValues("amount"), note: "" },
|
{
|
||||||
{ categoryId: "", amount: "", note: "" },
|
categoryId: first,
|
||||||
|
amount: form.getValues("amount"),
|
||||||
|
note: "",
|
||||||
|
kakeiboGroup: form.getValues("kakeiboGroup"),
|
||||||
|
},
|
||||||
|
{ categoryId: "", amount: "", note: "", kakeiboGroup: "" },
|
||||||
]);
|
]);
|
||||||
|
form.setValue("kakeiboGroup", "");
|
||||||
form.setValue("categoryId", "");
|
form.setValue("categoryId", "");
|
||||||
setAdvancedOpen(true);
|
setAdvancedOpen(true);
|
||||||
}
|
}
|
||||||
@@ -711,6 +724,30 @@ export function TransactionFormDialog({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{type === "expense" && !splitMode && categoryIdValue ? (
|
||||||
|
<Controller
|
||||||
|
name="kakeiboGroup"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => {
|
||||||
|
const kk = options?.categoryKakeibo[categoryIdValue];
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="tx-kakeibo">{tr.kakeibo.field}</FieldLabel>
|
||||||
|
<KakeiboSelect
|
||||||
|
id="tx-kakeibo"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
suggested={kk?.group ?? null}
|
||||||
|
bankCost={kk?.bankCost ?? false}
|
||||||
|
/>
|
||||||
|
{field.value ? (
|
||||||
|
<FieldDescription>{tr.kakeibo.overrideHint}</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="payee"
|
name="payee"
|
||||||
@@ -862,10 +899,30 @@ export function TransactionFormDialog({
|
|||||||
{...field}
|
{...field}
|
||||||
placeholder={f.splitNote}
|
placeholder={f.splitNote}
|
||||||
aria-label={`${f.splitNote} ${i + 1}`}
|
aria-label={`${f.splitNote} ${i + 1}`}
|
||||||
className="col-span-2 h-8 text-sm"
|
className={cn("h-8 text-sm", type !== "expense" && "col-span-2")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{type === "expense" ? (
|
||||||
|
<Controller
|
||||||
|
name={`splits.${i}.kakeiboGroup`}
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => {
|
||||||
|
const kk =
|
||||||
|
options?.categoryKakeibo[splitValues?.[i]?.categoryId ?? ""];
|
||||||
|
return (
|
||||||
|
<KakeiboSelect
|
||||||
|
compact
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={field.onChange}
|
||||||
|
suggested={kk?.group ?? null}
|
||||||
|
bankCost={kk?.bankCost ?? false}
|
||||||
|
ariaLabel={`${tr.kakeibo.field} ${i + 1}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{splitMode ? (
|
{splitMode ? (
|
||||||
@@ -874,7 +931,14 @@ export function TransactionFormDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => splits.append({ categoryId: "", amount: "", note: "" })}
|
onClick={() =>
|
||||||
|
splits.append({
|
||||||
|
categoryId: "",
|
||||||
|
amount: "",
|
||||||
|
note: "",
|
||||||
|
kakeiboGroup: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
disabled={splits.fields.length >= MAX_SPLITS}
|
disabled={splits.fields.length >= MAX_SPLITS}
|
||||||
>
|
>
|
||||||
<Plus />
|
<Plus />
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronLeft, ChevronRight, Download, Filter, Paperclip, Plus, X } from "lucide-react";
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Download,
|
||||||
|
Filter,
|
||||||
|
Paperclip,
|
||||||
|
Plus,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -279,6 +288,14 @@ export function TransactionsView({
|
|||||||
<span className="hidden sm:inline">{t.export}</span>
|
<span className="hidden sm:inline">{t.export}</span>
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
{canWrite ? (
|
||||||
|
<Button variant="ghost" asChild>
|
||||||
|
<Link href="/islemler/ice-aktar">
|
||||||
|
<Upload />
|
||||||
|
<span className="hidden sm:inline">{t.import}</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{canWrite ? (
|
{canWrite ? (
|
||||||
<Button onClick={() => openQuickAdd()}>
|
<Button onClick={() => openQuickAdd()}>
|
||||||
<Plus />
|
<Plus />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { allowedAttachmentKinds } from "@/shared/attachments";
|
|||||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
import { installmentSchedule } from "@/shared/card-periods";
|
import { installmentSchedule } from "@/shared/card-periods";
|
||||||
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||||
|
import { classify, resolveKakeibo, type KakeiboGroup, type SpendingClass } from "@/shared/kakeibo";
|
||||||
import type { CategoryKind } from "@/shared/schemas/category";
|
import type { CategoryKind } from "@/shared/schemas/category";
|
||||||
import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction";
|
import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction";
|
||||||
import { compareTr, foldTr } from "@/shared/search";
|
import { compareTr, foldTr } from "@/shared/search";
|
||||||
@@ -85,6 +86,8 @@ export async function loadContext(
|
|||||||
icon: 1,
|
icon: 1,
|
||||||
color: 1,
|
color: 1,
|
||||||
ancestors: 1,
|
ancestors: 1,
|
||||||
|
kakeiboGroup: 1,
|
||||||
|
bankCost: 1,
|
||||||
})
|
})
|
||||||
.lean(),
|
.lean(),
|
||||||
ContactModel.find({ householdId: m.householdId })
|
ContactModel.find({ householdId: m.householdId })
|
||||||
@@ -134,6 +137,15 @@ export async function loadContext(
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
// Kakeibo: kategori başına etkin grup ve banka maliyeti (devralma dahil; Faz 16).
|
||||||
|
const kakeibo = resolveKakeibo(
|
||||||
|
categories.map((c) => ({
|
||||||
|
id: String(c._id),
|
||||||
|
parentId: c.parentId ? String(c.parentId) : null,
|
||||||
|
kakeiboGroup: (c.kakeiboGroup ?? null) as KakeiboGroup | null,
|
||||||
|
bankCost: Boolean(c.bankCost),
|
||||||
|
})),
|
||||||
|
);
|
||||||
const hiddenAccountIds = [...accountMap.values()].filter((a) => a.hidden).map((a) => a.id);
|
const hiddenAccountIds = [...accountMap.values()].filter((a) => a.hidden).map((a) => a.id);
|
||||||
const contactMap = new Map(
|
const contactMap = new Map(
|
||||||
contacts.map((c) => [
|
contacts.map((c) => [
|
||||||
@@ -170,6 +182,7 @@ export async function loadContext(
|
|||||||
assetMap,
|
assetMap,
|
||||||
hiddenAssetIds,
|
hiddenAssetIds,
|
||||||
flatCategories: flat,
|
flatCategories: flat,
|
||||||
|
kakeibo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +304,12 @@ export function buildDoc(
|
|||||||
|
|
||||||
let toAccountId: Types.ObjectId | null = null;
|
let toAccountId: Types.ObjectId | null = null;
|
||||||
let categoryId: Types.ObjectId | null = null;
|
let categoryId: Types.ObjectId | null = null;
|
||||||
let splits: { categoryId: Types.ObjectId; amountMinor: number; note: string }[] = [];
|
let splits: {
|
||||||
|
categoryId: Types.ObjectId;
|
||||||
|
amountMinor: number;
|
||||||
|
note: string;
|
||||||
|
kakeiboGroup: string | null;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
if (input.type === "transfer") {
|
if (input.type === "transfer") {
|
||||||
toAccountId = checkAccount(
|
toAccountId = checkAccount(
|
||||||
@@ -316,7 +334,13 @@ export function buildDoc(
|
|||||||
return fail(t.validation.categoryInvalid, {
|
return fail(t.validation.categoryInvalid, {
|
||||||
[`splits.${i}.categoryId`]: t.validation.categoryInvalid,
|
[`splits.${i}.categoryId`]: t.validation.categoryInvalid,
|
||||||
});
|
});
|
||||||
splits.push({ categoryId: cid, amountMinor: s.amount, note: s.note });
|
splits.push({
|
||||||
|
categoryId: cid,
|
||||||
|
amountMinor: s.amount,
|
||||||
|
note: s.note,
|
||||||
|
// Kakeibo grubu yalnızca giderde anlamlı.
|
||||||
|
kakeiboGroup: input.type === "expense" ? (s.kakeiboGroup ?? null) : null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
categoryId = input.categoryId ? checkCategory(ctx, input.categoryId, kind, keep) : null;
|
categoryId = input.categoryId ? checkCategory(ctx, input.categoryId, kind, keep) : null;
|
||||||
@@ -338,6 +362,8 @@ export function buildDoc(
|
|||||||
amountMinor: input.amount,
|
amountMinor: input.amount,
|
||||||
categoryId,
|
categoryId,
|
||||||
splits,
|
splits,
|
||||||
|
kakeiboGroup:
|
||||||
|
input.type === "expense" && splits.length === 0 ? (input.kakeiboGroup ?? null) : null,
|
||||||
effectiveCategoryIds: effective,
|
effectiveCategoryIds: effective,
|
||||||
payeeName,
|
payeeName,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
@@ -650,7 +676,14 @@ export type TransactionDetail = TransactionListItem & {
|
|||||||
color: Swatch;
|
color: Swatch;
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
note: string;
|
note: string;
|
||||||
|
/** Bu kalem için değiştirilmiş Kakeibo grubu (null = kategoriden). */
|
||||||
|
kakeiboGroup: KakeiboGroup | null;
|
||||||
|
/** Etkin sınıf (grup, banka maliyeti ya da grupsuz). */
|
||||||
|
kakeiboClass: SpendingClass;
|
||||||
}[];
|
}[];
|
||||||
|
/** Bölünmemiş giderde değiştirilmiş Kakeibo grubu ve etkin sınıf (gider değilse null). */
|
||||||
|
kakeiboGroup: KakeiboGroup | null;
|
||||||
|
kakeiboClass: SpendingClass | null;
|
||||||
updatedBy: { id: string; name: string };
|
updatedBy: { id: string; name: string };
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -830,8 +863,17 @@ export async function getTransaction(
|
|||||||
attachments,
|
attachments,
|
||||||
...toItem(ctx, tx, names),
|
...toItem(ctx, tx, names),
|
||||||
categoryId: tx.categoryId ? String(tx.categoryId) : null,
|
categoryId: tx.categoryId ? String(tx.categoryId) : null,
|
||||||
|
kakeiboGroup: (tx.kakeiboGroup ?? null) as KakeiboGroup | null,
|
||||||
|
kakeiboClass:
|
||||||
|
tx.type === "expense" && tx.categoryId
|
||||||
|
? classify(
|
||||||
|
ctx.kakeibo.get(String(tx.categoryId)),
|
||||||
|
(tx.kakeiboGroup ?? null) as KakeiboGroup | null,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
splits: (tx.splits ?? []).map((s) => {
|
splits: (tx.splits ?? []).map((s) => {
|
||||||
const c = ctx.categoryMap.get(String(s.categoryId));
|
const c = ctx.categoryMap.get(String(s.categoryId));
|
||||||
|
const override = (s.kakeiboGroup ?? null) as KakeiboGroup | null;
|
||||||
return {
|
return {
|
||||||
categoryId: String(s.categoryId),
|
categoryId: String(s.categoryId),
|
||||||
path: c?.path ?? "—",
|
path: c?.path ?? "—",
|
||||||
@@ -839,6 +881,8 @@ export async function getTransaction(
|
|||||||
color: c?.color ?? "slate",
|
color: c?.color ?? "slate",
|
||||||
amountMinor: s.amountMinor,
|
amountMinor: s.amountMinor,
|
||||||
note: s.note ?? "",
|
note: s.note ?? "",
|
||||||
|
kakeiboGroup: override,
|
||||||
|
kakeiboClass: classify(ctx.kakeibo.get(String(s.categoryId)), override),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
updatedBy: { id: String(tx.updatedBy), name: names.get(String(tx.updatedBy)) ?? "—" },
|
updatedBy: { id: String(tx.updatedBy), name: names.get(String(tx.updatedBy)) ?? "—" },
|
||||||
@@ -924,6 +968,8 @@ export type TransactionFormOptions = {
|
|||||||
accounts: { id: string; name: string; type: AccountType; icon: IconKey; color: Swatch }[];
|
accounts: { id: string; name: string; type: AccountType; icon: IconKey; color: Swatch }[];
|
||||||
categories: CategoryOption[];
|
categories: CategoryOption[];
|
||||||
categoryAppearance: Record<string, { icon: IconKey; color: Swatch }>;
|
categoryAppearance: Record<string, { icon: IconKey; color: Swatch }>;
|
||||||
|
/** Gider kategorisi başına etkin Kakeibo grubu (formda öneri) ve banka maliyeti. */
|
||||||
|
categoryKakeibo: Record<string, { group: KakeiboGroup | null; bankCost: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getTransactionFormOptions(
|
export async function getTransactionFormOptions(
|
||||||
@@ -950,7 +996,10 @@ export async function getTransactionFormOptions(
|
|||||||
const categoryAppearance = Object.fromEntries(
|
const categoryAppearance = Object.fromEntries(
|
||||||
[...ctx.categoryMap.values()].map((c) => [String(c.id), { icon: c.icon, color: c.color }]),
|
[...ctx.categoryMap.values()].map((c) => [String(c.id), { icon: c.icon, color: c.color }]),
|
||||||
);
|
);
|
||||||
return { accounts, categories, categoryAppearance };
|
const categoryKakeibo = Object.fromEntries(
|
||||||
|
[...ctx.kakeibo.entries()].map(([id, k]) => [id, { group: k.group, bankCost: k.bankCost }]),
|
||||||
|
);
|
||||||
|
return { accounts, categories, categoryAppearance, categoryKakeibo };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PayeeSuggestion = {
|
export type PayeeSuggestion = {
|
||||||
|
|||||||
+186
@@ -340,6 +340,7 @@ export const tr = {
|
|||||||
deleteDescription:
|
deleteDescription:
|
||||||
"İşlem listeden ve bakiyelerden kaldırılır. Bildirimdeki “Geri al” ile geri getirebilirsiniz.",
|
"İşlem listeden ve bakiyelerden kaldırılır. Bildirimdeki “Geri al” ile geri getirebilirsiniz.",
|
||||||
export: "CSV indir",
|
export: "CSV indir",
|
||||||
|
import: "İçe aktar",
|
||||||
csv: {
|
csv: {
|
||||||
header: [
|
header: [
|
||||||
"İşlem No",
|
"İşlem No",
|
||||||
@@ -1862,6 +1863,174 @@ export const tr = {
|
|||||||
identifierRequired: "Kullanıcı adı veya e-posta girin",
|
identifierRequired: "Kullanıcı adı veya e-posta girin",
|
||||||
passwordRequired: "Şifre girin",
|
passwordRequired: "Şifre girin",
|
||||||
},
|
},
|
||||||
|
imports: {
|
||||||
|
title: "Ekstre içe aktarma",
|
||||||
|
description:
|
||||||
|
"Kakeibo çalışma kitabından kredi kartı ve maaş hesabı hareketlerini önizleyerek aktarın. Aynı satır iki kez aktarılmaz; işlenen aktarma tek tuşla geri alınır.",
|
||||||
|
tag: "içe-aktarma",
|
||||||
|
upload: "Dosya yükle",
|
||||||
|
uploadHint:
|
||||||
|
'Kakeibo çalışma kitabı (.xlsx): "Kredi Kartı" ve "Maaş Hesabı" sayfaları, "Özet" ekstre kontrolü için.',
|
||||||
|
uploading: "Okunuyor…",
|
||||||
|
history: "Aktarmalar",
|
||||||
|
empty: "Henüz aktarma yok.",
|
||||||
|
open: "Aç",
|
||||||
|
statuses: { draft: "Taslak", committed: "İşlendi", reverted: "Geri alındı" } as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>,
|
||||||
|
rowsCount: (n: number) => `${n} satır`,
|
||||||
|
back: "Ekstre içe aktarma",
|
||||||
|
steps: {
|
||||||
|
accounts: "1. Hesaplar",
|
||||||
|
checks: "2. Kontroller",
|
||||||
|
rows: "3. Önizleme",
|
||||||
|
commit: "4. İşle",
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
card: "Kredi kartı",
|
||||||
|
bank: "Maaş hesabı (KMH)",
|
||||||
|
cash: "Nakit",
|
||||||
|
extra: "Ek hesap",
|
||||||
|
gold: "Altın hesabı",
|
||||||
|
} as Record<string, string>,
|
||||||
|
roleHints: {
|
||||||
|
card: "Kart ekstresindeki harcama ve iadeler bu hesaba yazılır.",
|
||||||
|
bank: "Maaş hesabı hareketleri; kart ödemeleri bu hesaptan karta transfer olur.",
|
||||||
|
cash: "ATM nakit çekimleri bu hesaba transfer olur (gider değildir).",
|
||||||
|
extra: '"Hesaplar arası" havaleler bu hesaba transfer olur.',
|
||||||
|
gold: "",
|
||||||
|
} as Record<string, string>,
|
||||||
|
presets: {
|
||||||
|
bank: "Yapı Kredi Maaş",
|
||||||
|
card: "KoçAilem Worldcard",
|
||||||
|
cash: "Nakit",
|
||||||
|
extra: "Ek Hesap",
|
||||||
|
},
|
||||||
|
selectAccount: "Hesap seçin",
|
||||||
|
createSuggested: "Önerilen hesabı oluştur",
|
||||||
|
createTitle: (role: string) => `${role} hesabı oluştur`,
|
||||||
|
accountName: "Hesap adı",
|
||||||
|
openingBalance: "Açılış bakiyesi (₺)",
|
||||||
|
openingDebt: "Açılış borcu (₺)",
|
||||||
|
openingDate: "Açılış tarihi",
|
||||||
|
openingHintBank: "Dosyadaki devreden bakiye. Eksi bakiye (KMH) için başına − koyun.",
|
||||||
|
openingHintCard:
|
||||||
|
"Önceki ekstrenin borcu (dosyadaki ilk kart ödemesi). Kesim günü 28, son ödeme +12 gün; Hesaplar sayfasından düzenlenebilir.",
|
||||||
|
ownerOnly: "Hesap oluşturmak için hane sahibi olmalısınız; mevcut bir hesabı seçin.",
|
||||||
|
checksTitle: "Dosya kontrolleri",
|
||||||
|
statementOk: (cut: string) => `${cut} ekstresi tutuyor`,
|
||||||
|
statementBad: (cut: string, rows: string, statement: string) =>
|
||||||
|
`${cut} ekstresi tutmuyor: satırlar ${rows}, ekstre ${statement}`,
|
||||||
|
statementsSummary: (ok: number, total: number) =>
|
||||||
|
`${total} ekstrenin ${ok}'i satır toplamıyla birebir aynı.`,
|
||||||
|
noStatements: "Özet sayfası yok; ekstre toplamı kontrol edilemedi.",
|
||||||
|
balanceOk: (closing: string) => `Maaş hesabı bakiye zinciri tam; son bakiye ${closing}.`,
|
||||||
|
balanceBad: (line: number) => `Maaş hesabı bakiye sütunu ${line}. satırda tutmuyor.`,
|
||||||
|
duplicates: (n: number) => `${n} satır daha önce içe aktarılmış; atlanacak.`,
|
||||||
|
deferred: (n: number, net: string) =>
|
||||||
|
`${n} altın satırı (net ${net}) altın hesabı dosyasıyla eşleştirilmek üzere bekletiliyor; o zamana dek maaş hesabı bakiyesi bu tutar kadar farklı görünür.`,
|
||||||
|
newCategories: (names: string) => `İşlenince oluşturulacak kategoriler: ${names}.`,
|
||||||
|
newCategoryName: (name: string) => `${name} (yeni)`,
|
||||||
|
filters: {
|
||||||
|
all: "Tümü",
|
||||||
|
review: "İnceleme gereken",
|
||||||
|
ungrouped: "Grupsuz",
|
||||||
|
transfer: "Transfer",
|
||||||
|
skip: "Atlanacak",
|
||||||
|
},
|
||||||
|
sheets: { card: "Kredi kartı", bank: "Maaş hesabı" },
|
||||||
|
columns: {
|
||||||
|
date: "Tarih",
|
||||||
|
description: "Açıklama",
|
||||||
|
amount: "Tutar",
|
||||||
|
kind: "Tür",
|
||||||
|
category: "Kategori",
|
||||||
|
group: "Grup",
|
||||||
|
},
|
||||||
|
kinds: {
|
||||||
|
expense: "Gider",
|
||||||
|
income: "Gelir",
|
||||||
|
transfer: "Transfer",
|
||||||
|
skip: "Atla",
|
||||||
|
deferred: "Bekliyor",
|
||||||
|
} as Record<string, string>,
|
||||||
|
transferTo: (role: string) => `→ ${role}`,
|
||||||
|
transferFrom: (role: string) => `← ${role}`,
|
||||||
|
reviews: {
|
||||||
|
ungrouped: "Grup seçin",
|
||||||
|
unknownCategory: "Kategori eşleşmedi",
|
||||||
|
unknownType: "Tür tanınmadı",
|
||||||
|
signMismatch: "Yön tutarsız",
|
||||||
|
promo: "Promosyon",
|
||||||
|
} as Record<string, string>,
|
||||||
|
duplicate: "Daha önce aktarılmış",
|
||||||
|
installment: (i: number, n: number) => `Taksit ${i}/${n}`,
|
||||||
|
applyToSame: "Aynı açıklamalı tüm satırlara uygula",
|
||||||
|
page: (from: number, to: number, total: number) => `${from}–${to} / ${total}`,
|
||||||
|
prev: "Önceki",
|
||||||
|
next: "Sonraki",
|
||||||
|
commitTitle: "İşle",
|
||||||
|
commitSummary: (n: number, skipped: number) =>
|
||||||
|
`${n} işlem oluşturulacak, ${skipped} satır atlanacak.`,
|
||||||
|
commitHint:
|
||||||
|
"İşlem tek seferde yapılır; bir hata olursa hiçbir kayıt yazılmaz. Sonradan tek tuşla geri alınabilir.",
|
||||||
|
commit: "İçe aktar",
|
||||||
|
committing: "İşleniyor…",
|
||||||
|
committed: (n: number) => `${n} işlem içe aktarıldı.`,
|
||||||
|
committedSummary: (n: number, skipped: number, cats: number) =>
|
||||||
|
`${n} işlem oluşturuldu, ${skipped} satır atlandı${cats ? `, ${cats} kategori eklendi` : ""}.`,
|
||||||
|
revert: "Aktarmayı geri al",
|
||||||
|
revertTitle: "Aktarma geri alınsın mı?",
|
||||||
|
revertDescription:
|
||||||
|
"Bu aktarmayla oluşan tüm işlemler silinir (oluşturulan hesap ve kategoriler kalır). Aynı dosya daha sonra yeniden aktarılabilir.",
|
||||||
|
reverted: (n: number) => `${n} işlem geri alındı.`,
|
||||||
|
deleteDraft: "Taslağı sil",
|
||||||
|
goTransactions: "İşlemlere git",
|
||||||
|
errors: {
|
||||||
|
unreadable: "Dosya okunamadı. Kakeibo çalışma kitabını .xlsx olarak yükleyin.",
|
||||||
|
noSheets: 'Dosyada "Kredi Kartı" ya da "Maaş Hesabı" sayfası bulunamadı.',
|
||||||
|
empty: "Dosyada aktarılacak satır yok.",
|
||||||
|
tooLarge: "Dosya 1 MB'tan büyük olamaz.",
|
||||||
|
notDraft: "Bu aktarma işlenmiş; değiştirilemez.",
|
||||||
|
notCommitted: "Yalnızca işlenmiş aktarma geri alınabilir.",
|
||||||
|
accountType: "Bu rol için uygun türde bir hesap seçin.",
|
||||||
|
roleMissing: (role: string) => `"${role}" için hesap seçilmedi.`,
|
||||||
|
category: (row: string) => `Kategori seçilmemiş satır var: ${row}`,
|
||||||
|
duplicate:
|
||||||
|
"Bazı satırlar bu arada başka bir aktarmayla eklenmiş; sayfayı yenileyip tekrar deneyin.",
|
||||||
|
noFile: "Bir dosya seçin.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kakeibo: {
|
||||||
|
groups: {
|
||||||
|
ZORUNLU: "Zorunlu",
|
||||||
|
ISTEK: "İstek",
|
||||||
|
KULTUR: "Kültür",
|
||||||
|
BEKLENMEYEN: "Beklenmeyen",
|
||||||
|
},
|
||||||
|
groupHints: {
|
||||||
|
ZORUNLU: "İhtiyaç: market, faturalar, ulaşım, sağlık, sigorta, resmi ödemeler.",
|
||||||
|
ISTEK:
|
||||||
|
"Opsiyonel: dışarıda yemek, kahve-börek, giyim, kozmetik, hobi, online alışveriş, abonelik, tatil.",
|
||||||
|
KULTUR: "Kitap, kurs, eğitim, çocuk etkinlikleri, müze/konser, bağış.",
|
||||||
|
BEKLENMEYEN: "Plansız tamir/tadilat, büyük sağlık gideri, arıza, hediye/düğün.",
|
||||||
|
},
|
||||||
|
bankCost: "Banka maliyeti",
|
||||||
|
bankCostHint:
|
||||||
|
"KMH faizi, KKDF/BSMV, kart ve hesap ücretleri. Harcama değildir; dört gruba ve harcama toplamına girmez.",
|
||||||
|
ungrouped: "Grupsuz",
|
||||||
|
ungroupedHint: "Kakeibo grubu seçilmemiş. Kategoriye grup atayın.",
|
||||||
|
field: "Kakeibo grubu",
|
||||||
|
inherit: "Üst kategoriden devral",
|
||||||
|
inheritWith: (group: string) => `Üst kategoriden: ${group}`,
|
||||||
|
fromCategory: (group: string) => `Kategoriden: ${group}`,
|
||||||
|
fromCategoryNone: "Kategoriden (grupsuz)",
|
||||||
|
overrideHint: "Kategoriyi ürün değil amaç belirler; bu kayıt için grubu değiştirebilirsiniz.",
|
||||||
|
inherited: "devralınan",
|
||||||
|
ungroupedCount: (n: number) =>
|
||||||
|
`${n} gider kategorisinin Kakeibo grubu yok; kategoriyi düzenleyip grup seçin.`,
|
||||||
|
},
|
||||||
reports: {
|
reports: {
|
||||||
title: "Raporlar",
|
title: "Raporlar",
|
||||||
description:
|
description:
|
||||||
@@ -1904,6 +2073,10 @@ export const tr = {
|
|||||||
title: "Cari ve aynî borçlar",
|
title: "Cari ve aynî borçlar",
|
||||||
description: "Kim kime ne kadar borçlu; açık altın/döviz borç ve alacakları.",
|
description: "Kim kime ne kadar borçlu; açık altın/döviz borç ve alacakları.",
|
||||||
},
|
},
|
||||||
|
kakeibo: {
|
||||||
|
title: "Kakeibo grupları",
|
||||||
|
description: "Zorunlu, İstek, Kültür, Beklenmeyen; banka maliyeti ve grupsuz harcamalar.",
|
||||||
|
},
|
||||||
assets: {
|
assets: {
|
||||||
title: "Birikim kâr/zarar",
|
title: "Birikim kâr/zarar",
|
||||||
description: "Varlık başına maliyet, değer ve kâr/zarar; dönem özeti.",
|
description: "Varlık başına maliyet, değer ve kâr/zarar; dönem özeti.",
|
||||||
@@ -1992,6 +2165,19 @@ export const tr = {
|
|||||||
periods: { 3: "Son 3 ay", 6: "Son 6 ay", 12: "Son 12 ay" } as Record<number, string>,
|
periods: { 3: "Son 3 ay", 6: "Son 6 ay", 12: "Son 12 ay" } as Record<number, string>,
|
||||||
periodLabel: "Dönem",
|
periodLabel: "Dönem",
|
||||||
rangeText: (from: string, to: string) => `${from} – ${to}`,
|
rangeText: (from: string, to: string) => `${from} – ${to}`,
|
||||||
|
kakeiboReport: {
|
||||||
|
title: "Kakeibo grupları",
|
||||||
|
description: (month: string) =>
|
||||||
|
`${month} bütçe ayı; harcamanın Zorunlu, İstek, Kültür ve Beklenmeyen gruplarına dağılımı.`,
|
||||||
|
spending: "Kakeibo harcaması",
|
||||||
|
chartLabel: "Harcamanın dört Kakeibo grubuna dağılımı, yığılmış çubuk",
|
||||||
|
bankCostNote: "Harcamaya ve gruplara dahil değil.",
|
||||||
|
noSpending: "Bu ay harcama yok.",
|
||||||
|
ungroupedHelp: "Bu kategorilerin Kakeibo grubu seçilmemiş.",
|
||||||
|
goCategories: "Kategorilere grup ata",
|
||||||
|
csvHeader: ["Grup", "Kategori", "Tutar"],
|
||||||
|
csvFile: (month: string) => `butcem-kakeibo-${month}.csv`,
|
||||||
|
},
|
||||||
contacts: {
|
contacts: {
|
||||||
title: "Cari ve aynî borçlar",
|
title: "Cari ve aynî borçlar",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export const AUDIT_ACTIONS = [
|
|||||||
"product.unarchive",
|
"product.unarchive",
|
||||||
"price.delete",
|
"price.delete",
|
||||||
"shoppingList.delete",
|
"shoppingList.delete",
|
||||||
|
"import.commit",
|
||||||
|
"import.revert",
|
||||||
] as const;
|
] as const;
|
||||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
import { ICON_KEYS, SWATCHES } from "@/shared/appearance";
|
import { ICON_KEYS, SWATCHES } from "@/shared/appearance";
|
||||||
|
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||||
import { CATEGORY_KINDS } from "@/shared/schemas/category";
|
import { CATEGORY_KINDS } from "@/shared/schemas/category";
|
||||||
|
|
||||||
/** Gelir/gider kategori ağacı (Bölüm 6.2). Silme yok; arşivleme (alt ağaçla birlikte). */
|
/** Gelir/gider kategori ağacı (Bölüm 6.2). Silme yok; arşivleme (alt ağaçla birlikte). */
|
||||||
@@ -16,6 +17,13 @@ const categorySchema = new Schema(
|
|||||||
icon: { type: String, enum: ICON_KEYS, default: "tag" },
|
icon: { type: String, enum: ICON_KEYS, default: "tag" },
|
||||||
color: { type: String, enum: SWATCHES, default: "slate" },
|
color: { type: String, enum: SWATCHES, default: "slate" },
|
||||||
order: { type: Number, default: 0 },
|
order: { type: Number, default: 0 },
|
||||||
|
/**
|
||||||
|
* Kakeibo grubu (yalnızca gider; Faz 16). null ise üst kategoriden devralınır; zincirde yoksa
|
||||||
|
* grupsuz.
|
||||||
|
*/
|
||||||
|
kakeiboGroup: { type: String, enum: [...KAKEIBO_GROUPS, null], default: null },
|
||||||
|
/** Banka maliyeti (KMH faizi, KKDF/BSMV, ücret): Kakeibo harcamasına girmez; alt ağaç devralır. */
|
||||||
|
bankCost: { type: Boolean, default: false },
|
||||||
archivedAt: { type: Date, default: null },
|
archivedAt: { type: Date, default: null },
|
||||||
/** Varsayılan setten geldiyse sabit anahtar (tekrar yüklemede kopya olmaz). */
|
/** Varsayılan setten geldiyse sabit anahtar (tekrar yüklemede kopya olmaz). */
|
||||||
defaultKey: { type: String, default: null },
|
defaultKey: { type: String, default: null },
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ekstre içe aktarma partisi (Faz 17). Taslak (önizleme, düzenleme) → işlendi → geri alındı.
|
||||||
|
* Satırlar önerilen sınıflandırmayla birlikte saklanır; kullanıcı düzenlemeleri satırda tutulur.
|
||||||
|
* İşlenince oluşan işlemler `importBatchId` ile partiye bağlıdır (toplu geri alma).
|
||||||
|
*/
|
||||||
|
const importRowSchema = new Schema(
|
||||||
|
{
|
||||||
|
sheet: { type: String, enum: ["card", "bank"], required: true },
|
||||||
|
line: { type: Number, required: true },
|
||||||
|
date: { type: String, required: true },
|
||||||
|
statementCut: { type: String, default: null },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
amountMinor: { type: Number, required: true },
|
||||||
|
kind: {
|
||||||
|
type: String,
|
||||||
|
enum: ["expense", "income", "transfer", "skip", "deferred"],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
categoryKey: { type: String, default: null },
|
||||||
|
/** Kullanıcının seçtiği kategori (varsa categoryKey'in yerine). */
|
||||||
|
categoryId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
kakeiboGroup: { type: String, default: null },
|
||||||
|
counterRole: { type: String, default: null },
|
||||||
|
installment: { type: Schema.Types.Mixed, default: null },
|
||||||
|
source: { type: Schema.Types.Mixed, default: {} },
|
||||||
|
review: { type: String, default: null },
|
||||||
|
key: { type: String, required: true },
|
||||||
|
/** Bu anahtar hanede zaten içe aktarılmış (varsayılan: atlanır). */
|
||||||
|
duplicate: { type: Boolean, default: false },
|
||||||
|
edited: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
{ _id: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const importBatchSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
source: { type: String, enum: ["kakeibo-workbook"], required: true },
|
||||||
|
fileName: { type: String, required: true, maxlength: 200 },
|
||||||
|
fileHash: { type: String, required: true },
|
||||||
|
status: { type: String, enum: ["draft", "committed", "reverted"], default: "draft" },
|
||||||
|
/** Rol → hesap (card, bank, cash, extra). */
|
||||||
|
accounts: { type: Schema.Types.Mixed, default: {} },
|
||||||
|
rows: { type: [importRowSchema], default: [] },
|
||||||
|
/** Dosyadan çıkan kontroller (ekstre toplamları, bakiye zinciri, açılış). */
|
||||||
|
checks: { type: Schema.Types.Mixed, default: {} },
|
||||||
|
committed: { type: Schema.Types.Mixed, default: null },
|
||||||
|
committedAt: { type: Date, default: null },
|
||||||
|
revertedAt: { type: Date, default: null },
|
||||||
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true, collection: "importBatches" },
|
||||||
|
);
|
||||||
|
|
||||||
|
importBatchSchema.index({ householdId: 1, createdAt: -1 });
|
||||||
|
|
||||||
|
export type ImportBatch = InferSchemaType<typeof importBatchSchema>;
|
||||||
|
|
||||||
|
export const ImportBatchModel: Model<ImportBatch> =
|
||||||
|
(models.ImportBatch as Model<ImportBatch> | undefined) ??
|
||||||
|
model<ImportBatch>("ImportBatch", importBatchSchema);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
import { CONTACT_ENTRY_KINDS } from "@/shared/contacts";
|
import { CONTACT_ENTRY_KINDS } from "@/shared/contacts";
|
||||||
|
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||||
import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions";
|
import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,6 +12,8 @@ const splitSchema = new Schema(
|
|||||||
categoryId: { type: Schema.Types.ObjectId, required: true },
|
categoryId: { type: Schema.Types.ObjectId, required: true },
|
||||||
amountMinor: { type: Number, required: true, min: 1 },
|
amountMinor: { type: Number, required: true, min: 1 },
|
||||||
note: { type: String, default: "", maxlength: 120 },
|
note: { type: String, default: "", maxlength: 120 },
|
||||||
|
/** Kakeibo grubu bu kalem için değiştirildiyse; null = kategoriden (Faz 16). */
|
||||||
|
kakeiboGroup: { type: String, enum: [...KAKEIBO_GROUPS, null], default: null },
|
||||||
},
|
},
|
||||||
{ _id: true },
|
{ _id: true },
|
||||||
);
|
);
|
||||||
@@ -29,6 +32,8 @@ const transactionSchema = new Schema(
|
|||||||
/** Bölünmemiş gelir/giderde kategori; bölünmüşte null ve kalemler `splits`te. */
|
/** Bölünmemiş gelir/giderde kategori; bölünmüşte null ve kalemler `splits`te. */
|
||||||
categoryId: { type: Schema.Types.ObjectId, default: null },
|
categoryId: { type: Schema.Types.ObjectId, default: null },
|
||||||
splits: { type: [splitSchema], default: [] },
|
splits: { type: [splitSchema], default: [] },
|
||||||
|
/** Bölünmemiş giderde Kakeibo grubu değiştirildiyse; null = kategoriden (Faz 16). */
|
||||||
|
kakeiboGroup: { type: String, enum: [...KAKEIBO_GROUPS, null], default: null },
|
||||||
/** Kategori filtresi/raporlar için: categoryId veya kalem kategorileri (tekilleştirilmiş). */
|
/** Kategori filtresi/raporlar için: categoryId veya kalem kategorileri (tekilleştirilmiş). */
|
||||||
effectiveCategoryIds: { type: [Schema.Types.ObjectId], default: [] },
|
effectiveCategoryIds: { type: [Schema.Types.ObjectId], default: [] },
|
||||||
payeeId: { type: Schema.Types.ObjectId, default: null },
|
payeeId: { type: Schema.Types.ObjectId, default: null },
|
||||||
@@ -76,6 +81,9 @@ const transactionSchema = new Schema(
|
|||||||
reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] },
|
reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] },
|
||||||
/** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */
|
/** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */
|
||||||
isAdjustment: { type: Boolean, default: false },
|
isAdjustment: { type: Boolean, default: false },
|
||||||
|
/** Ekstre içe aktarmasından geldiyse parti ve tekrar engeli anahtarı (Faz 17). */
|
||||||
|
importBatchId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
importKey: { type: String, default: null },
|
||||||
/** Alışverişi tamamlamadan gelen mağaza gideri (Faz 13b). */
|
/** Alışverişi tamamlamadan gelen mağaza gideri (Faz 13b). */
|
||||||
shoppingListId: { type: Schema.Types.ObjectId, default: null },
|
shoppingListId: { type: Schema.Types.ObjectId, default: null },
|
||||||
deletedAt: { type: Date, default: null },
|
deletedAt: { type: Date, default: null },
|
||||||
@@ -87,6 +95,15 @@ const transactionSchema = new Schema(
|
|||||||
{ timestamps: true, collection: "transactions" },
|
{ timestamps: true, collection: "transactions" },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Aynı ekstre satırı iki kez içe aktarılamaz. Geri alınan partide anahtar boşaltılır (yeniden
|
||||||
|
// içe aktarılabilir).
|
||||||
|
transactionSchema.index(
|
||||||
|
{ householdId: 1, importKey: 1 },
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
partialFilterExpression: { importKey: { $type: "string" } },
|
||||||
|
},
|
||||||
|
);
|
||||||
transactionSchema.index({ householdId: 1, deletedAt: 1, date: -1, createdAt: -1 });
|
transactionSchema.index({ householdId: 1, deletedAt: 1, date: -1, createdAt: -1 });
|
||||||
transactionSchema.index({ householdId: 1, status: 1, date: 1 });
|
transactionSchema.index({ householdId: 1, status: 1, date: 1 });
|
||||||
transactionSchema.index({ householdId: 1, accountId: 1, date: -1 });
|
transactionSchema.index({ householdId: 1, accountId: 1, date: -1 });
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
bankExpenseCategory,
|
||||||
|
checkStatements,
|
||||||
|
excelDate,
|
||||||
|
firstCardPayment,
|
||||||
|
parseBankSheet,
|
||||||
|
parseCardSheet,
|
||||||
|
parseInstallment,
|
||||||
|
parseStatementTotals,
|
||||||
|
refineCategory,
|
||||||
|
} from "./kakeibo-workbook";
|
||||||
|
|
||||||
|
/** "YYYY-MM-DD" → Excel seri günü (test verisi için). */
|
||||||
|
const serial = (ymd: string) =>
|
||||||
|
Date.UTC(+ymd.slice(0, 4), +ymd.slice(5, 7) - 1, +ymd.slice(8, 10)) / 86_400_000 + 25569;
|
||||||
|
|
||||||
|
const CARD_HEAD = [
|
||||||
|
"İşlem tarihi",
|
||||||
|
"Ekstre kesim",
|
||||||
|
"İşyeri",
|
||||||
|
"Kategori",
|
||||||
|
"Kakeibo",
|
||||||
|
"Kart",
|
||||||
|
"Taksit",
|
||||||
|
"Tutar (TL)",
|
||||||
|
];
|
||||||
|
const BANK_HEAD = ["Tarih", "Açıklama", "Tür", "Tutar (TL)", "Bakiye (TL)", "Ay"];
|
||||||
|
|
||||||
|
describe("hücre yardımcıları", () => {
|
||||||
|
it("Excel seri günü saat dilimi kaymadan güne çözülür", () => {
|
||||||
|
expect(excelDate(serial("2026-01-28"))).toBe("2026-01-28");
|
||||||
|
expect(excelDate(serial("2025-12-31"))).toBe("2025-12-31");
|
||||||
|
expect(excelDate("x")).toBeNull();
|
||||||
|
});
|
||||||
|
it("taksit metni", () => {
|
||||||
|
expect(parseInstallment("2/7 (3.465,95 TL)")).toEqual({
|
||||||
|
index: 2,
|
||||||
|
count: 7,
|
||||||
|
totalMinor: 346_595,
|
||||||
|
});
|
||||||
|
expect(parseInstallment("1/9 (28.200,00 TL)")).toEqual({
|
||||||
|
index: 1,
|
||||||
|
count: 9,
|
||||||
|
totalMinor: 2_820_000,
|
||||||
|
});
|
||||||
|
expect(parseInstallment("")).toBeNull();
|
||||||
|
});
|
||||||
|
it("işyerine göre alt kategori ve banka açıklaması", () => {
|
||||||
|
expect(refineCategory("faturalar", "CK Boğaziçi Elektrik")).toBe("faturalar.elektrik");
|
||||||
|
expect(refineCategory("faturalar", "İGDAŞ")).toBe("faturalar.dogalgaz");
|
||||||
|
expect(refineCategory("faturalar", "Vodafone")).toBe("faturalar.telefon");
|
||||||
|
expect(refineCategory("ulasim", "Petrol Ofisi")).toBe("ulasim.yakit");
|
||||||
|
expect(refineCategory("ulasim", "İstanbulkart")).toBe("ulasim.toplu-tasima");
|
||||||
|
expect(refineCategory("saglik", "Yaşam Eczanesi")).toBe("saglik.ilac");
|
||||||
|
expect(refineCategory("market.gida", "BİM")).toBe("market.gida");
|
||||||
|
expect(bankExpenseCategory("Banka kartı harcaması", "Banka kartı – börekçi")).toBe(
|
||||||
|
"eglence.kafe",
|
||||||
|
);
|
||||||
|
expect(bankExpenseCategory("KMH faiz ve vergi", "KMH KKDF / BSMV")).toBe("banka.vergi");
|
||||||
|
expect(bankExpenseCategory("KMH faiz ve vergi", "KMH faiz tahakkuku")).toBe("banka.faiz");
|
||||||
|
expect(bankExpenseCategory("Havale/EFT (kişi)", "Giden havale – yemek")).toBe(
|
||||||
|
"eglence.disarida-yemek",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseCardSheet", () => {
|
||||||
|
const rows = [
|
||||||
|
CARD_HEAD,
|
||||||
|
[
|
||||||
|
serial("2025-12-28"),
|
||||||
|
serial("2026-01-28"),
|
||||||
|
"Elma Market",
|
||||||
|
"Market & gıda",
|
||||||
|
"Zorunlu",
|
||||||
|
"Fiziksel kart",
|
||||||
|
null,
|
||||||
|
98,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
serial("2025-12-29"),
|
||||||
|
serial("2026-01-28"),
|
||||||
|
"Elma Market",
|
||||||
|
"Market & gıda",
|
||||||
|
"Zorunlu",
|
||||||
|
"Fiziksel kart",
|
||||||
|
null,
|
||||||
|
98,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
serial("2025-12-29"),
|
||||||
|
serial("2026-01-28"),
|
||||||
|
"Elma Market",
|
||||||
|
"Market & gıda",
|
||||||
|
"Zorunlu",
|
||||||
|
"Fiziksel kart",
|
||||||
|
null,
|
||||||
|
98,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
serial("2025-12-06"),
|
||||||
|
serial("2026-01-28"),
|
||||||
|
"Koçtaş (taksit)",
|
||||||
|
"Ev / tadilat / yapı market",
|
||||||
|
"Beklenmeyen",
|
||||||
|
"Fiziksel kart",
|
||||||
|
"2/7 (3.465,95 TL)",
|
||||||
|
495.14,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
serial("2026-01-05"),
|
||||||
|
serial("2026-01-28"),
|
||||||
|
"KoçAilem kampanya iadesi",
|
||||||
|
"Kampanya iadesi",
|
||||||
|
"İstek",
|
||||||
|
"Fiziksel kart",
|
||||||
|
null,
|
||||||
|
-50,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
serial("2026-01-06"),
|
||||||
|
serial("2026-01-28"),
|
||||||
|
"Test Esnaf",
|
||||||
|
"Tanımsız esnaf",
|
||||||
|
null,
|
||||||
|
"Fiziksel kart",
|
||||||
|
null,
|
||||||
|
120,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
serial("2026-01-07"),
|
||||||
|
serial("2026-01-28"),
|
||||||
|
"Simitçi",
|
||||||
|
"Börek, simit, pastane, büfe",
|
||||||
|
"İstek",
|
||||||
|
"Dijital kart",
|
||||||
|
null,
|
||||||
|
35.5,
|
||||||
|
],
|
||||||
|
[null, null, null, null, null, null, null, null],
|
||||||
|
];
|
||||||
|
const out = parseCardSheet(rows);
|
||||||
|
it("harcama çıkış (−), iade giriş (+) ve gelir 'İade'; boş satır atlanır", () => {
|
||||||
|
expect(out).toHaveLength(7);
|
||||||
|
expect(out[0]).toMatchObject({
|
||||||
|
kind: "expense",
|
||||||
|
amountMinor: -9_800,
|
||||||
|
categoryKey: "market.gida",
|
||||||
|
kakeiboGroup: "ZORUNLU",
|
||||||
|
});
|
||||||
|
expect(out[4]).toMatchObject({
|
||||||
|
kind: "income",
|
||||||
|
amountMinor: 5_000,
|
||||||
|
categoryKey: "iade",
|
||||||
|
kakeiboGroup: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("aynı gün aynı tutar ayrı anahtar alır; taksit kesim tarihinde, plan anahtarıyla", () => {
|
||||||
|
expect(out[1]!.key).not.toBe(out[2]!.key);
|
||||||
|
expect(out[3]).toMatchObject({
|
||||||
|
date: "2026-01-28",
|
||||||
|
categoryKey: "ev",
|
||||||
|
kakeiboGroup: "BEKLENMEYEN",
|
||||||
|
installment: { index: 2, count: 7, totalMinor: 346_595 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("tanımsız esnaf grupsuz ve incelemeye düşer", () => {
|
||||||
|
expect(out[5]).toMatchObject({
|
||||||
|
categoryKey: "diger-gider",
|
||||||
|
kakeiboGroup: null,
|
||||||
|
review: "ungrouped",
|
||||||
|
});
|
||||||
|
expect(out[6]).toMatchObject({ categoryKey: "eglence.kafe", kakeiboGroup: "ISTEK" });
|
||||||
|
});
|
||||||
|
it("ekstre kontrolü: satır toplamı (harcama − iade) = ekstredeki harcama", () => {
|
||||||
|
const total = 98 * 3 + 495.14 - 50 + 120 + 35.5;
|
||||||
|
const ok = checkStatements(out, new Map([["2026-01-28", Math.round(total * 100)]]));
|
||||||
|
expect(ok).toEqual([
|
||||||
|
{
|
||||||
|
cut: "2026-01-28",
|
||||||
|
rowsMinor: Math.round(total * 100),
|
||||||
|
statementMinor: Math.round(total * 100),
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(checkStatements(out, new Map([["2026-01-28", 1]]))[0]!.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseBankSheet", () => {
|
||||||
|
const rows = [
|
||||||
|
BANK_HEAD,
|
||||||
|
[serial("2025-12-31"), "Devreden bakiye", "Açılış", null, 1000, null],
|
||||||
|
[serial("2026-01-02"), "Yol parası ödemesi", "Yol parası", 200, 1200, null],
|
||||||
|
[serial("2026-01-03"), "ATM nakit çekim", "Nakit çekim (ATM)", -500, 700, null],
|
||||||
|
[
|
||||||
|
serial("2026-01-04"),
|
||||||
|
"Kredi kartı borç ödemesi",
|
||||||
|
"Kredi kartı ödemesi",
|
||||||
|
-330.79,
|
||||||
|
369.21,
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
[serial("2026-01-05"), "Banka kartı – börekçi", "Banka kartı harcaması", -45, 324.21, null],
|
||||||
|
[serial("2026-01-06"), "KMH KKDF / BSMV", "KMH faiz ve vergi", -10, 314.21, null],
|
||||||
|
[serial("2026-01-07"), "Altın alımı (Birikim hesabı)", "Altın", -100, 214.21, null],
|
||||||
|
[serial("2026-01-08"), "Maaş promosyonu", "Promosyon", 250, 464.21, null],
|
||||||
|
[serial("2026-01-08"), "Giden FAST (promosyon)", "Promosyon", -250, 214.21, null],
|
||||||
|
[serial("2026-01-09"), "Bilinmeyen", "Yeni tür", -14.21, 200, null],
|
||||||
|
];
|
||||||
|
const b = parseBankSheet(rows);
|
||||||
|
it("açılış, kapanış ve bakiye zinciri", () => {
|
||||||
|
expect(b.opening).toEqual({ date: "2025-12-31", balanceMinor: 100_000 });
|
||||||
|
expect(b.closingBalanceMinor).toBe(20_000);
|
||||||
|
expect(b.balanceMismatch).toBeNull();
|
||||||
|
});
|
||||||
|
it("türler: gelir, transfer (nakit/kart), gider, altın bekler, promosyon çifti atlanır", () => {
|
||||||
|
const by = (desc: string) => b.rows.find((r) => r.description === desc)!;
|
||||||
|
expect(by("Yol parası ödemesi")).toMatchObject({
|
||||||
|
kind: "income",
|
||||||
|
categoryKey: "kakeibo.yol",
|
||||||
|
amountMinor: 20_000,
|
||||||
|
});
|
||||||
|
expect(by("ATM nakit çekim")).toMatchObject({ kind: "transfer", counterRole: "cash" });
|
||||||
|
expect(by("Kredi kartı borç ödemesi")).toMatchObject({ kind: "transfer", counterRole: "card" });
|
||||||
|
expect(by("Banka kartı – börekçi")).toMatchObject({
|
||||||
|
kind: "expense",
|
||||||
|
categoryKey: "eglence.kafe",
|
||||||
|
});
|
||||||
|
expect(by("KMH KKDF / BSMV")).toMatchObject({ kind: "expense", categoryKey: "banka.vergi" });
|
||||||
|
expect(by("Altın alımı (Birikim hesabı)")).toMatchObject({
|
||||||
|
kind: "deferred",
|
||||||
|
counterRole: "gold",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
b.rows.filter((r) => r.source.type === "Promosyon").every((r) => r.kind === "skip"),
|
||||||
|
).toBe(true);
|
||||||
|
expect(by("Bilinmeyen")).toMatchObject({ kind: "expense", review: "unknownType" });
|
||||||
|
expect(firstCardPayment(b.rows)).toEqual({ date: "2026-01-04", amountMinor: 33_079 });
|
||||||
|
});
|
||||||
|
it("bakiye zinciri kırılırsa ilk uyuşmayan satır bildirilir", () => {
|
||||||
|
const broken = rows.map((r, i) => (i === 3 ? [...r.slice(0, 4), 999, null] : r));
|
||||||
|
expect(parseBankSheet(broken).balanceMismatch).toMatchObject({
|
||||||
|
line: 3,
|
||||||
|
expectedMinor: 70_000,
|
||||||
|
actualMinor: 99_900,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseStatementTotals", () => {
|
||||||
|
it("Özet 1. bölümünden kesim → ekstredeki harcama", () => {
|
||||||
|
const rows = [
|
||||||
|
["Kakeibo özeti"],
|
||||||
|
[],
|
||||||
|
["1. Kart harcaması"],
|
||||||
|
[
|
||||||
|
"Ekstre kesim",
|
||||||
|
"Zorunlu",
|
||||||
|
"İstek",
|
||||||
|
"Kültür",
|
||||||
|
"Beklenmeyen",
|
||||||
|
"Toplam",
|
||||||
|
"Ekstredeki harcama",
|
||||||
|
"Fark",
|
||||||
|
],
|
||||||
|
[serial("2026-01-28"), 1, 2, 3, 4, 10, 476.54, 0],
|
||||||
|
[serial("2026-03-01"), 1, 2, 3, 4, 10, 374.7, 0],
|
||||||
|
["Toplam"],
|
||||||
|
];
|
||||||
|
expect([...parseStatementTotals(rows)]).toEqual([
|
||||||
|
["2026-01-28", 47_654],
|
||||||
|
["2026-03-01", 37_470],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
import { isKakeiboGroup, type KakeiboGroup } from "../kakeibo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kakeibo çalışma kitabı içe aktarma motoru (Faz 17a). Saf fonksiyonlar; Excel okuma (SheetJS)
|
||||||
|
* dışarıda yapılır, burada ham satır dizileri ([[hücre, …], …]) işlenir.
|
||||||
|
*
|
||||||
|
* Sayfalar:
|
||||||
|
* "Kredi Kartı": İşlem tarihi | Ekstre kesim | İşyeri | Kategori | Kakeibo | Kart | Taksit | Tutar (TL)
|
||||||
|
* "Maaş Hesabı": Tarih | Açıklama | Tür | Tutar (TL) | Bakiye (TL) | Ay
|
||||||
|
* "Özet": 1. bölümde ekstre kesimi başına "Ekstredeki harcama" (doğrulama için).
|
||||||
|
*
|
||||||
|
* Tarihler Excel seri numarası (gün) olarak gelir ve gün olarak çözülür (saat dilimi kayması
|
||||||
|
* yok). Tutarlar kuruşa yuvarlanır. Kart satırında pozitif tutar harcama, negatif tutar iadedir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ImportRole = "card" | "bank" | "cash" | "extra" | "gold";
|
||||||
|
export type RowKind = "expense" | "income" | "transfer" | "skip" | "deferred";
|
||||||
|
|
||||||
|
/** Yeni oluşturulacak (varsayılan sette olmayan) kategoriler; anahtar `kakeibo.*`. */
|
||||||
|
export const IMPORT_CATEGORIES: {
|
||||||
|
key: string;
|
||||||
|
kind: "expense" | "income";
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
kakeiboGroup: KakeiboGroup | null;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: "kakeibo.sigorta",
|
||||||
|
kind: "expense",
|
||||||
|
name: "Sigorta",
|
||||||
|
icon: "heart-pulse",
|
||||||
|
color: "red",
|
||||||
|
kakeiboGroup: "ZORUNLU",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kakeibo.online",
|
||||||
|
kind: "expense",
|
||||||
|
name: "Online alışveriş",
|
||||||
|
icon: "shopping-basket",
|
||||||
|
color: "purple",
|
||||||
|
kakeiboGroup: "ISTEK",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kakeibo.elektronik",
|
||||||
|
kind: "expense",
|
||||||
|
name: "Elektronik ve hobi",
|
||||||
|
icon: "gamepad",
|
||||||
|
color: "indigo",
|
||||||
|
kakeiboGroup: "ISTEK",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kakeibo.kultur",
|
||||||
|
kind: "expense",
|
||||||
|
name: "Kitap ve kültür",
|
||||||
|
icon: "book",
|
||||||
|
color: "teal",
|
||||||
|
kakeiboGroup: "KULTUR",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kakeibo.prim",
|
||||||
|
kind: "income",
|
||||||
|
name: "Prim (maaş farkı)",
|
||||||
|
icon: "trending-up",
|
||||||
|
color: "green",
|
||||||
|
kakeiboGroup: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kakeibo.yol",
|
||||||
|
kind: "income",
|
||||||
|
name: "Yol parası",
|
||||||
|
icon: "bus",
|
||||||
|
color: "teal",
|
||||||
|
kakeiboGroup: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kakeibo.avans",
|
||||||
|
kind: "income",
|
||||||
|
name: "Avans",
|
||||||
|
icon: "briefcase",
|
||||||
|
color: "slate",
|
||||||
|
kakeiboGroup: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kakeibo.es-katkisi",
|
||||||
|
kind: "income",
|
||||||
|
name: "Eş katkısı",
|
||||||
|
icon: "coins",
|
||||||
|
color: "pink",
|
||||||
|
kakeiboGroup: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kakeibo.aile-destegi",
|
||||||
|
kind: "income",
|
||||||
|
name: "Aile desteği",
|
||||||
|
icon: "gift",
|
||||||
|
color: "amber",
|
||||||
|
kakeiboGroup: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export type ProposedRow = {
|
||||||
|
/** Sayfa ve sıra (1'den, başlık hariç). */
|
||||||
|
sheet: "card" | "bank";
|
||||||
|
line: number;
|
||||||
|
date: string;
|
||||||
|
/** Kart satırında ekstre kesimi. */
|
||||||
|
statementCut: string | null;
|
||||||
|
description: string;
|
||||||
|
/** İşaretli tutar: hesap açısından giriş (+) / çıkış (−), kuruş. */
|
||||||
|
amountMinor: number;
|
||||||
|
kind: RowKind;
|
||||||
|
/** Kategori varsayılan anahtarı (gelir/giderde). */
|
||||||
|
categoryKey: string | null;
|
||||||
|
/** Kaynağın Kakeibo grubu (yalnızca giderde; kategorinin grubundan farklıysa kayıtta değiştirilir). */
|
||||||
|
kakeiboGroup: KakeiboGroup | null;
|
||||||
|
/** Transferde karşı hesabın rolü (bank satırında). */
|
||||||
|
counterRole: ImportRole | null;
|
||||||
|
installment: { key: string; index: number; count: number; totalMinor: number } | null;
|
||||||
|
/** Kaynaktaki ham sınıflandırma (önizlemede gösterilir). */
|
||||||
|
source: { category: string; type: string };
|
||||||
|
/** Kullanıcının bakması gereken satır (grupsuz, işaret uyuşmazlığı, bilinmeyen tür). */
|
||||||
|
review: string | null;
|
||||||
|
/** Tekrar anahtarı (aynı gün aynı tutar ve açıklamada sıra numarası dahil). */
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Hücre yardımcıları ---
|
||||||
|
|
||||||
|
/** Excel seri günü → "YYYY-MM-DD" (1900 tarih sistemi, 1900-02-29 hatası dahil). */
|
||||||
|
export function excelDate(v: unknown): string | null {
|
||||||
|
if (typeof v === "number" && Number.isFinite(v)) {
|
||||||
|
const ms = Math.round((v - 25569) * 86_400_000);
|
||||||
|
const d = new Date(ms);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v)) return v.slice(0, 10);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toMinor = (v: unknown): number | null =>
|
||||||
|
typeof v === "number" && Number.isFinite(v) ? Math.round(v * 100) : null;
|
||||||
|
|
||||||
|
const text = (v: unknown) => (v === null || v === undefined ? "" : String(v).trim());
|
||||||
|
|
||||||
|
const fold = (s: string) =>
|
||||||
|
s
|
||||||
|
.toLocaleLowerCase("tr-TR")
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/ı/g, "i")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// --- Kategori eşleştirme ---
|
||||||
|
|
||||||
|
const CARD_CATEGORY: Record<string, string> = {
|
||||||
|
"market & gida": "market.gida",
|
||||||
|
"borek, simit, pastane, bufe": "eglence.kafe",
|
||||||
|
"disarida yemek & paket servis": "eglence.disarida-yemek",
|
||||||
|
"dijital abonelik & yazilim": "abonelikler",
|
||||||
|
"elektronik, beyaz esya & hobi": "kakeibo.elektronik",
|
||||||
|
"ev / tadilat / yapi market": "ev",
|
||||||
|
"faturalar (elektrik, su, gaz, internet, gsm)": "faturalar",
|
||||||
|
"giyim & ayakkabi": "giyim",
|
||||||
|
"hayat/kredi sigortasi": "kakeibo.sigorta",
|
||||||
|
"kitap & kultur & eglence": "kakeibo.kultur",
|
||||||
|
"kozmetik & kisisel bakim": "market.kisisel-bakim",
|
||||||
|
"online alisveris": "kakeibo.online",
|
||||||
|
"saglik (eczane, hastane, doktor)": "saglik",
|
||||||
|
"tanimsiz esnaf": "diger-gider",
|
||||||
|
"tatil & seyahat": "eglence.tatil",
|
||||||
|
"ulasim & yakit": "ulasim",
|
||||||
|
"cocuk, okul & egitim": "cocuk",
|
||||||
|
"belediye & resmi": "vergi",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Üst kategoriyi işyerine göre alt kategoriye inceltir (fatura türü, yakıt/toplu taşıma, eczane). */
|
||||||
|
export function refineCategory(key: string, merchant: string): string {
|
||||||
|
const m = fold(merchant);
|
||||||
|
if (key === "faturalar") {
|
||||||
|
if (/ck bogazici|enerjisa|bedas|ayedas|elektrik/.test(m)) return "faturalar.elektrik";
|
||||||
|
if (/igdas|dogalgaz|dogal gaz/.test(m)) return "faturalar.dogalgaz";
|
||||||
|
if (/iski|\bsu\b/.test(m)) return "faturalar.su";
|
||||||
|
if (/turknet|superonline|ttnet|turk telekom|internet/.test(m)) return "faturalar.internet";
|
||||||
|
if (/vodafone|turkcell|gsm/.test(m)) return "faturalar.telefon";
|
||||||
|
}
|
||||||
|
if (key === "ulasim") {
|
||||||
|
if (/petrol|opet|shell|\bbp\b|total|akaryakit|yakit/.test(m)) return "ulasim.yakit";
|
||||||
|
if (/istanbulkart|taksi|marti|bitaksi|metro|otobus/.test(m)) return "ulasim.toplu-tasima";
|
||||||
|
}
|
||||||
|
if (key === "saglik" && /eczane/.test(m)) return "saglik.ilac";
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Taksit metni: "2/7 (3.465,95 TL)" → sıra, adet, toplam. */
|
||||||
|
export function parseInstallment(
|
||||||
|
v: unknown,
|
||||||
|
): { index: number; count: number; totalMinor: number } | null {
|
||||||
|
const m = /^(\d+)\s*\/\s*(\d+)\s*\(([\d.]+),(\d{2})\s*TL\)/.exec(text(v));
|
||||||
|
if (!m) return null;
|
||||||
|
return {
|
||||||
|
index: Number(m[1]),
|
||||||
|
count: Number(m[2]),
|
||||||
|
totalMinor: Number(m[3]!.replace(/\./g, "")) * 100 + Number(m[4]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupOf = (v: unknown): KakeiboGroup | null => {
|
||||||
|
const g = fold(text(v));
|
||||||
|
const map: Record<string, KakeiboGroup> = {
|
||||||
|
zorunlu: "ZORUNLU",
|
||||||
|
istek: "ISTEK",
|
||||||
|
kultur: "KULTUR",
|
||||||
|
beklenmeyen: "BEKLENMEYEN",
|
||||||
|
};
|
||||||
|
const x = map[g];
|
||||||
|
return x && isKakeiboGroup(x) ? x : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Aynı gün + tutar + açıklama tekrarlarını ayırmak için sıra numaralı anahtar. */
|
||||||
|
function keyer() {
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
return (base: string) => {
|
||||||
|
const n = (seen.get(base) ?? 0) + 1;
|
||||||
|
seen.set(base, n);
|
||||||
|
return `${base}#${n}`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kart sayfası ---
|
||||||
|
|
||||||
|
export function parseCardSheet(rows: readonly (readonly unknown[])[]): ProposedRow[] {
|
||||||
|
const out: ProposedRow[] = [];
|
||||||
|
const key = keyer();
|
||||||
|
rows.slice(1).forEach((r, i) => {
|
||||||
|
const date = excelDate(r[0]);
|
||||||
|
const amount = toMinor(r[7]);
|
||||||
|
if (!date || amount === null || amount === 0) return;
|
||||||
|
const cut = excelDate(r[1]);
|
||||||
|
const merchant = text(r[2]);
|
||||||
|
const category = text(r[3]);
|
||||||
|
const inst = parseInstallment(r[6]);
|
||||||
|
const refund = amount < 0;
|
||||||
|
const mapped = CARD_CATEGORY[fold(category)];
|
||||||
|
const isCampaign = fold(category) === "kampanya iadesi";
|
||||||
|
const group = groupOf(r[4]);
|
||||||
|
let review: string | null = null;
|
||||||
|
let categoryKey: string | null;
|
||||||
|
let kind: RowKind;
|
||||||
|
if (refund || isCampaign) {
|
||||||
|
kind = "income";
|
||||||
|
categoryKey = "iade";
|
||||||
|
} else {
|
||||||
|
kind = "expense";
|
||||||
|
categoryKey = mapped ? refineCategory(mapped, merchant) : "diger-gider";
|
||||||
|
if (!mapped) review = "unknownCategory";
|
||||||
|
else if (categoryKey === "diger-gider" && !group) review = "ungrouped";
|
||||||
|
}
|
||||||
|
// Taksit: ekstreye yansıdığı kesim tarihinde; plan anahtarı işyeri + toplam + adet.
|
||||||
|
const effectiveDate = inst && cut ? cut : date;
|
||||||
|
out.push({
|
||||||
|
sheet: "card",
|
||||||
|
line: i + 1,
|
||||||
|
date: effectiveDate,
|
||||||
|
statementCut: cut,
|
||||||
|
description: merchant,
|
||||||
|
amountMinor: -amount,
|
||||||
|
kind,
|
||||||
|
categoryKey,
|
||||||
|
kakeiboGroup: kind === "expense" ? group : null,
|
||||||
|
counterRole: null,
|
||||||
|
installment: inst
|
||||||
|
? { key: `${fold(merchant)}|${inst.totalMinor}|${inst.count}`, ...inst }
|
||||||
|
: null,
|
||||||
|
source: { category, type: text(r[5]) },
|
||||||
|
review,
|
||||||
|
key: key(`card|${effectiveDate}|${amount}|${fold(merchant)}`),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Maaş hesabı sayfası ---
|
||||||
|
|
||||||
|
type BankRule =
|
||||||
|
| { kind: "income" | "expense"; categoryKey: string }
|
||||||
|
| { kind: "transfer"; counterRole: ImportRole }
|
||||||
|
| { kind: "deferred"; reason: string }
|
||||||
|
| { kind: "opening" }
|
||||||
|
| { kind: "promo" };
|
||||||
|
|
||||||
|
const BANK_TYPE: Record<string, BankRule> = {
|
||||||
|
acilis: { kind: "opening" },
|
||||||
|
maas: { kind: "income", categoryKey: "maas" },
|
||||||
|
"prim (maas farki)": { kind: "income", categoryKey: "kakeibo.prim" },
|
||||||
|
"yol parasi": { kind: "income", categoryKey: "kakeibo.yol" },
|
||||||
|
avans: { kind: "income", categoryKey: "kakeibo.avans" },
|
||||||
|
"es katkisi": { kind: "income", categoryKey: "kakeibo.es-katkisi" },
|
||||||
|
"aile destegi": { kind: "income", categoryKey: "kakeibo.aile-destegi" },
|
||||||
|
"diger giris": { kind: "income", categoryKey: "diger-gelir" },
|
||||||
|
promosyon: { kind: "promo" },
|
||||||
|
"kredi karti odemesi": { kind: "transfer", counterRole: "card" },
|
||||||
|
"nakit cekim (atm)": { kind: "transfer", counterRole: "cash" },
|
||||||
|
"hesaplar arasi": { kind: "transfer", counterRole: "extra" },
|
||||||
|
altin: { kind: "deferred", reason: "gold" },
|
||||||
|
"kmh faiz ve vergi": { kind: "expense", categoryKey: "banka.faiz" },
|
||||||
|
"banka ucreti": { kind: "expense", categoryKey: "banka.ucret" },
|
||||||
|
"banka karti harcamasi": { kind: "expense", categoryKey: "diger-gider" },
|
||||||
|
"havale/eft (kisi)": { kind: "expense", categoryKey: "diger-gider" },
|
||||||
|
"ev bakim/onarim": { kind: "expense", categoryKey: "ev" },
|
||||||
|
"cocuk ve okul": { kind: "expense", categoryKey: "cocuk" },
|
||||||
|
aidat: { kind: "expense", categoryKey: "aidat" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Banka kartı ve havale açıklamasından kategori önerisi. */
|
||||||
|
export function bankExpenseCategory(type: string, description: string): string {
|
||||||
|
const d = fold(description);
|
||||||
|
const t = fold(type);
|
||||||
|
if (t === "kmh faiz ve vergi") return /kkdf|bsmv/.test(d) ? "banka.vergi" : "banka.faiz";
|
||||||
|
if (/borekci|borek|simit|pastane|otomat|kahve|bufe/.test(d)) return "eglence.kafe";
|
||||||
|
if (/market/.test(d)) return "market.gida";
|
||||||
|
if (/saglik|eczane/.test(d)) return "saglik";
|
||||||
|
if (/istanbulkart/.test(d)) return "ulasim.toplu-tasima";
|
||||||
|
if (/yemek/.test(d)) return "eglence.disarida-yemek";
|
||||||
|
return "diger-gider";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BankSheet = {
|
||||||
|
rows: ProposedRow[];
|
||||||
|
/** Açılış satırının bakiyesi ve tarihi (dosyada varsa). */
|
||||||
|
opening: { date: string; balanceMinor: number } | null;
|
||||||
|
/** Bakiye sütununa göre zincir kontrolü: ilk uyuşmayan satır (yoksa null). */
|
||||||
|
balanceMismatch: { line: number; expectedMinor: number; actualMinor: number } | null;
|
||||||
|
closingBalanceMinor: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseBankSheet(rows: readonly (readonly unknown[])[]): BankSheet {
|
||||||
|
const out: ProposedRow[] = [];
|
||||||
|
const key = keyer();
|
||||||
|
let opening: BankSheet["opening"] = null;
|
||||||
|
let running: number | null = null;
|
||||||
|
let mismatch: BankSheet["balanceMismatch"] = null;
|
||||||
|
let closing: number | null = null;
|
||||||
|
const parsed = rows
|
||||||
|
.slice(1)
|
||||||
|
.map((r, i) => ({ r, line: i + 1 }))
|
||||||
|
.filter(({ r }) => excelDate(r[0]));
|
||||||
|
// Aynı tutarda giriş/çıkışı olan promosyon çiftleri net etkisizdir (atlanır).
|
||||||
|
const promo = parsed.filter(({ r }) => fold(text(r[2])) === "promosyon");
|
||||||
|
const promoNet = promo.reduce((a, { r }) => a + (toMinor(r[3]) ?? 0), 0);
|
||||||
|
for (const { r, line } of parsed) {
|
||||||
|
const date = excelDate(r[0])!;
|
||||||
|
const type = text(r[2]);
|
||||||
|
const desc = text(r[1]);
|
||||||
|
const amount = toMinor(r[3]);
|
||||||
|
const balance = toMinor(r[4]);
|
||||||
|
const rule = BANK_TYPE[fold(type)];
|
||||||
|
if (rule?.kind === "opening") {
|
||||||
|
if (balance !== null) {
|
||||||
|
opening = { date, balanceMinor: balance };
|
||||||
|
running = balance;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (amount === null || amount === 0) continue;
|
||||||
|
if (running !== null) running += amount;
|
||||||
|
if (balance !== null) {
|
||||||
|
if (running !== null && running !== balance && !mismatch)
|
||||||
|
mismatch = { line, expectedMinor: running, actualMinor: balance };
|
||||||
|
running = balance;
|
||||||
|
closing = balance;
|
||||||
|
}
|
||||||
|
let kind: RowKind;
|
||||||
|
let categoryKey: string | null = null;
|
||||||
|
let counterRole: ImportRole | null = null;
|
||||||
|
let review: string | null = null;
|
||||||
|
if (!rule) {
|
||||||
|
kind = amount > 0 ? "income" : "expense";
|
||||||
|
categoryKey = amount > 0 ? "diger-gelir" : "diger-gider";
|
||||||
|
review = "unknownType";
|
||||||
|
} else if (rule.kind === "promo") {
|
||||||
|
if (promoNet === 0) kind = "skip";
|
||||||
|
else {
|
||||||
|
kind = amount > 0 ? "income" : "expense";
|
||||||
|
categoryKey = amount > 0 ? "ek-gelir" : "diger-gider";
|
||||||
|
review = "promo";
|
||||||
|
}
|
||||||
|
} else if (rule.kind === "transfer") {
|
||||||
|
kind = "transfer";
|
||||||
|
counterRole = rule.counterRole;
|
||||||
|
} else if (rule.kind === "deferred") {
|
||||||
|
kind = "deferred";
|
||||||
|
counterRole = "gold";
|
||||||
|
} else {
|
||||||
|
kind = rule.kind;
|
||||||
|
categoryKey =
|
||||||
|
rule.kind === "expense" && (rule.categoryKey === "diger-gider" || type.startsWith("KMH"))
|
||||||
|
? bankExpenseCategory(type, desc)
|
||||||
|
: rule.categoryKey;
|
||||||
|
// Yön uyuşmazlığı (ör. gider türünde giriş): kullanıcı baksın.
|
||||||
|
if ((rule.kind === "income") !== amount > 0) review = "signMismatch";
|
||||||
|
else if (categoryKey === "diger-gider") review = "ungrouped";
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
sheet: "bank",
|
||||||
|
line,
|
||||||
|
date,
|
||||||
|
statementCut: null,
|
||||||
|
description: desc,
|
||||||
|
amountMinor: amount,
|
||||||
|
kind,
|
||||||
|
categoryKey,
|
||||||
|
kakeiboGroup: null,
|
||||||
|
counterRole,
|
||||||
|
installment: null,
|
||||||
|
source: { category: "", type },
|
||||||
|
review,
|
||||||
|
key: key(`bank|${date}|${amount}|${fold(desc)}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { rows: out, opening, balanceMismatch: mismatch, closingBalanceMinor: closing };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Özet: ekstre toplamları ---
|
||||||
|
|
||||||
|
/** "Özet" sayfasının 1. bölümünden ekstre kesimi → ekstredeki harcama (kuruş). */
|
||||||
|
export function parseStatementTotals(rows: readonly (readonly unknown[])[]): Map<string, number> {
|
||||||
|
const out = new Map<string, number>();
|
||||||
|
const start = rows.findIndex((r) => fold(text(r[0])) === "ekstre kesim");
|
||||||
|
if (start < 0) return out;
|
||||||
|
for (const r of rows.slice(start + 1)) {
|
||||||
|
const cut = excelDate(r[0]);
|
||||||
|
if (!cut) break;
|
||||||
|
const total = toMinor(r[6]);
|
||||||
|
if (total !== null) out.set(cut, total);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatementCheck = {
|
||||||
|
cut: string;
|
||||||
|
rowsMinor: number;
|
||||||
|
statementMinor: number | null;
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Her ekstre için satır toplamı (harcama − iade) = ekstredeki harcama. */
|
||||||
|
export function checkStatements(
|
||||||
|
rows: readonly ProposedRow[],
|
||||||
|
totals: ReadonlyMap<string, number>,
|
||||||
|
): StatementCheck[] {
|
||||||
|
const sums = new Map<string, number>();
|
||||||
|
for (const r of rows)
|
||||||
|
if (r.sheet === "card" && r.statementCut)
|
||||||
|
sums.set(r.statementCut, (sums.get(r.statementCut) ?? 0) - r.amountMinor);
|
||||||
|
return [...sums.entries()]
|
||||||
|
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||||
|
.map(([cut, rowsMinor]) => {
|
||||||
|
const statementMinor = totals.get(cut) ?? null;
|
||||||
|
return { cut, rowsMinor, statementMinor, ok: statementMinor === rowsMinor };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Banka hesabından karta yapılan ilk ödeme: kartın açılış borcu önerisi (kuruş, pozitif). */
|
||||||
|
export function firstCardPayment(
|
||||||
|
rows: readonly ProposedRow[],
|
||||||
|
): { date: string; amountMinor: number } | null {
|
||||||
|
const r = rows.find(
|
||||||
|
(x) => x.sheet === "bank" && x.kind === "transfer" && x.counterRole === "card",
|
||||||
|
);
|
||||||
|
return r ? { date: r.date, amountMinor: -r.amountMinor } : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
classify,
|
||||||
|
classifyAmounts,
|
||||||
|
resolveKakeibo,
|
||||||
|
summarizeGroups,
|
||||||
|
type KakeiboCategory,
|
||||||
|
} from "./kakeibo";
|
||||||
|
|
||||||
|
const cat = (
|
||||||
|
id: string,
|
||||||
|
parentId: string | null,
|
||||||
|
kakeiboGroup: KakeiboCategory["kakeiboGroup"] = null,
|
||||||
|
bankCost = false,
|
||||||
|
): KakeiboCategory => ({ id, parentId, kakeiboGroup, bankCost });
|
||||||
|
|
||||||
|
describe("resolveKakeibo", () => {
|
||||||
|
const map = resolveKakeibo([
|
||||||
|
cat("market", null, "ZORUNLU"),
|
||||||
|
cat("gida", "market"),
|
||||||
|
cat("bakim", "market", "ISTEK"),
|
||||||
|
cat("banka", null, null, true),
|
||||||
|
cat("faiz", "banka"),
|
||||||
|
cat("diger", null),
|
||||||
|
// Döngü: kendine üst (bozuk veri) sonsuz döngüye girmemeli.
|
||||||
|
cat("dongu", "dongu"),
|
||||||
|
]);
|
||||||
|
it("alt kategori grubu üstten devralır; kendi değeri önceliklidir", () => {
|
||||||
|
expect(map.get("gida")).toEqual({ group: "ZORUNLU", inherited: true, bankCost: false });
|
||||||
|
expect(map.get("bakim")).toEqual({ group: "ISTEK", inherited: false, bankCost: false });
|
||||||
|
expect(map.get("market")!.inherited).toBe(false);
|
||||||
|
});
|
||||||
|
it("banka maliyeti alt kategorilere geçer; grupsuz ve döngülü kategori güvenli", () => {
|
||||||
|
expect(map.get("faiz")!.bankCost).toBe(true);
|
||||||
|
expect(map.get("diger")).toEqual({ group: null, inherited: false, bankCost: false });
|
||||||
|
expect(map.get("dongu")!.group).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("classify", () => {
|
||||||
|
const map = resolveKakeibo([cat("market", null, "ZORUNLU"), cat("faiz", null, null, true)]);
|
||||||
|
it("işlem bazında grup kategoriyi ezer; banka maliyeti her zaman banka maliyeti", () => {
|
||||||
|
expect(classify(map.get("market"), null)).toBe("ZORUNLU");
|
||||||
|
expect(classify(map.get("market"), "ISTEK")).toBe("ISTEK");
|
||||||
|
expect(classify(map.get("faiz"), "ISTEK")).toBe("BANK_COST");
|
||||||
|
expect(classify(undefined, null)).toBe("UNGROUPED");
|
||||||
|
expect(classify(undefined, "KULTUR")).toBe("KULTUR");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("summarizeGroups", () => {
|
||||||
|
it("banka maliyeti harcamaya ve paylara girmez; grupsuz harcamaya girer", () => {
|
||||||
|
const s = summarizeGroups([
|
||||||
|
{ cls: "ZORUNLU", amountMinor: 4_600 },
|
||||||
|
{ cls: "ISTEK", amountMinor: 3_900 },
|
||||||
|
{ cls: "KULTUR", amountMinor: 150 },
|
||||||
|
{ cls: "BEKLENMEYEN", amountMinor: 1_250 },
|
||||||
|
{ cls: "UNGROUPED", amountMinor: 100 },
|
||||||
|
{ cls: "BANK_COST", amountMinor: 999 },
|
||||||
|
]);
|
||||||
|
expect(s.spendingMinor).toBe(10_000);
|
||||||
|
expect(s.bankCostMinor).toBe(999);
|
||||||
|
expect(s.shares).toEqual({ ZORUNLU: 46, ISTEK: 39, KULTUR: 1.5, BEKLENMEYEN: 12.5 });
|
||||||
|
expect(s.ungroupedShare).toBe(1);
|
||||||
|
});
|
||||||
|
it("harcama yoksa paylar 0 (sıfıra bölme yok)", () => {
|
||||||
|
const s = summarizeGroups([{ cls: "BANK_COST", amountMinor: 50 }]);
|
||||||
|
expect(s.spendingMinor).toBe(0);
|
||||||
|
expect(s.shares.ZORUNLU).toBe(0);
|
||||||
|
expect(s.ungroupedShare).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("classifyAmounts", () => {
|
||||||
|
const resolved = resolveKakeibo([
|
||||||
|
cat("market", null, "ZORUNLU"),
|
||||||
|
cat("faiz", null, null, true),
|
||||||
|
cat("diger", null),
|
||||||
|
]);
|
||||||
|
it("değiştirilen tutar seçilen gruba taşınır, kategori toplamı korunur", () => {
|
||||||
|
const out = classifyAmounts(
|
||||||
|
new Map([
|
||||||
|
["market", 10_000],
|
||||||
|
["faiz", 500],
|
||||||
|
["diger", 300],
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
{ categoryId: "market", group: "ISTEK", amountMinor: 2_500 },
|
||||||
|
// Banka maliyetinde grup değiştirme etkisiz.
|
||||||
|
{ categoryId: "faiz", group: "ISTEK", amountMinor: 500 },
|
||||||
|
// Kategori toplamını aşan değiştirme kırpılır.
|
||||||
|
{ categoryId: "diger", group: "KULTUR", amountMinor: 900 },
|
||||||
|
],
|
||||||
|
resolved,
|
||||||
|
);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ categoryId: "market", cls: "ISTEK", amountMinor: 2_500 },
|
||||||
|
{ categoryId: "market", cls: "ZORUNLU", amountMinor: 7_500 },
|
||||||
|
{ categoryId: "faiz", cls: "BANK_COST", amountMinor: 500 },
|
||||||
|
{ categoryId: "diger", cls: "KULTUR", amountMinor: 300 },
|
||||||
|
]);
|
||||||
|
const s = summarizeGroups(out);
|
||||||
|
expect(s.spendingMinor + s.bankCostMinor).toBe(10_800);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Kakeibo (家計簿) dört harcama grubu. Saf fonksiyonlar.
|
||||||
|
*
|
||||||
|
* Her gider tam olarak bir gruba girer: ZORUNLU (ihtiyaç), ISTEK (opsiyonel), KULTUR,
|
||||||
|
* BEKLENMEYEN. Grup kategoriden gelir (kategori → yoksa üst kategoriden devralınır); kayıt
|
||||||
|
* sırasında işlem ya da bölünmüş işlem kalemi bazında değiştirilebilir ("kategoriyi ürün değil
|
||||||
|
* amaç belirler").
|
||||||
|
*
|
||||||
|
* Banka maliyeti (KMH faizi, KKDF/BSMV, kart/hesap ücretleri) harcama değildir: dört gruba ve
|
||||||
|
* Kakeibo harcama toplamına girmez, ayrı gösterilir. Kategori ya da üst kategorisi "banka
|
||||||
|
* maliyeti" işaretliyse kalem grup değiştirilse bile banka maliyetidir.
|
||||||
|
*
|
||||||
|
* Grubu belirlenemeyen gider "grupsuz"dur: harcama toplamına girer, kullanıcıdan grup seçmesi
|
||||||
|
* istenir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const KAKEIBO_GROUPS = ["ZORUNLU", "ISTEK", "KULTUR", "BEKLENMEYEN"] as const;
|
||||||
|
export type KakeiboGroup = (typeof KAKEIBO_GROUPS)[number];
|
||||||
|
|
||||||
|
export const isKakeiboGroup = (x: unknown): x is KakeiboGroup =>
|
||||||
|
KAKEIBO_GROUPS.includes(x as KakeiboGroup);
|
||||||
|
|
||||||
|
/** Harcama sınıfı: dört grup, banka maliyeti ya da grupsuz. */
|
||||||
|
export type SpendingClass = KakeiboGroup | "BANK_COST" | "UNGROUPED";
|
||||||
|
|
||||||
|
export type KakeiboCategory = {
|
||||||
|
id: string;
|
||||||
|
parentId: string | null;
|
||||||
|
kakeiboGroup: KakeiboGroup | null;
|
||||||
|
bankCost: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedKakeibo = {
|
||||||
|
/** Etkin grup (kendisi ya da en yakın üst kategori); yoksa null. */
|
||||||
|
group: KakeiboGroup | null;
|
||||||
|
/** Grup üst kategoriden mi geliyor? */
|
||||||
|
inherited: boolean;
|
||||||
|
/** Kendisi ya da bir üst kategorisi banka maliyeti. */
|
||||||
|
bankCost: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Kategori başına etkin grup ve banka maliyeti (döngüye karşı korumalı). */
|
||||||
|
export function resolveKakeibo(
|
||||||
|
categories: readonly KakeiboCategory[],
|
||||||
|
): Map<string, ResolvedKakeibo> {
|
||||||
|
const byId = new Map(categories.map((c) => [c.id, c]));
|
||||||
|
const out = new Map<string, ResolvedKakeibo>();
|
||||||
|
for (const c of categories) {
|
||||||
|
let group: KakeiboGroup | null = null;
|
||||||
|
let inherited = false;
|
||||||
|
let bankCost = false;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let cur: KakeiboCategory | undefined = c;
|
||||||
|
while (cur && !seen.has(cur.id)) {
|
||||||
|
seen.add(cur.id);
|
||||||
|
if (cur.bankCost) bankCost = true;
|
||||||
|
if (!group && cur.kakeiboGroup) {
|
||||||
|
group = cur.kakeiboGroup;
|
||||||
|
inherited = cur !== c;
|
||||||
|
}
|
||||||
|
cur = cur.parentId ? byId.get(cur.parentId) : undefined;
|
||||||
|
}
|
||||||
|
out.set(c.id, { group, inherited, bankCost });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bir gider kaleminin sınıfı: banka maliyeti > işlem bazında grup > kategorinin grubu. */
|
||||||
|
export function classify(
|
||||||
|
category: ResolvedKakeibo | undefined,
|
||||||
|
override: KakeiboGroup | null,
|
||||||
|
): SpendingClass {
|
||||||
|
if (category?.bankCost) return "BANK_COST";
|
||||||
|
return override ?? category?.group ?? "UNGROUPED";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupSummary = {
|
||||||
|
/** Grup başına tutar. */
|
||||||
|
groups: Record<KakeiboGroup, number>;
|
||||||
|
ungroupedMinor: number;
|
||||||
|
bankCostMinor: number;
|
||||||
|
/** Kakeibo harcaması: dört grup + grupsuz (banka maliyeti hariç). */
|
||||||
|
spendingMinor: number;
|
||||||
|
/** Grubun harcamadaki payı (bir ondalık yüzde; harcama 0 ise 0). */
|
||||||
|
shares: Record<KakeiboGroup, number>;
|
||||||
|
ungroupedShare: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pct = (part: number, total: number) =>
|
||||||
|
total > 0 ? Math.round((part * 1000) / total) / 10 : 0;
|
||||||
|
|
||||||
|
export function summarizeGroups(
|
||||||
|
items: readonly { cls: SpendingClass; amountMinor: number }[],
|
||||||
|
): GroupSummary {
|
||||||
|
const groups = Object.fromEntries(KAKEIBO_GROUPS.map((g) => [g, 0])) as Record<
|
||||||
|
KakeiboGroup,
|
||||||
|
number
|
||||||
|
>;
|
||||||
|
let ungrouped = 0;
|
||||||
|
let bank = 0;
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.cls === "BANK_COST") bank += it.amountMinor;
|
||||||
|
else if (it.cls === "UNGROUPED") ungrouped += it.amountMinor;
|
||||||
|
else groups[it.cls] += it.amountMinor;
|
||||||
|
}
|
||||||
|
const spending = KAKEIBO_GROUPS.reduce((a, g) => a + groups[g], 0) + ungrouped;
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
ungroupedMinor: ungrouped,
|
||||||
|
bankCostMinor: bank,
|
||||||
|
spendingMinor: spending,
|
||||||
|
shares: Object.fromEntries(KAKEIBO_GROUPS.map((g) => [g, pct(groups[g], spending)])) as Record<
|
||||||
|
KakeiboGroup,
|
||||||
|
number
|
||||||
|
>,
|
||||||
|
ungroupedShare: pct(ungrouped, spending),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Varsayılan kategori setinin Kakeibo eşlemesi (anahtar → grup). Alt kategori kendi değeri
|
||||||
|
* yoksa üstünden devralır. Listede olmayan varsayılanlar (ör. "Diğer") grupsuz kalır.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_KAKEIBO_GROUPS: Record<string, KakeiboGroup> = {
|
||||||
|
market: "ZORUNLU",
|
||||||
|
"market.kisisel-bakim": "ISTEK",
|
||||||
|
faturalar: "ZORUNLU",
|
||||||
|
kira: "ZORUNLU",
|
||||||
|
aidat: "ZORUNLU",
|
||||||
|
ulasim: "ZORUNLU",
|
||||||
|
saglik: "ZORUNLU",
|
||||||
|
egitim: "KULTUR",
|
||||||
|
giyim: "ISTEK",
|
||||||
|
eglence: "ISTEK",
|
||||||
|
abonelikler: "ISTEK",
|
||||||
|
ev: "BEKLENMEYEN",
|
||||||
|
cocuk: "KULTUR",
|
||||||
|
"evcil-hayvan": "ZORUNLU",
|
||||||
|
hediye: "BEKLENMEYEN",
|
||||||
|
vergi: "ZORUNLU",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Banka maliyeti işaretli varsayılan kategoriler (alt kategoriler devralır). */
|
||||||
|
export const DEFAULT_BANK_COST_KEYS = ["banka"] as const;
|
||||||
|
|
||||||
|
export type ClassifiedAmount = { categoryId: string; cls: SpendingClass; amountMinor: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kategori tutarlarını sınıflara dağıtır. `overrides`: işlem/kalem bazında grubu değiştirilmiş
|
||||||
|
* tutarlar (kategori × grup); bunlar kategorinin varsayılan payından düşülüp seçilen gruba
|
||||||
|
* yazılır. Kategori toplamı her zaman korunur (taşma olursa varsayılan pay 0'a çekilir).
|
||||||
|
*/
|
||||||
|
export function classifyAmounts(
|
||||||
|
categoryAmounts: ReadonlyMap<string, number>,
|
||||||
|
overrides: readonly { categoryId: string; group: KakeiboGroup; amountMinor: number }[],
|
||||||
|
resolved: ReadonlyMap<string, ResolvedKakeibo>,
|
||||||
|
): ClassifiedAmount[] {
|
||||||
|
const out: ClassifiedAmount[] = [];
|
||||||
|
const byCategory = new Map<string, { group: KakeiboGroup; amountMinor: number }[]>();
|
||||||
|
for (const o of overrides)
|
||||||
|
byCategory.set(o.categoryId, [...(byCategory.get(o.categoryId) ?? []), o]);
|
||||||
|
for (const [categoryId, total] of categoryAmounts) {
|
||||||
|
if (!total) continue;
|
||||||
|
const info = resolved.get(categoryId);
|
||||||
|
let rest = total;
|
||||||
|
for (const o of byCategory.get(categoryId) ?? []) {
|
||||||
|
const part = Math.min(o.amountMinor, Math.max(0, rest));
|
||||||
|
if (!part) continue;
|
||||||
|
rest -= part;
|
||||||
|
out.push({ categoryId, cls: classify(info, o.group), amountMinor: part });
|
||||||
|
}
|
||||||
|
if (rest) out.push({ categoryId, cls: classify(info, null), amountMinor: rest });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { tr } from "../../i18n/tr";
|
import { tr } from "../../i18n/tr";
|
||||||
import { ICON_KEYS, SWATCHES } from "../appearance";
|
import { ICON_KEYS, SWATCHES } from "../appearance";
|
||||||
|
import { KAKEIBO_GROUPS } from "../kakeibo";
|
||||||
|
|
||||||
export const CATEGORY_KINDS = ["expense", "income"] as const;
|
export const CATEGORY_KINDS = ["expense", "income"] as const;
|
||||||
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
|
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
|
||||||
@@ -14,6 +15,13 @@ export const categoryFormSchema = z.object({
|
|||||||
.transform((s) => (s === "" ? null : s)),
|
.transform((s) => (s === "" ? null : s)),
|
||||||
icon: z.enum(ICON_KEYS),
|
icon: z.enum(ICON_KEYS),
|
||||||
color: z.enum(SWATCHES),
|
color: z.enum(SWATCHES),
|
||||||
|
/** Kakeibo grubu ("" = üst kategoriden devral); yalnızca gider. */
|
||||||
|
kakeiboGroup: z
|
||||||
|
.union([z.enum(KAKEIBO_GROUPS), z.literal("")])
|
||||||
|
.default("")
|
||||||
|
.transform((x) => (x === "" ? null : x)),
|
||||||
|
/** Banka maliyeti (KMH faizi, ücret): Kakeibo harcamasına girmez. */
|
||||||
|
bankCost: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CategoryFormInput = z.input<typeof categoryFormSchema>;
|
export type CategoryFormInput = z.input<typeof categoryFormSchema>;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { tr } from "../../i18n/tr";
|
|||||||
import { parseMoney } from "../money";
|
import { parseMoney } from "../money";
|
||||||
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
|
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
|
||||||
import { MAX_INSTALLMENTS } from "../card-periods";
|
import { MAX_INSTALLMENTS } from "../card-periods";
|
||||||
|
import { KAKEIBO_GROUPS } from "../kakeibo";
|
||||||
import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions";
|
import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions";
|
||||||
|
|
||||||
const v = tr.validation;
|
const v = tr.validation;
|
||||||
@@ -28,10 +29,17 @@ const positiveMoney = z
|
|||||||
return minor;
|
return minor;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Kakeibo grubu bu kayıt için değiştirildiyse; "" / yok = kategoriden (Faz 16). */
|
||||||
|
const kakeiboOverride = z
|
||||||
|
.union([z.enum(KAKEIBO_GROUPS), z.literal(""), z.null()])
|
||||||
|
.transform((x) => (x ? x : null))
|
||||||
|
.optional();
|
||||||
|
|
||||||
const splitSchema = z.object({
|
const splitSchema = z.object({
|
||||||
categoryId: objectId.refine(Boolean, t.selectCategory),
|
categoryId: objectId.refine(Boolean, t.selectCategory),
|
||||||
amount: positiveMoney,
|
amount: positiveMoney,
|
||||||
note: z.string().trim().max(120).default(""),
|
note: z.string().trim().max(120).default(""),
|
||||||
|
kakeiboGroup: kakeiboOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +56,7 @@ export const transactionFormSchema = z
|
|||||||
toAccountId: optionalId,
|
toAccountId: optionalId,
|
||||||
categoryId: optionalId,
|
categoryId: optionalId,
|
||||||
splits: z.array(splitSchema).max(MAX_SPLITS).default([]),
|
splits: z.array(splitSchema).max(MAX_SPLITS).default([]),
|
||||||
|
kakeiboGroup: kakeiboOverride,
|
||||||
payee: z.string().trim().max(80).default(""),
|
payee: z.string().trim().max(80).default(""),
|
||||||
description: z.string().trim().max(500).default(""),
|
description: z.string().trim().max(500).default(""),
|
||||||
tags: z
|
tags: z
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user