feat(ayarlar): hane ve üyeler, davet bağlantısı, hesap ve şifre, bütçe varsayılanları

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 22:32:28 +03:00
parent 9daeff3eb5
commit 77d7a081b3
31 changed files with 1564 additions and 21 deletions
@@ -33,6 +33,9 @@ export const paths = {
settingsCategories: '/ayarlar/kategoriler',
settingsPaymentMethods: '/ayarlar/odeme-turleri',
settingsData: '/ayarlar/veri',
settingsHousehold: '/ayarlar/hane',
settingsAccount: '/ayarlar/hesap',
settingsBudget: '/ayarlar/butce',
/** Banka / kart ekstresi içe aktarma */
statement: '/ayarlar/ekstre',
login: '/giris',
@@ -9,6 +9,7 @@ import { SummaryTiles } from '@/features/planning/summary-tiles'
import { ApiError } from '@/lib/api'
import { parseMoney } from '@/lib/format'
import { GROUPS } from '@/lib/groups'
import { useHousehold } from '@/lib/household'
import { toKurus } from '@/lib/money-schema'
import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods'
import { moneyToText } from '@/lib/plan-draft'
@@ -46,12 +47,18 @@ export function StepBudget({
}) {
const summary = useSummary(period.id)
const budget = useBudget(period.id)
const suggestion = useSuggestion(period.id, 'default')
// Hanenin varsayılan yöntemi (Ayarlar); verisi yoksa (ör. ilk ay, önceki dönem yok) yüzdeler
const household = useHousehold()
const preferred = household.data?.settings.budget_suggestion ?? 'default'
const fallback = useSuggestion(period.id, 'default')
const chosen = useSuggestion(period.id, preferred)
const suggestion = chosen.data?.available ? chosen : fallback
const queries = [summary, budget, suggestion]
const queries = [summary, budget, household, fallback, chosen]
const failed = queries.find((q) => q.isError)
if (failed?.error) return <PageError message={failed.error.message} onRetry={failed.refetch} />
if (!summary.data || !budget.data || !suggestion.data) return <PageLoading />
if (!summary.data || !budget.data || !household.data || !chosen.data || !suggestion.data)
return <PageLoading />
const saved = Object.fromEntries(budget.data.map((g) => [g.group, g.monthly])) as GroupAmounts
const hasSaved = budget.data.some((g) => g.monthly > 0)
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Loader2, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useHousehold } from '@/lib/household'
import { useSuggestion } from '@/lib/periods'
import { t } from '@/lib/strings'
import type { GroupAmounts } from '@/types/GroupAmounts'
@@ -19,7 +20,10 @@ export function SuggestionPicker({
onApply: (amounts: GroupAmounts) => void
hint?: string
}) {
const [method, setMethod] = useState<SuggestMethod>('default')
// Önce hanenin varsayılan yöntemi (Ayarlar → Bütçe varsayılanları); kullanıcı seçince o
const household = useHousehold()
const [chosen, setMethod] = useState<SuggestMethod | null>(null)
const method = chosen ?? household.data?.settings.budget_suggestion ?? 'default'
const suggestion = useSuggestion(periodId, method)
return (
@@ -0,0 +1,151 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2 } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { FormError, FormField, PasswordInput } from '@/components/form-field'
import { LogoutButton } from '@/components/layout/app-layout'
import { PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Section } from '@/features/planning/plan-read'
import { useMe } from '@/lib/auth'
import { useChangePassword, useUpdateProfile } from '@/lib/household'
import { t } from '@/lib/strings'
import type { MeResponse } from '@/types/MeResponse'
import { nameSchema, passwordSchema, type NameForm, type PasswordForm } from './schemas'
import { SettingsSubpage } from './subpage'
const s = t.settings.account
const message = (e: unknown) => (e instanceof Error ? e.message : String(e))
/** Ayarlar → Hesabım: ad, e-posta (salt okunur), şifre değiştirme, çıkış */
export function AccountPage() {
const me = useMe()
return (
<SettingsSubpage title={s.title} description={s.description}>
{!me.data ? (
<PageLoading />
) : (
<div className="max-w-xl space-y-4">
<Profile me={me.data} />
<ChangePassword />
<div className="flex justify-end">
<LogoutButton />
</div>
</div>
)}
</SettingsSubpage>
)
}
function Profile({ me }: { me: MeResponse }) {
const update = useUpdateProfile()
const {
register,
handleSubmit,
setError,
reset,
formState: { errors, isDirty },
} = useForm<NameForm>({
resolver: zodResolver(nameSchema),
mode: 'onTouched',
defaultValues: { name: me.user.name },
})
const onSubmit = handleSubmit(async (v) => {
try {
const next = await update.mutateAsync(v.name)
reset({ name: next.user.name })
toast.success(s.saved)
} catch (e) {
setError('name', { message: message(e) })
}
})
return (
<Section title={s.profile}>
<form onSubmit={onSubmit} className="space-y-4" noValidate>
<FormField label={s.name} error={errors.name}>
{(f) => <Input {...f} {...register('name')} autoComplete="name" maxLength={80} />}
</FormField>
<FormField label={s.email} hint={s.emailHint}>
{(f) => <Input {...f} value={me.user.email} readOnly disabled />}
</FormField>
<p className="text-sm">
<span className="text-muted-foreground">{s.role}: </span>
{me.user.role === 'owner' ? t.account.owner : t.account.member} · {me.household.name}
</p>
<Button type="submit" disabled={!isDirty || update.isPending}>
{update.isPending && <Loader2 className="animate-spin" aria-hidden />}
{s.save}
</Button>
</form>
</Section>
)
}
function ChangePassword() {
const change = useChangePassword()
const {
register,
handleSubmit,
setError,
reset,
formState: { errors, isSubmitting },
} = useForm<PasswordForm>({
resolver: zodResolver(passwordSchema),
mode: 'onTouched',
defaultValues: { current_password: '', new_password: '', repeat: '' },
})
const onSubmit = handleSubmit(async (v) => {
try {
await change.mutateAsync({
current_password: v.current_password,
new_password: v.new_password,
})
reset()
toast.success(s.changed)
} catch (e) {
const msg = message(e)
// "Mevcut şifre hatalı" → ilgili alana; diğerleri formun altına
if (/mevcut/i.test(msg)) setError('current_password', { message: msg }, { shouldFocus: true })
else setError('root', { message: msg })
}
})
return (
<Section title={s.password}>
<form onSubmit={onSubmit} className="space-y-4" noValidate>
<p className="text-sm text-muted-foreground">{s.otherDevices}</p>
{/* Parola yöneticileri için gizli kullanıcı adı alanı */}
<input
type="text"
autoComplete="username"
className="hidden"
readOnly
aria-hidden
tabIndex={-1}
/>
<FormField label={s.current} error={errors.current_password}>
{(f) => (
<PasswordInput
{...f}
{...register('current_password')}
autoComplete="current-password"
/>
)}
</FormField>
<FormField label={s.next} error={errors.new_password} hint={s.nextHint}>
{(f) => (
<PasswordInput {...f} {...register('new_password')} autoComplete="new-password" />
)}
</FormField>
<FormField label={s.repeat} error={errors.repeat}>
{(f) => <PasswordInput {...f} {...register('repeat')} autoComplete="new-password" />}
</FormField>
<FormError message={errors.root?.message} />
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
{s.change}
</Button>
</form>
</Section>
)
}
@@ -0,0 +1,177 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2 } from 'lucide-react'
import { useForm, useWatch, type FieldError } from 'react-hook-form'
import { toast } from 'sonner'
import { FormError, FormField } from '@/components/form-field'
import { PageError, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Section } from '@/features/planning/plan-read'
import { useMe } from '@/lib/auth'
import { GROUPS } from '@/lib/groups'
import { pctTotal, useHousehold, useUpdateHousehold } from '@/lib/household'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { Household } from '@/types/Household'
import { budgetSchema, type BudgetForm, type BudgetValues } from './schemas'
import { SettingsSubpage } from './subpage'
const s = t.settings.budget
const METHODS = ['default', 'previous', 'avg3'] as const
/** Ayarlar → Bütçe varsayılanları: öneri yüzdeleri + önce seçili öneri yöntemi */
export function BudgetDefaultsPage() {
const me = useMe()
const household = useHousehold()
return (
<SettingsSubpage title={s.title} description={s.description}>
{household.isError ? (
<PageError message={household.error.message} onRetry={household.refetch} />
) : !household.data || !me.data ? (
<PageLoading />
) : (
<BudgetForm household={household.data} readOnly={me.data.user.role !== 'owner'} />
)}
</SettingsSubpage>
)
}
function BudgetForm({ household, readOnly }: { household: Household; readOnly: boolean }) {
const update = useUpdateHousehold()
const st = household.settings
const {
register,
handleSubmit,
setError,
setValue,
reset,
control,
formState: { errors, isDirty },
} = useForm<BudgetForm, unknown, BudgetValues>({
resolver: zodResolver(budgetSchema),
mode: 'onTouched',
defaultValues: { ...st.budget_split_pct, budget_suggestion: st.budget_suggestion },
})
const watched = useWatch({ control })
const total = pctTotal({
needs: watched.needs as number,
wants: watched.wants as number,
culture: watched.culture as number,
unexpected: watched.unexpected as number,
})
const method = watched.budget_suggestion
const onSubmit = handleSubmit(async (v) => {
try {
const h = await update.mutateAsync({
budget_split_pct: {
needs: v.needs,
wants: v.wants,
culture: v.culture,
unexpected: v.unexpected,
},
budget_suggestion: v.budget_suggestion,
})
reset({ ...h.settings.budget_split_pct, budget_suggestion: h.settings.budget_suggestion })
toast.success(s.saved)
} catch (e) {
setError('root', { message: e instanceof Error ? e.message : String(e) })
}
})
return (
<form onSubmit={onSubmit} className="max-w-2xl space-y-4" noValidate>
{readOnly && (
<p className="rounded-xl bg-muted px-3 py-2 text-sm text-muted-foreground">{s.readOnly}</p>
)}
<Section title={s.split}>
<fieldset disabled={readOnly} className="space-y-4">
<p className="text-sm text-muted-foreground">{s.splitHint}</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{/* z.coerce girişi `unknown` → hata türü genişler; alanlar düz değer */}
{GROUPS.map((g) => (
<FormField
key={g.code}
label={`${g.emoji} ${g.label}`}
error={errors[g.code] as FieldError | undefined}
>
{(f) => (
<div className="relative">
<Input
{...f}
{...register(g.code)}
inputMode="numeric"
className="money pr-8 text-right"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-muted-foreground">
%
</span>
</div>
)}
</FormField>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p
role="status"
className={cn('money font-medium', total === 100 ? 'text-income' : 'text-warning')}
>
{s.total(total)}
</p>
{!readOnly && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const classic = { needs: 50, wants: 30, culture: 10, unexpected: 10 }
for (const [k, v] of Object.entries(classic))
setValue(k as keyof typeof classic, v, {
shouldDirty: true,
shouldValidate: true,
})
}}
>
{s.classic}
</Button>
)}
</div>
</fieldset>
</Section>
<Section title={s.method}>
<fieldset disabled={readOnly} className="space-y-3">
<legend className="sr-only">{s.method}</legend>
<p className="text-sm text-muted-foreground">{s.methodHint}</p>
<div className="flex flex-wrap gap-2">
{METHODS.map((m) => (
<label
key={m}
className={cn(
'flex min-h-11 cursor-pointer items-center gap-2 rounded-lg border px-3 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50',
method === m && 'border-primary bg-primary/10',
)}
>
<input
type="radio"
value={m}
{...register('budget_suggestion')}
className="accent-primary"
/>
{t.wizard.budget.methods[m]}
</label>
))}
</div>
</fieldset>
</Section>
<FormError message={errors.root?.message} />
{!readOnly && (
<Button type="submit" disabled={!isDirty || update.isPending}>
{update.isPending && <Loader2 className="animate-spin" aria-hidden />}
{s.save}
</Button>
)}
</form>
)
}
@@ -0,0 +1,251 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Copy, Loader2, Share2, UserMinus, UserPlus } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { FormError, FormField } from '@/components/form-field'
import { PageError, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Section } from '@/features/planning/plan-read'
import { useMe } from '@/lib/auth'
import { formatDate } from '@/lib/format'
import {
inviteLink,
useCreateInvite,
useHousehold,
useInvites,
useRemoveMember,
useRevokeInvite,
useUpdateHousehold,
} from '@/lib/household'
import { t } from '@/lib/strings'
import type { Household } from '@/types/Household'
import type { Invite } from '@/types/Invite'
import { nameSchema, type NameForm } from './schemas'
import { SettingsSubpage } from './subpage'
const s = t.settings.household
const message = (e: unknown) => (e instanceof Error ? e.message : String(e))
/** Ayarlar → Hane ve üyeler: ad, üyeler (çıkar), davet bağlantıları (yalnız sahip yönetir) */
export function HouseholdPage() {
const me = useMe()
const household = useHousehold()
const isOwner = me.data?.user.role === 'owner'
return (
<SettingsSubpage title={s.title} description={s.description}>
{household.isError ? (
<PageError message={household.error.message} onRetry={household.refetch} />
) : !household.data || !me.data ? (
<PageLoading />
) : (
<div className="max-w-2xl space-y-4">
{isOwner ? (
<NameCard household={household.data} />
) : (
<p className="rounded-xl bg-muted px-3 py-2 text-sm text-muted-foreground">
{s.ownerOnly}
</p>
)}
<Members household={household.data} myId={me.data.user.id} isOwner={isOwner} />
{isOwner && <Invites householdName={household.data.name} />}
</div>
)}
</SettingsSubpage>
)
}
function NameCard({ household }: { household: Household }) {
const update = useUpdateHousehold()
const {
register,
handleSubmit,
setError,
reset,
formState: { errors, isDirty },
} = useForm<NameForm>({
resolver: zodResolver(nameSchema),
mode: 'onTouched',
defaultValues: { name: household.name },
})
const onSubmit = handleSubmit(async (v) => {
try {
const h = await update.mutateAsync({ name: v.name })
reset({ name: h.name })
toast.success(s.saved)
} catch (e) {
setError('name', { message: message(e) })
}
})
return (
<Section title={s.name}>
<form onSubmit={onSubmit} className="flex flex-wrap items-start gap-2" noValidate>
<FormField label={s.name} error={errors.name} className="min-w-0 flex-1 [&>label]:sr-only">
{(f) => <Input {...f} {...register('name')} autoComplete="off" maxLength={80} />}
</FormField>
<Button type="submit" disabled={!isDirty || update.isPending}>
{update.isPending && <Loader2 className="animate-spin" aria-hidden />}
{s.save}
</Button>
</form>
</Section>
)
}
function Members({
household,
myId,
isOwner,
}: {
household: Household
myId: string
isOwner: boolean
}) {
const remove = useRemoveMember()
async function onRemove(id: string, name: string) {
if (!window.confirm(s.removeConfirm(name))) return
try {
await remove.mutateAsync(id)
toast.success(s.removed(name))
} catch (e) {
toast.error(message(e))
}
}
return (
<Section title={s.members}>
<ul className="divide-y">
{household.members.map((m) => (
<li key={m.id} className="flex min-h-14 items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{m.name} {m.id === myId && <span className="text-muted-foreground">{s.you}</span>}
</p>
<p className="truncate text-sm text-muted-foreground">
{m.email} · {m.role === 'owner' ? t.account.owner : t.account.member}
</p>
</div>
{isOwner && m.role !== 'owner' && (
<Button
variant="ghost"
size="icon"
className="text-expense"
aria-label={s.remove(m.name)}
disabled={remove.isPending}
onClick={() => void onRemove(m.id, m.name)}
>
<UserMinus aria-hidden />
</Button>
)}
</li>
))}
</ul>
</Section>
)
}
function Invites({ householdName }: { householdName: string }) {
const invites = useInvites(true)
const create = useCreateInvite()
const [error, setError] = useState<string>()
async function onCreate() {
setError(undefined)
try {
const inv = await create.mutateAsync()
await copy(inv)
} catch (e) {
setError(message(e))
}
}
return (
<Section title={s.invites}>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">{s.invitesHint}</p>
<Button onClick={() => void onCreate()} disabled={create.isPending}>
{create.isPending ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<UserPlus aria-hidden />
)}
{s.newInvite}
</Button>
<FormError message={error} />
{invites.data && invites.data.length === 0 && (
<p className="text-sm text-muted-foreground">{s.noInvites}</p>
)}
{invites.data && invites.data.length > 0 && (
<ul className="space-y-2">
{invites.data.map((inv) => (
<InviteRow key={inv.id} invite={inv} householdName={householdName} />
))}
</ul>
)}
</div>
</Section>
)
}
/** Panoya kopyala (HTTPS / localhost gerekir); olmazsa kullanıcı elle seçer */
async function copy(inv: Invite) {
try {
await navigator.clipboard.writeText(inviteLink(inv.code))
toast.success(s.copied)
} catch {
toast.error(s.copyFailed)
}
}
function InviteRow({ invite, householdName }: { invite: Invite; householdName: string }) {
const revoke = useRevokeInvite()
const link = inviteLink(invite.code)
const canShare = typeof navigator !== 'undefined' && 'share' in navigator
return (
<li className="space-y-2 rounded-xl border p-3">
<Input
readOnly
value={link}
aria-label={s.code(invite.code)}
onFocus={(e) => e.currentTarget.select()}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{s.code(invite.code)} · {s.expires(formatDate(invite.expires_at))}
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => void copy(invite)}>
<Copy aria-hidden />
{s.copy}
</Button>
{canShare && (
<Button
variant="outline"
onClick={() =>
void navigator
.share({ text: s.shareText(householdName), url: link })
.catch(() => undefined)
}
>
<Share2 aria-hidden />
{s.share}
</Button>
)}
<Button
variant="ghost"
className="ml-auto text-expense"
disabled={revoke.isPending}
onClick={() =>
revoke.mutate(invite.id, {
onSuccess: () => toast(s.revoked),
onError: (e) => toast.error(message(e)),
})
}
>
{s.revoke}
</Button>
</div>
</li>
)
}
+13 -1
View File
@@ -1,4 +1,13 @@
import { ChevronRight, Database, Tags, Wallet, type LucideIcon } from 'lucide-react'
import {
ChevronRight,
Database,
PieChart,
Tags,
UserRound,
Users,
Wallet,
type LucideIcon,
} from 'lucide-react'
import { Link } from 'react-router'
import { paths } from '@/components/layout/nav-items'
import { PageHeader } from '@/components/page'
@@ -10,6 +19,9 @@ 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.settingsBudget, icon: PieChart, ...s.menu.budget },
{ to: paths.settingsHousehold, icon: Users, ...s.menu.household },
{ to: paths.settingsAccount, icon: UserRound, ...s.menu.account },
{ to: paths.settingsData, icon: Database, ...s.menu.data },
]
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { budgetSchema, passwordSchema } from './schemas'
describe('settings forms', () => {
it('password: length, repeat and different from current', () => {
const ok = {
current_password: 'eski-sifre',
new_password: 'yeni-sifre-1',
repeat: 'yeni-sifre-1',
}
expect(passwordSchema.safeParse(ok).success).toBe(true)
const issues = (v: object) =>
passwordSchema.safeParse({ ...ok, ...v }).error?.issues.map((i) => i.path.join('.'))
expect(issues({ new_password: 'kisa', repeat: 'kisa' })).toContain('new_password')
expect(issues({ repeat: 'baska' })).toEqual(['repeat'])
expect(issues({ new_password: 'eski-sifre', repeat: 'eski-sifre' })).toEqual(['new_password'])
})
it('budget split must total 100', () => {
const base = {
needs: '50',
wants: '30',
culture: '10',
unexpected: '10',
budget_suggestion: 'avg3',
}
expect(budgetSchema.parse(base)).toMatchObject({ needs: 50, budget_suggestion: 'avg3' })
const r = budgetSchema.safeParse({ ...base, needs: '60' })
expect(r.success).toBe(false)
expect(r.error?.issues[0].path).toEqual(['root'])
expect(budgetSchema.safeParse({ ...base, needs: '50.5' }).success).toBe(false)
})
})
+44
View File
@@ -0,0 +1,44 @@
import { z } from 'zod'
import { t } from '@/lib/strings'
const a = t.settings.account
const b = t.settings.budget
export const nameSchema = z.object({
name: z.string().trim().min(1, t.settings.catalog.nameRequired).max(80, 'En fazla 80 karakter'),
})
export type NameForm = z.infer<typeof nameSchema>
export const passwordSchema = z
.object({
current_password: z.string().min(1, a.currentRequired),
new_password: z.string().min(8, a.tooShort).max(128, 'En fazla 128 karakter'),
repeat: z.string(),
})
.refine((v) => v.repeat === v.new_password, { path: ['repeat'], message: a.mismatch })
.refine((v) => v.new_password !== v.current_password, {
path: ['new_password'],
message: a.same,
})
export type PasswordForm = z.infer<typeof passwordSchema>
const pct = z.coerce
.number({ message: '0–100 arası' })
.int('Tam sayı girin')
.min(0, '0–100 arası')
.max(100, '0–100 arası')
export const budgetSchema = z
.object({
needs: pct,
wants: pct,
culture: pct,
unexpected: pct,
budget_suggestion: z.enum(['default', 'previous', 'avg3']),
})
.refine((v) => v.needs + v.wants + v.culture + v.unexpected === 100, {
path: ['root'],
message: b.totalError,
})
export type BudgetForm = z.input<typeof budgetSchema>
export type BudgetValues = z.output<typeof budgetSchema>
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { inviteLink, pctTotal } from './household'
describe('household helpers', () => {
it('builds the invite link for the register page', () => {
expect(inviteLink('ABCD-EFGH', 'https://kakeibo.ornek')).toBe(
'https://kakeibo.ornek/kayit?davet=ABCD-EFGH',
)
})
it('sums percentages, ignoring empty fields', () => {
expect(pctTotal({ needs: 50, wants: '30', culture: '', unexpected: 10 })).toBe(90)
})
})
+90
View File
@@ -0,0 +1,90 @@
/**
* Hane, üyeler, davetler, profil ve şifre (Ayarlar). Hane adı / üyeler değişince `me` de
* yenilenir (kenar çubuğunda hane adı görünür).
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { Household } from '@/types/Household'
import type { HouseholdUpdateRequest } from '@/types/HouseholdUpdateRequest'
import type { Invite } from '@/types/Invite'
import type { MeResponse } from '@/types/MeResponse'
import type { PasswordChangeRequest } from '@/types/PasswordChangeRequest'
import { api } from './api'
import { queryKeys } from './query'
export function useHousehold() {
return useQuery({
queryKey: queryKeys.household,
queryFn: () => api.get<Household>('/household'),
})
}
export function useUpdateHousehold() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: HouseholdUpdateRequest) => api.patch<Household>('/household', body),
onSuccess: (h) => {
qc.setQueryData(queryKeys.household, h)
void qc.invalidateQueries({ queryKey: queryKeys.me })
},
})
}
export function useRemoveMember() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete<void>(`/household/members/${id}`),
onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.household }),
})
}
export function useInvites(enabled: boolean) {
return useQuery({
queryKey: queryKeys.invites,
queryFn: () => api.get<Invite[]>('/household/invites'),
enabled,
})
}
export function useCreateInvite() {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post<Invite>('/household/invites'),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.invites }),
})
}
export function useRevokeInvite() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete<void>(`/household/invites/${id}`),
onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.invites }),
})
}
/** Davet bağlantısı: kayıt ekranı `?davet=` ile "Davetle katıl"ı seçili açar */
export function inviteLink(code: string, origin = window.location.origin): string {
return `${origin}/kayit?davet=${encodeURIComponent(code)}`
}
export function useUpdateProfile() {
const qc = useQueryClient()
return useMutation({
mutationFn: (name: string) => api.patch<MeResponse>('/auth/me', { name }),
onSuccess: (me) => {
qc.setQueryData(queryKeys.me, me)
void qc.invalidateQueries({ queryKey: queryKeys.household })
},
})
}
/** Başarılıysa sunucu bu cihaza yeni oturum çerezi verir; diğer cihazlar çıkış yapar */
export function useChangePassword() {
return useMutation({
mutationFn: (body: PasswordChangeRequest) => api.post<void>('/auth/me/password', body),
})
}
/** Yüzdelerin toplamı (bütçe varsayılanları formu) */
export function pctTotal(p: Record<string, number | string>): number {
return Object.values(p).reduce<number>((a, v) => a + (Number(v) || 0), 0)
}
+1
View File
@@ -10,6 +10,7 @@ export const queryKeys = {
registration: ['registration'] as const,
health: ['health'] as const,
household: ['household'] as const,
invites: ['household', 'invites'] as const,
categories: (includeInactive = false) => ['categories', { includeInactive }] as const,
paymentMethods: (includeInactive = false) => ['payment-methods', { includeInactive }] as const,
/** Dönem listesi; `currentPeriod` da bu önekle başlar (birlikte yenilenir) */
+71
View File
@@ -308,6 +308,77 @@ export const t = {
},
paymentMethods: { title: 'Ödeme türleri', hint: 'Nakit, kart, havale…' },
data: { title: 'Veri ve yedek', hint: 'JSON yedek, CSV, banka ekstresi' },
household: { title: 'Hane ve üyeler', hint: 'Hane adı, üyeler, davet bağlantısı' },
account: { title: 'Hesabım', hint: 'Adın, e-postan, şifre değiştirme' },
budget: { title: 'Bütçe varsayılanları', hint: 'Öneri yüzdeleri ve yöntemi' },
},
household: {
title: 'Hane ve üyeler',
description:
'Defteri birlikte tuttuğun kişiler. Herkes aynı verileri görür ve kayıt girebilir.',
name: 'Hane adı',
save: 'Kaydet',
saved: 'Hane adı kaydedildi',
members: 'Üyeler',
you: '(sen)',
remove: (name: string) => `${name}: haneden çıkar`,
removeConfirm: (name: string) =>
`${name} haneden çıkarılsın mı? Oturumu hemen kapanır; girdiği kayıtlar hanede kalır. İstersen sonra yeniden davet edebilirsin.`,
removed: (name: string) => `${name} haneden çıkarıldı`,
invites: 'Davet',
invitesHint:
'Bağlantıyı eşine ya da ev arkadaşına gönder: açınca kayıt ekranı “Davetle katıl” seçili gelir. Her bağlantı bir kez kullanılır ve 7 gün geçerlidir.',
newInvite: 'Davet bağlantısı oluştur',
copy: 'Bağlantıyı kopyala',
copied: 'Bağlantı kopyalandı',
copyFailed: 'Kopyalanamadı; bağlantıyı seçip elle kopyala.',
share: 'Paylaş',
shareText: (household: string) => `Kakeibo'da “${household}” defterine katıl:`,
code: (code: string) => `Kod: ${code}`,
expires: (date: string) => `${date} tarihine kadar geçerli`,
revoke: 'İptal et',
revoked: 'Davet iptal edildi',
noInvites: 'Bekleyen davet yok.',
ownerOnly: 'Üye ekleme ve hane ayarları yalnızca hane sahibindedir.',
},
account: {
title: 'Hesabım',
description: 'Profil bilgilerin ve şifren.',
profile: 'Profil',
name: 'Adın',
email: 'E-posta',
emailHint: 'E-posta adresi giriş için kullanılır; şimdilik değiştirilemez.',
role: 'Rolün',
save: 'Kaydet',
saved: 'Adın kaydedildi',
password: 'Şifre değiştir',
current: 'Mevcut şifre',
next: 'Yeni şifre',
nextHint: 'En az 8 karakter.',
repeat: 'Yeni şifre (tekrar)',
change: 'Şifreyi değiştir',
changed: 'Şifren değişti. Diğer cihazlarda oturum kapandı.',
otherDevices:
'Şifreni değiştirince bu cihaz açık kalır, diğer tüm cihazlarda yeniden giriş gerekir.',
currentRequired: 'Mevcut şifreyi girin',
tooShort: 'Yeni şifre en az 8 karakter olmalı',
mismatch: 'Şifreler aynı değil',
same: 'Yeni şifre mevcut şifreden farklı olmalı',
},
budget: {
title: 'Bütçe varsayılanları',
description:
'Yeni dönemde bütçe önerisi bu ayarlardan gelir; her dönemde istediğin gibi değiştirebilirsin.',
split: 'Varsayılan dağılım',
splitHint: 'Harcanabilir tutarın gruplara yüzdesi. Kakeibo klasiği: 50 / 30 / 10 / 10.',
total: (n: number) => `Toplam: %${n}`,
totalError: 'Toplam %100 olmalı',
classic: '50 / 30 / 10 / 10',
method: 'Önce seçili öneri yöntemi',
methodHint: 'Plan ekranında ve yeni dönem sihirbazında ilk gösterilen öneri.',
save: 'Kaydet',
saved: 'Bütçe varsayılanları kaydedildi',
readOnly: 'Bu ayarları yalnızca hane sahibi değiştirebilir.',
},
categories: {
title: 'Kategoriler',
+6
View File
@@ -18,7 +18,10 @@ 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 { AccountPage } from '@/features/settings/account-page'
import { BudgetDefaultsPage } from '@/features/settings/budget-page'
import { DataPage } from '@/features/settings/data-page'
import { HouseholdPage } from '@/features/settings/household-page'
import { SettingsPage } from '@/features/settings/page'
import { StatementPage } from '@/features/statement/page'
@@ -52,6 +55,9 @@ const routes: RouteObject[] = [
{ path: paths.settingsCategories, element: <CategoriesPage /> },
{ path: paths.settingsPaymentMethods, element: <PaymentMethodsPage /> },
{ path: paths.settingsData, element: <DataPage /> },
{ path: paths.settingsHousehold, element: <HouseholdPage /> },
{ path: paths.settingsAccount, element: <AccountPage /> },
{ path: paths.settingsBudget, element: <BudgetDefaultsPage /> },
{ path: paths.statement, element: <StatementPage /> },
{ path: paths.more, element: <MorePage /> },
{ path: '*', element: <NotFoundPage /> },
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type BudgetSplitPct = { needs: number, wants: number, culture: number, unexpected: number, };
+2 -1
View File
@@ -1,4 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HouseholdSettings } from "./HouseholdSettings";
import type { Member } from "./Member";
export type Household = { id: string, name: string, members: Array<Member>, };
export type Household = { id: string, name: string, members: Array<Member>, settings: HouseholdSettings, };
+12
View File
@@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BudgetSplitPct } from "./BudgetSplitPct";
export type HouseholdSettings = {
/**
* Varsayılan bütçe dağılımı (yüzde, toplam 100)
*/
budget_split_pct: BudgetSplitPct,
/**
* Plan ekranında önce seçili öneri yöntemi
*/
budget_suggestion: "default" | "previous" | "avg3", };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BudgetSplitPct } from "./BudgetSplitPct";
export type HouseholdUpdateRequest = { name?: string, budget_split_pct?: BudgetSplitPct, budget_suggestion?: "default" | "previous" | "avg3", };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PasswordChangeRequest = { current_password: string, new_password: string, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ProfileRequest = { name: string, };