feat(ayarlar): kategori ve ödeme türü yönetimi, ayarlar menüsü
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,7 @@ npm run db:restore -- <dosya|latest> <hedef_db> [--force]
|
|||||||
- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç.
|
- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç.
|
||||||
- JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b. Arayüz: `features/settings/backup-section.tsx`, `lib/backup.ts` (geri yükleme/geri alma sonrası tüm sorgular yenilenir).
|
- JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b. Arayüz: `features/settings/backup-section.tsx`, `lib/backup.ts` (geri yükleme/geri alma sonrası tüm sorgular yenilenir).
|
||||||
- Aylık rapor: `services/monthly_report.rs` (`gather` → `Facts` → saf `analyze` → `render`), şablon `templates/monthly_report.md` (`include_str!`; Dockerfile `templates/`'u kopyalar). Filtreler `tl`/`pct`/`cell`. Şablonda ham HTML yok (arayüz göstermez). Arayüz `features/review/report-page.tsx` (`/degerlendirme/rapor?donem=`), `lib/monthly-report.ts`; `api.getText`; `Markdown` tabloları `.table-wrap` içinde kayar; yazdırma `@page A4` + `PageHeader printHidden`. Cümlelerde sayıdan sonra ek kullanma ("%429'u" gibi ünlü uyumu gerekir) → "X / Y", "kullanımı %N".
|
- Aylık rapor: `services/monthly_report.rs` (`gather` → `Facts` → saf `analyze` → `render`), şablon `templates/monthly_report.md` (`include_str!`; Dockerfile `templates/`'u kopyalar). Filtreler `tl`/`pct`/`cell`. Şablonda ham HTML yok (arayüz göstermez). Arayüz `features/review/report-page.tsx` (`/degerlendirme/rapor?donem=`), `lib/monthly-report.ts`; `api.getText`; `Markdown` tabloları `.table-wrap` içinde kayar; yazdırma `@page A4` + `PageHeader printHidden`. Cümlelerde sayıdan sonra ek kullanma ("%429'u" gibi ünlü uyumu gerekir) → "X / Y", "kullanımı %N".
|
||||||
|
- Ayarlar: `/ayarlar` menü (+ tema) → alt sayfalar `SettingsSubpage` ("‹ Ayarlar"): `/ayarlar/kategoriler`, `/odeme-turleri`, `/veri` (yedek, CSV, ekstre). Katalog yönetimi `features/settings/catalog-editor.tsx` + `lib/catalog.ts` (`useCreateCatalog`/`useUpdateCatalog`/`useReorderCatalog`, `moveId`); sıralama isteği grubun **tüm** kimlikleri (pasifler dahil); grup sonradan değişmez.
|
||||||
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
|
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
|
||||||
|
|
||||||
## Ortam
|
## Ortam
|
||||||
|
|||||||
+3
-1
@@ -1,10 +1,11 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ Sıradaki Adım
|
||||||
**Faz 10 planı:** PWA (vite-plugin-pwa, kurulabilir, çevrimdışı kabuk), performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** (ekle, düzenle, emoji, sırala, pasifleştir; raporlar tüm kategorileri gösteriyor). Önce kapsamı ve adımları kullanıcıya sun.
|
**Faz 10 – Adım 2:** Hane ve hesap (Ayarlar'a yeni alt sayfalar). Backend'de var: `GET /household`, davetler `GET/POST /household/invites`, `DELETE /household/invites/{id}` (arayüz yok → davet şu an uygulamadan yapılamıyor!). Eksik backend: hane adı değiştir, üye çıkar (sahip), profil adı, şifre değiştir (eski şifre + argon2, diğer oturumlar?), varsayılan bütçe yüzdeleri + öneri yöntemi (`households.settings`). Önce mevcut household/auth route'larını oku.
|
||||||
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
|
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
|
||||||
|
|
||||||
## 📌 Alınan Kararlar
|
## 📌 Alınan Kararlar
|
||||||
|
- **Tauri / mobil + masaüstü uygulamalar (kullanıcı, 2026-11-13):** Faz 11 (Windows, Linux, Android) **kullanıcının geniş kapsamlı testinden sonra, kullanıcı prompt verdiğinde** yapılır; kendiliğinden başlanmaz. Şimdi Faz 10: web uygulamasının eksikleri + arayüz cilası (PWA web kurulumu Faz 10'da kalır).
|
||||||
- **Aylık rapor tasarımı (kullanıcı onayı, 2026-11-13):** 7 bölüm — Bir bakışta (4 soru + önceki dönem) · Ne yaptım (gruplar ✅≤%90/⚠️≤%100/❌, ilk 5 kategori + ortalamaya göre, haftalar, ödeme türü, birikim) · İyi gitti · Zorladı · Ne yapılabilirdi ("olsaydı" hesapları) · Kendi notların (plan notları + yansıtma, yorumsuz) · Gelecek dönem (önerilen bütçe = son ≤4 dönem ortalaması 50 ₺ yukarı, ≤3 somut adım, devir, boş hedef kutuları) + Ek: tüm kategoriler. Eşik: kategori %20 **ve** 250 ₺. Karşılaştırma: önceki ≤3 kapalı/arşiv dönem. "Sen" dili. Açık dönem → ara rapor (hedef yargısı yok, bitmemiş hafta yok).
|
- **Aylık rapor tasarımı (kullanıcı onayı, 2026-11-13):** 7 bölüm — Bir bakışta (4 soru + önceki dönem) · Ne yaptım (gruplar ✅≤%90/⚠️≤%100/❌, ilk 5 kategori + ortalamaya göre, haftalar, ödeme türü, birikim) · İyi gitti · Zorladı · Ne yapılabilirdi ("olsaydı" hesapları) · Kendi notların (plan notları + yansıtma, yorumsuz) · Gelecek dönem (önerilen bütçe = son ≤4 dönem ortalaması 50 ₺ yukarı, ≤3 somut adım, devir, boş hedef kutuları) + Ek: tüm kategoriler. Eşik: kategori %20 **ve** 250 ₺. Karşılaştırma: önceki ≤3 kapalı/arşiv dönem. "Sen" dili. Açık dönem → ara rapor (hedef yargısı yok, bitmemiş hafta yok).
|
||||||
- **Hafta kuralı değişti (kullanıcı, 2026-11-13):** Pazartesi başlangıçlı haftalar yerine dönemin 1–8, 9–15, 16–22, 23–son günleri (ay = 4 hafta). Kasım 2026'da 6 hafta (01.11 tek gün) çıkması üzerine. Genel: `max(1, gün/7)` hafta, ilk 8 gün, ara 7, son kalan (6–12). Defter her açılışta bugünün haftası; hafta artık adrese yazılmaz (sekme geri yüklemede eski hafta açılıyordu).
|
- **Hafta kuralı değişti (kullanıcı, 2026-11-13):** Pazartesi başlangıçlı haftalar yerine dönemin 1–8, 9–15, 16–22, 23–son günleri (ay = 4 hafta). Kasım 2026'da 6 hafta (01.11 tek gün) çıkması üzerine. Genel: `max(1, gün/7)` hafta, ilk 8 gün, ara 7, son kalan (6–12). Defter her açılışta bugünün haftası; hafta artık adrese yazılmaz (sekme geri yüklemede eski hafta açılıyordu).
|
||||||
- **Ekstre kararları (kullanıcı, 2026-11-13):** biçim Excel (.xlsx/.xls) + CSV (kullanıcı tam bilmiyor; ilk gerçek ekstreyle denenecek), hesaba gelenler → **gelir** (plan kalemi), arşiv dönemi **takvim ayı**, iadeler **aynı kategoriden düşülür** (eksi tutarlı kayıt).
|
- **Ekstre kararları (kullanıcı, 2026-11-13):** biçim Excel (.xlsx/.xls) + CSV (kullanıcı tam bilmiyor; ilk gerçek ekstreyle denenecek), hesaba gelenler → **gelir** (plan kalemi), arşiv dönemi **takvim ayı**, iadeler **aynı kategoriden düşülür** (eksi tutarlı kayıt).
|
||||||
@@ -147,3 +148,4 @@
|
|||||||
- **Faz 9 – Adım 5b (2026-11-13):** `features/settings/backup-section.tsx` (Veri bölümünün en üstü): "Yedeği indir" (`<a download>`, tıklayınca 1,5 sn sonra durum yenilenir) + hatırlatma (`daysSince`, hiç yok ya da ≥ 30 gün → uyarı rengi); sahip: dosya → önizleme (kaynak, aynı/başka hane, dönem aralığı, sayılar yedek/şimdi — farklı olan uyarı rengi, uyarılar) → onay kutusu (`#backup-confirm`; Radix `name`'i düğmeye taşımaz) → "Geri yükle" (destructive) → sonuç kutusu + "Geri al"; geri dönüş noktaları listesi (`window.confirm`). Üye: yalnız indirme + "yalnız sahip" notu. `lib/backup.ts` (geri yükleme/geri alma → `qc.invalidateQueries()` tümü), `api.postJsonText`, `queryKeys.backup`, `t.backup`. Frontend 134 test; e2e 27 kontrol (8091, DB silindi).
|
- **Faz 9 – Adım 5b (2026-11-13):** `features/settings/backup-section.tsx` (Veri bölümünün en üstü): "Yedeği indir" (`<a download>`, tıklayınca 1,5 sn sonra durum yenilenir) + hatırlatma (`daysSince`, hiç yok ya da ≥ 30 gün → uyarı rengi); sahip: dosya → önizleme (kaynak, aynı/başka hane, dönem aralığı, sayılar yedek/şimdi — farklı olan uyarı rengi, uyarılar) → onay kutusu (`#backup-confirm`; Radix `name`'i düğmeye taşımaz) → "Geri yükle" (destructive) → sonuç kutusu + "Geri al"; geri dönüş noktaları listesi (`window.confirm`). Üye: yalnız indirme + "yalnız sahip" notu. `lib/backup.ts` (geri yükleme/geri alma → `qc.invalidateQueries()` tümü), `api.postJsonText`, `queryKeys.backup`, `t.backup`. Frontend 134 test; e2e 27 kontrol (8091, DB silindi).
|
||||||
- **Faz 9 – Adım 6a (2026-11-13):** `services/monthly_report.rs` + `routes/monthly_report.rs` (`GET /periods/{id}/report.md`, `text/markdown`), `templates/monthly_report.md` (minijinja 3.1, `serde` özelliği; trim/lstrip blocks; art arda boş satırlar sadeleştirilir). `minijinja` yeni bağımlılık (şablonda koşul/döngü). Dockerfile: `COPY templates/ /app/templates/` (`include_str!("../../../templates/…")`), `docker build --target api` geçti. Testler: 5 birim (biçim, eşikler, kapalı dönem yorumları, ara rapor + ilk dönem, tam şablon + hücre kaçışı) + 1 entegrasyon (Ekim kapalı → Kasım raporu: karşılaştırma, notlar, devir, öneri, 404). 5 bilinçli bozmadan 2'si önce kaçtı → eşik testleri eklendi, hepsi yakalandı. bson `DateTime::to_time_0_3` crate-içi → `from_unix_timestamp`. Backend 196 test.
|
- **Faz 9 – Adım 6a (2026-11-13):** `services/monthly_report.rs` + `routes/monthly_report.rs` (`GET /periods/{id}/report.md`, `text/markdown`), `templates/monthly_report.md` (minijinja 3.1, `serde` özelliği; trim/lstrip blocks; art arda boş satırlar sadeleştirilir). `minijinja` yeni bağımlılık (şablonda koşul/döngü). Dockerfile: `COPY templates/ /app/templates/` (`include_str!("../../../templates/…")`), `docker build --target api` geçti. Testler: 5 birim (biçim, eşikler, kapalı dönem yorumları, ara rapor + ilk dönem, tam şablon + hücre kaçışı) + 1 entegrasyon (Ekim kapalı → Kasım raporu: karşılaştırma, notlar, devir, öneri, 404). 5 bilinçli bozmadan 2'si önce kaçtı → eşik testleri eklendi, hepsi yakalandı. bson `DateTime::to_time_0_3` crate-içi → `from_unix_timestamp`. Backend 196 test.
|
||||||
- **Faz 9 – Adım 6b (2026-11-13) → Faz 9 ✅:** `features/review/report-page.tsx` (`paths.monthlyReport = /degerlendirme/rapor`, dönem seçici, Yazdır/PDF `printSection()`, "Markdown indir" `downloadFile` (csv.ts'e genel yardımcı), `reportFileName` = backend slug). Bağlantılar: Değerlendirme başlığı, kapatma sonucu, dönem geçmişinde ayrı simge bağlantı (iç içe `<a>` olmasın). Bulunan/düzeltilen: (1) şablondaki `<small>` dipnot react-markdown'da hiç görünmüyordu → düz `_…_`; (2) telefonda tablolar sayfayı 541 px'e taşırıyordu (mobil emülasyonda `innerWidth` da büyür → kontrol 390 ile) → `Markdown` tablo sarmalayıcısı `.table-wrap`; (3) PDF ilk sayfada sayfa başlığı/dönem seçici → `PageHeader printHidden`; (4) ara raporda başlamamış haftalar "✅ %0" → "—"; (5) hedef kutuları sayfa sonunda bölünüyordu. PDF: Chrome `printToPDF` → 4 sayfa A4, `pdftoppm` ile gözle kontrol. Frontend 135, backend 196 test; e2e 22 kontrol (DB silindi).
|
- **Faz 9 – Adım 6b (2026-11-13) → Faz 9 ✅:** `features/review/report-page.tsx` (`paths.monthlyReport = /degerlendirme/rapor`, dönem seçici, Yazdır/PDF `printSection()`, "Markdown indir" `downloadFile` (csv.ts'e genel yardımcı), `reportFileName` = backend slug). Bağlantılar: Değerlendirme başlığı, kapatma sonucu, dönem geçmişinde ayrı simge bağlantı (iç içe `<a>` olmasın). Bulunan/düzeltilen: (1) şablondaki `<small>` dipnot react-markdown'da hiç görünmüyordu → düz `_…_`; (2) telefonda tablolar sayfayı 541 px'e taşırıyordu (mobil emülasyonda `innerWidth` da büyür → kontrol 390 ile) → `Markdown` tablo sarmalayıcısı `.table-wrap`; (3) PDF ilk sayfada sayfa başlığı/dönem seçici → `PageHeader printHidden`; (4) ara raporda başlamamış haftalar "✅ %0" → "—"; (5) hedef kutuları sayfa sonunda bölünüyordu. PDF: Chrome `printToPDF` → 4 sayfa A4, `pdftoppm` ile gözle kontrol. Frontend 135, backend 196 test; e2e 22 kontrol (DB silindi).
|
||||||
|
- **Faz 10 – Adım 1 (2026-11-13):** Ayarlar menüsü (`features/settings/page.tsx`: Kategoriler, Ödeme türleri, Veri ve yedek + tema) ve alt sayfalar (`subpage.tsx`, `catalog-pages.tsx`, `data-page.tsx`); ekstre sayfası "‹ Veri ve yedek"e döner. `catalog-editor.tsx`: satırda ↑ ↓ ✎ (44 px, `aria-label` adlı), düzenleme formu ad + emoji (hızlı seçim paleti, `aria-pressed`), Pasifleştir/Yeniden etkinleştir + toast "Geri al" (sürümsüz PATCH), aynı ad hatası backend'den. `lib/catalog.ts` mutasyonları `['categories'|'payment-methods']` + rapor/dönem önbelleğini yeniler. Telefonda grup başlığı bölünüyordu → açıklama alta. Frontend 136 test; e2e 25 kontrol (DB silindi).
|
||||||
|
|||||||
+9
-2
@@ -14,8 +14,8 @@
|
|||||||
| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ |
|
| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ |
|
||||||
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ✅ |
|
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ✅ |
|
||||||
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | ✅ |
|
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | ✅ |
|
||||||
| **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | ⏳ |
|
| **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | 🔄 |
|
||||||
| **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) | Masaüstü ve mobil uygulamalar | ⏳ |
|
| **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) — **kullanıcının geniş kapsamlı testinden sonra, kullanıcı istediğinde** | Masaüstü ve mobil uygulamalar | ⏸️ |
|
||||||
|
|
||||||
## Faz 0 Adımları
|
## Faz 0 Adımları
|
||||||
- ✅ 1. Ortam kontrolü ve kurulum yönergeleri
|
- ✅ 1. Ortam kontrolü ve kurulum yönergeleri
|
||||||
@@ -85,3 +85,10 @@
|
|||||||
- ✅ 4. **Banka / kredi kartı ekstresi içe aktarma** — 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) + 4b arayüz (`/ayarlar/ekstre`: Excel/CSV, sütun eşleştirme + profil, gözden geçirme)
|
- ✅ 4. **Banka / kredi kartı ekstresi içe aktarma** — 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) + 4b arayüz (`/ayarlar/ekstre`: Excel/CSV, sütun eşleştirme + profil, gözden geçirme)
|
||||||
- ✅ 5. JSON tam hane yedeği — 5a backend (sürümlü dosya + SHA-256, önizleme, tam değiştirme tek transaction, geri dönüş noktaları + "geri al", başka haneye kimlik çevirerek taşıma) · 5b arayüz (Ayarlar → Veri: indir + "son yedek" hatırlatması, sahip için önizleme → onay → geri yükle → "Geri al", geri dönüş noktaları)
|
- ✅ 5. JSON tam hane yedeği — 5a backend (sürümlü dosya + SHA-256, önizleme, tam değiştirme tek transaction, geri dönüş noktaları + "geri al", başka haneye kimlik çevirerek taşıma) · 5b arayüz (Ayarlar → Veri: indir + "son yedek" hatırlatması, sahip için önizleme → onay → geri yükle → "Geri al", geri dönüş noktaları)
|
||||||
- ✅ 6. Aylık değerlendirme çıktısı — 6a backend (`templates/monthly_report.md` + minijinja, `GET /periods/{id}/report.md`, kural tabanlı yorum ve öneriler) · 6b arayüz (`/degerlendirme/rapor`: uygulama içi görünüm, Yazdır/PDF A4, Markdown indir; Değerlendirme, kapatma sonucu ve dönem geçmişinden bağlantı)
|
- ✅ 6. Aylık değerlendirme çıktısı — 6a backend (`templates/monthly_report.md` + minijinja, `GET /periods/{id}/report.md`, kural tabanlı yorum ve öneriler) · 6b arayüz (`/degerlendirme/rapor`: uygulama içi görünüm, Yazdır/PDF A4, Markdown indir; Değerlendirme, kapatma sonucu ve dönem geçmişinden bağlantı)
|
||||||
|
|
||||||
|
## Faz 10 Adımları (web uygulamasının eksikleri + cila)
|
||||||
|
- ✅ 1. Ayarlar yeniden düzeni (`/ayarlar` menü + tema) · **Kategori yönetimi** (grup grup ekle, ad/emoji düzenle, sırala, pasifleştir) · **Ödeme türü yönetimi** · Veri sayfası `/ayarlar/veri`
|
||||||
|
- 🔄 2. Hane ve hesap: hane adı, üyeler, **davet bağlantısı** (oluştur/kopyala/iptal), üye çıkarma; profil adı, **şifre değiştirme**; varsayılan bütçe yüzdeleri + öneri yöntemi
|
||||||
|
- ⏳ 3. Arayüz cilası ve erişilebilirlik: tüm sayfaların telefon/masaüstü/koyu tema taraması, boş durumlar, skeleton, %80 bütçe uyarısı, odak/klavye, kontrast, onay diyalogları
|
||||||
|
- ⏳ 4. Performans: sayfa bazlı kod bölme (lazy route), statik dosyalarda uzun önbellek, paket boyutu
|
||||||
|
- ⏳ 5. PWA: manifest + service worker (uygulama kabuğu; `/api` asla önbelleğe alınmaz), kurulum, güncelleme bildirimi
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export const paths = {
|
|||||||
monthlyReport: '/degerlendirme/rapor',
|
monthlyReport: '/degerlendirme/rapor',
|
||||||
reports: '/raporlar',
|
reports: '/raporlar',
|
||||||
settings: '/ayarlar',
|
settings: '/ayarlar',
|
||||||
|
settingsCategories: '/ayarlar/kategoriler',
|
||||||
|
settingsPaymentMethods: '/ayarlar/odeme-turleri',
|
||||||
|
settingsData: '/ayarlar/veri',
|
||||||
/** Banka / kart ekstresi içe aktarma */
|
/** Banka / kart ekstresi içe aktarma */
|
||||||
statement: '/ayarlar/ekstre',
|
statement: '/ayarlar/ekstre',
|
||||||
login: '/giris',
|
login: '/giris',
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { useId, useState, type FormEvent } from 'react'
|
||||||
|
import { ArrowDown, ArrowUp, Loader2, Pencil, Plus } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { FormError } from '@/components/form-field'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
moveId,
|
||||||
|
useCreateCatalog,
|
||||||
|
useReorderCatalog,
|
||||||
|
useUpdateCatalog,
|
||||||
|
type CatalogKind,
|
||||||
|
} from '@/lib/catalog'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { CatalogItem } from '@/types/CatalogItem'
|
||||||
|
import type { Group } from '@/types/Group'
|
||||||
|
|
||||||
|
const s = t.settings.catalog
|
||||||
|
const message = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
||||||
|
|
||||||
|
/** Hızlı seçim için sık kullanılan emojiler */
|
||||||
|
const EMOJIS: Record<CatalogKind, string[]> = {
|
||||||
|
categories: [
|
||||||
|
'🛒',
|
||||||
|
'🏠',
|
||||||
|
'💡',
|
||||||
|
'🚌',
|
||||||
|
'⛽',
|
||||||
|
'🚗',
|
||||||
|
'📱',
|
||||||
|
'🧾',
|
||||||
|
'🍽️',
|
||||||
|
'☕',
|
||||||
|
'👕',
|
||||||
|
'🎉',
|
||||||
|
'🎮',
|
||||||
|
'✈️',
|
||||||
|
'💇',
|
||||||
|
'🏋️',
|
||||||
|
'📖',
|
||||||
|
'🎓',
|
||||||
|
'🎬',
|
||||||
|
'🎨',
|
||||||
|
'💊',
|
||||||
|
'🩺',
|
||||||
|
'🔧',
|
||||||
|
'🎁',
|
||||||
|
'👶',
|
||||||
|
'🐶',
|
||||||
|
'🧹',
|
||||||
|
'💻',
|
||||||
|
],
|
||||||
|
'payment-methods': ['💵', '💳', '🏧', '🔄', '📱', '🏦', '🪙', '🧾'],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tek bir listenin yönetimi: kategorilerde bir grup, ödeme türlerinde tüm liste.
|
||||||
|
* `items` sıralı ve pasifler dahil (sıralama isteği listenin tamamını ister).
|
||||||
|
*/
|
||||||
|
export function CatalogEditor({
|
||||||
|
kind,
|
||||||
|
group,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
kind: CatalogKind
|
||||||
|
group?: Group
|
||||||
|
items: CatalogItem[]
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState<string | null>(null)
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const create = useCreateCatalog(kind)
|
||||||
|
const update = useUpdateCatalog(kind)
|
||||||
|
const reorder = useReorderCatalog(kind)
|
||||||
|
const what = kind === 'categories' ? s.category : s.paymentMethod
|
||||||
|
|
||||||
|
function move(item: CatalogItem, dir: -1 | 1) {
|
||||||
|
const ids = moveId(
|
||||||
|
items.map((i) => i.id),
|
||||||
|
item.id,
|
||||||
|
dir,
|
||||||
|
)
|
||||||
|
if (!ids) return
|
||||||
|
reorder.mutate({ group, ids }, { onError: (e) => toast.error(message(e)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setActive(item: CatalogItem, active: boolean) {
|
||||||
|
await update.mutateAsync({ id: item.id, is_active: active, version: item.version })
|
||||||
|
setEditing(null)
|
||||||
|
toast(active ? s.activated(item.name) : s.deactivated(item.name), {
|
||||||
|
action: {
|
||||||
|
label: s.undo,
|
||||||
|
// Geri al: sürüm değişti → sürümsüz gönder (yalnızca durum değişir)
|
||||||
|
onClick: () => update.mutate({ id: item.id, is_active: !active }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.length === 0 && !adding && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{kind === 'categories' ? s.empty : s.emptyMethods}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="divide-y rounded-xl border">
|
||||||
|
{items.map((item, i) =>
|
||||||
|
editing === item.id ? (
|
||||||
|
<li key={item.id} className="p-3">
|
||||||
|
<ItemForm
|
||||||
|
kind={kind}
|
||||||
|
initial={item}
|
||||||
|
pending={update.isPending}
|
||||||
|
onCancel={() => setEditing(null)}
|
||||||
|
onSubmit={async (v) => {
|
||||||
|
await update.mutateAsync({ id: item.id, ...v, version: item.version })
|
||||||
|
setEditing(null)
|
||||||
|
toast.success(s.saved)
|
||||||
|
}}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className={item.is_active ? 'text-expense' : undefined}
|
||||||
|
disabled={update.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
setActive(item, !item.is_active).catch((e) => toast.error(message(e)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.is_active ? s.deactivate : s.activate}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-14 items-center gap-1 py-1 pr-1 pl-3',
|
||||||
|
!item.is_active && 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="w-7 shrink-0 text-lg" aria-hidden>
|
||||||
|
{item.emoji}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium">{item.name}</span>
|
||||||
|
{!item.is_active && (
|
||||||
|
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||||
|
{s.inactive}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={s.up(item.name)}
|
||||||
|
disabled={i === 0 || reorder.isPending}
|
||||||
|
onClick={() => move(item, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUp aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={s.down(item.name)}
|
||||||
|
disabled={i === items.length - 1 || reorder.isPending}
|
||||||
|
onClick={() => move(item, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDown aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={s.edit(item.name)}
|
||||||
|
onClick={() => {
|
||||||
|
setAdding(false)
|
||||||
|
setEditing(item.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{adding && (
|
||||||
|
<li className="p-3">
|
||||||
|
<ItemForm
|
||||||
|
kind={kind}
|
||||||
|
pending={create.isPending}
|
||||||
|
onCancel={() => setAdding(false)}
|
||||||
|
onSubmit={async (v) => {
|
||||||
|
const created = await create.mutateAsync({ group, ...v })
|
||||||
|
setAdding(false)
|
||||||
|
toast.success(s.added(created.name))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
{!adding && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null)
|
||||||
|
setAdding(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus aria-hidden />
|
||||||
|
{s.add(what)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ad + emoji formu (ekleme ve düzenleme) */
|
||||||
|
function ItemForm({
|
||||||
|
kind,
|
||||||
|
initial,
|
||||||
|
pending,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
extra,
|
||||||
|
}: {
|
||||||
|
kind: CatalogKind
|
||||||
|
initial?: CatalogItem
|
||||||
|
pending: boolean
|
||||||
|
onSubmit: (v: { name: string; emoji: string }) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
extra?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const id = useId()
|
||||||
|
const [name, setName] = useState(initial?.name ?? '')
|
||||||
|
const [emoji, setEmoji] = useState(initial?.emoji ?? '')
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const n = name.trim()
|
||||||
|
if (!n) return setError(s.nameRequired)
|
||||||
|
if (n.length > 40) return setError(s.nameTooLong)
|
||||||
|
if ([...emoji.trim()].length > 4) return setError(s.emojiTooLong)
|
||||||
|
setError(undefined)
|
||||||
|
try {
|
||||||
|
await onSubmit({ name: n, emoji: emoji.trim() })
|
||||||
|
} catch (err) {
|
||||||
|
setError(message(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={(e) => void submit(e)} className="space-y-3" noValidate>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-20 shrink-0 space-y-1.5">
|
||||||
|
<Label htmlFor={`${id}-emoji`}>{s.emoji}</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-emoji`}
|
||||||
|
name="emoji"
|
||||||
|
value={emoji}
|
||||||
|
onChange={(e) => setEmoji(e.target.value)}
|
||||||
|
className="text-center text-lg"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
<Label htmlFor={`${id}-name`}>{s.name}</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-name`}
|
||||||
|
name="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
maxLength={40}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
aria-invalid={!!error || undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs text-muted-foreground">{s.emojiHint}</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{EMOJIS[kind].map((e) => (
|
||||||
|
<button
|
||||||
|
key={e}
|
||||||
|
type="button"
|
||||||
|
aria-label={s.pick(e)}
|
||||||
|
aria-pressed={emoji === e}
|
||||||
|
onClick={() => setEmoji(e)}
|
||||||
|
className={cn(
|
||||||
|
'flex size-11 items-center justify-center rounded-lg text-xl hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none',
|
||||||
|
emoji === e && 'bg-primary/10 ring-1 ring-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{e}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormError message={error} />
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
{s.save}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel} disabled={pending}>
|
||||||
|
{s.cancel}
|
||||||
|
</Button>
|
||||||
|
{extra && <div className="ml-auto">{extra}</div>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Info } from 'lucide-react'
|
||||||
|
import { PageError, PageLoading } from '@/components/page'
|
||||||
|
import { useCategories, usePaymentMethods } from '@/lib/catalog'
|
||||||
|
import { GROUPS } from '@/lib/groups'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { CatalogEditor } from './catalog-editor'
|
||||||
|
import { SettingsSubpage } from './subpage'
|
||||||
|
|
||||||
|
const s = t.settings
|
||||||
|
|
||||||
|
function Hint({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<p className="flex items-start gap-2 rounded-xl bg-muted px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ayarlar → Kategoriler: 4 grup, her birinde sıralı alt kategoriler (pasifler dahil) */
|
||||||
|
export function CategoriesPage() {
|
||||||
|
const cats = useCategories(true)
|
||||||
|
return (
|
||||||
|
<SettingsSubpage title={s.categories.title} description={s.categories.description}>
|
||||||
|
{cats.isError ? (
|
||||||
|
<PageError message={cats.error.message} onRetry={cats.refetch} />
|
||||||
|
) : !cats.data ? (
|
||||||
|
<PageLoading />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{GROUPS.map((g) => (
|
||||||
|
<section
|
||||||
|
key={g.code}
|
||||||
|
aria-labelledby={`grp-${g.code}`}
|
||||||
|
className="space-y-3 rounded-2xl border bg-card p-4"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<h2
|
||||||
|
id={`grp-${g.code}`}
|
||||||
|
className={cn('flex items-center gap-2 font-semibold', g.className.text)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn('size-3 shrink-0 rounded-full', g.className.fill)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{g.emoji} {g.label}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">{g.description}</p>
|
||||||
|
</header>
|
||||||
|
<CatalogEditor
|
||||||
|
kind="categories"
|
||||||
|
group={g.code}
|
||||||
|
items={cats.data.filter((c) => c.group === g.code)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Hint>{s.catalog.inactiveHint}</Hint>
|
||||||
|
<Hint>{s.catalog.groupFixed}</Hint>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsSubpage>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ayarlar → Ödeme türleri */
|
||||||
|
export function PaymentMethodsPage() {
|
||||||
|
const methods = usePaymentMethods(true)
|
||||||
|
return (
|
||||||
|
<SettingsSubpage title={s.paymentMethods.title} description={s.paymentMethods.description}>
|
||||||
|
{methods.isError ? (
|
||||||
|
<PageError message={methods.error.message} onRetry={methods.refetch} />
|
||||||
|
) : !methods.data ? (
|
||||||
|
<PageLoading />
|
||||||
|
) : (
|
||||||
|
<div className="max-w-xl space-y-4">
|
||||||
|
<CatalogEditor kind="payment-methods" items={methods.data} />
|
||||||
|
<Hint>{s.catalog.inactiveHint}</Hint>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsSubpage>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import { DataSection } from './data-section'
|
||||||
|
import { SettingsSubpage } from './subpage'
|
||||||
|
|
||||||
|
/** Ayarlar → Veri ve yedek: JSON yedek, CSV, banka ekstresi */
|
||||||
|
export function DataPage() {
|
||||||
|
return (
|
||||||
|
<SettingsSubpage title={t.settings.data.title} description={t.settings.data.description}>
|
||||||
|
<DataSection />
|
||||||
|
</SettingsSubpage>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,19 +1,47 @@
|
|||||||
import { ComingSoon, PageHeader } from '@/components/page'
|
import { ChevronRight, Database, Tags, Wallet, type LucideIcon } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router'
|
||||||
|
import { paths } from '@/components/layout/nav-items'
|
||||||
|
import { PageHeader } from '@/components/page'
|
||||||
|
import { ThemeToggle } from '@/components/theme-toggle'
|
||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
import { DataSection } from './data-section'
|
|
||||||
|
|
||||||
|
const s = t.settings
|
||||||
|
|
||||||
|
const items: { to: string; icon: LucideIcon; title: string; hint: string }[] = [
|
||||||
|
{ to: paths.settingsCategories, icon: Tags, ...s.menu.categories },
|
||||||
|
{ to: paths.settingsPaymentMethods, icon: Wallet, ...s.menu.paymentMethods },
|
||||||
|
{ to: paths.settingsData, icon: Database, ...s.menu.data },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Ayarlar menüsü: her bölüm kendi sayfasında (telefonda kısa, odaklı ekranlar) */
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={t.pages.settings.title} description={t.pages.settings.description} />
|
<PageHeader title={t.pages.settings.title} description={t.pages.settings.description} />
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section aria-labelledby="data-title" className="space-y-3">
|
<nav
|
||||||
<h2 id="data-title" className="text-lg font-semibold">
|
aria-label={t.pages.settings.title}
|
||||||
{t.data.title}
|
className="divide-y overflow-hidden rounded-xl border bg-card"
|
||||||
</h2>
|
>
|
||||||
<DataSection />
|
{items.map(({ to, icon: Icon, title, hint }) => (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className="flex min-h-16 items-center gap-3 px-4 py-2 transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Icon className="size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block font-medium">{title}</span>
|
||||||
|
<span className="block text-sm text-muted-foreground">{hint}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground">{s.appearance}</h2>
|
||||||
|
<ThemeToggle className="w-full sm:w-auto [&>button]:flex-1" />
|
||||||
</section>
|
</section>
|
||||||
<ComingSoon when="ilerleyen fazlarda (tema, kategoriler, hane)" />
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { ChevronLeft } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router'
|
||||||
|
import { paths } from '@/components/layout/nav-items'
|
||||||
|
import { PageHeader } from '@/components/page'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
|
||||||
|
/** Ayarlar alt sayfası: "‹ Ayarlar" geri bağlantısı + başlık */
|
||||||
|
export function SettingsSubpage({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to={paths.settings}
|
||||||
|
className="-ml-1 mb-2 inline-flex min-h-11 items-center gap-1 rounded-lg px-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" aria-hidden />
|
||||||
|
{t.settings.back}
|
||||||
|
</Link>
|
||||||
|
<PageHeader title={title} description={description} />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ export function StatementPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl">
|
<div className="mx-auto max-w-5xl">
|
||||||
<Link
|
<Link
|
||||||
to={paths.settings}
|
to={paths.settingsData}
|
||||||
className="mb-3 inline-flex min-h-11 items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
className="mb-3 inline-flex min-h-11 items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="size-4" aria-hidden />
|
<ArrowLeft className="size-4" aria-hidden />
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { moveId } from './catalog'
|
||||||
|
|
||||||
|
describe('catalog ordering', () => {
|
||||||
|
it('moves an item one step and refuses at the edges', () => {
|
||||||
|
expect(moveId(['a', 'b', 'c'], 'b', -1)).toEqual(['b', 'a', 'c'])
|
||||||
|
expect(moveId(['a', 'b', 'c'], 'b', 1)).toEqual(['a', 'c', 'b'])
|
||||||
|
expect(moveId(['a', 'b', 'c'], 'a', -1)).toBeNull()
|
||||||
|
expect(moveId(['a', 'b', 'c'], 'c', 1)).toBeNull()
|
||||||
|
expect(moveId(['a'], 'x', 1)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Kategoriler ve ödeme türleri (sunucu verisi). Yönetim ekranı Ayarlar'da gelecek;
|
* Kategoriler ve ödeme türleri (sunucu verisi) + Ayarlar'daki yönetim işlemleri.
|
||||||
* Defter pasifler dahil okur: eski kayıtların adı gösterilir, yeni seçimde yalnızca aktifler sunulur.
|
* Defter pasifler dahil okur: eski kayıtların adı gösterilir, yeni seçimde yalnızca aktifler sunulur.
|
||||||
*/
|
*/
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import type { CatalogCreateRequest } from '@/types/CatalogCreateRequest'
|
||||||
import type { CatalogItem } from '@/types/CatalogItem'
|
import type { CatalogItem } from '@/types/CatalogItem'
|
||||||
|
import type { CatalogUpdateRequest } from '@/types/CatalogUpdateRequest'
|
||||||
|
import type { Group } from '@/types/Group'
|
||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import { queryKeys } from './query'
|
import { queryKeys } from './query'
|
||||||
|
|
||||||
@@ -25,3 +28,56 @@ export function usePaymentMethods(includeInactive = false) {
|
|||||||
staleTime: STALE,
|
staleTime: STALE,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Yönetim (Ayarlar) ----------
|
||||||
|
|
||||||
|
export type CatalogKind = 'categories' | 'payment-methods'
|
||||||
|
|
||||||
|
/** Bir değişiklikten sonra listenin tüm biçimleri (aktif / pasif dahil) ve raporlar yenilenir */
|
||||||
|
function useInvalidate(kind: CatalogKind) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return () =>
|
||||||
|
Promise.all([
|
||||||
|
qc.invalidateQueries({ queryKey: [kind] }),
|
||||||
|
// Rapor ve değerlendirmelerde kategori adları görünür
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.reports }),
|
||||||
|
qc.invalidateQueries({ queryKey: ['period'] }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateCatalog(kind: CatalogKind) {
|
||||||
|
const invalidate = useInvalidate(kind)
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: CatalogCreateRequest) => api.post<CatalogItem>(`/${kind}`, body),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCatalog(kind: CatalogKind) {
|
||||||
|
const invalidate = useInvalidate(kind)
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, ...body }: CatalogUpdateRequest & { id: string }) =>
|
||||||
|
api.patch<CatalogItem>(`/${kind}/${id}`, body),
|
||||||
|
// Hata (ör. başkası değiştirdi) olsa da güncel liste gelsin
|
||||||
|
onSettled: invalidate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReorderCatalog(kind: CatalogKind) {
|
||||||
|
const invalidate = useInvalidate(kind)
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: { group?: Group; ids: string[] }) =>
|
||||||
|
api.put<CatalogItem[]>(`/${kind}/order`, body),
|
||||||
|
onSettled: invalidate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sıralamada bir öğeyi bir yukarı (-1) ya da aşağı (+1) taşı; uçta ise `null` */
|
||||||
|
export function moveId(ids: readonly string[], id: string, dir: -1 | 1): string[] | null {
|
||||||
|
const i = ids.indexOf(id)
|
||||||
|
const j = i + dir
|
||||||
|
if (i < 0 || j < 0 || j >= ids.length) return null
|
||||||
|
const next = [...ids]
|
||||||
|
;[next[i], next[j]] = [next[j], next[i]]
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|||||||
@@ -298,6 +298,62 @@ export const t = {
|
|||||||
over: 'Aşıldı',
|
over: 'Aşıldı',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
back: 'Ayarlar',
|
||||||
|
appearance: 'Görünüm',
|
||||||
|
menu: {
|
||||||
|
categories: {
|
||||||
|
title: 'Kategoriler',
|
||||||
|
hint: 'Grup grup alt kategoriler: ekle, düzenle, sırala',
|
||||||
|
},
|
||||||
|
paymentMethods: { title: 'Ödeme türleri', hint: 'Nakit, kart, havale…' },
|
||||||
|
data: { title: 'Veri ve yedek', hint: 'JSON yedek, CSV, banka ekstresi' },
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
title: 'Kategoriler',
|
||||||
|
description:
|
||||||
|
'Harcamalar 4 Kakeibo grubunda toplanır; her grubun altındaki kategorileri sen belirlersin.',
|
||||||
|
},
|
||||||
|
paymentMethods: {
|
||||||
|
title: 'Ödeme türleri',
|
||||||
|
description: 'Harcamayı nasıl ödediğin: raporlarda ve ekstre aktarımında kullanılır.',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title: 'Veri ve yedek',
|
||||||
|
description: 'Yedek, CSV dışa / içe aktarma ve banka ekstresi.',
|
||||||
|
},
|
||||||
|
catalog: {
|
||||||
|
add: (what: string) => `${what} ekle`,
|
||||||
|
category: 'Kategori',
|
||||||
|
paymentMethod: 'Ödeme türü',
|
||||||
|
name: 'Ad',
|
||||||
|
emoji: 'Emoji',
|
||||||
|
emojiHint: 'İsteğe bağlı; aşağıdan seçebilir ya da klavyeden yazabilirsin.',
|
||||||
|
pick: (e: string) => `${e} seç`,
|
||||||
|
save: 'Kaydet',
|
||||||
|
cancel: 'Vazgeç',
|
||||||
|
edit: (name: string) => `${name}: düzenle`,
|
||||||
|
up: (name: string) => `${name}: yukarı taşı`,
|
||||||
|
down: (name: string) => `${name}: aşağı taşı`,
|
||||||
|
deactivate: 'Pasifleştir',
|
||||||
|
activate: 'Yeniden etkinleştir',
|
||||||
|
inactive: 'Pasif',
|
||||||
|
inactiveHint:
|
||||||
|
'Pasif kayıt yeni harcamada seçilemez; eski kayıtlar, raporlar ve aktarımlar etkilenmez. Kalıcı silme yoktur, istediğinde geri açabilirsin.',
|
||||||
|
nameRequired: 'Ad girin',
|
||||||
|
nameTooLong: 'Ad en fazla 40 karakter olabilir',
|
||||||
|
emojiTooLong: 'Tek bir emoji girin',
|
||||||
|
added: (name: string) => `“${name}” eklendi`,
|
||||||
|
saved: 'Kaydedildi',
|
||||||
|
deactivated: (name: string) => `“${name}” pasifleştirildi`,
|
||||||
|
activated: (name: string) => `“${name}” yeniden etkin`,
|
||||||
|
undo: 'Geri al',
|
||||||
|
empty: 'Bu grupta henüz kategori yok.',
|
||||||
|
emptyMethods: 'Henüz ödeme türü yok.',
|
||||||
|
groupFixed:
|
||||||
|
'Kategorinin grubu sonradan değiştirilemez (eski harcamaların grubu bozulmasın); gerekirse doğru grupta yeni kategori aç, eskisini pasifleştir.',
|
||||||
|
},
|
||||||
|
},
|
||||||
monthlyReport: {
|
monthlyReport: {
|
||||||
title: 'Aylık Rapor',
|
title: 'Aylık Rapor',
|
||||||
description: 'Dönemin değerlendirmesi: ne yaptın, ne iyi gitti, gelecek ay için öneriler.',
|
description: 'Dönemin değerlendirmesi: ne yaptın, ne iyi gitti, gelecek ay için öneriler.',
|
||||||
@@ -675,7 +731,7 @@ export const t = {
|
|||||||
entryHint:
|
entryHint:
|
||||||
'Excel ya da CSV ekstreyi yükle; satırlar harcama, iade, gelir ya da yok say olarak ayrılır. Dönemi olmayan aylar için arşiv dönemi açılır.',
|
'Excel ya da CSV ekstreyi yükle; satırlar harcama, iade, gelir ya da yok say olarak ayrılır. Dönemi olmayan aylar için arşiv dönemi açılır.',
|
||||||
open: 'Ekstre içe aktar',
|
open: 'Ekstre içe aktar',
|
||||||
back: 'Ayarlar',
|
back: 'Veri ve yedek',
|
||||||
steps: ['Dosya', 'Eşleştir', 'Gözden geçir'],
|
steps: ['Dosya', 'Eşleştir', 'Gözden geçir'],
|
||||||
fileHint:
|
fileHint:
|
||||||
'İnternet bankacılığından “Excel’e aktar” ile indirdiğin dosyayı seç (.xlsx, .xls ya da .csv). Dosya bu cihazda okunur; yalnızca tarih, açıklama ve tutar gönderilir.',
|
'İnternet bankacılığından “Excel’e aktar” ile indirdiğin dosyayı seç (.xlsx, .xls ya da .csv). Dosya bu cihazda okunur; yalnızca tarih, açıklama ve tutar gönderilir.',
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { MonthlyReportPage } from '@/features/review/report-page'
|
|||||||
import { ReviewPage } from '@/features/review/page'
|
import { ReviewPage } from '@/features/review/page'
|
||||||
import { AssetPage } from '@/features/savings/asset-page'
|
import { AssetPage } from '@/features/savings/asset-page'
|
||||||
import { SavingsPage } from '@/features/savings/page'
|
import { SavingsPage } from '@/features/savings/page'
|
||||||
|
import { CategoriesPage, PaymentMethodsPage } from '@/features/settings/catalog-pages'
|
||||||
|
import { DataPage } from '@/features/settings/data-page'
|
||||||
import { SettingsPage } from '@/features/settings/page'
|
import { SettingsPage } from '@/features/settings/page'
|
||||||
import { StatementPage } from '@/features/statement/page'
|
import { StatementPage } from '@/features/statement/page'
|
||||||
|
|
||||||
@@ -47,6 +49,9 @@ const routes: RouteObject[] = [
|
|||||||
{ path: paths.monthlyReport, element: <MonthlyReportPage /> },
|
{ path: paths.monthlyReport, element: <MonthlyReportPage /> },
|
||||||
{ path: paths.reports, element: <ReportsPage /> },
|
{ path: paths.reports, element: <ReportsPage /> },
|
||||||
{ path: paths.settings, element: <SettingsPage /> },
|
{ path: paths.settings, element: <SettingsPage /> },
|
||||||
|
{ path: paths.settingsCategories, element: <CategoriesPage /> },
|
||||||
|
{ path: paths.settingsPaymentMethods, element: <PaymentMethodsPage /> },
|
||||||
|
{ path: paths.settingsData, element: <DataPage /> },
|
||||||
{ path: paths.statement, element: <StatementPage /> },
|
{ path: paths.statement, element: <StatementPage /> },
|
||||||
{ path: paths.more, element: <MorePage /> },
|
{ path: paths.more, element: <MorePage /> },
|
||||||
{ path: '*', element: <NotFoundPage /> },
|
{ path: '*', element: <NotFoundPage /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user