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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
Reference in New Issue
Block a user