From c0b114efeb0a96cad4e984d86f8987ca836a732c Mon Sep 17 00:00:00 2001 From: suphicakir Date: Sat, 7 Nov 2026 04:51:35 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Kakeibo=20gruplar=C4=B1=20=E2=80=94?= =?UTF-8?q?=20kategori,=20i=C5=9Flem=20formu,=20rapor=20ve=20b=C3=BCt?= =?UTF-8?q?=C3=A7e=20=C3=B6zeti?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/(app)/butce/page.tsx | 9 +- src/app/(app)/raporlar/kakeibo/csv/route.ts | 24 +++ src/app/(app)/raporlar/kakeibo/page.tsx | 44 ++++ src/app/(app)/raporlar/page.tsx | 2 + src/components/layout/nav-config.ts | 2 +- .../budgets/components/budget-view.tsx | 4 +- .../components/categories-manager.tsx | 12 ++ .../components/category-form-dialog.tsx | 65 ++++++ .../categories/components/kakeibo-badge.tsx | 69 +++++++ .../reports/components/kakeibo-card.tsx | 68 +++++++ .../reports/components/kakeibo-view.tsx | 191 ++++++++++++++++++ .../components/kakeibo-select.tsx | 90 +++++++++ .../components/transaction-form.tsx | 74 ++++++- 13 files changed, 646 insertions(+), 8 deletions(-) create mode 100644 src/app/(app)/raporlar/kakeibo/csv/route.ts create mode 100644 src/app/(app)/raporlar/kakeibo/page.tsx create mode 100644 src/features/categories/components/kakeibo-badge.tsx create mode 100644 src/features/reports/components/kakeibo-card.tsx create mode 100644 src/features/reports/components/kakeibo-view.tsx create mode 100644 src/features/transactions/components/kakeibo-select.tsx diff --git a/src/app/(app)/butce/page.tsx b/src/app/(app)/butce/page.tsx index 94c8fc3..fa530d1 100644 --- a/src/app/(app)/butce/page.tsx +++ b/src/app/(app)/butce/page.tsx @@ -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 ( <> - + + } + /> + 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), + ); +} diff --git a/src/app/(app)/raporlar/kakeibo/page.tsx b/src/app/(app)/raporlar/kakeibo/page.tsx new file mode 100644 index 0000000..9d31b81 --- /dev/null +++ b/src/app/(app)/raporlar/kakeibo/page.tsx @@ -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 ( + + + + + + + } + > + {data.includesHidden ? ( +

{tr.reports.includesHidden}

+ ) : null} + +
+ ); +} diff --git a/src/app/(app)/raporlar/page.tsx b/src/app/(app)/raporlar/page.tsx index 9bc4ae8..8ea0a30 100644 --- a/src/app/(app)/raporlar/page.tsx +++ b/src/app/(app)/raporlar/page.tsx @@ -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 }, diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index c8c1d5e..a3c99eb 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -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; diff --git a/src/features/budgets/components/budget-view.tsx b/src/features/budgets/components/budget-view.tsx index 40d7e77..e6f54ab 100644 --- a/src/features/budgets/components/budget-view.tsx +++ b/src/features/budgets/components/budget-view.tsx @@ -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(null); const [busy, setBusy] = useState(false); const close = () => setPending(null); @@ -306,6 +306,8 @@ export function BudgetView({ data }: { data: Data }) { ) : null} + {summary} + {data.lines.length ? (
    {data.lines.map((l) => ( diff --git a/src/features/categories/components/categories-manager.tsx b/src/features/categories/components/categories-manager.tsx index cf40f8b..907b5bf 100644 --- a/src/features/categories/components/categories-manager.tsx +++ b/src/features/categories/components/categories-manager.tsx @@ -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({ {node.name} + {node.kind === "expense" && !node.archived ? ( + + ) : null} {node.archived ? {t.archived} : null} {canEdit ? ( @@ -191,6 +198,11 @@ export function CategoriesManager({ ) : null} {!canEdit ?

    {t.readOnly}

    : null} + {kind === "expense" && ungrouped ? ( +

    + {tr.kakeibo.ungroupedCount(ungrouped)} +

    + ) : null} {tree.length ? (
      diff --git a/src/features/categories/components/category-form-dialog.tsx b/src/features/categories/components/category-form-dialog.tsx index 5e5ba35..041acf7 100644 --- a/src/features/categories/components/category-form-dialog.tsx +++ b/src/features/categories/components/category-form-dialog.tsx @@ -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 ( + {kind === "expense" ? ( + <> + ( + + {k.field} + + + {field.value + ? k.groupHints[field.value as keyof typeof k.groupHints] + : k.ungroupedHint} + + + )} + /> + ( + + field.onChange(x === true)} + /> +
      + {k.bankCost} + {k.bankCostHint} +
      +
      + )} + /> + + ) : null} = { + 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 ( + + ); +} + +/** Kategorinin etkin Kakeibo grubu; devralınan grup soluk, banka maliyeti ve grupsuz ayrı. */ +export function KakeiboBadge({ kakeibo }: { kakeibo: ResolvedKakeibo }) { + if (kakeibo.bankCost) + return ( + + {k.bankCost} + + ); + if (!kakeibo.group) + return ( + + + {k.ungrouped} + + ); + return ( + + + {k.groups[kakeibo.group]} + + ); +} diff --git a/src/features/reports/components/kakeibo-card.tsx b/src/features/reports/components/kakeibo-card.tsx new file mode 100644 index 0000000..066b5bc --- /dev/null +++ b/src/features/reports/components/kakeibo-card.tsx @@ -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 ( +
      +
      +

      + {tr.reports.kakeiboReport.title} +

      + + {tr.reports.hub.open} + + +
      +
      + {KAKEIBO_GROUPS.map((g) => ( +
      +
      + + {k.groups[g]} +
      +
      + {formatMoney(s.groups[g])}{" "} + + {formatPct(s.shares[g])} + +
      +
      + ))} +
      +
      {k.ungrouped}
      +
      {formatMoney(s.ungroupedMinor)}
      +
      +
      +
      {k.bankCost}
      +
      {formatMoney(s.bankCostMinor)}
      +
      +
      +
      + ); +} diff --git a/src/features/reports/components/kakeibo-view.tsx b/src/features/reports/components/kakeibo-view.tsx new file mode 100644 index 0000000..008ce2b --- /dev/null +++ b/src/features/reports/components/kakeibo-view.tsx @@ -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 ( +
      +
      + {parts.map((p) => ( +
      + ))} +
      +
        + {parts.map((p) => ( +
      • + + {p.label} {formatPct(p.share)} +
      • + ))} +
      +
      + ); +} + +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 ( +
      +
      + +
      +

      + {k.groups[group]} +

      +

      {k.groupHints[group]}

      +
      +
      +

      {formatMoney(s.groups[group])}

      +

      + {formatPct(s.shares[group])} ·{" "} + +

      +
      +
      + {rows.length ? ( +
        + {rows.map((r) => ( +
      • + {r.path} + {formatMoney(r.amountMinor)} +
      • + ))} +
      + ) : ( +

      {t.noSpending}

      + )} +
      + ); +} + +/** /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 ( +
      +
      +
      +
      + {t.spending} + + {formatMoney(s.spendingMinor)} + +
      + +
      +
      + + + {k.bankCost} + + {formatMoney(s.bankCostMinor)} + + + + {t.bankCostNote} +
      +
      + + {ungrouped.length ? ( +
      +

      + + {k.ungrouped}: {formatMoney(s.ungroupedMinor)} +

      +

      + {t.ungroupedHelp}{" "} + + {t.goCategories} + +

      +
        + {ungrouped.map((r) => ( +
      • + {r.path} + {formatMoney(r.amountMinor)} +
      • + ))} +
      +
      + ) : null} + +
      + {KAKEIBO_GROUPS.map((g) => ( + + ))} +
      + + {bank.length ? ( +
      +

      + {k.bankCost} +

      +
        + {bank.map((r) => ( +
      • + {r.path} + {formatMoney(r.amountMinor)} +
      • + ))} +
      +
      + ) : null} +
      + ); +} diff --git a/src/features/transactions/components/kakeibo-select.tsx b/src/features/transactions/components/kakeibo-select.tsx new file mode 100644 index 0000000..75b3b9e --- /dev/null +++ b/src/features/transactions/components/kakeibo-select.tsx @@ -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 ( +

      + {k.bankCost} +

      + ); + const effective = (value || suggested) as KakeiboGroup | null; + return ( + + ); +} diff --git a/src/features/transactions/components/transaction-form.tsx b/src/features/transactions/components/transaction-form.tsx index 8be7bd7..f08e5cb 100644 --- a/src/features/transactions/components/transaction-form.tsx +++ b/src/features/transactions/components/transaction-form.tsx @@ -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 ? ( + { + const kk = options?.categoryKakeibo[categoryIdValue]; + return ( + + {tr.kakeibo.field} + + {field.value ? ( + {tr.kakeibo.overrideHint} + ) : null} + + ); + }} + /> + ) : null} )} /> + {type === "expense" ? ( + { + const kk = + options?.categoryKakeibo[splitValues?.[i]?.categoryId ?? ""]; + return ( + + ); + }} + /> + ) : null}
      ))} {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} >