Files
butcem/src/features/reports/components/kakeibo-view.tsx
T
suphicakir c0b114efeb 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>
2026-11-07 04:51:35 +03:00

192 lines
6.9 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 { 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>
);
}