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:
@@ -5,6 +5,7 @@ import { BudgetView } from "@/features/budgets/components/budget-view";
|
||||
import { IncomePlanSection } from "@/features/budgets/components/income-plan";
|
||||
import { getIncomePlan } from "@/features/budgets/plan";
|
||||
import { getBudget } from "@/features/budgets/service";
|
||||
import { KakeiboBudgetCard } from "@/features/reports/components/kakeibo-card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
@@ -27,7 +28,13 @@ export default async function BudgetPage({ searchParams }: PageProps<"/butce">)
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.budget.title} description={tr.budget.description} />
|
||||
<BudgetView key={data.month} data={data} />
|
||||
<BudgetView
|
||||
key={data.month}
|
||||
data={data}
|
||||
summary={
|
||||
<KakeiboBudgetCard userId={user.id} householdId={household.id} month={data.month} />
|
||||
}
|
||||
/>
|
||||
<IncomePlanSection
|
||||
key={`income-${data.month}`}
|
||||
month={data.month}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||
import { getKakeiboReport } from "@/features/reports/kakeibo";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { csvMoney, toCsv } from "@/shared/csv";
|
||||
import { KAKEIBO_GROUPS, type SpendingClass } from "@/shared/kakeibo";
|
||||
|
||||
/** GET /raporlar/kakeibo/csv?ay=YYYY-MM — sınıf × kategori tutarları. */
|
||||
export async function GET(request: Request) {
|
||||
const c = await csvContext(request);
|
||||
if (!c) return unauthorized();
|
||||
const r = await getKakeiboReport(c.userId, c.householdId, c.month);
|
||||
if (!r) return forbidden();
|
||||
const k = tr.kakeibo;
|
||||
const label = (cls: SpendingClass) =>
|
||||
cls === "BANK_COST" ? k.bankCost : cls === "UNGROUPED" ? k.ungrouped : k.groups[cls];
|
||||
const order: SpendingClass[] = [...KAKEIBO_GROUPS, "UNGROUPED", "BANK_COST"];
|
||||
const rows = order.flatMap((cls) =>
|
||||
r.now.byClass[cls].map((x) => [label(cls), x.path, csvMoney(x.amountMinor)]),
|
||||
);
|
||||
return csvResponse(
|
||||
toCsv(tr.reports.kakeiboReport.csvHeader, rows),
|
||||
tr.reports.kakeiboReport.csvFile(r.month),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { KakeiboReportView } from "@/features/reports/components/kakeibo-view";
|
||||
import {
|
||||
CsvButton,
|
||||
MonthNav,
|
||||
ReportFrame,
|
||||
ToolsEnd,
|
||||
monthLabel,
|
||||
} from "@/features/reports/components/report-frame";
|
||||
import { getKakeiboReport } from "@/features/reports/kakeibo";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||
|
||||
const t = tr.reports.kakeiboReport;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
/** /raporlar/kakeibo?ay=YYYY-MM — Kakeibo dört grup, banka maliyeti, grupsuzlar. */
|
||||
export default async function KakeiboReportPage({ searchParams }: PageProps<"/raporlar/kakeibo">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||
const data = await getKakeiboReport(user.id, household.id, ay.success ? ay.data : undefined);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<ReportFrame
|
||||
title={t.title}
|
||||
description={t.description(monthLabel(data.month))}
|
||||
tools={
|
||||
<>
|
||||
<MonthNav month={data.month} currentMonth={data.currentMonth} path="/raporlar/kakeibo" />
|
||||
<ToolsEnd>
|
||||
<CsvButton href={`/raporlar/kakeibo/csv?ay=${data.month}`} />
|
||||
</ToolsEnd>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.includesHidden ? (
|
||||
<p className="text-xs text-muted-foreground">{tr.reports.includesHidden}</p>
|
||||
) : null}
|
||||
<KakeiboReportView data={data} />
|
||||
</ReportFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BookOpenCheck,
|
||||
ChartPie,
|
||||
ChevronRight,
|
||||
Coins,
|
||||
@@ -25,6 +26,7 @@ const t = tr.reports;
|
||||
export const metadata: Metadata = { title: t.title };
|
||||
|
||||
const REPORTS = [
|
||||
{ href: "/raporlar/kakeibo", key: "kakeibo", icon: BookOpenCheck },
|
||||
{ href: "/raporlar/kategoriler", key: "categories", icon: ChartPie },
|
||||
{ href: "/raporlar/trend", key: "trend", icon: TrendingUp },
|
||||
{ href: "/raporlar/kisi-hesap", key: "people", icon: Users },
|
||||
|
||||
@@ -28,7 +28,7 @@ export type NavItem = {
|
||||
|
||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||
export const CURRENT_PHASE = 15;
|
||||
export const CURRENT_PHASE = 16;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: str
|
||||
|
||||
type Pending = { kind: "edit" } | { kind: "replace"; source: "copy" | "actuals" } | null;
|
||||
|
||||
export function BudgetView({ data }: { data: Data }) {
|
||||
export function BudgetView({ data, summary }: { data: Data; summary?: React.ReactNode }) {
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const close = () => setPending(null);
|
||||
@@ -306,6 +306,8 @@ export function BudgetView({ data }: { data: Data }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summary}
|
||||
|
||||
{data.lines.length ? (
|
||||
<ul className="flex flex-col divide-y rounded-xl border bg-card">
|
||||
{data.lines.map((l) => (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { IconBadge } from "@/components/appearance/swatch";
|
||||
import { KakeiboBadge } from "./kakeibo-badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
@@ -61,6 +62,9 @@ export function CategoriesManager({
|
||||
[categories, kind, showArchived],
|
||||
);
|
||||
const archivedCount = categories.filter((c) => c.kind === kind && c.archived).length;
|
||||
const ungrouped = categories.filter(
|
||||
(c) => c.kind === "expense" && !c.archived && !c.kakeibo.group && !c.kakeibo.bankCost,
|
||||
).length;
|
||||
|
||||
function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) {
|
||||
startTransition(async () => {
|
||||
@@ -90,6 +94,9 @@ export function CategoriesManager({
|
||||
<span className={cn("min-w-0 flex-1 truncate", node.depth === 0 && "font-medium")}>
|
||||
{node.name}
|
||||
</span>
|
||||
{node.kind === "expense" && !node.archived ? (
|
||||
<KakeiboBadge kakeibo={node.kakeibo} />
|
||||
) : null}
|
||||
{node.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
||||
{canEdit ? (
|
||||
<DropdownMenu modal={false}>
|
||||
@@ -191,6 +198,11 @@ export function CategoriesManager({
|
||||
) : null}
|
||||
</div>
|
||||
{!canEdit ? <p className="text-sm text-muted-foreground">{t.readOnly}</p> : null}
|
||||
{kind === "expense" && ungrouped ? (
|
||||
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||
{tr.kakeibo.ungroupedCount(ungrouped)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tree.length ? (
|
||||
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
||||
import { IconPicker, SwatchPicker } from "@/components/appearance/pickers";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||
import {
|
||||
categoryFormSchema,
|
||||
type CategoryForm,
|
||||
@@ -60,6 +62,8 @@ export function CategoryFormDialog({
|
||||
parentId: c.parentId ?? "",
|
||||
icon: c.icon,
|
||||
color: c.color,
|
||||
kakeiboGroup: c.kakeiboGroup ?? "",
|
||||
bankCost: c.bankCost,
|
||||
});
|
||||
} else {
|
||||
const parent = categories.find((c) => c.id === state.parentId);
|
||||
@@ -69,6 +73,9 @@ export function CategoryFormDialog({
|
||||
parentId: state.parentId ?? "",
|
||||
icon: parent?.icon ?? "tag",
|
||||
color: parent?.color ?? "blue",
|
||||
// Yeni alt kategori grubu üstten devralır.
|
||||
kakeiboGroup: "",
|
||||
bankCost: false,
|
||||
});
|
||||
}
|
||||
}, [state, categories, form]);
|
||||
@@ -105,6 +112,10 @@ export function CategoryFormDialog({
|
||||
}
|
||||
|
||||
const color = useWatch({ control: form.control, name: "color" }) ?? "blue";
|
||||
const k = tr.kakeibo;
|
||||
const parentId = useWatch({ control: form.control, name: "parentId" });
|
||||
const parentGroup = categories.find((c) => c.id === parentId)?.kakeibo.group ?? null;
|
||||
const parentBank = categories.find((c) => c.id === parentId)?.kakeibo.bankCost ?? false;
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
@@ -177,6 +188,60 @@ export function CategoryFormDialog({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{kind === "expense" ? (
|
||||
<>
|
||||
<Controller
|
||||
name="kakeiboGroup"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cat-kakeibo">{k.field}</FieldLabel>
|
||||
<Select
|
||||
value={field.value || ROOT}
|
||||
onValueChange={(v) => field.onChange(v === ROOT ? "" : v)}
|
||||
>
|
||||
<SelectTrigger id="cat-kakeibo" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ROOT}>
|
||||
{parentGroup ? k.inheritWith(k.groups[parentGroup]) : k.inherit}
|
||||
</SelectItem>
|
||||
{KAKEIBO_GROUPS.map((g) => (
|
||||
<SelectItem key={g} value={g}>
|
||||
{k.groups[g]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>
|
||||
{field.value
|
||||
? k.groupHints[field.value as keyof typeof k.groupHints]
|
||||
: k.ungroupedHint}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="bankCost"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="cat-bank"
|
||||
checked={Boolean(field.value) || parentBank}
|
||||
disabled={parentBank}
|
||||
onCheckedChange={(x) => field.onChange(x === true)}
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<FieldLabel htmlFor="cat-bank">{k.bankCost}</FieldLabel>
|
||||
<FieldDescription>{k.bankCostHint}</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Controller
|
||||
name="color"
|
||||
control={form.control}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { KakeiboGroup, ResolvedKakeibo } from "@/shared/kakeibo";
|
||||
|
||||
const k = tr.kakeibo;
|
||||
|
||||
/** Kakeibo grup renkleri: doğrulanmış kategorik palet (series-1…4); etiket her zaman yanında. */
|
||||
export const KAKEIBO_COLOR: Record<KakeiboGroup, string> = {
|
||||
ZORUNLU: "var(--series-1)",
|
||||
ISTEK: "var(--series-2)",
|
||||
KULTUR: "var(--series-3)",
|
||||
BEKLENMEYEN: "var(--series-4)",
|
||||
};
|
||||
|
||||
export function KakeiboDot({
|
||||
group,
|
||||
className,
|
||||
}: {
|
||||
group: KakeiboGroup | null;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-block size-2.5 shrink-0 rounded-full",
|
||||
!group && "border border-dashed border-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
style={group ? { backgroundColor: KAKEIBO_COLOR[group] } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kategorinin etkin Kakeibo grubu; devralınan grup soluk, banka maliyeti ve grupsuz ayrı. */
|
||||
export function KakeiboBadge({ kakeibo }: { kakeibo: ResolvedKakeibo }) {
|
||||
if (kakeibo.bankCost)
|
||||
return (
|
||||
<Badge variant="secondary" title={k.bankCostHint}>
|
||||
{k.bankCost}
|
||||
</Badge>
|
||||
);
|
||||
if (!kakeibo.group)
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1.5 border-dashed text-muted-foreground"
|
||||
title={k.ungroupedHint}
|
||||
>
|
||||
<KakeiboDot group={null} />
|
||||
{k.ungrouped}
|
||||
</Badge>
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("gap-1.5", kakeibo.inherited && "text-muted-foreground")}
|
||||
title={
|
||||
kakeibo.inherited
|
||||
? `${k.groups[kakeibo.group]} (${k.inherited})`
|
||||
: k.groupHints[kakeibo.group]
|
||||
}
|
||||
>
|
||||
<KakeiboDot group={kakeibo.group} />
|
||||
{k.groups[kakeibo.group]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { KakeiboDot } from "@/features/categories/components/kakeibo-badge";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||
import { getKakeiboReport } from "../kakeibo";
|
||||
import { formatPct } from "./report-frame";
|
||||
|
||||
const k = tr.kakeibo;
|
||||
|
||||
/** Bütçe sayfası: seçili bütçe ayının Kakeibo grup özeti (rapora bağlantılı). */
|
||||
export async function KakeiboBudgetCard({
|
||||
userId,
|
||||
householdId,
|
||||
month,
|
||||
}: {
|
||||
userId: string;
|
||||
householdId: string;
|
||||
month: string;
|
||||
}) {
|
||||
const data = await getKakeiboReport(userId, householdId, month);
|
||||
if (!data) return null;
|
||||
const s = data.now.summary;
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="kb-card"
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 id="kb-card" className="font-medium">
|
||||
{tr.reports.kakeiboReport.title}
|
||||
</h2>
|
||||
<Link
|
||||
href={`/raporlar/kakeibo?ay=${month}`}
|
||||
className="ml-auto flex items-center gap-0.5 text-sm text-primary hover:underline"
|
||||
>
|
||||
{tr.reports.hub.open}
|
||||
<ChevronRight className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-3 md:grid-cols-6">
|
||||
{KAKEIBO_GROUPS.map((g) => (
|
||||
<div key={g}>
|
||||
<dt className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<KakeiboDot group={g} />
|
||||
{k.groups[g]}
|
||||
</dt>
|
||||
<dd className="font-semibold tabular-nums">
|
||||
{formatMoney(s.groups[g])}{" "}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{formatPct(s.shares[g])}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{k.ungrouped}</dt>
|
||||
<dd className="font-semibold tabular-nums">{formatMoney(s.ungroupedMinor)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{k.bankCost}</dt>
|
||||
<dd className="font-semibold tabular-nums">{formatMoney(s.bankCostMinor)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { KakeiboDot } from "@/features/categories/components/kakeibo-badge";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { KAKEIBO_GROUPS, type KakeiboGroup } from "@/shared/kakeibo";
|
||||
|
||||
const k = tr.kakeibo;
|
||||
const FROM_CATEGORY = "__category__";
|
||||
|
||||
/**
|
||||
* Kakeibo grubu seçimi: varsayılan "kategoriden" (önerilen grup gösterilir); kullanıcı bu kayıt
|
||||
* için değiştirebilir. Banka maliyeti kategorisinde seçim kapalıdır (harcama değildir).
|
||||
*/
|
||||
export function KakeiboSelect({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
suggested,
|
||||
bankCost,
|
||||
compact = false,
|
||||
ariaLabel,
|
||||
}: {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Kategorinin etkin grubu (öneri). */
|
||||
suggested: KakeiboGroup | null;
|
||||
bankCost: boolean;
|
||||
compact?: boolean;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
if (bankCost)
|
||||
return (
|
||||
<p
|
||||
className={cn("text-xs text-muted-foreground", compact ? "self-center" : "")}
|
||||
title={k.bankCostHint}
|
||||
>
|
||||
{k.bankCost}
|
||||
</p>
|
||||
);
|
||||
const effective = (value || suggested) as KakeiboGroup | null;
|
||||
return (
|
||||
<Select
|
||||
value={value || FROM_CATEGORY}
|
||||
onValueChange={(v) => onChange(v === FROM_CATEGORY ? "" : v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
aria-label={ariaLabel ?? k.field}
|
||||
className={cn("w-full", compact && "h-8 text-sm")}
|
||||
>
|
||||
<SelectValue>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<KakeiboDot group={effective} />
|
||||
<span className="truncate">
|
||||
{value
|
||||
? k.groups[value as KakeiboGroup]
|
||||
: suggested
|
||||
? compact
|
||||
? k.groups[suggested]
|
||||
: k.fromCategory(k.groups[suggested])
|
||||
: k.fromCategoryNone}
|
||||
</span>
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FROM_CATEGORY}>
|
||||
{suggested ? k.fromCategory(k.groups[suggested]) : k.fromCategoryNone}
|
||||
</SelectItem>
|
||||
{KAKEIBO_GROUPS.map((g) => (
|
||||
<SelectItem key={g} value={g}>
|
||||
<span className="flex items-center gap-2">
|
||||
<KakeiboDot group={g} />
|
||||
{k.groups[g]}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { KakeiboSelect } from "./kakeibo-select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
@@ -61,7 +62,7 @@ export type TransactionFormMode =
|
||||
| { kind: "edit"; transaction: TransactionDetail }
|
||||
| { kind: "copy"; transaction: TransactionDetail };
|
||||
|
||||
type SplitValue = { categoryId: string; amount: string; note: string };
|
||||
type SplitValue = { categoryId: string; amount: string; note: string; kakeiboGroup: string };
|
||||
type FormValues = {
|
||||
type: TransactionType;
|
||||
amount: string;
|
||||
@@ -70,6 +71,8 @@ type FormValues = {
|
||||
toAccountId: string;
|
||||
categoryId: string;
|
||||
splits: SplitValue[];
|
||||
/** Kakeibo grubu bu işlem için değiştirildiyse ("" = kategoriden). */
|
||||
kakeiboGroup: string;
|
||||
payee: string;
|
||||
description: string;
|
||||
tags: string;
|
||||
@@ -90,6 +93,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
||||
toAccountId: p.toAccountId ?? "",
|
||||
categoryId: p.categoryId ?? "",
|
||||
splits: [],
|
||||
kakeiboGroup: "",
|
||||
payee: "",
|
||||
description: p.description ?? "",
|
||||
tags: "",
|
||||
@@ -109,7 +113,9 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
||||
categoryId: s.categoryId,
|
||||
amount: formatMoneyInput(s.amountMinor),
|
||||
note: s.note,
|
||||
kakeiboGroup: s.kakeiboGroup ?? "",
|
||||
})),
|
||||
kakeiboGroup: x.kakeiboGroup ?? "",
|
||||
payee: x.payeeName,
|
||||
description: x.description,
|
||||
tags: x.tags.join(", "),
|
||||
@@ -359,6 +365,7 @@ export function TransactionFormDialog({
|
||||
const amount = useWatch({ control: form.control, name: "amount" });
|
||||
const accountId = useWatch({ control: form.control, name: "accountId" });
|
||||
const splitValues = useWatch({ control: form.control, name: "splits" });
|
||||
const categoryIdValue = useWatch({ control: form.control, name: "categoryId" });
|
||||
const attachmentCount = useWatch({ control: form.control, name: "attachments" })?.length ?? 0;
|
||||
const splitMode = splits.fields.length > 0;
|
||||
|
||||
@@ -426,9 +433,15 @@ export function TransactionFormDialog({
|
||||
}
|
||||
const first = form.getValues("categoryId");
|
||||
splits.replace([
|
||||
{ categoryId: first, amount: form.getValues("amount"), note: "" },
|
||||
{ categoryId: "", amount: "", note: "" },
|
||||
{
|
||||
categoryId: first,
|
||||
amount: form.getValues("amount"),
|
||||
note: "",
|
||||
kakeiboGroup: form.getValues("kakeiboGroup"),
|
||||
},
|
||||
{ categoryId: "", amount: "", note: "", kakeiboGroup: "" },
|
||||
]);
|
||||
form.setValue("kakeiboGroup", "");
|
||||
form.setValue("categoryId", "");
|
||||
setAdvancedOpen(true);
|
||||
}
|
||||
@@ -711,6 +724,30 @@ export function TransactionFormDialog({
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{type === "expense" && !splitMode && categoryIdValue ? (
|
||||
<Controller
|
||||
name="kakeiboGroup"
|
||||
control={form.control}
|
||||
render={({ field }) => {
|
||||
const kk = options?.categoryKakeibo[categoryIdValue];
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tx-kakeibo">{tr.kakeibo.field}</FieldLabel>
|
||||
<KakeiboSelect
|
||||
id="tx-kakeibo"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
suggested={kk?.group ?? null}
|
||||
bankCost={kk?.bankCost ?? false}
|
||||
/>
|
||||
{field.value ? (
|
||||
<FieldDescription>{tr.kakeibo.overrideHint}</FieldDescription>
|
||||
) : null}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Controller
|
||||
name="payee"
|
||||
@@ -862,10 +899,30 @@ export function TransactionFormDialog({
|
||||
{...field}
|
||||
placeholder={f.splitNote}
|
||||
aria-label={`${f.splitNote} ${i + 1}`}
|
||||
className="col-span-2 h-8 text-sm"
|
||||
className={cn("h-8 text-sm", type !== "expense" && "col-span-2")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{type === "expense" ? (
|
||||
<Controller
|
||||
name={`splits.${i}.kakeiboGroup`}
|
||||
control={form.control}
|
||||
render={({ field }) => {
|
||||
const kk =
|
||||
options?.categoryKakeibo[splitValues?.[i]?.categoryId ?? ""];
|
||||
return (
|
||||
<KakeiboSelect
|
||||
compact
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
suggested={kk?.group ?? null}
|
||||
bankCost={kk?.bankCost ?? false}
|
||||
ariaLabel={`${tr.kakeibo.field} ${i + 1}`}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{splitMode ? (
|
||||
@@ -874,7 +931,14 @@ export function TransactionFormDialog({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => splits.append({ categoryId: "", amount: "", note: "" })}
|
||||
onClick={() =>
|
||||
splits.append({
|
||||
categoryId: "",
|
||||
amount: "",
|
||||
note: "",
|
||||
kakeiboGroup: "",
|
||||
})
|
||||
}
|
||||
disabled={splits.fields.length >= MAX_SPLITS}
|
||||
>
|
||||
<Plus />
|
||||
|
||||
Reference in New Issue
Block a user