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ç.
|
||||
- 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".
|
||||
- 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.
|
||||
|
||||
## Ortam
|
||||
|
||||
+3
-1
@@ -1,10 +1,11 @@
|
||||
# 🧠 Proje Hafızası
|
||||
|
||||
## ▶️ 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 ✅.
|
||||
|
||||
## 📌 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).
|
||||
- **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).
|
||||
@@ -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 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 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 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 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 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) — **kullanıcının geniş kapsamlı testinden sonra, kullanıcı istediğinde** | Masaüstü ve mobil uygulamalar | ⏸️ |
|
||||
|
||||
## Faz 0 Adımları
|
||||
- ✅ 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)
|
||||
- ✅ 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ı)
|
||||
|
||||
## 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',
|
||||
reports: '/raporlar',
|
||||
settings: '/ayarlar',
|
||||
settingsCategories: '/ayarlar/kategoriler',
|
||||
settingsPaymentMethods: '/ayarlar/odeme-turleri',
|
||||
settingsData: '/ayarlar/veri',
|
||||
/** Banka / kart ekstresi içe aktarma */
|
||||
statement: '/ayarlar/ekstre',
|
||||
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 { 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() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.settings.title} description={t.pages.settings.description} />
|
||||
<div className="space-y-6">
|
||||
<section aria-labelledby="data-title" className="space-y-3">
|
||||
<h2 id="data-title" className="text-lg font-semibold">
|
||||
{t.data.title}
|
||||
</h2>
|
||||
<DataSection />
|
||||
<nav
|
||||
aria-label={t.pages.settings.title}
|
||||
className="divide-y overflow-hidden rounded-xl border bg-card"
|
||||
>
|
||||
{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>
|
||||
<ComingSoon when="ilerleyen fazlarda (tema, kategoriler, hane)" />
|
||||
</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 (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<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"
|
||||
>
|
||||
<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.
|
||||
*/
|
||||
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 { CatalogUpdateRequest } from '@/types/CatalogUpdateRequest'
|
||||
import type { Group } from '@/types/Group'
|
||||
import { api } from './api'
|
||||
import { queryKeys } from './query'
|
||||
|
||||
@@ -25,3 +28,56 @@ export function usePaymentMethods(includeInactive = false) {
|
||||
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ı',
|
||||
},
|
||||
|
||||
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: {
|
||||
title: 'Aylık Rapor',
|
||||
description: 'Dönemin değerlendirmesi: ne yaptın, ne iyi gitti, gelecek ay için öneriler.',
|
||||
@@ -675,7 +731,7 @@ export const t = {
|
||||
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.',
|
||||
open: 'Ekstre içe aktar',
|
||||
back: 'Ayarlar',
|
||||
back: 'Veri ve yedek',
|
||||
steps: ['Dosya', 'Eşleştir', 'Gözden geçir'],
|
||||
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.',
|
||||
|
||||
@@ -17,6 +17,8 @@ import { MonthlyReportPage } from '@/features/review/report-page'
|
||||
import { ReviewPage } from '@/features/review/page'
|
||||
import { AssetPage } from '@/features/savings/asset-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 { StatementPage } from '@/features/statement/page'
|
||||
|
||||
@@ -47,6 +49,9 @@ const routes: RouteObject[] = [
|
||||
{ path: paths.monthlyReport, element: <MonthlyReportPage /> },
|
||||
{ path: paths.reports, element: <ReportsPage /> },
|
||||
{ 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.more, element: <MorePage /> },
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
|
||||
Reference in New Issue
Block a user