feat(borç): borç takibi ekranları (Panel kartı, /borc, /taksitler)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 04:40:51 +03:00
parent 11402ba660
commit c063186e80
18 changed files with 1490 additions and 6 deletions
+16 -2
View File
@@ -2,6 +2,7 @@ import {
CalendarDays,
ChartLine,
ClipboardCheck,
Landmark,
LayoutDashboard,
Menu,
NotebookPen,
@@ -28,6 +29,9 @@ export const paths = {
/** Dönem kapatma sihirbazı */
closePeriod: '/degerlendirme/kapat',
monthlyReport: '/degerlendirme/rapor',
/** Borç takibi */
debt: '/borc',
installments: '/taksitler',
reports: '/raporlar',
settings: '/ayarlar',
settingsCategories: '/ayarlar/kategoriler',
@@ -51,6 +55,7 @@ const planning: NavItem = { to: paths.planning, label: t.nav.planning, icon: Cal
const savings: NavItem = { to: paths.savings, label: t.nav.savings, icon: PiggyBank }
const review: NavItem = { to: paths.review, label: t.nav.review, icon: ClipboardCheck }
const reports: NavItem = { to: paths.reports, label: t.nav.reports, icon: ChartLine }
const debt: NavItem = { to: paths.debt, label: t.nav.debt, icon: Landmark }
const settings: NavItem = { to: paths.settings, label: t.nav.settings, icon: Settings }
/** Mobil alt menü: 5 öğe (KAKEIBO_PROMPT §9) */
@@ -63,7 +68,16 @@ export const bottomNav: NavItem[] = [
]
/** "Diğer" altında toplananlar (mobil) */
export const moreNav: NavItem[] = [review, reports, settings]
export const moreNav: NavItem[] = [debt, review, reports, settings]
/** Masaüstü sol menü: hepsi doğrudan */
export const sideNav: NavItem[] = [dashboard, ledger, planning, review, savings, reports, settings]
export const sideNav: NavItem[] = [
dashboard,
ledger,
planning,
review,
savings,
debt,
reports,
settings,
]
+9
View File
@@ -3,7 +3,9 @@ import { Link } from 'react-router'
import { paths } from '@/components/layout/nav-items'
import { PageError, PageHeader } from '@/components/page'
import { Button } from '@/components/ui/button'
import { DebtCard } from '@/features/debt/debt-card'
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
import { useDebt } from '@/lib/debt'
import { dashboardPeriod } from '@/lib/dashboard'
import { useCurrentPeriod, usePeriods } from '@/lib/periods'
import { t } from '@/lib/strings'
@@ -26,6 +28,8 @@ export function DashboardPage() {
const ready = current.isSuccess && periods.isSuccess
const period = ready ? dashboardPeriod(periods.data, current.data) : undefined
const canAdd = !!current.data
// Borç takibi: ölçüm varsa en üstte (docs/BORC_TAKIBI.md)
const debt = useDebt()
return (
<>
@@ -43,6 +47,11 @@ export function DashboardPage() {
)
}
/>
{debt.data?.latest && (
<div className="mb-6">
<DebtCard debt={debt.data} link />
</div>
)}
{failed?.error ? (
<PageError
message={failed.error.message}
+109
View File
@@ -0,0 +1,109 @@
import { ArrowDownRight, ArrowUpRight, ChevronRight, Landmark } from 'lucide-react'
import { Link } from 'react-router'
import { paths } from '@/components/layout/nav-items'
import { formatDate, formatMoney, formatPercent } from '@/lib/format'
import { formatMonth } from '@/lib/reports'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { DebtSummary } from '@/types/DebtSummary'
const s = t.debt
/**
* Toplam banka borcu: büyük rakam, geçen aya göre değişim, başlangıç → 0 ilerleme,
* tahmini bitiş, KMH · Kart · Kalan taksit dökümü. Panel'in en üstünde ve /borc'ta.
*/
export function DebtCard({ debt, link = false }: { debt: DebtSummary; link?: boolean }) {
const l = debt.latest
if (!l) return null
const change = debt.change_vs_last_month
const down = change !== null && change < 0
const pct = debt.progress ?? 0
return (
<section aria-labelledby="debt-title" className="min-w-0 rounded-2xl border bg-card p-4 sm:p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 id="debt-title" className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Landmark className="size-4" aria-hidden />
{s.total}
</h2>
<p className="money mt-1 text-3xl font-bold tracking-tight">
{l.total <= 0 ? s.noDebt : formatMoney(l.total)}
</p>
{change !== null && (
<p
className={cn(
'mt-1 flex items-center gap-1 text-sm font-medium',
change === 0 ? 'text-muted-foreground' : down ? 'text-income' : 'text-expense',
)}
>
{change !== 0 &&
(down ? (
<ArrowDownRight className="size-4" aria-hidden />
) : (
<ArrowUpRight className="size-4" aria-hidden />
))}
{change === 0
? s.sameAsLastMonth
: s.vsLastMonth(formatMoney(Math.abs(change)), down)}
</p>
)}
</div>
{link && (
<Link
to={paths.debt}
className="inline-flex min-h-11 shrink-0 items-center gap-0.5 rounded-lg px-2 text-sm text-primary hover:bg-muted"
>
{s.details}
<ChevronRight className="size-4" aria-hidden />
</Link>
)}
</div>
{debt.start && (
<div className="mt-4 space-y-1.5">
<div
className="h-2.5 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-label={s.progress(formatPercent(pct, 0))}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(pct * 100)}
>
<div className="h-full rounded-full bg-income" style={{ width: `${pct * 100}%` }} />
</div>
<div className="flex flex-wrap justify-between gap-x-3 text-xs text-muted-foreground">
<span>
{s.start(formatDate(debt.start.date), formatMoney(debt.start.total))} ·{' '}
{s.progress(formatPercent(pct, 0))}
</span>
{debt.estimated_end && (
<span className="font-medium text-foreground">
{s.estimatedEnd(formatMonth(debt.estimated_end))}
</span>
)}
</div>
</div>
)}
<dl className="mt-4 grid grid-cols-3 gap-2 border-t pt-3 text-sm">
{(
[
[s.kmh, l.kmh],
[s.card, l.card_balance],
[s.remaining, l.remaining_installments],
] as const
).map(([label, v]) => (
<div key={label} className="min-w-0">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="money font-medium">{formatMoney(v)}</dd>
</div>
))}
</dl>
<p className="mt-2 text-xs text-muted-foreground">
{s.measuredOn(formatDate(l.date))}
{l.positive_balance > 0 && ` · ${s.positive} −${formatMoney(l.positive_balance)}`}
</p>
</section>
)
}
+96
View File
@@ -0,0 +1,96 @@
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { debtSeries } from '@/lib/debt'
import { formatCompact, formatDate, formatMoney } from '@/lib/format'
import { t } from '@/lib/strings'
import type { DebtSummary } from '@/types/DebtSummary'
const s = t.debt
const shortDate = new Intl.DateTimeFormat('tr-TR', {
month: 'short',
year: '2-digit',
timeZone: 'UTC',
})
/** Gerçek (ölçümler, düz) + hedef (yol haritası, kesikli). Ayrı JS parçası (lazy). */
export default function DebtChart({ debt }: { debt: DebtSummary }) {
const data = debtSeries(debt.snapshots, debt.targets)
// Eksen etiketleri ay başlarında (veri noktalarında olursa aynı ay iki kez yazılır)
const ticks: number[] = []
if (data.length > 0) {
const first = new Date(data[0].t)
for (
let d = Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 1);
d <= data[data.length - 1].t;
d = Date.UTC(new Date(d).getUTCFullYear(), new Date(d).getUTCMonth() + 1, 1)
)
ticks.push(d)
}
return (
<div
className="h-72 w-full"
role="img"
aria-label={`${s.chartTitle}: ${s.actual}, ${s.target}`}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 8, right: 12, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" vertical={false} />
<XAxis
dataKey="t"
type="number"
scale="time"
domain={['dataMin', 'dataMax']}
ticks={ticks.length > 0 ? ticks : undefined}
tickFormatter={(v: number) => shortDate.format(new Date(v))}
stroke="var(--muted-foreground)"
fontSize={12}
/>
<YAxis
tickFormatter={(v: number) => formatCompact(v)}
stroke="var(--muted-foreground)"
fontSize={12}
width={48}
/>
<Tooltip
labelFormatter={(v) => formatDate(new Date(Number(v)).toISOString().slice(0, 10))}
formatter={(v, name) => [formatMoney(Number(v)), name]}
contentStyle={{
background: 'var(--card)',
border: '1px solid var(--border)',
borderRadius: 8,
color: 'var(--foreground)',
}}
/>
<Legend />
<Line
name={s.actual}
dataKey="actual"
stroke="var(--primary)"
strokeWidth={2.5}
dot={{ r: 3.5, strokeWidth: 2, fill: 'var(--card)' }}
connectNulls
isAnimationActive={false}
/>
<Line
name={s.target}
dataKey="target"
stroke="var(--income)"
strokeWidth={2}
strokeDasharray="6 4"
dot={{ r: 3, strokeWidth: 2, fill: 'var(--card)' }}
connectNulls
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
)
}
+99
View File
@@ -0,0 +1,99 @@
import { Pencil, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { useDeleteSnapshot } from '@/lib/debt'
import { formatDate, formatMoney } from '@/lib/format'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { DebtSnapshot } from '@/types/DebtSnapshot'
const s = t.debt
/** Ölçüm tablosu (en yeni önce): tarih, KMH, kart, taksit, toplam, değişim */
export function History({
snapshots,
onEdit,
}: {
snapshots: DebtSnapshot[]
onEdit: (s: DebtSnapshot) => void
}) {
const del = useDeleteSnapshot()
function remove(sn: DebtSnapshot) {
if (!window.confirm(s.removeConfirm(formatDate(sn.date)))) return
del.mutate(sn.id, {
onSuccess: () => toast(s.removed),
onError: (e) => toast.error(e.message),
})
}
return (
<div className="table-wrap overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="py-2 pr-3 font-normal">{s.date}</th>
<th className="py-2 pr-3 text-right font-normal">{s.kmh}</th>
<th className="py-2 pr-3 text-right font-normal">{s.card}</th>
<th className="py-2 pr-3 text-right font-normal">{s.remaining}</th>
<th className="py-2 pr-3 text-right font-normal">{s.positive}</th>
<th className="py-2 pr-3 text-right font-normal">{s.total}</th>
<th className="py-2 pr-3 text-right font-normal">{s.change}</th>
<th className="py-2" />
</tr>
</thead>
<tbody>
{snapshots.map((sn) => (
<tr key={sn.id} className="border-b last:border-0 align-top">
<td className="py-2 pr-3 whitespace-nowrap">
{formatDate(sn.date)}
{sn.note && (
<div className="max-w-56 text-xs whitespace-normal text-muted-foreground">
{sn.note}
</div>
)}
</td>
{[sn.kmh, sn.card_balance, sn.remaining_installments, sn.positive_balance].map(
(v, i) => (
<td key={i} className="money py-2 pr-3 text-right whitespace-nowrap">
{formatMoney(v)}
</td>
),
)}
<td className="money py-2 pr-3 text-right font-semibold whitespace-nowrap">
{formatMoney(sn.total)}
</td>
<td
className={cn(
'money py-2 pr-3 text-right whitespace-nowrap',
sn.change !== null && sn.change < 0 && 'text-income',
sn.change !== null && sn.change > 0 && 'text-expense',
)}
>
{sn.change === null ? '—' : formatMoney(sn.change, 'TRY', { signed: true })}
</td>
<td className="py-1 whitespace-nowrap">
<Button
variant="ghost"
size="icon"
aria-label={s.edit(formatDate(sn.date))}
onClick={() => onEdit(sn)}
>
<Pencil aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
className="text-expense"
aria-label={s.remove(formatDate(sn.date))}
disabled={del.isPending}
onClick={() => remove(sn)}
>
<Trash2 aria-hidden />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,125 @@
import { useRef, useState } from 'react'
import { CircleCheck, FileUp, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { FormError } from '@/components/form-field'
import { Button } from '@/components/ui/button'
import { useDebtImport } from '@/lib/debt'
import { formatDate, formatMoney } from '@/lib/format'
import { formatMonth } from '@/lib/reports'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { DebtImportReport } from '@/types/DebtImportReport'
const s = t.debt
/** Borç verisi dosyası → ön izleme → onay (aynı dosya iki kez eklenmez) */
export function ImportSection() {
const input = useRef<HTMLInputElement>(null)
const run = useDebtImport()
const [json, setJson] = useState<unknown>(null)
const [report, setReport] = useState<DebtImportReport | null>(null)
const [error, setError] = useState<string>()
async function choose(f: File) {
setError(undefined)
setReport(null)
try {
const parsed: unknown = JSON.parse(await f.text())
setJson(parsed)
setReport(await run.mutateAsync({ json: parsed, commit: false }))
} catch (e) {
setError(e instanceof SyntaxError ? 'Dosya okunamadı: geçerli bir JSON değil.' : String(e))
}
}
async function commit() {
try {
const r = await run.mutateAsync({ json, commit: true })
setReport(r)
toast.success(s.imported)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const fresh = report?.rows.filter((r) => r.status === 'new' || r.status === 'update').length ?? 0
const label = (kind: string, l: string) =>
kind === 'snapshot' ? formatDate(l) : kind === 'target' ? formatMonth(l) : l
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">{s.importHint}</p>
<input
ref={input}
id="debt-import"
type="file"
accept=".json,application/json"
className="sr-only"
onChange={(e) => {
const f = e.target.files?.[0]
if (f) void choose(f)
e.target.value = ''
}}
/>
<Button asChild variant="outline" disabled={run.isPending}>
<label htmlFor="debt-import" className="cursor-pointer">
<FileUp aria-hidden />
{s.chooseFile}
</label>
</Button>
<FormError message={error} />
{report && (
<div className="space-y-3" aria-live="polite">
{report.errors.length > 0 && (
<ul className="rounded-xl bg-expense/5 p-3 text-sm text-expense">
{report.errors.map((e) => (
<li key={e}>{e}</li>
))}
</ul>
)}
<div className="table-wrap overflow-x-auto">
<table className="w-full text-sm">
<tbody>
{report.rows.map((r, i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1.5 pr-3 text-muted-foreground">{s.kinds[r.kind]}</td>
<td className="py-1.5 pr-3">{label(r.kind, r.label)}</td>
<td className="money py-1.5 pr-3 text-right whitespace-nowrap">
{formatMoney(r.amount)}
</td>
<td
className={cn(
'py-1.5 whitespace-nowrap',
r.status === 'error'
? 'text-expense'
: r.status === 'exists'
? 'text-muted-foreground'
: 'font-medium',
)}
>
{s.statuses[r.status]}
{r.message && <div className="text-xs">{r.message}</div>}
</td>
</tr>
))}
</tbody>
</table>
</div>
{report.committed ? (
<p className="flex items-center gap-2 font-medium text-income">
<CircleCheck className="size-5" aria-hidden />
{s.imported}
</p>
) : fresh > 0 ? (
<Button onClick={() => void commit()} disabled={run.isPending}>
{run.isPending && <Loader2 className="animate-spin" aria-hidden />}
{s.importCommit(fresh)}
</Button>
) : (
<p className="text-sm text-muted-foreground">{s.nothingNew}</p>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,280 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { ChevronLeft, Loader2, Pencil, Plus, Trash2, TriangleAlert } from 'lucide-react'
import { Controller, useForm } from 'react-hook-form'
import { Link } from 'react-router'
import { toast } from 'sonner'
import { z } from 'zod'
import { FormError, FormField } from '@/components/form-field'
import { paths } from '@/components/layout/nav-items'
import { MoneyInput } from '@/components/money-input'
import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Section } from '@/features/planning/plan-read'
import { useDebt, useDeleteInstallment, useSaveInstallment } from '@/lib/debt'
import { formatMoney, formatNumber } from '@/lib/format'
import { moneyText, toKurus } from '@/lib/money-schema'
import { formatMonth } from '@/lib/reports'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { InstallmentPlan } from '@/types/InstallmentPlan'
import type { MonthLoad } from '@/types/MonthLoad'
const s = t.debt
/** /taksitler: 6 aylık taksit yükü, açık planlar, yeni taksit (uyarıyla) */
export function InstallmentsPage() {
const debt = useDebt()
const [form, setForm] = useState<InstallmentPlan | 'new' | null>(null)
const d = debt.data
const inDebt = (d?.latest?.total ?? 0) > 0
const plans = d ? [...d.installments].sort((a, b) => b.remaining_count - a.remaining_count) : []
return (
<>
<Link
to={paths.debt}
className="-ml-1 mb-2 inline-flex min-h-11 items-center gap-1 rounded-lg px-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="size-4" aria-hidden />
{s.title}
</Link>
<PageHeader title={s.installmentsTitle} description={s.installmentsDescription} />
{debt.isError ? (
<PageError message={debt.error.message} onRetry={debt.refetch} />
) : !d ? (
<PageLoading />
) : (
<div className="space-y-4">
<Section title={s.loadTitle}>
<LoadBars schedule={d.schedule} />
</Section>
<Section
title={s.plansTitle}
action={
form === null && (
<Button variant="outline" onClick={() => setForm('new')}>
<Plus aria-hidden />
{s.addInstallment}
</Button>
)
}
>
<div className="space-y-4">
{form !== null && (
<div className="space-y-3 rounded-xl border p-3">
{form === 'new' && inDebt && (
<div role="alert" className="flex gap-2 rounded-lg bg-warning/10 p-3 text-sm">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
<div>
<p className="font-medium">{s.noNewInstallments}</p>
<p className="text-muted-foreground">{s.noNewInstallmentsHint}</p>
</div>
</div>
)}
<InstallmentForm
key={form === 'new' ? 'new' : form.id}
editing={form === 'new' ? undefined : form}
defaultMonth={d.this_month}
onDone={() => setForm(null)}
/>
</div>
)}
{plans.length === 0 ? (
<p className="text-sm text-muted-foreground">{s.noInstallments}</p>
) : (
<ul className="divide-y">
{plans.map((p) => (
<PlanRow key={p.id} p={p} onEdit={() => setForm(p)} />
))}
</ul>
)}
</div>
</Section>
</div>
)}
</>
)
}
/** Hafif çubuk grafik (grafik kütüphanesi gerekmez) */
function LoadBars({ schedule }: { schedule: MonthLoad[] }) {
const max = Math.max(1, ...schedule.map((m) => m.amount))
return (
<ul className="space-y-2">
{schedule.map((m) => (
<li key={m.month} className="grid grid-cols-[6.5rem_1fr_auto] items-center gap-3 text-sm">
<span className="text-muted-foreground">{formatMonth(m.month)}</span>
<div className="h-3 overflow-hidden rounded-full bg-muted" aria-hidden>
<div
className="h-full rounded-full bg-primary"
style={{ width: `${(m.amount / max) * 100}%` }}
/>
</div>
<span className="money text-right font-medium">{formatMoney(m.amount)}</span>
</li>
))}
</ul>
)
}
function PlanRow({ p, onEdit }: { p: InstallmentPlan; onEdit: () => void }) {
const del = useDeleteInstallment()
const done = p.remaining_count === 0
return (
<li className={cn('flex flex-wrap items-center gap-x-3 gap-y-1 py-3', done && 'opacity-60')}>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{p.merchant}</p>
<p className="text-xs text-muted-foreground">
{s.paid(p.paid_count, p.total_count)} · {s.until(formatMonth(p.last_month))}
</p>
<div className="mt-1.5 h-1.5 max-w-60 overflow-hidden rounded-full bg-muted" aria-hidden>
<div
className="h-full rounded-full bg-income"
style={{ width: `${(p.paid_count / p.total_count) * 100}%` }}
/>
</div>
</div>
<div className="text-right text-sm">
<p className="money font-semibold">{formatMoney(p.monthly_amount)}</p>
<p className="money text-xs text-muted-foreground">
{done ? s.finished : s.left(formatMoney(p.remaining_amount))}
</p>
</div>
<div className="flex">
<Button variant="ghost" size="icon" aria-label={`${p.merchant}: düzenle`} onClick={onEdit}>
<Pencil aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
className="text-expense"
aria-label={s.removeInstallment(p.merchant)}
disabled={del.isPending}
onClick={() => {
if (window.confirm(s.removeInstallmentConfirm(p.merchant)))
del.mutate(p.id, { onError: (e) => toast.error(e.message) })
}}
>
<Trash2 aria-hidden />
</Button>
</div>
</li>
)
}
const schema = z.object({
merchant: z.string().trim().min(1, 'İşyeri yazın').max(60, 'En fazla 60 karakter'),
total_amount: moneyText('positive'),
monthly_amount: moneyText('positive'),
total_count: z.coerce
.number({ message: '1–120 arası' })
.int('Tam sayı')
.min(1, '1–120 arası')
.max(120, '1–120 arası'),
first_month: z.string().regex(/^\d{4}-\d{2}$/, 'Ay seçin'),
})
type Form = z.input<typeof schema>
type Values = z.output<typeof schema>
function InstallmentForm({
editing,
defaultMonth,
onDone,
}: {
editing?: InstallmentPlan
defaultMonth: string
onDone: () => void
}) {
const save = useSaveInstallment()
const text = (k: number) => formatNumber(k / 100)
const {
register,
control,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<Form, unknown, Values>({
resolver: zodResolver(schema),
mode: 'onTouched',
defaultValues: editing
? {
merchant: editing.merchant,
total_amount: text(editing.total_amount),
monthly_amount: text(editing.monthly_amount),
total_count: editing.total_count,
first_month: editing.first_month,
}
: {
merchant: '',
total_amount: '',
monthly_amount: '',
total_count: '',
first_month: defaultMonth,
},
})
const onSubmit = handleSubmit(async (v) => {
try {
await save.mutateAsync({
id: editing?.id,
version: editing?.version,
merchant: v.merchant,
total_amount: toKurus(v.total_amount),
monthly_amount: toKurus(v.monthly_amount),
total_count: v.total_count,
first_month: v.first_month,
})
toast.success(s.installmentSaved)
onDone()
} catch (e) {
setError('root', { message: e instanceof Error ? e.message : String(e) })
}
})
const money = (name: 'total_amount' | 'monthly_amount', label: string) => (
<Controller
control={control}
name={name}
render={({ field }) => (
<FormField label={label} error={errors[name]}>
{(f) => (
<MoneyInput
{...f}
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
</FormField>
)}
/>
)
return (
<form onSubmit={onSubmit} noValidate className="space-y-4">
<FormField label={s.merchant} error={errors.merchant}>
{(f) => <Input {...f} {...register('merchant')} maxLength={60} autoComplete="off" />}
</FormField>
<div className="grid gap-4 sm:grid-cols-2">
{money('total_amount', s.totalAmount)}
{money('monthly_amount', s.monthly)}
<FormField label={s.count} error={errors.total_count as never}>
{(f) => <Input {...f} {...register('total_count')} inputMode="numeric" />}
</FormField>
<FormField label={s.firstMonth} error={errors.first_month} hint={s.firstMonthHint}>
{(f) => <Input {...f} type="month" {...register('first_month')} />}
</FormField>
</div>
<FormError message={errors.root?.message} />
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
{s.save}
</Button>
<Button type="button" variant="outline" onClick={onDone}>
{s.cancel}
</Button>
</div>
</form>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { lazy, Suspense, useState } from 'react'
import { CreditCard, Landmark } from 'lucide-react'
import { Link } from 'react-router'
import { paths } from '@/components/layout/nav-items'
import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Skeleton } from '@/components/skeleton'
import { Button } from '@/components/ui/button'
import { Section } from '@/features/planning/plan-read'
import { useDebt } from '@/lib/debt'
import { t } from '@/lib/strings'
import type { DebtSnapshot } from '@/types/DebtSnapshot'
import { DebtCard } from './debt-card'
import { History } from './history'
import { ImportSection } from './import-section'
import { SnapshotForm } from './snapshot-form'
import { TargetsEditor } from './targets-editor'
const DebtChart = lazy(() => import('./debt-chart'))
const s = t.debt
/** /borc: toplam borç, gerçek + hedef grafiği, ölçümler, yeni ölçüm, yol haritası, içe aktarma */
export function DebtPage() {
const debt = useDebt()
const [editing, setEditing] = useState<DebtSnapshot | null>(null)
const d = debt.data
return (
<>
<PageHeader
title={s.title}
description={s.description}
actions={
<Button asChild variant="outline">
<Link to={paths.installments}>
<CreditCard aria-hidden />
{s.toInstallments}
</Link>
</Button>
}
/>
{debt.isError ? (
<PageError message={debt.error.message} onRetry={debt.refetch} />
) : !d ? (
<PageLoading />
) : (
<div className="space-y-4">
{d.latest ? (
<DebtCard debt={d} />
) : (
<section className="flex flex-col items-center gap-3 rounded-2xl border bg-card p-8 text-center">
<div className="grid size-14 place-items-center rounded-full bg-primary/10 text-primary">
<Landmark className="size-7" aria-hidden />
</div>
<h2 className="text-lg font-semibold">{s.emptyTitle}</h2>
<p className="max-w-md text-muted-foreground">{s.emptyText}</p>
</section>
)}
{d.snapshots.length > 0 && (
<Section title={s.chartTitle}>
<Suspense fallback={<Skeleton className="h-72" />}>
<DebtChart debt={d} />
</Suspense>
</Section>
)}
{d.snapshots.length > 0 && (
<Section title={s.historyTitle}>
<History
snapshots={d.snapshots}
onEdit={(sn) => {
setEditing(sn)
document
.getElementById('debt-form')
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}}
/>
</Section>
)}
<div className="grid items-start gap-4 lg:grid-cols-2">
<div id="debt-form" className="scroll-mt-4">
<Section title={editing ? s.formEditTitle : s.formTitle}>
<SnapshotForm
// Taksit planları değişince (ör. içe aktarma) kalan taksit önerisi yeniden dolsun
key={
editing?.id ?? `new-${d.installments.map((p) => `${p.id}:${p.version}`).join()}`
}
plans={d.installments}
editing={editing ?? undefined}
onDone={() => setEditing(null)}
/>
</Section>
</div>
<Section title={s.targetsTitle}>
<TargetsEditor
key={d.targets.map((x) => `${x.month}:${x.closing_target}`).join()}
targets={d.targets}
thisMonth={d.this_month}
/>
</Section>
<Section title={s.importTitle}>
<ImportSection />
</Section>
</div>
</div>
)}
</>
)
}
@@ -0,0 +1,185 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2 } from 'lucide-react'
import { Controller, useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { FormError, FormField } from '@/components/form-field'
import { MoneyInput } from '@/components/money-input'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { debtTotal, monthIndex, remainingAfter, useSaveSnapshot } from '@/lib/debt'
import { formatMoney, formatNumber, toIsoDate } from '@/lib/format'
import { moneyText, toKurus } from '@/lib/money-schema'
import { t } from '@/lib/strings'
import type { DebtSnapshot } from '@/types/DebtSnapshot'
import type { InstallmentPlan } from '@/types/InstallmentPlan'
const s = t.debt
const schema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Tarih seçin'),
kmh: moneyText('optional'),
card_balance: moneyText('optional'),
remaining_installments: moneyText('optional'),
positive_balance: moneyText('optional'),
note: z.string().max(200, 'En fazla 200 karakter'),
})
type Form = z.infer<typeof schema>
const text = (k: number) => (k === 0 ? '' : formatNumber(k / 100))
/** "Yeni ölçüm" ya da düzenleme. Toplam canlı; kalan taksit planlardan önerilir. */
export function SnapshotForm({
plans,
editing,
onDone,
}: {
plans: InstallmentPlan[]
editing?: DebtSnapshot
onDone?: () => void
}) {
const save = useSaveSnapshot()
const suggestFor = (date: string) => {
const m = monthIndex(date.slice(0, 7))
return m === null ? 0 : remainingAfter(plans, m)
}
const [today] = useState(() => toIsoDate(new Date()))
const {
register,
control,
handleSubmit,
setValue,
setError,
reset,
formState: { errors, isSubmitting },
} = useForm<Form>({
resolver: zodResolver(schema),
mode: 'onTouched',
defaultValues: editing
? {
date: editing.date,
kmh: text(editing.kmh),
card_balance: text(editing.card_balance),
remaining_installments: text(editing.remaining_installments),
positive_balance: text(editing.positive_balance),
note: editing.note ?? '',
}
: {
date: today,
kmh: '',
card_balance: '',
remaining_installments: text(suggestFor(today)),
positive_balance: '',
note: '',
},
})
const v = useWatch({ control })
const live = debtTotal({
kmh: toKurus(v.kmh ?? ''),
card_balance: toKurus(v.card_balance ?? ''),
remaining_installments: toKurus(v.remaining_installments ?? ''),
positive_balance: toKurus(v.positive_balance ?? ''),
})
const suggestion = plans.length > 0 && v.date ? suggestFor(v.date) : null
const showSuggestion =
suggestion !== null && toKurus(v.remaining_installments ?? '') !== suggestion
const onSubmit = handleSubmit(async (f) => {
try {
await save.mutateAsync({
id: editing?.id,
version: editing?.version,
date: f.date,
kmh: toKurus(f.kmh),
card_balance: toKurus(f.card_balance),
remaining_installments: toKurus(f.remaining_installments),
positive_balance: toKurus(f.positive_balance),
note: f.note,
})
toast.success(s.saved)
if (!editing) reset({ ...f, kmh: '', card_balance: '', positive_balance: '', note: '' })
onDone?.()
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
if (/IBAN|Not/.test(msg)) setError('note', { message: msg })
else setError('root', { message: msg })
}
})
const money = (
name: 'kmh' | 'card_balance' | 'remaining_installments' | 'positive_balance',
label: string,
hint: string,
) => (
<Controller
control={control}
name={name}
render={({ field }) => (
<FormField label={label} error={errors[name]} hint={hint}>
{(f) => (
<MoneyInput
{...f}
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
</FormField>
)}
/>
)
return (
<form onSubmit={onSubmit} noValidate className="space-y-4">
<p className="text-sm text-muted-foreground">{s.formHint}</p>
<FormField label={s.date} error={errors.date} className="max-w-56">
{(f) => <Input {...f} type="date" max={today} {...register('date')} />}
</FormField>
<div className="grid gap-4 sm:grid-cols-2">
{money('kmh', s.kmh, s.kmhHint)}
{money('card_balance', s.card, s.cardHint)}
<div className="space-y-1.5">
{money('remaining_installments', s.remaining, s.remainingHint)}
{showSuggestion && (
<p className="flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
{s.suggested(formatMoney(suggestion))}
<button
type="button"
className="min-h-11 font-medium text-primary underline-offset-4 hover:underline"
onClick={() =>
setValue('remaining_installments', text(suggestion), {
shouldDirty: true,
shouldValidate: true,
})
}
>
{s.useSuggested}
</button>
</p>
)}
</div>
{money('positive_balance', s.positive, s.positiveHint)}
</div>
<FormField label={s.note} error={errors.note} hint={s.noteHint}>
{(f) => <Input {...f} {...register('note')} maxLength={200} autoComplete="off" />}
</FormField>
<p role="status" className="money rounded-xl bg-muted px-3 py-2 text-lg font-semibold">
{s.liveTotal(formatMoney(live))}
</p>
<FormError message={errors.root?.message} />
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
{s.save}
</Button>
{editing && (
<Button type="button" variant="outline" onClick={onDone}>
{s.cancel}
</Button>
)}
</div>
</form>
)
}
@@ -0,0 +1,106 @@
import { useState } from 'react'
import { Loader2, Plus, X } from 'lucide-react'
import { toast } from 'sonner'
import { FormError } from '@/components/form-field'
import { MoneyInput } from '@/components/money-input'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { monthIndex, monthName, useSetTargets } from '@/lib/debt'
import { formatNumber, parseMoney } from '@/lib/format'
import { formatMonth } from '@/lib/reports'
import { t } from '@/lib/strings'
import type { DebtTarget } from '@/types/DebtTarget'
const s = t.debt
type Row = { key: number; month: string; amount: string }
/** Yol haritası: ay → ay sonu toplam borç hedefi (liste olduğu gibi kaydedilir) */
export function TargetsEditor({
targets,
thisMonth,
}: {
targets: DebtTarget[]
thisMonth: string
}) {
const save = useSetTargets()
const [rows, setRows] = useState<Row[]>(() =>
targets.map((tg, i) => ({
key: i,
month: tg.month,
amount: formatNumber(tg.closing_target / 100),
})),
)
const [error, setError] = useState<string>()
function add() {
const last = rows.length ? monthIndex(rows[rows.length - 1].month) : monthIndex(thisMonth)
setRows([
...rows,
{ key: Date.now(), month: monthName((last ?? 0) + (rows.length ? 1 : 0)), amount: '' },
])
}
async function submit() {
setError(undefined)
const out: DebtTarget[] = []
for (const r of rows) {
const k = parseMoney(r.amount)
if (monthIndex(r.month) === null || k === null || k < 0)
return setError(`${r.month || '?'}: ay ya da tutar geçersiz`)
out.push({ month: r.month, closing_target: k })
}
try {
await save.mutateAsync(out.sort((a, b) => a.month.localeCompare(b.month)))
toast.success(s.targetsSaved)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">{s.targetsHint}</p>
<ul className="space-y-2">
{rows.map((r, i) => (
<li key={r.key} className="flex items-center gap-2">
<Input
type="month"
aria-label={s.month}
value={r.month}
onChange={(e) =>
setRows(rows.map((x, j) => (j === i ? { ...x, month: e.target.value } : x)))
}
className="w-40 shrink-0"
/>
<div className="min-w-0 flex-1">
<MoneyInput
aria-label={`${s.targetAmount} ${r.month ? formatMonth(r.month) : ''}`}
value={r.amount}
onChange={(v) => setRows(rows.map((x, j) => (j === i ? { ...x, amount: v } : x)))}
/>
</div>
<Button
variant="ghost"
size="icon"
aria-label={s.removeTarget(r.month)}
onClick={() => setRows(rows.filter((_, j) => j !== i))}
>
<X aria-hidden />
</Button>
</li>
))}
</ul>
<FormError message={error} />
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={add}>
<Plus aria-hidden />
{s.addTarget}
</Button>
<Button onClick={() => void submit()} disabled={save.isPending}>
{save.isPending && <Loader2 className="animate-spin" aria-hidden />}
{s.saveTargets}
</Button>
</div>
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import {
debtSeries,
debtTotal,
installmentLoad,
monthEnd,
monthIndex,
monthName,
remainingAfter,
} from './debt'
// Kaynak belgedeki 8 taksit (paidCount Kasım 2026 itibarıyla → ilk ay = Kasım − paid + 1)
const nov = monthIndex('2026-11')!
const plans = (
[
[271_761, 7, 5],
[33_324, 9, 5],
[11_945, 7, 5],
[12_717, 7, 4],
[17_067, 7, 3],
[7_428, 7, 3],
[18_202, 7, 2],
[58_738, 3, 2],
] as const
).map(([monthly_amount, total_count, paid]) => ({
monthly_amount,
total_count,
first_month: monthName(nov - paid + 1),
}))
describe('debt (backend ile aynı, kabul rakamları)', () => {
it('totals', () => {
expect(
debtTotal({
kmh: 0,
positive_balance: 236_311,
card_balance: 6_671_590,
remaining_installments: 1_417_769,
}),
).toBe(7_853_048)
expect(
debtTotal({
kmh: 3_926_444,
positive_balance: 0,
card_balance: 2_994_854,
remaining_installments: 986_587,
}),
).toBe(7_907_885)
})
it('installment loads and remaining', () => {
const loads = ['2026-11', '2026-12', '2027-01', '2027-02', '2027-03', '2027-04'].map((m) =>
installmentLoad(plans, monthIndex(m)!),
)
expect(loads).toEqual([431_182, 431_182, 372_444, 88_738, 76_021, 18_202])
expect(remainingAfter(plans, monthIndex('2026-10')!)).toBe(1_417_769)
expect(remainingAfter(plans, nov)).toBe(986_587)
})
it('months', () => {
expect(monthName(monthIndex('2026-12')! + 1)).toBe('2027-01')
expect(monthIndex('2026-13')).toBeNull()
expect(monthEnd('2026-11')).toBe('2026-11-30')
expect(monthEnd('2028-02')).toBe('2028-02-29')
})
it('chart series: actual + target from the first snapshot', () => {
const s = debtSeries(
[
{ date: '2026-11-13', total: 7_907_885 },
{ date: '2026-10-31', total: 7_853_048 },
],
[{ month: '2026-11', closing_target: 6_699_500 }],
)
expect(s.map((p) => [p.date, p.actual, p.target])).toEqual([
['2026-10-31', 7_853_048, 7_853_048],
['2026-11-13', 7_907_885, undefined],
['2026-11-30', undefined, 6_699_500],
])
})
})
+171
View File
@@ -0,0 +1,171 @@
/**
* Borç takibi (docs/BORC_TAKIBI.md): sorgu kancaları ve saf hesaplar.
* Hesaplar backend `services/debt.rs` ile aynı tutulmalı (form önizlemesi, grafik).
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { DebtImportReport } from '@/types/DebtImportReport'
import type { DebtSnapshot } from '@/types/DebtSnapshot'
import type { DebtSnapshotRequest } from '@/types/DebtSnapshotRequest'
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'
// ---------- Saf hesaplar ----------
/** KMH + kart + kalan taksit − artı bakiye */
export function debtTotal(x: {
kmh: number
card_balance: number
remaining_installments: number
positive_balance: number
}): number {
return x.kmh + x.card_balance + x.remaining_installments - x.positive_balance
}
/** `"2026-11"` → ay sırası; geçersizse `null` */
export function monthIndex(m: string): number | null {
const r = /^(\d{4})-(\d{2})$/.exec(m)
if (!r) return null
const mo = Number(r[2])
return mo >= 1 && mo <= 12 ? Number(r[1]) * 12 + mo - 1 : null
}
export function monthName(i: number): string {
return `${Math.floor(i / 12)}-${String((i % 12) + 1).padStart(2, '0')}`
}
type PlanShape = Pick<InstallmentPlan, 'monthly_amount' | 'total_count' | 'first_month'>
/** `month`'tan sonra ekstreye gelecek taksitler (ölçümdeki "kalan taksit" önerisi) */
export function remainingAfter(plans: readonly PlanShape[], month: number): number {
return plans.reduce((sum, p) => {
const first = monthIndex(p.first_month)
if (first === null) return sum
const paid = Math.min(Math.max(month - first + 1, 0), p.total_count)
return sum + (p.total_count - paid) * p.monthly_amount
}, 0)
}
/** Ayın taksit yükü */
export function installmentLoad(plans: readonly PlanShape[], month: number): number {
return plans.reduce((sum, p) => {
const first = monthIndex(p.first_month)
if (first === null) return sum
return month >= first && month <= first + p.total_count - 1 ? sum + p.monthly_amount : sum
}, 0)
}
/** Ayın son günü `"YYYY-MM-DD"` (hedef noktaları grafikte ay sonuna konur) */
export function monthEnd(ym: string): string {
const i = monthIndex(ym)
if (i === null) return ym
const d = new Date(Date.UTC(Math.floor(i / 12), (i % 12) + 1, 0))
return d.toISOString().slice(0, 10)
}
export type DebtPoint = { t: number; date: string; actual?: number; target?: number }
/**
* Grafik: ölçümler (gerçek) + yol haritası (hedef, ay sonları). Hedef çizgisi ilk ölçümden
* başlar ki başlangıçla bağlansın. Zaman ekseni gün (ms).
*/
export function debtSeries(
snapshots: readonly Pick<DebtSnapshot, 'date' | 'total'>[],
targets: readonly DebtTarget[],
): DebtPoint[] {
const map = new Map<string, DebtPoint>()
const at = (date: string) => {
let p = map.get(date)
if (!p) map.set(date, (p = { t: Date.parse(`${date}T00:00:00Z`), date }))
return p
}
const sorted = [...snapshots].sort((a, b) => a.date.localeCompare(b.date))
for (const s of sorted) at(s.date).actual = s.total
if (sorted.length > 0 && targets.length > 0) at(sorted[0].date).target = sorted[0].total
for (const tg of targets) at(monthEnd(tg.month)).target = tg.closing_target
return [...map.values()].sort((a, b) => a.t - b.t)
}
// ---------- Sorgular ----------
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()
return () =>
Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.debt }),
qc.invalidateQueries({ queryKey: ['period'] }),
])
}
export function useSaveSnapshot() {
const invalidate = useInvalidateDebt()
return useMutation({
mutationFn: ({ id, ...body }: DebtSnapshotRequest & { id?: string }) =>
id
? api.patch<DebtSnapshot>(`/debt/snapshots/${id}`, body)
: api.post<DebtSnapshot>('/debt/snapshots', body),
onSuccess: invalidate,
})
}
export function useDeleteSnapshot() {
const invalidate = useInvalidateDebt()
return useMutation({
mutationFn: (id: string) => api.delete(`/debt/snapshots/${id}`),
onSettled: invalidate,
})
}
export function useSaveInstallment() {
const invalidate = useInvalidateDebt()
return useMutation({
mutationFn: ({ id, ...body }: InstallmentRequest & { id?: string }) =>
id
? api.patch<InstallmentPlan>(`/debt/installments/${id}`, body)
: api.post<InstallmentPlan>('/debt/installments', body),
onSuccess: invalidate,
})
}
export function useDeleteInstallment() {
const invalidate = useInvalidateDebt()
return useMutation({
mutationFn: (id: string) => api.delete(`/debt/installments/${id}`),
onSettled: invalidate,
})
}
export function useSetTargets() {
const invalidate = useInvalidateDebt()
return useMutation({
mutationFn: (targets: DebtTarget[]) => api.put<void>('/debt/targets', { targets }),
onSuccess: invalidate,
})
}
export function useDebtImport() {
const invalidate = useInvalidateDebt()
return useMutation({
mutationFn: ({ json, commit }: { json: unknown; commit: boolean }) =>
api.post<DebtImportReport>(`/debt/import?commit=${commit}`, json),
onSuccess: (_, v) => {
if (v.commit) void invalidate()
},
})
}
+4
View File
@@ -30,6 +30,10 @@ export const queryKeys = {
reports: ['reports'] as const,
/** JSON yedeği: son yedek zamanı + geri dönüş noktaları */
backup: ['backup'] as const,
/** Borç takibi özeti */
debt: ['debt'] as const,
/** Dönemin borç bilgisi (dönem önekiyle de yenilenir) */
periodDebt: (id: string) => ['period', id, 'debt'] as const,
period: (id: string) => ['period', id] as const,
planItems: (id: string) => ['period', id, 'plan-items'] as const,
summary: (id: string) => ['period', id, 'summary'] as const,
+92
View File
@@ -17,6 +17,7 @@ export const t = {
review: 'Değerlendirme',
reports: 'Raporlar',
settings: 'Ayarlar',
debt: 'Borç',
main: 'Ana menü',
},
@@ -434,6 +435,97 @@ export const t = {
'Kategorinin grubu sonradan değiştirilemez (eski harcamaların grubu bozulmasın); gerekirse doğru grupta yeni kategori aç, eskisini pasifleştir.',
},
},
debt: {
title: 'Borç Takibi',
description: 'KMH, kredi kartı ve taksitler: toplam borcun her ay nasıl küçüldüğü.',
installmentsTitle: 'Taksitler',
installmentsDescription: 'Açık taksit planları ve önümüzdeki ayların taksit yükü.',
total: 'Toplam borç',
noDebt: 'Borç yok 🎉',
vsLastMonth: (amount: string, down: boolean) =>
down ? `Geçen aya göre ${amount} azaldı` : `Geçen aya göre ${amount} arttı`,
sameAsLastMonth: 'Geçen ayla aynı',
progress: (pct: string) => `Ödenen: ${pct}`,
start: (date: string, amount: string) => `Başlangıç ${date}: ${amount}`,
estimatedEnd: (month: string) => `Tahmini bitiş: ${month}`,
kmh: 'KMH',
card: 'Kart',
remaining: 'Kalan taksit',
positive: 'Artı bakiye',
measuredOn: (date: string) => `Ölçüm: ${date}`,
details: 'Ayrıntılar',
emptyTitle: 'Borç takibi henüz başlamadı',
emptyText:
'Ayın son günü, maaş yattıktan sonra bankadaki üç rakamı (KMH, kart borcu, kalan taksitler) gir; toplam borcun her ay nasıl küçüldüğünü buradan izle.',
chartTitle: 'Gerçek ve hedef',
actual: 'Gerçek',
target: 'Hedef',
historyTitle: 'Ölçümler',
date: 'Tarih',
change: 'Değişim',
note: 'Not',
edit: (date: string) => `${date} ölçümünü düzenle`,
remove: (date: string) => `${date} ölçümünü sil`,
removeConfirm: (date: string) => `${date} ölçümü silinsin mi?`,
removed: 'Ölçüm silindi',
formTitle: 'Yeni ölçüm',
formEditTitle: 'Ölçümü düzenle',
formHint:
'Ayın son günü, maaş yattıktan sonra ölç. Tutarları bankadaki gibi, artı sayı olarak yaz.',
kmhHint: 'Eksi bakiye, artı sayı olarak (artıdaysa 0).',
cardHint: 'Güncel borç + provizyon.',
remainingHint: 'Ekstreye henüz gelmemiş taksitler.',
positiveHint: 'Maaş hesabı artıdaysa bakiye.',
suggested: (amount: string) => `Taksit planlarına göre: ${amount}`,
useSuggested: 'Bunu kullan',
liveTotal: (amount: string) => `Toplam: ${amount}`,
noteHint: 'Ör. "maaş sonrası". Kart ya da IBAN numarası yazma.',
save: 'Kaydet',
cancel: 'Vazgeç',
saved: 'Ölçüm kaydedildi',
targetsTitle: 'Yol haritası',
targetsHint: 'Her ayın sonunda ulaşmak istediğin toplam borç. Grafikte kesikli çizgi.',
addTarget: 'Ay ekle',
month: 'Ay',
targetAmount: 'Ay sonu hedefi',
removeTarget: (m: string) => `${m} hedefini kaldır`,
saveTargets: 'Hedefleri kaydet',
targetsSaved: 'Yol haritası kaydedildi',
importTitle: 'İçe aktar (JSON)',
importHint:
'Borç takibi dosyası (ölçümler, taksitler, hedefler). Önce ön izleme gösterilir, onaylarsan eklenir; aynı dosya iki kez eklenmez.',
chooseFile: 'Dosya seç',
importCommit: (n: number) => `${n} kaydı ekle`,
imported: 'Borç verisi içe aktarıldı',
nothingNew: 'Eklenecek yeni kayıt yok.',
kinds: { snapshot: 'Ölçüm', installment: 'Taksit', target: 'Hedef' } as Record<string, string>,
statuses: {
new: 'Yeni',
exists: 'Zaten var',
update: 'Güncellenecek',
error: 'Hatalı',
} as Record<string, string>,
loadTitle: 'Önümüzdeki 6 ayın taksit yükü',
plansTitle: 'Taksit planları',
merchant: 'İşyeri',
monthly: 'Aylık',
totalAmount: 'Toplam tutar',
count: 'Taksit sayısı',
firstMonth: 'İlk taksit ayı',
firstMonthHint: 'İlk taksitin ekstreye geldiği ay.',
paid: (paid: number, total: number) => `${paid} / ${total} ödendi`,
left: (amount: string) => `Kalan ${amount}`,
until: (month: string) => `Son taksit ${month}`,
finished: 'Bitti',
addInstallment: 'Yeni taksit',
noNewInstallments: 'Borç sıfırlanana kadar yeni taksit planı yok.',
noNewInstallmentsHint: 'Var olan bir taksiti kaydetmek için devam edebilirsin.',
installmentSaved: 'Taksit kaydedildi',
removeInstallment: (m: string) => `${m} taksitini sil`,
removeInstallmentConfirm: (m: string) => `${m} taksit planı silinsin mi?`,
noInstallments: 'Kayıtlı taksit planı yok.',
toInstallments: 'Taksitler',
},
monthlyReport: {
title: 'Aylık Rapor',
description: 'Dönemin değerlendirmesi: ne yaptın, ne iyi gitti, gelecek ay için öneriler.',
+5
View File
@@ -64,6 +64,11 @@ const routes: RouteObject[] = [
path: paths.reports,
lazy: page(() => import('@/features/reports/page'), 'ReportsPage'),
},
{ path: paths.debt, lazy: page(() => import('@/features/debt/page'), 'DebtPage') },
{
path: paths.installments,
lazy: page(() => import('@/features/debt/installments-page'), 'InstallmentsPage'),
},
{
path: paths.settings,
lazy: page(() => import('@/features/settings/page'), 'SettingsPage'),