feat(borç): aylık planda bu ayın borcu, borç ödemesi etiketi, taksit önerisi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 04:47:59 +03:00
parent c063186e80
commit dbed3da1b1
12 changed files with 376 additions and 9 deletions
+283
View File
@@ -0,0 +1,283 @@
import { useState } from 'react'
import { CircleCheck, Landmark, Loader2, Pencil, Plus } from 'lucide-react'
import { Link } from 'react-router'
import { toast } from 'sonner'
import { FormError } from '@/components/form-field'
import { paths } from '@/components/layout/nav-items'
import { MoneyInput } from '@/components/money-input'
import { Button } from '@/components/ui/button'
import { findInstallmentItem, usePeriodDebt, useSetOpeningDebt } from '@/lib/debt'
import { formatDate, formatMoney, formatNumber, parseMoney } from '@/lib/format'
import { useCreatePlanItem } from '@/lib/periods'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { Period } from '@/types/Period'
import type { PeriodDebt } from '@/types/PeriodDebt'
import type { PlanItem } from '@/types/PlanItem'
const s = t.debt.period
/**
* Aylık plan: bu ayın banka borcu (docs/BORC_TAKIBI.md §3). Ay başı borç son ölçümden
* (elle düzeltilebilir), devreden, borç ödemesi (= birikim hedefi), taksit yükü, hedef ay sonu.
* Borç takibinde hiç veri yoksa görünmez.
*/
export function PeriodDebtSection({
period,
items,
writable,
}: {
period: Period
items: PlanItem[]
writable: boolean
}) {
const q = usePeriodDebt(period.id)
const d = q.data
if (!d || (d.opening_total === null && d.installment_load === 0)) return null
return (
<section
aria-labelledby="period-debt-title"
className="min-w-0 rounded-2xl border bg-card p-4 sm:p-5"
>
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
<h3 id="period-debt-title" className="flex items-center gap-1.5 font-semibold">
<Landmark className="size-4" aria-hidden />
{s.title}
</h3>
<Link
to={paths.debt}
className="inline-flex min-h-11 items-center px-2 text-sm text-primary"
>
{s.toDebt}
</Link>
</div>
<p className="mb-3 text-sm text-muted-foreground">{s.hint}</p>
<dl className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
<Opening period={period} d={d} writable={writable} />
<Tile label={s.carried} hint={s.carriedHint} amount={d.carried} />
<Tile label={s.payment} sign="−" amount={d.payment} />
<Tile label={s.load} sign="−" amount={d.installment_load} />
<Target d={d} />
</dl>
<Progress d={d} />
{writable && (
<InstallmentsSuggestion period={period} items={items} load={d.installment_load} />
)}
</section>
)
}
function Tile({
label,
amount,
sign,
hint,
}: {
label: string
amount: number | null
sign?: string
hint?: string
}) {
return (
<div className="rounded-xl border p-3">
<dt className="text-xs text-muted-foreground">
{sign && <span aria-hidden>{sign} </span>}
{label}
</dt>
<dd className="money mt-1 font-semibold">{amount === null ? '—' : formatMoney(amount)}</dd>
{hint && <p className="mt-0.5 text-[11px] text-muted-foreground">{hint}</p>}
</div>
)
}
/** Ay başı borç: kaynağı yazılı; düzeltilebilir (dönem açıkken) */
function Opening({ period, d, writable }: { period: Period; d: PeriodDebt; writable: boolean }) {
const set = useSetOpeningDebt(period.id)
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const [error, setError] = useState<string>()
const source =
d.opening_source === 'manual'
? s.manual
: d.opening_date
? s.fromSnapshot(formatDate(d.opening_date))
: s.noOpening
async function save(amount: number | null) {
setError(undefined)
try {
await set.mutateAsync(amount)
setEditing(false)
toast.success(s.openingSaved)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
return (
<div
className={cn('rounded-xl border p-3', editing && 'col-span-2 sm:col-span-3 lg:col-span-5')}
>
<dt className="flex items-center justify-between gap-1 text-xs text-muted-foreground">
{s.opening}
{writable && !editing && (
<button
type="button"
aria-label={s.editOpening}
className="-m-2 grid size-11 place-items-center rounded-lg hover:bg-muted"
onClick={() => {
setValue(d.opening_total === null ? '' : formatNumber(d.opening_total / 100))
setEditing(true)
}}
>
<Pencil className="size-3.5" aria-hidden />
</button>
)}
</dt>
{editing ? (
<div className="mt-2 space-y-2">
<MoneyInput aria-label={s.opening} value={value} onChange={setValue} autoFocus />
<FormError message={error} />
<div className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={set.isPending}
onClick={() => {
const k = parseMoney(value)
if (k === null || k < 0) return setError('Geçerli bir tutar yazın')
void save(k)
}}
>
{set.isPending && <Loader2 className="animate-spin" aria-hidden />}
{t.debt.save}
</Button>
{d.opening_source === 'manual' && (
<Button size="sm" variant="outline" onClick={() => void save(null)}>
{s.resetOpening}
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
{t.debt.cancel}
</Button>
</div>
</div>
) : (
<>
<dd className="money mt-1 font-semibold">
{d.opening_total === null ? '—' : formatMoney(d.opening_total)}
</dd>
<p className="mt-0.5 text-[11px] text-muted-foreground">{source}</p>
</>
)}
</div>
)
}
/** Hedef ay sonu borç (+ yol haritasıyla karşılaştırma) */
function Target({ d }: { d: PeriodDebt }) {
const diff =
d.closing_target !== null && d.roadmap_target !== null
? d.closing_target - d.roadmap_target
: null
return (
<div className="col-span-2 rounded-xl border-2 border-primary/40 bg-primary/5 p-3 sm:col-span-1">
<dt className="text-xs font-medium text-muted-foreground">
<span aria-hidden>= </span>
{s.target}
</dt>
<dd className="money mt-1 text-lg font-bold">
{d.closing_target === null ? '—' : formatMoney(d.closing_target)}
</dd>
{d.roadmap_target !== null && (
<p className="mt-0.5 text-[11px] text-muted-foreground">
{s.roadmap(formatMoney(d.roadmap_target))}
{diff !== null && diff !== 0 && (
<span className={diff > 0 ? 'text-expense' : 'text-income'}>
{' · '}
{diff > 0
? s.vsRoadmapAbove(formatMoney(diff))
: s.vsRoadmapBelow(formatMoney(-diff))}
</span>
)}
</p>
)}
</div>
)
}
/** Aylık başarı: ay başı − son ölçüm ≥ borç ödemesi + taksit */
function Progress({ d }: { d: PeriodDebt }) {
if (d.opening_total === null || d.latest_total === null || !d.latest_date) return null
const down = d.opening_total - d.latest_total
const needed = d.payment + d.installment_load
const ok = down >= needed && needed > 0
const ratio = needed > 0 ? Math.min(Math.max(down / needed, 0), 1) : 0
return (
<div className="mt-3 space-y-1.5 rounded-xl bg-muted p-3 text-sm">
<p className="text-xs text-muted-foreground">{s.progressTitle(formatDate(d.latest_date))}</p>
<p className={cn('font-medium', down < 0 ? 'text-expense' : ok ? 'text-income' : '')}>
{ok && <CircleCheck className="mr-1 inline size-4" aria-hidden />}
{down >= 0 ? s.paidDown(formatMoney(down)) : s.grew(formatMoney(-down))}
<span className="font-normal text-muted-foreground">
{' '}
· {s.needed(formatMoney(needed))}
</span>
</p>
<div
className="h-2 overflow-hidden rounded-full bg-background"
role="progressbar"
aria-label={s.needed(formatMoney(needed))}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(ratio * 100)}
>
<div className="h-full rounded-full bg-income" style={{ width: `${ratio * 100}%` }} />
</div>
</div>
)
}
/** Taksit yükü planda "Taksitler" sabit gideri olarak yoksa: tek tıkla ekle */
function InstallmentsSuggestion({
period,
items,
load,
}: {
period: Period
items: PlanItem[]
load: number
}) {
const create = useCreatePlanItem(period.id)
if (load <= 0) return null
const existing = findInstallmentItem(items)
if (existing) {
if (existing.amount === load) return null
return (
<p className="mt-3 text-sm text-warning">
{s.installmentsMismatch(formatMoney(existing.amount), formatMoney(load))}
</p>
)
}
return (
<Button
variant="outline"
className="mt-3"
disabled={create.isPending}
onClick={() =>
create.mutate(
{ kind: 'expense', name: s.installmentsName, amount: load },
{
onSuccess: () => toast.success(s.installmentsAdded),
onError: (e) => toast.error(e.message),
},
)
}
>
{create.isPending ? <Loader2 className="animate-spin" aria-hidden /> : <Plus aria-hidden />}
{s.addInstallments(formatMoney(load))}
</Button>
)
}