feat(borç): aylık planda bu ayın borcu, borç ödemesi etiketi, taksit önerisi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ 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'
|
||||
@@ -87,6 +88,7 @@ function BudgetStepForm({
|
||||
onBack: () => void
|
||||
onDone: () => void
|
||||
}) {
|
||||
const debt = useDebt()
|
||||
const setBudget = useSetBudget(period.id)
|
||||
const {
|
||||
control,
|
||||
@@ -122,7 +124,7 @@ function BudgetStepForm({
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<StepIntro title={s.title} hint={s.hint} />
|
||||
<SummaryTiles summary={summary} />
|
||||
<SummaryTiles summary={summary} debtPayment={(debt.data?.latest?.total ?? 0) > 0} />
|
||||
|
||||
<div className="mt-6">
|
||||
<SuggestionPicker periodId={period.id} onApply={(a) => reset(toForm(a))} />
|
||||
|
||||
@@ -4,7 +4,9 @@ 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'
|
||||
@@ -32,6 +34,8 @@ 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)
|
||||
@@ -67,7 +71,8 @@ function PlanView({ period }: { period: Period }) {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SummaryTiles summary={summary.data} />
|
||||
<SummaryTiles summary={summary.data} debtPayment={inDebt} />
|
||||
<PeriodDebtSection period={period} items={items.data} writable={editable} />
|
||||
<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')} />
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GroupBudget } from '@/types/GroupBudget'
|
||||
@@ -59,6 +60,7 @@ export function PlanEdit({
|
||||
summary: PeriodSummary
|
||||
onClose: () => void
|
||||
}) {
|
||||
const debt = useDebt()
|
||||
const save = useSavePlan(period)
|
||||
const {
|
||||
control,
|
||||
@@ -146,7 +148,7 @@ export function PlanEdit({
|
||||
</div>
|
||||
|
||||
<PeriodHeading period={period} actions={actions} />
|
||||
<SummaryTiles summary={preview} />
|
||||
<SummaryTiles summary={preview} debtPayment={(debt.data?.latest?.total ?? 0) > 0} />
|
||||
|
||||
<div className="grid gap-4">
|
||||
<ItemsEdit
|
||||
|
||||
@@ -9,11 +9,18 @@ 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.
|
||||
*/
|
||||
export function SummaryTiles({ summary }: { summary: PeriodSummary }) {
|
||||
export function SummaryTiles({
|
||||
summary,
|
||||
debtPayment = false,
|
||||
}: {
|
||||
summary: PeriodSummary
|
||||
/** Borç takibinde borç varken birikim hedefi "Borç ödemesi" adıyla (docs/BORC_TAKIBI.md) */
|
||||
debtPayment?: boolean
|
||||
}) {
|
||||
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: s.savings, amount: summary.savings_goal, 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' })
|
||||
|
||||
@@ -79,3 +79,17 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -159,6 +159,24 @@ export function useSetTargets() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetOpeningDebt(periodId: string) {
|
||||
const invalidate = useInvalidateDebt()
|
||||
return useMutation({
|
||||
mutationFn: (amount: number | null) =>
|
||||
api.put<void>(`/periods/${periodId}/opening-debt`, { amount }),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
}
|
||||
|
||||
/** 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({
|
||||
|
||||
@@ -253,6 +253,8 @@ export const t = {
|
||||
income: 'Gelir',
|
||||
fixed: 'Sabit giderler',
|
||||
savings: 'Birikim',
|
||||
/** Borç takibinde borç > 0 iken birikim hedefinin adı */
|
||||
debtPayment: 'Borç ödemesi',
|
||||
debt: 'Devreden borç',
|
||||
surplus: 'Devreden artı',
|
||||
spendable: 'Harcanabilir',
|
||||
@@ -525,6 +527,37 @@ 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',
|
||||
|
||||
Reference in New Issue
Block a user