feat(borç): borç takibi ekranları (Panel kartı, /borc, /taksitler)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user