Files
kakeibo/frontend/src/features/debt/period-debt.tsx
T

284 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}