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
+8 -1
View File
@@ -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),
);
}
+44
View File
@@ -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>
);
}
+2
View File
@@ -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 },
+1 -1
View File
@@ -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 />