c0b114efeb
- kategori formunda grup ve banka maliyeti; listede grup rozeti ve grupsuz uyarısı - işlem formunda "Kategoriden: …" önerili grup seçimi, bölünmüş kalem başına - /raporlar/kakeibo (yığılmış pay çubuğu, grup kartları, banka maliyeti, grupsuzlar, CSV) - bütçe sayfasında Kakeibo özeti Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
192 lines
6.9 KiB
TypeScript
192 lines
6.9 KiB
TypeScript
import { AlertTriangle, Landmark } from "lucide-react";
|
||
import Link from "next/link";
|
||
import { KAKEIBO_COLOR, KakeiboDot } from "@/features/categories/components/kakeibo-badge";
|
||
import { tr } from "@/i18n/tr";
|
||
import { cn } from "@/lib/utils";
|
||
import { formatMoney } from "@/shared/format";
|
||
import { KAKEIBO_GROUPS, type KakeiboGroup } from "@/shared/kakeibo";
|
||
import type { KakeiboReport } from "../kakeibo";
|
||
import { Change, formatPct } from "./report-frame";
|
||
|
||
const k = tr.kakeibo;
|
||
const t = tr.reports.kakeiboReport;
|
||
|
||
/** Dört grubun (ve grupsuzun) harcamadaki payı: tek yığılmış çubuk + lejant + tablo. */
|
||
function Composition({ data }: { data: KakeiboReport }) {
|
||
const s = data.now.summary;
|
||
const parts = [
|
||
...KAKEIBO_GROUPS.map((g) => ({
|
||
key: g,
|
||
label: k.groups[g],
|
||
share: s.shares[g],
|
||
color: KAKEIBO_COLOR[g],
|
||
})),
|
||
...(s.ungroupedMinor
|
||
? [
|
||
{
|
||
key: "UNGROUPED",
|
||
label: k.ungrouped,
|
||
share: s.ungroupedShare,
|
||
color: "var(--muted-foreground)",
|
||
},
|
||
]
|
||
: []),
|
||
].filter((p) => p.share > 0);
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<div
|
||
className="flex h-4 w-full gap-0.5 overflow-hidden rounded-md bg-muted"
|
||
role="img"
|
||
aria-label={t.chartLabel}
|
||
>
|
||
{parts.map((p) => (
|
||
<div
|
||
key={p.key}
|
||
className="h-full first:rounded-l-md last:rounded-r-md"
|
||
style={{ width: `${p.share}%`, backgroundColor: p.color }}
|
||
title={`${p.label}: ${formatPct(p.share)}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||
{parts.map((p) => (
|
||
<li key={p.key} className="flex items-center gap-1.5">
|
||
<span
|
||
aria-hidden
|
||
className="size-2.5 rounded-sm"
|
||
style={{ backgroundColor: p.color }}
|
||
/>
|
||
{p.label} <span className="tabular-nums">{formatPct(p.share)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GroupCard({ data, group }: { data: KakeiboReport; group: KakeiboGroup }) {
|
||
const s = data.now.summary;
|
||
const rows = data.now.byClass[group];
|
||
const ch = data.change[group];
|
||
return (
|
||
<section
|
||
aria-labelledby={`kg-${group}`}
|
||
className="flex flex-col gap-2 rounded-xl border bg-card p-4"
|
||
>
|
||
<header className="flex items-start gap-2">
|
||
<KakeiboDot group={group} className="mt-1.5" />
|
||
<div className="min-w-0 flex-1">
|
||
<h2 id={`kg-${group}`} className="font-medium">
|
||
{k.groups[group]}
|
||
</h2>
|
||
<p className="text-xs text-muted-foreground">{k.groupHints[group]}</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-lg font-semibold tabular-nums">{formatMoney(s.groups[group])}</p>
|
||
<p className="text-xs text-muted-foreground tabular-nums">
|
||
{formatPct(s.shares[group])} ·{" "}
|
||
<Change minor={ch.minor} pct={ch.pct} format={formatMoney} />
|
||
</p>
|
||
</div>
|
||
</header>
|
||
{rows.length ? (
|
||
<ul className="divide-y text-sm">
|
||
{rows.map((r) => (
|
||
<li key={r.id} className="flex justify-between gap-3 py-1.5">
|
||
<span className="min-w-0 truncate">{r.path}</span>
|
||
<span className="shrink-0 tabular-nums">{formatMoney(r.amountMinor)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">{t.noSpending}</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** /raporlar/kakeibo — dört grup, banka maliyeti ve grupsuz harcamalar. */
|
||
export function KakeiboReportView({ data }: { data: KakeiboReport }) {
|
||
const s = data.now.summary;
|
||
const bank = data.now.byClass.BANK_COST;
|
||
const ungrouped = data.now.byClass.UNGROUPED;
|
||
const bankChange = data.change.BANK_COST;
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4">
|
||
<div className="flex items-baseline justify-between gap-3">
|
||
<span className="text-sm text-muted-foreground">{t.spending}</span>
|
||
<span className="text-xl font-semibold tabular-nums">
|
||
{formatMoney(s.spendingMinor)}
|
||
</span>
|
||
</div>
|
||
<Composition data={data} />
|
||
</div>
|
||
<div className="flex flex-col justify-between gap-1 rounded-xl border bg-card p-4 md:w-64">
|
||
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||
<Landmark className="size-4" aria-hidden />
|
||
{k.bankCost}
|
||
</span>
|
||
<span className="text-xl font-semibold tabular-nums">{formatMoney(s.bankCostMinor)}</span>
|
||
<span className="text-xs">
|
||
<Change minor={bankChange.minor} pct={bankChange.pct} format={formatMoney} />
|
||
</span>
|
||
<span className="text-[11px] leading-tight text-muted-foreground">{t.bankCostNote}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{ungrouped.length ? (
|
||
<section
|
||
aria-labelledby="kg-ungrouped"
|
||
className="flex flex-col gap-2 rounded-xl border border-dashed border-amber-500/50 bg-amber-500/5 p-4"
|
||
>
|
||
<h2 id="kg-ungrouped" className="flex items-center gap-1.5 font-medium">
|
||
<AlertTriangle className="size-4 text-amber-600" aria-hidden />
|
||
{k.ungrouped}: {formatMoney(s.ungroupedMinor)}
|
||
</h2>
|
||
<p className="text-sm text-muted-foreground">
|
||
{t.ungroupedHelp}{" "}
|
||
<Link href="/kategoriler" className="text-primary hover:underline">
|
||
{t.goCategories}
|
||
</Link>
|
||
</p>
|
||
<ul className="divide-y text-sm">
|
||
{ungrouped.map((r) => (
|
||
<li key={r.id} className="flex justify-between gap-3 py-1.5">
|
||
<span className="min-w-0 truncate">{r.path}</span>
|
||
<span className="tabular-nums">{formatMoney(r.amountMinor)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
) : null}
|
||
|
||
<div className="grid gap-3 lg:grid-cols-2">
|
||
{KAKEIBO_GROUPS.map((g) => (
|
||
<GroupCard key={g} data={data} group={g} />
|
||
))}
|
||
</div>
|
||
|
||
{bank.length ? (
|
||
<section
|
||
aria-labelledby="kg-bank"
|
||
className={cn("flex flex-col gap-2 rounded-xl border bg-card p-4")}
|
||
>
|
||
<h2 id="kg-bank" className="font-medium">
|
||
{k.bankCost}
|
||
</h2>
|
||
<ul className="divide-y text-sm">
|
||
{bank.map((r) => (
|
||
<li key={r.id} className="flex justify-between gap-3 py-1.5">
|
||
<span className="min-w-0 truncate">{r.path}</span>
|
||
<span className="tabular-nums">{formatMoney(r.amountMinor)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|