feat(ui): kart özeti, KMH paneli, taksit seçimi ve kalan taksit iptali
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, CreditCard, Landmark, Percent, Wallet } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useQuickAdd } from "@/components/layout/quick-add";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import type { CardSummary, OverdraftSummary } from "../card";
|
||||
import { Amount, rowTitle } from "./transaction-parts";
|
||||
|
||||
const c = tr.card;
|
||||
const o = tr.overdraft;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
const pct = (bp: number) => formatMoneyInput(bp).replace(/,00$/, "");
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
sub,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: string;
|
||||
sub?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={cn("text-base font-semibold tabular-nums", tone)}>{value}</p>
|
||||
{sub ? <p className="text-xs text-muted-foreground">{sub}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Limit kullanım çubuğu: borç + gelecek taksitler / limit. */
|
||||
function UsageBar({ used, pending, limit }: { used: number; pending: number; limit: number }) {
|
||||
if (limit <= 0) return null;
|
||||
const u = Math.min(100, (used / limit) * 100);
|
||||
const p = Math.min(100 - u, (pending / limit) * 100);
|
||||
return (
|
||||
<div
|
||||
className="flex h-2 w-full overflow-hidden rounded-full bg-muted"
|
||||
role="img"
|
||||
aria-label={`${Math.round(u + p)}%`}
|
||||
>
|
||||
<div className="bg-expense" style={{ width: `${u}%` }} />
|
||||
<div className="bg-expense/40" style={{ width: `${p}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardSummaryPanel({
|
||||
summary: s,
|
||||
accountId,
|
||||
canWrite,
|
||||
}: {
|
||||
summary: CardSummary;
|
||||
accountId: string;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const { openTransactionForm } = useQuickAdd();
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const st = s.statement;
|
||||
|
||||
function pay() {
|
||||
openTransactionForm({
|
||||
kind: "create",
|
||||
type: "transfer",
|
||||
prefill: {
|
||||
accountId: s.paymentAccount?.id,
|
||||
toAccountId: accountId,
|
||||
amountMinor: st.remainingMinor || undefined,
|
||||
description: c.payDescription,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CreditCard className="size-4" aria-hidden />
|
||||
{c.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Stat label={c.debt} value={formatMoney(s.debtMinor)} tone="text-expense" />
|
||||
<Stat
|
||||
label={c.available}
|
||||
value={formatMoney(s.availableMinor)}
|
||||
sub={c.limitOf(formatMoney(s.limitMinor))}
|
||||
/>
|
||||
</div>
|
||||
<UsageBar used={s.debtMinor} pending={s.pendingInstallmentsMinor} limit={s.limitMinor} />
|
||||
{s.pendingInstallmentsMinor ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{c.pendingInstallments(formatMoney(s.pendingInstallmentsMinor))}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-3 rounded-xl border p-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">{c.statementPeriod}</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{c.periodRange(d(st.period.start), d(st.period.end))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label={c.statementDebt} value={formatMoney(st.debtMinor)} />
|
||||
<Stat label={c.minimum(pct(s.minimumRateBp))} value={formatMoney(st.minimumMinor)} />
|
||||
<Stat label={c.paid} value={formatMoney(st.paidMinor)} tone="text-income" />
|
||||
<Stat
|
||||
label={c.remaining}
|
||||
value={formatMoney(st.remainingMinor)}
|
||||
tone={st.remainingMinor ? "text-expense" : "text-income"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm">
|
||||
{c.dueDate}: <span className="font-medium">{d(st.dueDate)}</span>{" "}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
st.remainingMinor && s.daysToDue < 0 ? "text-expense" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{st.remainingMinor ? `· ${c.daysLeft(s.daysToDue)}` : `· ${c.paidInFull}`}
|
||||
</span>
|
||||
</p>
|
||||
{canWrite ? (
|
||||
<Button size="sm" onClick={pay}>
|
||||
<Wallet />
|
||||
{c.pay}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{c.currentPeriod}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{c.periodRange(d(s.current.start), d(s.current.end))}
|
||||
</p>
|
||||
</div>
|
||||
<Stat label={c.spent} value={formatMoney(s.current.spentMinor)} tone="text-expense" />
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-medium">{c.upcomingInstallments}</h3>
|
||||
{s.upcomingInstallments.length ? (
|
||||
<ul className="divide-y rounded-xl border text-sm">
|
||||
{s.upcomingInstallments.map((x) => (
|
||||
<li key={x.periodEnd} className="flex items-center justify-between gap-3 p-2.5">
|
||||
<span className="text-muted-foreground">
|
||||
{c.installmentPeriod(d(x.periodEnd), d(x.dueDate), x.count)}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">{formatMoney(x.totalMinor)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{c.noInstallments}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{s.planned.length ? (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-medium">{c.planned}</h3>
|
||||
<ul className="divide-y rounded-xl border text-sm">
|
||||
{s.planned.map((p) => (
|
||||
<li key={p.id} className="flex items-center justify-between gap-3 p-2.5">
|
||||
<span className="min-w-0 truncate">
|
||||
{d(p.date)} · {rowTitle(p)}
|
||||
</span>
|
||||
<Amount type={p.type} amountMinor={p.amountMinor} className="font-medium" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<Collapsible open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit">
|
||||
<ChevronDown className={cn("transition-transform", historyOpen && "rotate-180")} />
|
||||
{c.history}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="p-2 text-left font-normal">{c.historyHeaders.period}</th>
|
||||
<th className="p-2 text-right font-normal">{c.historyHeaders.debt}</th>
|
||||
<th className="p-2 text-right font-normal">{c.historyHeaders.minimum}</th>
|
||||
<th className="p-2 text-right font-normal">{c.historyHeaders.paid}</th>
|
||||
<th className="p-2 text-right font-normal">{c.historyHeaders.due}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y tabular-nums">
|
||||
{s.history.map((h) => (
|
||||
<tr key={h.period.end}>
|
||||
<td className="p-2 whitespace-nowrap">
|
||||
{c.periodRange(d(h.period.start), d(h.period.end))}
|
||||
</td>
|
||||
<td className="p-2 text-right">{formatMoney(h.debtMinor)}</td>
|
||||
<td className="p-2 text-right">{formatMoney(h.minimumMinor)}</td>
|
||||
<td className="p-2 text-right">{formatMoney(h.paidMinor)}</td>
|
||||
<td className="p-2 text-right whitespace-nowrap">{d(h.dueDate)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverdraftPanel({
|
||||
summary: s,
|
||||
accountId,
|
||||
canWrite,
|
||||
}: {
|
||||
summary: OverdraftSummary;
|
||||
accountId: string;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const { openTransactionForm } = useQuickAdd();
|
||||
const range = c.periodRange(d(s.statement.start), d(s.statement.end));
|
||||
|
||||
function bookInterest() {
|
||||
openTransactionForm({
|
||||
kind: "create",
|
||||
type: "expense",
|
||||
prefill: {
|
||||
accountId,
|
||||
amountMinor: s.statement.interestMinor || undefined,
|
||||
categoryId: s.interestCategoryId ?? undefined,
|
||||
date: s.statement.end,
|
||||
description: o.interestDescription(range),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Landmark className="size-4" aria-hidden />
|
||||
{o.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Stat
|
||||
label={o.limit}
|
||||
value={formatMoney(s.limitMinor)}
|
||||
sub={o.rate(pct(s.monthlyRateBp))}
|
||||
/>
|
||||
<Stat
|
||||
label={o.used}
|
||||
value={formatMoney(s.usedMinor)}
|
||||
tone={s.usedMinor ? "text-expense" : undefined}
|
||||
/>
|
||||
<Stat label={o.available} value={formatMoney(Math.max(0, s.availableMinor))} />
|
||||
</div>
|
||||
<UsageBar used={s.usedMinor} pending={0} limit={s.limitMinor} />
|
||||
<section className="flex flex-col gap-2 rounded-xl border p-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">{o.lastPeriod}</h3>
|
||||
<span className="text-xs text-muted-foreground">{range}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<Stat
|
||||
label={o.estimatedInterest}
|
||||
value={formatMoney(s.statement.interestMinor)}
|
||||
tone={s.statement.interestMinor ? "text-expense" : undefined}
|
||||
sub={o.averageUsed(formatMoney(s.statement.averageUsedMinor))}
|
||||
/>
|
||||
{s.interestBooked ? (
|
||||
<p className="text-xs text-muted-foreground">{o.booked}</p>
|
||||
) : canWrite && s.statement.interestMinor > 0 ? (
|
||||
<Button size="sm" variant="outline" onClick={bookInterest}>
|
||||
<Percent />
|
||||
{o.bookInterest}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{o.taxNote}</p>
|
||||
</section>
|
||||
<p className="text-sm">
|
||||
{o.currentSoFar}:{" "}
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatMoney(s.current.interestSoFarMinor)}
|
||||
</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user