feat(plan): devreden borç planın parçası, borçlu ayda %45 kuralı

- Devreden borç: elle girilen ya da dönem başından önceki son ay sonu ölçümü
- Borçlu ay: harcanabilir = gelir × oran (vars. %45), kalan borca, artan birikime
- Birikim hedefi ve Kakeibo devri borçlu ayda uygulanmaz; kapanış devir üretmez
- Plan düzenleme ve sihirbazda devreden borç alanı + oran; canlı önizleme
- Plan'daki eski borç bölümü ve taksit önerisi kaldırıldı (veri yerinde)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 06:27:12 +03:00
parent d3e012f50b
commit f0d285a9ba
34 changed files with 1376 additions and 462 deletions
-283
View File
@@ -1,283 +0,0 @@
import { useState } from 'react'
import { CircleCheck, Landmark, Loader2, Pencil, Plus } from 'lucide-react'
import { Link } from 'react-router'
import { toast } from 'sonner'
import { FormError } from '@/components/form-field'
import { paths } from '@/components/layout/nav-items'
import { MoneyInput } from '@/components/money-input'
import { Button } from '@/components/ui/button'
import { findInstallmentItem, usePeriodDebt, useSetOpeningDebt } from '@/lib/debt'
import { formatDate, formatMoney, formatNumber, parseMoney } from '@/lib/format'
import { useCreatePlanItem } from '@/lib/periods'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { Period } from '@/types/Period'
import type { PeriodDebt } from '@/types/PeriodDebt'
import type { PlanItem } from '@/types/PlanItem'
const s = t.debt.period
/**
* Aylık plan: bu ayın banka borcu (docs/BORC_TAKIBI.md §3). Ay başı borç son ölçümden
* (elle düzeltilebilir), devreden, borç ödemesi (= birikim hedefi), taksit yükü, hedef ay sonu.
* Borç takibinde hiç veri yoksa görünmez.
*/
export function PeriodDebtSection({
period,
items,
writable,
}: {
period: Period
items: PlanItem[]
writable: boolean
}) {
const q = usePeriodDebt(period.id)
const d = q.data
if (!d || (d.opening_total === null && d.installment_load === 0)) return null
return (
<section
aria-labelledby="period-debt-title"
className="min-w-0 rounded-2xl border bg-card p-4 sm:p-5"
>
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
<h3 id="period-debt-title" className="flex items-center gap-1.5 font-semibold">
<Landmark className="size-4" aria-hidden />
{s.title}
</h3>
<Link
to={paths.debt}
className="inline-flex min-h-11 items-center px-2 text-sm text-primary"
>
{s.toDebt}
</Link>
</div>
<p className="mb-3 text-sm text-muted-foreground">{s.hint}</p>
<dl className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
<Opening period={period} d={d} writable={writable} />
<Tile label={s.carried} hint={s.carriedHint} amount={d.carried} />
<Tile label={s.payment} sign="−" amount={d.payment} />
<Tile label={s.load} sign="−" amount={d.installment_load} />
<Target d={d} />
</dl>
<Progress d={d} />
{writable && (
<InstallmentsSuggestion period={period} items={items} load={d.installment_load} />
)}
</section>
)
}
function Tile({
label,
amount,
sign,
hint,
}: {
label: string
amount: number | null
sign?: string
hint?: string
}) {
return (
<div className="rounded-xl border p-3">
<dt className="text-xs text-muted-foreground">
{sign && <span aria-hidden>{sign} </span>}
{label}
</dt>
<dd className="money mt-1 font-semibold">{amount === null ? '—' : formatMoney(amount)}</dd>
{hint && <p className="mt-0.5 text-[11px] text-muted-foreground">{hint}</p>}
</div>
)
}
/** Ay başı borç: kaynağı yazılı; düzeltilebilir (dönem açıkken) */
function Opening({ period, d, writable }: { period: Period; d: PeriodDebt; writable: boolean }) {
const set = useSetOpeningDebt(period.id)
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const [error, setError] = useState<string>()
const source =
d.opening_source === 'manual'
? s.manual
: d.opening_date
? s.fromSnapshot(formatDate(d.opening_date))
: s.noOpening
async function save(amount: number | null) {
setError(undefined)
try {
await set.mutateAsync(amount)
setEditing(false)
toast.success(s.openingSaved)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
return (
<div
className={cn('rounded-xl border p-3', editing && 'col-span-2 sm:col-span-3 lg:col-span-5')}
>
<dt className="flex items-center justify-between gap-1 text-xs text-muted-foreground">
{s.opening}
{writable && !editing && (
<button
type="button"
aria-label={s.editOpening}
className="-m-2 grid size-11 place-items-center rounded-lg hover:bg-muted"
onClick={() => {
setValue(d.opening_total === null ? '' : formatNumber(d.opening_total / 100))
setEditing(true)
}}
>
<Pencil className="size-3.5" aria-hidden />
</button>
)}
</dt>
{editing ? (
<div className="mt-2 space-y-2">
<MoneyInput aria-label={s.opening} value={value} onChange={setValue} autoFocus />
<FormError message={error} />
<div className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={set.isPending}
onClick={() => {
const k = parseMoney(value)
if (k === null || k < 0) return setError('Geçerli bir tutar yazın')
void save(k)
}}
>
{set.isPending && <Loader2 className="animate-spin" aria-hidden />}
{t.debt.save}
</Button>
{d.opening_source === 'manual' && (
<Button size="sm" variant="outline" onClick={() => void save(null)}>
{s.resetOpening}
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
{t.debt.cancel}
</Button>
</div>
</div>
) : (
<>
<dd className="money mt-1 font-semibold">
{d.opening_total === null ? '—' : formatMoney(d.opening_total)}
</dd>
<p className="mt-0.5 text-[11px] text-muted-foreground">{source}</p>
</>
)}
</div>
)
}
/** Hedef ay sonu borç (+ yol haritasıyla karşılaştırma) */
function Target({ d }: { d: PeriodDebt }) {
const diff =
d.closing_target !== null && d.roadmap_target !== null
? d.closing_target - d.roadmap_target
: null
return (
<div className="col-span-2 rounded-xl border-2 border-primary/40 bg-primary/5 p-3 sm:col-span-1">
<dt className="text-xs font-medium text-muted-foreground">
<span aria-hidden>= </span>
{s.target}
</dt>
<dd className="money mt-1 text-lg font-bold">
{d.closing_target === null ? '—' : formatMoney(d.closing_target)}
</dd>
{d.roadmap_target !== null && (
<p className="mt-0.5 text-[11px] text-muted-foreground">
{s.roadmap(formatMoney(d.roadmap_target))}
{diff !== null && diff !== 0 && (
<span className={diff > 0 ? 'text-expense' : 'text-income'}>
{' · '}
{diff > 0
? s.vsRoadmapAbove(formatMoney(diff))
: s.vsRoadmapBelow(formatMoney(-diff))}
</span>
)}
</p>
)}
</div>
)
}
/** Aylık başarı: ay başı − son ölçüm ≥ borç ödemesi + taksit */
function Progress({ d }: { d: PeriodDebt }) {
if (d.opening_total === null || d.latest_total === null || !d.latest_date) return null
const down = d.opening_total - d.latest_total
const needed = d.payment + d.installment_load
const ok = down >= needed && needed > 0
const ratio = needed > 0 ? Math.min(Math.max(down / needed, 0), 1) : 0
return (
<div className="mt-3 space-y-1.5 rounded-xl bg-muted p-3 text-sm">
<p className="text-xs text-muted-foreground">{s.progressTitle(formatDate(d.latest_date))}</p>
<p className={cn('font-medium', down < 0 ? 'text-expense' : ok ? 'text-income' : '')}>
{ok && <CircleCheck className="mr-1 inline size-4" aria-hidden />}
{down >= 0 ? s.paidDown(formatMoney(down)) : s.grew(formatMoney(-down))}
<span className="font-normal text-muted-foreground">
{' '}
· {s.needed(formatMoney(needed))}
</span>
</p>
<div
className="h-2 overflow-hidden rounded-full bg-background"
role="progressbar"
aria-label={s.needed(formatMoney(needed))}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(ratio * 100)}
>
<div className="h-full rounded-full bg-income" style={{ width: `${ratio * 100}%` }} />
</div>
</div>
)
}
/** Taksit yükü planda "Taksitler" sabit gideri olarak yoksa: tek tıkla ekle */
function InstallmentsSuggestion({
period,
items,
load,
}: {
period: Period
items: PlanItem[]
load: number
}) {
const create = useCreatePlanItem(period.id)
if (load <= 0) return null
const existing = findInstallmentItem(items)
if (existing) {
if (existing.amount === load) return null
return (
<p className="mt-3 text-sm text-warning">
{s.installmentsMismatch(formatMoney(existing.amount), formatMoney(load))}
</p>
)
}
return (
<Button
variant="outline"
className="mt-3"
disabled={create.isPending}
onClick={() =>
create.mutate(
{ kind: 'expense', name: s.installmentsName, amount: load },
{
onSuccess: () => toast.success(s.installmentsAdded),
onError: (e) => toast.error(e.message),
},
)
}
>
{create.isPending ? <Loader2 className="animate-spin" aria-hidden /> : <Plus aria-hidden />}
{s.addInstallments(formatMoney(load))}
</Button>
)
}
@@ -42,7 +42,10 @@ export function itemSchema(start: string, last: string) {
export type ItemForm = z.infer<ReturnType<typeof itemSchema>>
/** 4. adım */
export const savingsSchema = z.object({ goal: moneyText('optional') })
export const savingsSchema = z.object({
goal: moneyText('optional'),
carried: moneyText('optional'),
})
export type SavingsForm = z.infer<typeof savingsSchema>
/** 5. adım: 4 grubun aylık bütçesi */
@@ -13,7 +13,6 @@ 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'
import { useDebt } from '@/lib/debt'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { GroupAmounts } from '@/types/GroupAmounts'
@@ -88,7 +87,6 @@ function BudgetStepForm({
onBack: () => void
onDone: () => void
}) {
const debt = useDebt()
const setBudget = useSetBudget(period.id)
const {
control,
@@ -124,7 +122,7 @@ function BudgetStepForm({
return (
<form onSubmit={onSubmit} noValidate>
<StepIntro title={s.title} hint={s.hint} />
<SummaryTiles summary={summary} debtPayment={(debt.data?.latest?.total ?? 0) > 0} />
<SummaryTiles summary={summary} />
<div className="mt-6">
<SuggestionPicker periodId={period.id} onApply={(a) => reset(toForm(a))} />
@@ -3,19 +3,33 @@ import { Controller, useForm, useWatch } from 'react-hook-form'
import { FormError, FormField } from '@/components/form-field'
import { MoneyInput } from '@/components/money-input'
import { PageError, PageLoading } from '@/components/page'
import { CarriedDebtField } from '@/features/planning/carried-debt-field'
import { ApiError } from '@/lib/api'
import {
carriedText,
DEFAULT_SPENDABLE_RATE_BP,
effectiveCarried,
formatRate,
parseCarried,
previewSummary,
} from '@/lib/debt-plan'
import { useSetOpeningDebt } from '@/lib/debt'
import { formatMoney, formatNumber, parseMoney } from '@/lib/format'
import { toKurus } from '@/lib/money-schema'
import { useSummary, useUpdatePeriod } from '@/lib/periods'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { Period } from '@/types/Period'
import type { PeriodSummary } from '@/types/PeriodSummary'
import { savingsSchema, type SavingsForm } from './schemas'
import { StepActions, StepIntro } from './wizard-ui'
const s = t.wizard.savings
/** 4. adım: birikim hedefi (harcamadan önce ayrılır). */
/**
* 4. adım: devreden borç + birikim hedefi (harcamadan önce ayrılır).
* Devreden borç > 0 → borçlu ay: harcanabilir gelirin %45'i, kalan borca; birikim kapalı.
*/
export function StepSavings({
period,
onBack,
@@ -26,7 +40,25 @@ export function StepSavings({
onNext: () => void
}) {
const summary = useSummary(period.id)
if (summary.isPending) return <PageLoading />
if (summary.isError)
return <PageError message={summary.error.message} onRetry={summary.refetch} />
return <SavingsForm period={period} summary={summary.data} onBack={onBack} onNext={onNext} />
}
function SavingsForm({
period,
summary,
onBack,
onNext,
}: {
period: Period
summary: PeriodSummary
onBack: () => void
onNext: () => void
}) {
const update = useUpdatePeriod()
const setCarried = useSetOpeningDebt(period.id)
const {
control,
@@ -36,23 +68,32 @@ export function StepSavings({
} = useForm<SavingsForm>({
resolver: zodResolver(savingsSchema),
mode: 'onTouched',
defaultValues: { goal: period.savings_goal ? formatNumber(period.savings_goal / 100) : '' },
defaultValues: {
goal: period.savings_goal ? formatNumber(period.savings_goal / 100) : '',
carried: carriedText(summary),
},
})
const goalText = useWatch({ control, name: 'goal' })
const carriedValue = useWatch({ control, name: 'carried' })
if (summary.isPending) return <PageLoading />
if (summary.isError)
return <PageError message={summary.error.message} onRetry={summary.refetch} />
// Birikimden önceki kalan = harcanabilir + (kayıtlı) birikim hedefi → formül yalnızca backend'de
const available = summary.data.spendable + summary.data.savings_goal
const goal = parseMoney(goalText) ?? 0
const after = available - Math.max(goal, 0)
const preview = previewSummary(summary, {
income: summary.income_total,
fixed: summary.fixed_expense_total,
savings: Math.max(parseMoney(goalText) ?? 0, 0),
carried: effectiveCarried(carriedValue, summary),
rateBp: period.spendable_rate_bp ?? DEFAULT_SPENDABLE_RATE_BP,
budget: summary.budget_total,
})
const available = summary.income_total - summary.fixed_expense_total
const after = preview.spendable
const onSubmit = handleSubmit(async (v) => {
const kurus = toKurus(v.goal)
const carried = parseCarried(v.carried)
const manual = summary.carried_debt_source === 'manual' ? summary.carried_debt : null
try {
if (kurus !== period.savings_goal) {
if (carried !== manual) await setCarried.mutateAsync(carried)
if (!preview.debt_mode && kurus !== period.savings_goal) {
await update.mutateAsync({ id: period.id, version: period.version, savings_goal: kurus })
}
onNext()
@@ -67,18 +108,54 @@ export function StepSavings({
<div className="space-y-5">
<Row label={s.available} amount={available} />
<FormField label={s.goal} error={errors.goal}>
{(f) => (
<Controller
control={control}
name="goal"
render={({ field }) => (
<MoneyInput {...f} {...field} placeholder="0,00" className="h-12 text-lg" />
)}
<Controller
control={control}
name="carried"
render={({ field }) => (
<CarriedDebtField
summary={summary}
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
error={errors.carried}
/>
)}
</FormField>
<Row label={s.after} amount={after} strong />
/>
{preview.debt_mode ? (
<>
<Row label={t.summary.debtPayment} amount={preview.debt_payment} />
<p className="text-sm text-muted-foreground italic">
{s.goal}: {t.carried.savingsLocked}
</p>
</>
) : (
<FormField label={s.goal} error={errors.goal}>
{(f) => (
<Controller
control={control}
name="goal"
render={({ field }) => (
<MoneyInput {...f} {...field} placeholder="0,00" className="h-12 text-lg" />
)}
/>
)}
</FormField>
)}
<Row
label={
preview.debt_mode
? `${s.after} (${t.summary.rateShort(formatRate(preview.spendable_rate_bp))})`
: s.after
}
amount={after}
strong
/>
{preview.debt_fixed_too_high && (
<p role="alert" className="text-sm font-medium text-expense">
{t.summary.fixedTooHigh}
</p>
)}
{after < 0 && (
<p role="status" className="text-sm text-warning">
{s.negative}
@@ -0,0 +1,72 @@
import type { FieldError } from 'react-hook-form'
import { FormField } from '@/components/form-field'
import { MoneyInput } from '@/components/money-input'
import { formatDate, formatMoney, formatNumber } from '@/lib/format'
import { parseCarried } from '@/lib/debt-plan'
import { t } from '@/lib/strings'
import type { PeriodSummary } from '@/types/PeriodSummary'
const s = t.carried
/**
* Devreden banka borcu (KMH + kart). Boş → son ay sonu ölçümünden otomatik; yazılırsa elle
* düzeltme ("elle düzeltildi"), `0` → borç bitti. Kaydetme çağıranın formunda.
*/
export function CarriedDebtField({
summary,
value,
onChange,
onBlur,
name,
error,
}: {
summary: PeriodSummary
value: string
onChange: (v: string) => void
onBlur?: () => void
name?: string
error?: FieldError
}) {
const manual = parseCarried(value)
const auto = summary.carried_debt_auto
const info =
manual === 0
? s.zero
: manual === null
? auto !== null && summary.carried_debt_date
? s.fromSnapshot(formatDate(summary.carried_debt_date), formatMoney(auto))
: s.noSnapshot
: null
return (
<div className="space-y-1.5">
<FormField label={s.label} error={error} className="max-w-xs">
{(f) => (
<MoneyInput
{...f}
name={name}
value={value}
onChange={onChange}
onBlur={onBlur}
placeholder={auto !== null ? formatNumber(auto / 100) : '0,00'}
/>
)}
</FormField>
{manual !== null && (
<span className="inline-block rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning">
{s.manual}
</span>
)}
{info && <p className="money text-xs text-muted-foreground">{info}</p>}
{manual !== null && auto !== null && (
<button
type="button"
className="min-h-11 text-sm font-medium text-primary underline-offset-4 hover:underline"
onClick={() => onChange('')}
>
{s.backToAuto} ({formatMoney(auto)})
</button>
)}
</div>
)
}
+1 -6
View File
@@ -4,9 +4,7 @@ import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { NotesSection } from '@/features/notes/notes-section'
import { CopyPrevious } from '@/features/period-wizard/step-items'
import { PeriodDebtSection } from '@/features/debt/period-debt'
import { PeriodPicker } from '@/features/periods/period-picker'
import { useDebt } from '@/lib/debt'
import { SelectedPeriod } from '@/features/periods/selected-period'
import { isWritable } from '@/lib/period-select'
import { useBudget, usePlanItems, useSummary } from '@/lib/periods'
@@ -34,8 +32,6 @@ function PlanView({ period }: { period: Period }) {
const items = usePlanItems(period.id)
const summary = useSummary(period.id)
const budget = useBudget(period.id)
const debt = useDebt()
const inDebt = (debt.data?.latest?.total ?? 0) > 0
const [editing, setEditing] = useState(false)
const failed = [items, summary, budget].find((q) => q.isError)
@@ -71,8 +67,7 @@ function PlanView({ period }: { period: Period }) {
)
}
/>
<SummaryTiles summary={summary.data} debtPayment={inDebt} />
<PeriodDebtSection period={period} items={items.data} writable={editable} />
<SummaryTiles summary={summary.data} />
<div className="grid gap-4 lg:grid-cols-2">
<ItemsList kind="income" list={items.data.filter((i) => i.kind === 'income')} />
<ItemsList kind="expense" list={items.data.filter((i) => i.kind === 'expense')} />
+67 -25
View File
@@ -23,7 +23,12 @@ import { formatDayMonth, formatMoney, formatNumber, parseMoney } from '@/lib/for
import { group, GROUPS } from '@/lib/groups'
import { useSavePlan } from '@/lib/periods'
import { diffPlan, GROUP_CODES, hasChanges, moneyToText, toDraft } from '@/lib/plan-draft'
import { useDebt } from '@/lib/debt'
import {
DEFAULT_SPENDABLE_RATE_BP,
effectiveCarried,
parseRate,
previewSummary,
} from '@/lib/debt-plan'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { GroupBudget } from '@/types/GroupBudget'
@@ -35,6 +40,7 @@ import { DistributionBar } from './distribution-bar'
import { PeriodHeading, Section } from './plan-read'
import { planSchema, type PlanForm } from './schema'
import { SuggestionPicker } from './suggestion-picker'
import { CarriedDebtField } from './carried-debt-field'
import { SummaryTiles } from './summary-tiles'
const s = t.plan
@@ -60,7 +66,6 @@ export function PlanEdit({
summary: PeriodSummary
onClose: () => void
}) {
const debt = useDebt()
const save = useSavePlan(period)
const {
control,
@@ -71,29 +76,27 @@ export function PlanEdit({
} = useForm<PlanForm>({
resolver: zodResolver(planSchema(period.start_date, period.last_date)),
mode: 'onTouched',
defaultValues: toDraft(period, items, budget),
defaultValues: toDraft(period, items, budget, summary),
})
const v = useWatch({ control })
// ---- Canlı önizleme: kaydedilmiş özet + taslaktaki farklar (formül backend'de kalır)
const incomeTotal = (v.incomes ?? []).reduce((a, r) => a + k(r?.amount), 0)
const fixedTotal = (v.expenses ?? []).reduce((a, r) => a + k(r?.amount), 0)
const savings = k(v.savings)
const spendable =
summary.spendable +
(incomeTotal - summary.income_total) -
(fixedTotal - summary.fixed_expense_total) -
(savings - summary.savings_goal)
const budgetTotal = GROUP_CODES.reduce((a, g) => a + k(v.budget?.[g]), 0)
const preview: PeriodSummary = {
...summary,
income_total: incomeTotal,
fixed_expense_total: fixedTotal,
savings_goal: savings,
spendable,
budget_total: budgetTotal,
unallocated: spendable - budgetTotal,
}
const rateBp = parseRate(v.rate)
const preview = previewSummary(summary, {
income: incomeTotal,
fixed: fixedTotal,
savings: k(v.savings),
carried: effectiveCarried(v.carried, summary),
rateBp:
rateBp !== null && rateBp >= 1000 && rateBp <= 9000
? rateBp
: (period.spendable_rate_bp ?? DEFAULT_SPENDABLE_RATE_BP),
budget: budgetTotal,
})
const spendable = preview.spendable
// ---- Kaydedilmemiş değişiklik koruması
const blocker = useBlocker(
@@ -113,7 +116,7 @@ export function PlanEdit({
}, [isDirty])
const onSubmit = handleSubmit(async (draft) => {
const ops = diffPlan(period, items, budget, draft)
const ops = diffPlan(period, items, budget, draft, summary)
if (!hasChanges(ops)) {
toast(s.noChanges)
return onClose()
@@ -148,7 +151,7 @@ export function PlanEdit({
</div>
<PeriodHeading period={period} actions={actions} />
<SummaryTiles summary={preview} debtPayment={(debt.data?.latest?.total ?? 0) > 0} />
<SummaryTiles summary={preview} />
<div className="grid gap-4">
<ItemsEdit
@@ -178,15 +181,54 @@ export function PlanEdit({
</Button>
}
>
<FormField label={s.savings} error={errors.savings} className="max-w-xs">
{(f) => (
<div className="space-y-5">
<div className="space-y-3 rounded-xl border p-3">
<h4 className="font-medium">{t.carried.title}</h4>
<p className="text-sm text-muted-foreground">{t.carried.hint}</p>
<Controller
control={control}
name="savings"
render={({ field }) => <MoneyInput {...f} {...field} placeholder="0,00" />}
name="carried"
render={({ field }) => (
<CarriedDebtField
summary={summary}
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
error={errors.carried}
/>
)}
/>
{preview.debt_mode && (
<FormField
label={t.carried.rate}
error={errors.rate}
hint={t.carried.rateHint}
className="max-w-40"
>
{(f) => (
<Input {...f} inputMode="decimal" autoComplete="off" {...register('rate')} />
)}
</FormField>
)}
</div>
{preview.debt_mode ? (
<div>
<p className="text-sm font-medium text-muted-foreground">{s.savings}</p>
<p className="text-sm text-muted-foreground italic">{t.carried.savingsLocked}</p>
</div>
) : (
<FormField label={s.savings} error={errors.savings} className="max-w-xs">
{(f) => (
<Controller
control={control}
name="savings"
render={({ field }) => <MoneyInput {...f} {...field} placeholder="0,00" />}
/>
)}
</FormField>
)}
</FormField>
</div>
</Section>
<BudgetEdit
+7
View File
@@ -1,6 +1,8 @@
import { z } from 'zod'
import { itemSchema } from '@/features/period-wizard/schemas'
import { parseRate } from '@/lib/debt-plan'
import { moneyText } from '@/lib/money-schema'
import { t } from '@/lib/strings'
/** Plan ekranı düzenleme formu (`lib/plan-draft.ts` → `PlanDraft` ile aynı biçim). */
export function planSchema(start: string, last: string) {
@@ -13,6 +15,11 @@ export function planSchema(start: string, last: string) {
incomes: z.array(item),
expenses: z.array(item),
savings: money,
carried: money,
rate: z.string().refine((v) => {
const bp = parseRate(v)
return bp !== null && bp >= 1000 && bp <= 9000
}, t.carried.rateError),
budget: z.object({ needs: money, wants: money, culture: money, unexpected: money }),
weeks: z.object({
needs: z.array(money),
@@ -1,3 +1,5 @@
import { TriangleAlert } from 'lucide-react'
import { formatRate } from '@/lib/debt-plan'
import { formatMoney } from '@/lib/format'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
@@ -6,61 +8,96 @@ import type { PeriodSummary } from '@/types/PeriodSummary'
const s = t.summary
/**
* Plan özeti: Gelir − Sabit giderler − Birikim − Devreden borç (+ Devreden artı) = Harcanabilir.
* Mobilde 2 sütun, geniş ekranda tek satır. Devir kutucukları yalnızca tutar varsa görünür.
* Plan özeti.
* - Normal ay: Gelir − Sabit giderler − Birikim − Önceki ay aşımı (+ Devreden artı) = Harcanabilir.
* - Borçlu ay (devreden banka borcu > 0, docs/KAKEIBO_PANEL_ISTEKLERI.md A):
* Gelir − Sabit giderler − Bu ay borca giden = Harcanabilir (gelirin %45'i); birikim kapalı.
* Mobilde 2 sütun, geniş ekranda tek satır.
*/
export function SummaryTiles({
summary,
debtPayment = false,
}: {
summary: PeriodSummary
/** Borç takibinde borç varken birikim hedefi "Borç ödemesi" adıyla (docs/BORC_TAKIBI.md) */
debtPayment?: boolean
}) {
export function SummaryTiles({ summary }: { summary: PeriodSummary }) {
const debt = summary.debt_mode
const tiles: { label: string; amount: number; sign?: '−' | '+'; tone?: string }[] = [
{ label: s.income, amount: summary.income_total, tone: 'text-income' },
{ label: s.fixed, amount: summary.fixed_expense_total, sign: '−' },
{ label: debtPayment ? s.debtPayment : s.savings, amount: summary.savings_goal, sign: '−' },
]
if (summary.carryover_debt > 0) {
tiles.push({ label: s.debt, amount: summary.carryover_debt, sign: '−', tone: 'text-expense' })
}
if (summary.carryover_surplus > 0) {
tiles.push({ label: s.surplus, amount: summary.carryover_surplus, sign: '+' })
if (debt) {
tiles.push({ label: s.debtPayment, amount: summary.debt_payment, sign: '−' })
} else {
tiles.push({ label: s.savings, amount: summary.savings_goal, sign: '−' })
if (summary.carryover_debt > 0) {
tiles.push({ label: s.debt, amount: summary.carryover_debt, sign: '−', tone: 'text-expense' })
}
if (summary.carryover_surplus > 0) {
tiles.push({ label: s.surplus, amount: summary.carryover_surplus, sign: '+' })
}
}
return (
<dl className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
{tiles.map((tile) => (
<div key={tile.label} className="rounded-xl border bg-card p-3 sm:min-w-32 sm:flex-1">
<dt className="text-xs text-muted-foreground">
{tile.sign && <span aria-hidden>{tile.sign} </span>}
{tile.label}
</dt>
<dd className={cn('money mt-1 font-semibold', tile.tone)}>{formatMoney(tile.amount)}</dd>
</div>
))}
<div
className={cn(
'col-span-2 rounded-xl border-2 p-3 sm:min-w-40 sm:flex-[1.4]',
summary.spendable < 0
? 'border-expense/50 bg-expense/5'
: 'border-primary/40 bg-primary/5',
)}
>
<dt className="text-xs font-medium text-muted-foreground">
<span aria-hidden>= </span>
{s.spendable}
</dt>
<dd
<div className="space-y-2">
<dl className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
{tiles.map((tile) => (
<div key={tile.label} className="rounded-xl border bg-card p-3 sm:min-w-32 sm:flex-1">
<dt className="text-xs text-muted-foreground">
{tile.sign && <span aria-hidden>{tile.sign} </span>}
{tile.label}
</dt>
<dd className={cn('money mt-1 font-semibold', tile.tone)}>
{formatMoney(tile.amount)}
</dd>
</div>
))}
<div
className={cn(
'money mt-1 text-xl font-bold',
summary.spendable < 0 ? 'text-expense' : 'text-foreground',
'col-span-2 rounded-xl border-2 p-3 sm:min-w-40 sm:flex-[1.4]',
summary.spendable < 0
? 'border-expense/50 bg-expense/5'
: 'border-primary/40 bg-primary/5',
)}
>
{formatMoney(summary.spendable)}
</dd>
</div>
</dl>
<dt className="text-xs font-medium text-muted-foreground">
<span aria-hidden>= </span>
{s.spendable}
</dt>
<dd
className={cn(
'money mt-1 text-xl font-bold',
summary.spendable < 0 ? 'text-expense' : 'text-foreground',
)}
>
{formatMoney(summary.spendable)}
</dd>
{debt && (
<p className="mt-0.5 text-[11px] text-muted-foreground">
{s.rateLabel(formatRate(summary.spendable_rate_bp))}
</p>
)}
</div>
</dl>
{debt && (
<div className="space-y-1 text-sm">
<p className="flex flex-wrap gap-x-1.5 text-muted-foreground">
<span className="money">{s.carriedPart(formatMoney(summary.carried_debt))}</span>
<span aria-hidden>·</span>
<span className="money">
{s.targetPart(formatMoney(summary.closing_debt_target ?? summary.carried_debt))}
</span>
</p>
{summary.debt_to_savings > 0 && (
<p className="money text-income">
{s.toSavingsLine(formatMoney(summary.debt_to_savings))}
</p>
)}
{summary.debt_to_savings === 0 && (
<p className="text-muted-foreground italic">{s.savingsLocked}</p>
)}
{summary.debt_fixed_too_high && (
<p role="alert" className="flex items-start gap-1.5 font-medium text-expense">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
{s.fixedTooHigh}
</p>
)}
</div>
)}
</div>
)
}
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest'
import {
carriedText,
debtPlan,
effectiveCarried,
parseCarried,
parseRate,
previewSummary,
} from './debt-plan'
// docs/KAKEIBO_PANEL_ISTEKLERI.md A5 "Kontrol" rakamları (backend testiyle aynı)
describe('debtPlan', () => {
it('A5: gelir 79.509,64 · sabit 23.006,90 · devreden 57.100', () => {
const p = debtPlan(7_950_964, 2_300_690, 5_710_000, 4500)
expect(p.spendable).toBe(3_577_934)
expect(p.payment).toBe(2_072_340)
expect(p.toSavings).toBe(0)
expect(p.closingTarget).toBe(3_637_660)
expect(p.fixedTooHigh).toBe(false)
})
it('borç küçükse artan birikime gider, hedef 0', () => {
const p = debtPlan(7_950_964, 2_300_690, 1_000_000, 4500)
expect([p.payment, p.toSavings, p.closingTarget]).toEqual([1_000_000, 1_072_340, 0])
})
it('sabitler çok yüksek: harcanabilir = gelir − sabit, borca 0', () => {
const p = debtPlan(7_950_964, 5_000_000, 5_710_000, 4500)
expect([p.spendable, p.payment, p.fixedTooHigh]).toEqual([2_950_964, 0, true])
expect(p.closingTarget).toBe(5_710_000)
})
it('oran değişebilir; yarım kuruş yukarı yuvarlanır', () => {
expect(debtPlan(1_000_000, 0, 1, 5000).spendable).toBe(500_000)
expect(debtPlan(3, 0, 1, 5000).spendable).toBe(2)
})
})
describe('devreden borç alanı ve önizleme', () => {
const auto = {
carried_debt_source: 'auto' as const,
carried_debt: 5_710_000,
carried_debt_auto: 5_710_000,
}
const manual0 = {
carried_debt_source: 'manual' as const,
carried_debt: 0,
carried_debt_auto: 5_710_000,
}
it('form metni: otomatik → boş, elle 0 → "0"', () => {
expect(carriedText(auto)).toBe('')
expect(carriedText(manual0)).toBe('0')
expect(parseCarried('')).toBeNull()
expect(parseCarried('0')).toBe(0)
expect(parseCarried('10.000')).toBe(1_000_000)
})
it('boş alan son ölçüme döner; elle değer önce gelir', () => {
expect(effectiveCarried('', manual0)).toBe(5_710_000)
expect(effectiveCarried('0', auto)).toBe(0)
expect(
effectiveCarried('', { ...auto, carried_debt_source: null, carried_debt_auto: null }),
).toBe(0)
})
it('oran metni', () => {
expect(parseRate('45')).toBe(4500)
expect(parseRate('45,5')).toBe(4550)
expect(parseRate('')).toBeNull()
expect(parseRate('abc')).toBeNull()
})
it('önizleme: borçlu ay %45, borç bitince normal Kakeibo', () => {
const saved = {
income_total: 0,
fixed_expense_total: 0,
savings_goal: 500_000,
carryover_debt: 100_000,
carryover_surplus: 0,
spendable: 0,
carried_debt: 0,
carried_debt_source: null,
carried_debt_date: null,
carried_debt_auto: null,
debt_mode: false,
spendable_rate_bp: 4500,
debt_payment: 0,
debt_to_savings: 0,
debt_fixed_too_high: false,
closing_debt_target: null,
budget_total: 0,
unallocated: 0,
spent_total: 1_000,
remaining: 0,
}
const d = {
income: 7_950_964,
fixed: 2_300_690,
savings: 500_000,
rateBp: 4500,
budget: 3_000_000,
}
const debt = previewSummary(saved, { ...d, carried: 5_710_000 })
expect(debt.debt_mode).toBe(true)
expect([debt.spendable, debt.debt_payment, debt.closing_debt_target]).toEqual([
3_577_934, 2_072_340, 3_637_660,
])
expect(debt.unallocated).toBe(577_934)
expect(debt.remaining).toBe(3_576_934)
const normal = previewSummary(saved, { ...d, carried: 0 })
expect(normal.debt_mode).toBe(false)
// gelir − sabit − birikim − önceki ay aşımı
expect(normal.spendable).toBe(7_950_964 - 2_300_690 - 500_000 - 100_000)
})
})
+130
View File
@@ -0,0 +1,130 @@
/**
* Borçlu ayın planı (docs/KAKEIBO_PANEL_ISTEKLERI.md A). Backend `services/budget.rs`
* `debt_plan` ile birebir aynı; asıl değer backend'den gelir, bu yalnızca düzenleme önizlemesi.
*
* harcanabilir = gelir × oran (varsayılan %45, yarım yukarı yuvarlanır)
* borca giden = min(gelir − sabit − harcanabilir, devreden borç); artan → birikim
* gelir − sabit < gelir × oran → harcanabilir = gelir − sabit, borca 0, uyarı
*/
import type { PeriodSummary } from '@/types/PeriodSummary'
import { formatNumber } from './format'
import { toKurus } from './money-schema'
export const DEFAULT_SPENDABLE_RATE_BP = 4500
export type DebtPlan = {
carried: number
rateBp: number
spendable: number
payment: number
toSavings: number
fixedTooHigh: boolean
closingTarget: number
}
export function debtPlan(income: number, fixed: number, carried: number, rateBp: number): DebtPlan {
const byRate = Number((BigInt(Math.max(income, 0)) * BigInt(rateBp) + 5_000n) / 10_000n)
const afterFixed = income - fixed
const fixedTooHigh = afterFixed < byRate
const spendable = fixedTooHigh ? afterFixed : byRate
const raw = fixedTooHigh ? 0 : afterFixed - byRate
const payment = Math.min(raw, carried)
return {
carried,
rateBp,
spendable,
payment,
toSavings: raw - payment,
fixedTooHigh,
closingTarget: carried - payment,
}
}
/** Baz puan → `45` / `45,5` */
export function formatRate(bp: number): string {
return formatNumber(bp / 100, bp % 100 === 0 ? 0 : 2)
}
// ---------- Plan düzenleme: devreden borç alanı + canlı önizleme ----------
type CarriedSource = Pick<
PeriodSummary,
'carried_debt_source' | 'carried_debt' | 'carried_debt_auto'
>
/** Form metni: elle girilmişse tutar (`0` dahil — borç bitti), otomatikse boş */
export function carriedText(s: CarriedSource): string {
if (s.carried_debt_source !== 'manual') return ''
return s.carried_debt ? formatNumber(s.carried_debt / 100) : '0'
}
/** Form metni → istek: boş → `null` (son ölçümden), aksi halde kuruş */
export function parseCarried(text: string | undefined): number | null {
return (text ?? '').trim() === '' ? null : Math.max(toKurus(text ?? ''), 0)
}
/** Formdaki metne göre geçerli devreden borç */
export function effectiveCarried(text: string | undefined, s: CarriedSource): number {
return parseCarried(text) ?? s.carried_debt_auto ?? 0
}
/** Oran metni (`45`, `45,5`) → baz puan; geçersizse `null` */
export function parseRate(text: string | undefined): number | null {
const v = Number((text ?? '').trim().replace(',', '.'))
if (!(text ?? '').trim() || !Number.isFinite(v)) return null
return Math.round(v * 100)
}
/**
* Düzenleme taslağından özet önizlemesi (backend `budget::summary` ile aynı kural).
* Borçlu ay: harcanabilir = `debtPlan`; değilse gelir − sabit − birikim − devir borç + devir artı.
*/
export function previewSummary(
saved: PeriodSummary,
d: {
income: number
fixed: number
savings: number
carried: number
rateBp: number
budget: number
},
): PeriodSummary {
const base = {
...saved,
income_total: d.income,
fixed_expense_total: d.fixed,
savings_goal: d.savings,
}
let next: PeriodSummary
if (d.carried > 0) {
const p = debtPlan(d.income, d.fixed, d.carried, d.rateBp)
next = {
...base,
spendable: p.spendable,
carried_debt: d.carried,
debt_mode: true,
spendable_rate_bp: d.rateBp,
debt_payment: p.payment,
debt_to_savings: p.toSavings,
debt_fixed_too_high: p.fixedTooHigh,
closing_debt_target: p.closingTarget,
}
} else {
next = {
...base,
spendable: d.income - d.fixed - d.savings - saved.carryover_debt + saved.carryover_surplus,
carried_debt: 0,
debt_mode: false,
spendable_rate_bp: d.rateBp,
debt_payment: 0,
debt_to_savings: 0,
debt_fixed_too_high: false,
closing_debt_target: null,
}
}
next.budget_total = d.budget
next.unallocated = next.spendable - d.budget
next.remaining = next.spendable - saved.spent_total
return next
}
-14
View File
@@ -79,17 +79,3 @@ describe('debt (backend ile aynı, kabul rakamları)', () => {
])
})
})
describe('plan integration', () => {
it('finds the installments expense item by name', async () => {
const { findInstallmentItem } = await import('./debt')
const items = [
{ kind: 'income', name: 'Taksitler' },
{ kind: 'expense', name: 'Kira' },
{ kind: 'expense', name: ' TAKSİTLER ' },
]
expect(findInstallmentItem(items)?.name).toBe(' TAKSİTLER ')
expect(findInstallmentItem([{ kind: 'expense', name: 'Taksit' }])).toBeTruthy()
expect(findInstallmentItem([{ kind: 'expense', name: 'Taksitli alışveriş' }])).toBeUndefined()
})
})
-18
View File
@@ -10,7 +10,6 @@ import type { DebtSummary } from '@/types/DebtSummary'
import type { DebtTarget } from '@/types/DebtTarget'
import type { InstallmentPlan } from '@/types/InstallmentPlan'
import type { InstallmentRequest } from '@/types/InstallmentRequest'
import type { PeriodDebt } from '@/types/PeriodDebt'
import { api } from './api'
import { queryKeys } from './query'
@@ -96,13 +95,6 @@ export function useDebt() {
return useQuery({ queryKey: queryKeys.debt, queryFn: () => api.get<DebtSummary>('/debt') })
}
export function usePeriodDebt(periodId: string) {
return useQuery({
queryKey: queryKeys.periodDebt(periodId),
queryFn: () => api.get<PeriodDebt>(`/periods/${periodId}/debt`),
})
}
/** Borç verisi değişince özet ve dönem borçları yenilenir */
function useInvalidateDebt() {
const qc = useQueryClient()
@@ -168,16 +160,6 @@ export function useSetOpeningDebt(periodId: string) {
})
}
/** Plan kalemlerinde "Taksit(ler)" adlı sabit gider (büyük/küçük harf ve Türkçe harf duyarsız) */
export function findInstallmentItem<T extends { kind: string; name: string }>(
items: readonly T[],
): T | undefined {
return items.find(
(i) =>
i.kind === 'expense' && /^taks[iı]t(ler|lar)?$/.test(i.name.trim().toLocaleLowerCase('tr')),
)
}
export function useDebtImport() {
const invalidate = useInvalidateDebt()
return useMutation({
+4 -2
View File
@@ -214,12 +214,14 @@ export function useSavePlan(period: Period) {
return useMutation({
mutationFn: async (ops: PlanOps) => {
const pid = period.id
if (ops.savings !== null) {
if (ops.savings !== null || ops.rateBp !== null) {
await api.patch<Period>(`/periods/${pid}`, {
savings_goal: ops.savings,
savings_goal: ops.savings ?? undefined,
spendable_rate_bp: ops.rateBp ?? undefined,
version: period.version,
})
}
if (ops.carried) await api.put(`/periods/${pid}/opening-debt`, ops.carried)
for (const id of ops.deletes) await api.delete(`/plan-items/${id}`)
for (const { id, ...body } of ops.updates) await api.patch(`/plan-items/${id}`, body)
for (const body of ops.creates) await api.post(`/periods/${pid}/plan-items`, body)
+23
View File
@@ -13,6 +13,7 @@ const period: Period = {
days: 31,
status: 'open',
savings_goal: 500_000,
spendable_rate_bp: null,
reflection_md: null,
to_savings: null,
closed_at: null,
@@ -118,4 +119,26 @@ describe('plan draft', () => {
const ops = diffPlan(period, items, budget, draft)
expect(ops.weeks).toEqual([{ group: 'wants', week_start: '2026-12-01', amount: 0 }])
})
it('devreden borç ve oran yalnızca değişince gönderilir', () => {
const auto = {
carried_debt_source: 'auto' as const,
carried_debt: 5_710_000,
carried_debt_auto: 5_710_000,
}
const draft = toDraft(period, items, budget, auto)
expect([draft.carried, draft.rate]).toEqual(['', '45'])
expect(hasChanges(diffPlan(period, items, budget, draft, auto))).toBe(false)
draft.carried = '0' // borç bitti
draft.rate = '50'
const ops = diffPlan(period, items, budget, draft, auto)
expect(ops.carried).toEqual({ amount: 0 })
expect(ops.rateBp).toBe(5000)
// Elle → boş: otomatiğe dön
const manual = { ...auto, carried_debt_source: 'manual' as const, carried_debt: 1_000_000 }
const d2 = toDraft(period, items, budget, manual)
expect(d2.carried).toBe('10.000,00')
d2.carried = ''
expect(diffPlan(period, items, budget, d2, manual).carried).toEqual({ amount: null })
})
})
+48 -1
View File
@@ -13,6 +13,14 @@ import type { Period } from '@/types/Period'
import type { PlanItem } from '@/types/PlanItem'
import type { PlanItemCreateRequest } from '@/types/PlanItemCreateRequest'
import type { PlanItemUpdateRequest } from '@/types/PlanItemUpdateRequest'
import type { PeriodSummary } from '@/types/PeriodSummary'
import {
carriedText,
DEFAULT_SPENDABLE_RATE_BP,
formatRate,
parseCarried,
parseRate,
} from './debt-plan'
import { formatNumber } from './format'
import { toKurus } from './money-schema'
@@ -32,13 +40,32 @@ export type PlanDraft = {
incomes: ItemDraft[]
expenses: ItemDraft[]
savings: string
/** Devreden borç: `""` → son ay sonu ölçümünden, aksi halde elle (`0` → borç bitti) */
carried: string
/** Borçlu ayda harcanabilir oranı, yüzde (`45`) */
rate: string
budget: Record<Group, string>
/** Her grup için dönemin haftaları sırasıyla; `""` → otomatik */
weeks: Record<Group, string[]>
}
/** Dönemin kayıtlı devreden borç durumu (özetten) */
export type CarriedState = Pick<
PeriodSummary,
'carried_debt_source' | 'carried_debt' | 'carried_debt_auto'
>
const NO_CARRIED: CarriedState = {
carried_debt_source: null,
carried_debt: 0,
carried_debt_auto: null,
}
export type PlanOps = {
savings: number | null
/** Değişmediyse `null`; `{ amount: null }` → otomatiğe dön */
carried: { amount: number | null } | null
/** Değişmediyse `null` */
rateBp: number | null
deletes: string[]
updates: (PlanItemUpdateRequest & { id: string })[]
creates: PlanItemCreateRequest[]
@@ -65,13 +92,20 @@ function budgetFor(budget: GroupBudget[], g: Group): GroupBudget | undefined {
return budget.find((b) => b.group === g)
}
export function toDraft(period: Period, items: PlanItem[], budget: GroupBudget[]): PlanDraft {
export function toDraft(
period: Period,
items: PlanItem[],
budget: GroupBudget[],
carried: CarriedState = NO_CARRIED,
): PlanDraft {
const rec = <T>(f: (g: Group) => T) =>
Object.fromEntries(GROUP_CODES.map((g) => [g, f(g)])) as Record<Group, T>
return {
incomes: items.filter((i) => i.kind === 'income').map(itemToDraft),
expenses: items.filter((i) => i.kind === 'expense').map(itemToDraft),
savings: moneyToText(period.savings_goal),
carried: carriedText(carried),
rate: formatRate(period.spendable_rate_bp ?? DEFAULT_SPENDABLE_RATE_BP),
budget: rec((g) => moneyToText(budgetFor(budget, g)?.monthly ?? 0)),
weeks: rec((g) =>
period.weeks.map((w) => {
@@ -88,9 +122,12 @@ export function diffPlan(
items: PlanItem[],
budget: GroupBudget[],
draft: PlanDraft,
carried: CarriedState = NO_CARRIED,
): PlanOps {
const ops: PlanOps = {
savings: null,
carried: null,
rateBp: null,
deletes: [],
updates: [],
creates: [],
@@ -101,6 +138,14 @@ export function diffPlan(
const savings = toKurus(draft.savings)
if (savings !== period.savings_goal) ops.savings = savings
const manual = carried.carried_debt_source === 'manual' ? carried.carried_debt : null
const nextCarried = parseCarried(draft.carried)
if (nextCarried !== manual) ops.carried = { amount: nextCarried }
const rate = parseRate(draft.rate)
if (rate !== null && rate !== (period.spendable_rate_bp ?? DEFAULT_SPENDABLE_RATE_BP)) {
ops.rateBp = rate
}
const byId = new Map(items.map((i) => [i.id, i]))
const kept = new Set<string>()
const rows = [
@@ -146,6 +191,8 @@ export function diffPlan(
export function hasChanges(ops: PlanOps): boolean {
return (
ops.savings !== null ||
ops.carried !== null ||
ops.rateBp !== null ||
ops.deletes.length + ops.updates.length + ops.creates.length + ops.weeks.length > 0 ||
ops.monthly !== null
)
+30 -34
View File
@@ -253,13 +253,40 @@ export const t = {
income: 'Gelir',
fixed: 'Sabit giderler',
savings: 'Birikim',
/** Borç takibinde borç > 0 iken birikim hedefinin adı */
debtPayment: 'Borç ödemesi',
debt: 'Devreden borç',
/** Borçlu ay (devreden borç > 0): gelir − sabit − bu ay borca giden = harcanabilir */
debtPayment: 'Bu ay borca giden',
carriedPart: (amount: string) => `Devreden borç ${amount}`,
targetPart: (amount: string) => `ay sonu hedef ${amount}`,
rateShort: (pct: string) => `gelirin %${pct}`,
toSavingsLine: (amount: string) => `Borç bu ay kapanıyor; artan ${amount} birikime`,
savingsLocked: 'Birikim: borç bitince açılır',
fixedTooHigh:
'Sabit giderler çok yüksek: harcanabilir oranın altında kaldı, bu ay borca para ayrılamıyor.',
rateLabel: (pct: string) => `Harcanabilir: gelirin %${pct}`,
/** Kakeibo'nun kendi devri (önceki ayın bütçe aşımı); banka borcuyla karışmasın */
debt: 'Önceki ay aşımı',
surplus: 'Devreden artı',
spendable: 'Harcanabilir',
},
/** Planda devreden banka borcu alanı (docs/KAKEIBO_PANEL_ISTEKLERI.md A) */
carried: {
title: 'Devreden borç',
label: 'Devreden borç (KMH + kart)',
hint: 'Borç varken harcanabilir gelirin belli bir oranıdır; kalan önce borca gider, birikim borç bitince açılır.',
fromSnapshot: (date: string, amount: string) =>
`Ay sonu ölçümünden (${date}) otomatik: ${amount}`,
noSnapshot:
'Ay sonu ölçümü yok. Önceki aydan devreden banka borcu (KMH + kart) varsa yaz; yoksa boş bırak.',
manual: 'elle düzeltildi',
backToAuto: 'Ölçüme dön',
zero: '0 → borç bitti; normal Kakeibo düzeni (birikim hedefi) geçerli.',
rate: 'Harcanabilir oranı (%)',
rateHint: 'Borçlu ayda harcanabilir = gelir × bu oran. Varsayılan %45.',
rateError: '%10 ile %90 arasında bir sayı yazın',
savingsLocked: 'Borç bitince açılır. Bu ay gelirin kalanı önce borca gider.',
},
ledger: {
refund: 'İade',
refundHint: 'İade kaydı: bu tutar kategorinin harcamasından düşülür.',
@@ -527,37 +554,6 @@ export const t = {
removeInstallmentConfirm: (m: string) => `${m} taksit planı silinsin mi?`,
noInstallments: 'Kayıtlı taksit planı yok.',
toInstallments: 'Taksitler',
period: {
title: 'Bu ayın borcu',
hint: 'Banka borcu (KMH, kart, taksitler). Harcanabilir tutarı düşürmez; birikim hedefin borç bitene kadar borç ödemesi sayılır.',
opening: 'Ay başı borç',
fromSnapshot: (date: string) => `${date} ölçümünden`,
manual: 'Elle girildi',
noOpening: 'Ölçüm yok',
editOpening: 'Ay başı borcu düzelt',
resetOpening: 'Son ölçüme dön',
openingSaved: 'Ay başı borç kaydedildi',
carried: 'Devreden banka borcu',
carriedHint: 'Ay başı borç − ekstreye gelmemiş taksitler',
payment: 'Borç ödemesi',
load: 'Bu ayın taksiti',
target: 'Hedef ay sonu borç',
targetHint: 'Ay başı − borç ödemesi − bu ayın taksiti',
roadmap: (amount: string) => `Yol haritası: ${amount}`,
vsRoadmapAbove: (amount: string) => `yol haritasının ${amount} üstünde`,
vsRoadmapBelow: (amount: string) => `yol haritasının ${amount} altında`,
progressTitle: (date: string) => `${date} ölçümüne göre`,
paidDown: (amount: string) => `Borç ${amount} azaldı`,
grew: (amount: string) => `Borç ${amount} arttı`,
needed: (amount: string) => `ayın hedefi: ${amount} azalma (borç ödemesi + taksit)`,
onTrack: 'Hedefe ulaşıldı',
addInstallments: (amount: string) => `"Taksitler" sabit giderini ekle (${amount})`,
installmentsAdded: 'Taksitler sabit giderlere eklendi',
installmentsMismatch: (plan: string, load: string) =>
`Plandaki "Taksitler" ${plan}; bu ayın taksit yükü ${load}. Düzenle'den güncelleyebilirsin.`,
installmentsName: 'Taksitler',
toDebt: 'Borç takibi',
},
},
monthlyReport: {
title: 'Aylık Rapor',
+4
View File
@@ -19,6 +19,10 @@ last_date: string, days: number, status: PeriodStatus,
* Kuruş
*/
savings_goal: number,
/**
* Borçlu ayda harcanabilir oranı (baz puan); `null` → varsayılan %45
*/
spendable_rate_bp: number | null,
/**
* "Nasıl daha iyi yapabilirim?" (Markdown)
*/
+37 -1
View File
@@ -10,9 +10,45 @@ carryover_debt: number,
*/
carryover_surplus: number,
/**
* gelir − sabit gider − birikim − borç + artı
* Normal ay: gelir − sabit gider − birikim − borç + artı · Borçlu ay: gelir × oran
*/
spendable: number,
/**
* Devreden (banka) borcu; yoksa 0. > 0 → borçlu ay (birikim hedefi plana girmez)
*/
carried_debt: number,
/**
* `auto` (ay sonu ölçümü) | `manual` (planda elle) | `null` (hiç girilmedi)
*/
carried_debt_source: "auto" | "manual" | null,
/**
* Son ay sonu ölçümünün tarihi (elle girilmiş olsa da); ölçüm yoksa `null`
*/
carried_debt_date: string | null,
/**
* Son ay sonu ölçümünden gelen tutar (elle düzeltme kaldırılınca kullanılacak); yoksa `null`
*/
carried_debt_auto: number | null, debt_mode: boolean,
/**
* Etkin harcanabilir oranı (baz puan)
*/
spendable_rate_bp: number,
/**
* Bu ay borca giden
*/
debt_payment: number,
/**
* Borca giden devreden borcu aşarsa birikime kalan
*/
debt_to_savings: number,
/**
* Sabit giderler çok yüksek: bu ay borç azalmıyor
*/
debt_fixed_too_high: boolean,
/**
* Ay sonu hedef borç (borçlu ayda)
*/
closing_debt_target: number | null,
/**
* 4 grubun aylık bütçe toplamı
*/
@@ -1,6 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PeriodUpdateRequest = { name?: string, start_date?: string, end_date?: string, savings_goal?: number,
/**
* Borçlu ayda harcanabilir oranı (baz puan; 4500 = %45)
*/
spendable_rate_bp?: number,
/**
* "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır
*/