feat(ui): Kakeibo grupları — kategori, işlem formu, rapor ve bütçe özeti

- 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>
This commit is contained in:
2026-11-07 04:51:35 +03:00
parent afc6da1606
commit c0b114efeb
13 changed files with 646 additions and 8 deletions
@@ -0,0 +1,191 @@
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>
);
}