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 { IncomePlanSection } from "@/features/budgets/components/income-plan";
|
||||||
import { getIncomePlan } from "@/features/budgets/plan";
|
import { getIncomePlan } from "@/features/budgets/plan";
|
||||||
import { getBudget } from "@/features/budgets/service";
|
import { getBudget } from "@/features/budgets/service";
|
||||||
|
import { KakeiboBudgetCard } from "@/features/reports/components/kakeibo-card";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||||
@@ -27,7 +28,13 @@ export default async function BudgetPage({ searchParams }: PageProps<"/butce">)
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={tr.budget.title} description={tr.budget.description} />
|
<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
|
<IncomePlanSection
|
||||||
key={`income-${data.month}`}
|
key={`income-${data.month}`}
|
||||||
month={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 {
|
import {
|
||||||
|
BookOpenCheck,
|
||||||
ChartPie,
|
ChartPie,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Coins,
|
Coins,
|
||||||
@@ -25,6 +26,7 @@ const t = tr.reports;
|
|||||||
export const metadata: Metadata = { title: t.title };
|
export const metadata: Metadata = { title: t.title };
|
||||||
|
|
||||||
const REPORTS = [
|
const REPORTS = [
|
||||||
|
{ href: "/raporlar/kakeibo", key: "kakeibo", icon: BookOpenCheck },
|
||||||
{ href: "/raporlar/kategoriler", key: "categories", icon: ChartPie },
|
{ href: "/raporlar/kategoriler", key: "categories", icon: ChartPie },
|
||||||
{ href: "/raporlar/trend", key: "trend", icon: TrendingUp },
|
{ href: "/raporlar/trend", key: "trend", icon: TrendingUp },
|
||||||
{ href: "/raporlar/kisi-hesap", key: "people", icon: Users },
|
{ 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. */
|
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** 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;
|
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;
|
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 [pending, setPending] = useState<Pending>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const close = () => setPending(null);
|
const close = () => setPending(null);
|
||||||
@@ -306,6 +306,8 @@ export function BudgetView({ data }: { data: Data }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{summary}
|
||||||
|
|
||||||
{data.lines.length ? (
|
{data.lines.length ? (
|
||||||
<ul className="flex flex-col divide-y rounded-xl border bg-card">
|
<ul className="flex flex-col divide-y rounded-xl border bg-card">
|
||||||
{data.lines.map((l) => (
|
{data.lines.map((l) => (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { IconBadge } from "@/components/appearance/swatch";
|
import { IconBadge } from "@/components/appearance/swatch";
|
||||||
|
import { KakeiboBadge } from "./kakeibo-badge";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
@@ -61,6 +62,9 @@ export function CategoriesManager({
|
|||||||
[categories, kind, showArchived],
|
[categories, kind, showArchived],
|
||||||
);
|
);
|
||||||
const archivedCount = categories.filter((c) => c.kind === kind && c.archived).length;
|
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) {
|
function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -90,6 +94,9 @@ export function CategoriesManager({
|
|||||||
<span className={cn("min-w-0 flex-1 truncate", node.depth === 0 && "font-medium")}>
|
<span className={cn("min-w-0 flex-1 truncate", node.depth === 0 && "font-medium")}>
|
||||||
{node.name}
|
{node.name}
|
||||||
</span>
|
</span>
|
||||||
|
{node.kind === "expense" && !node.archived ? (
|
||||||
|
<KakeiboBadge kakeibo={node.kakeibo} />
|
||||||
|
) : null}
|
||||||
{node.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
{node.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
@@ -191,6 +198,11 @@ export function CategoriesManager({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!canEdit ? <p className="text-sm text-muted-foreground">{t.readOnly}</p> : null}
|
{!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 ? (
|
{tree.length ? (
|
||||||
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
|
<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 { IconPicker, SwatchPicker } from "@/components/appearance/pickers";
|
||||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||||
import {
|
import {
|
||||||
categoryFormSchema,
|
categoryFormSchema,
|
||||||
type CategoryForm,
|
type CategoryForm,
|
||||||
@@ -60,6 +62,8 @@ export function CategoryFormDialog({
|
|||||||
parentId: c.parentId ?? "",
|
parentId: c.parentId ?? "",
|
||||||
icon: c.icon,
|
icon: c.icon,
|
||||||
color: c.color,
|
color: c.color,
|
||||||
|
kakeiboGroup: c.kakeiboGroup ?? "",
|
||||||
|
bankCost: c.bankCost,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const parent = categories.find((c) => c.id === state.parentId);
|
const parent = categories.find((c) => c.id === state.parentId);
|
||||||
@@ -69,6 +73,9 @@ export function CategoryFormDialog({
|
|||||||
parentId: state.parentId ?? "",
|
parentId: state.parentId ?? "",
|
||||||
icon: parent?.icon ?? "tag",
|
icon: parent?.icon ?? "tag",
|
||||||
color: parent?.color ?? "blue",
|
color: parent?.color ?? "blue",
|
||||||
|
// Yeni alt kategori grubu üstten devralır.
|
||||||
|
kakeiboGroup: "",
|
||||||
|
bankCost: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [state, categories, form]);
|
}, [state, categories, form]);
|
||||||
@@ -105,6 +112,10 @@ export function CategoryFormDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const color = useWatch({ control: form.control, name: "color" }) ?? "blue";
|
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 (
|
return (
|
||||||
<ResponsiveDialog
|
<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
|
<Controller
|
||||||
name="color"
|
name="color"
|
||||||
control={form.control}
|
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 { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { KakeiboSelect } from "./kakeibo-select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { todayIstanbul } from "@/shared/dates";
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
import { formatMoney } from "@/shared/format";
|
import { formatMoney } from "@/shared/format";
|
||||||
@@ -61,7 +62,7 @@ export type TransactionFormMode =
|
|||||||
| { kind: "edit"; transaction: TransactionDetail }
|
| { kind: "edit"; transaction: TransactionDetail }
|
||||||
| { kind: "copy"; 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 FormValues = {
|
||||||
type: TransactionType;
|
type: TransactionType;
|
||||||
amount: string;
|
amount: string;
|
||||||
@@ -70,6 +71,8 @@ type FormValues = {
|
|||||||
toAccountId: string;
|
toAccountId: string;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
splits: SplitValue[];
|
splits: SplitValue[];
|
||||||
|
/** Kakeibo grubu bu işlem için değiştirildiyse ("" = kategoriden). */
|
||||||
|
kakeiboGroup: string;
|
||||||
payee: string;
|
payee: string;
|
||||||
description: string;
|
description: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
@@ -90,6 +93,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
|||||||
toAccountId: p.toAccountId ?? "",
|
toAccountId: p.toAccountId ?? "",
|
||||||
categoryId: p.categoryId ?? "",
|
categoryId: p.categoryId ?? "",
|
||||||
splits: [],
|
splits: [],
|
||||||
|
kakeiboGroup: "",
|
||||||
payee: "",
|
payee: "",
|
||||||
description: p.description ?? "",
|
description: p.description ?? "",
|
||||||
tags: "",
|
tags: "",
|
||||||
@@ -109,7 +113,9 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
|||||||
categoryId: s.categoryId,
|
categoryId: s.categoryId,
|
||||||
amount: formatMoneyInput(s.amountMinor),
|
amount: formatMoneyInput(s.amountMinor),
|
||||||
note: s.note,
|
note: s.note,
|
||||||
|
kakeiboGroup: s.kakeiboGroup ?? "",
|
||||||
})),
|
})),
|
||||||
|
kakeiboGroup: x.kakeiboGroup ?? "",
|
||||||
payee: x.payeeName,
|
payee: x.payeeName,
|
||||||
description: x.description,
|
description: x.description,
|
||||||
tags: x.tags.join(", "),
|
tags: x.tags.join(", "),
|
||||||
@@ -359,6 +365,7 @@ export function TransactionFormDialog({
|
|||||||
const amount = useWatch({ control: form.control, name: "amount" });
|
const amount = useWatch({ control: form.control, name: "amount" });
|
||||||
const accountId = useWatch({ control: form.control, name: "accountId" });
|
const accountId = useWatch({ control: form.control, name: "accountId" });
|
||||||
const splitValues = useWatch({ control: form.control, name: "splits" });
|
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 attachmentCount = useWatch({ control: form.control, name: "attachments" })?.length ?? 0;
|
||||||
const splitMode = splits.fields.length > 0;
|
const splitMode = splits.fields.length > 0;
|
||||||
|
|
||||||
@@ -426,9 +433,15 @@ export function TransactionFormDialog({
|
|||||||
}
|
}
|
||||||
const first = form.getValues("categoryId");
|
const first = form.getValues("categoryId");
|
||||||
splits.replace([
|
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", "");
|
form.setValue("categoryId", "");
|
||||||
setAdvancedOpen(true);
|
setAdvancedOpen(true);
|
||||||
}
|
}
|
||||||
@@ -711,6 +724,30 @@ export function TransactionFormDialog({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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
|
<Controller
|
||||||
name="payee"
|
name="payee"
|
||||||
@@ -862,10 +899,30 @@ export function TransactionFormDialog({
|
|||||||
{...field}
|
{...field}
|
||||||
placeholder={f.splitNote}
|
placeholder={f.splitNote}
|
||||||
aria-label={`${f.splitNote} ${i + 1}`}
|
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{splitMode ? (
|
{splitMode ? (
|
||||||
@@ -874,7 +931,14 @@ export function TransactionFormDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => splits.append({ categoryId: "", amount: "", note: "" })}
|
onClick={() =>
|
||||||
|
splits.append({
|
||||||
|
categoryId: "",
|
||||||
|
amount: "",
|
||||||
|
note: "",
|
||||||
|
kakeiboGroup: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
disabled={splits.fields.length >= MAX_SPLITS}
|
disabled={splits.fields.length >= MAX_SPLITS}
|
||||||
>
|
>
|
||||||
<Plus />
|
<Plus />
|
||||||
|
|||||||
Reference in New Issue
Block a user