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:
2026-11-13 22:04:22 +03:00
parent c3ec91b2af
commit 9daeff3eb5
14 changed files with 625 additions and 15 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+36 -8
View File
@@ -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}
</>
)
}
+1 -1
View File
@@ -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 />
+12
View File
@@ -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()
})
})
+58 -2
View File
@@ -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
}
+57 -1
View File
@@ -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.',
+5
View File
@@ -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 /> },