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
@@ -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 /> },