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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user