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:
@@ -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