Files
butcem/src/features/transactions/components/card-summary.tsx
T

315 lines
12 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.
"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>
);
}