From 62023ef35d6dc8768188ce093364434be96af89f Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 00:58:51 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20b=C3=BCt=C3=A7e=20sayfas=C4=B1,=20l?= =?UTF-8?q?imit=20d=C3=BCzenleyici=20ve=20dashboard=20uyar=C4=B1s=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/butce/page.tsx | 24 + src/app/(app)/dashboard/page.tsx | 4 + src/components/layout/nav-config.ts | 4 +- .../budgets/components/budget-view.tsx | 480 ++++++++++++++++++ .../budgets/components/warning-banner.tsx | 29 ++ 5 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 src/app/(app)/butce/page.tsx create mode 100644 src/features/budgets/components/budget-view.tsx create mode 100644 src/features/budgets/components/warning-banner.tsx diff --git a/src/app/(app)/butce/page.tsx b/src/app/(app)/butce/page.tsx new file mode 100644 index 0000000..9ebe6f3 --- /dev/null +++ b/src/app/(app)/butce/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { BudgetView } from "@/features/budgets/components/budget-view"; +import { getBudget } from "@/features/budgets/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; +import { budgetMonthSchema } from "@/shared/schemas/budget"; + +export const metadata: Metadata = { title: tr.budget.title }; + +/** /butce?ay=YYYY-MM — aylık bütçe (varsayılan: içinde bulunulan bütçe ayı). */ +export default async function BudgetPage({ searchParams }: PageProps<"/butce">) { + const { user, household } = await requireActiveHousehold("viewer"); + const ay = budgetMonthSchema.safeParse((await searchParams).ay); + const data = await getBudget(user.id, household.id, ay.success ? ay.data : undefined); + if (!data) notFound(); + return ( + <> + + + + ); +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index fb0f8c2..a6b41a0 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -4,6 +4,7 @@ import { Suspense } from "react"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; +import { BudgetWarningBanner } from "@/features/budgets/components/warning-banner"; import { ContactsDueBanner } from "@/features/contacts/components/due-banner"; import { getNetWorth } from "@/features/net-worth/service"; import { OverdueBanner } from "@/features/recurring/components/overdue-banner"; @@ -79,6 +80,9 @@ export default async function DashboardPage() { + + + diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 36571a8..e5daebc 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -27,7 +27,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 = 9.2; +export const CURRENT_PHASE = 10.1; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; @@ -39,7 +39,7 @@ export const MAIN_NAV: NavItem[] = [ { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 }, { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 }, { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 }, - { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 }, + { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 }, { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 }, { href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 }, { href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 }, diff --git a/src/features/budgets/components/budget-view.tsx b/src/features/budgets/components/budget-view.tsx new file mode 100644 index 0000000..40d7e77 --- /dev/null +++ b/src/features/budgets/components/budget-view.tsx @@ -0,0 +1,480 @@ +"use client"; + +import { + AlertTriangle, + ChevronLeft, + ChevronRight, + Copy, + EllipsisVertical, + History, + Pencil, +} from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; +import { IconBadge } from "@/components/appearance/swatch"; +import { MoneyInput } from "@/components/money-input"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { shiftMonth, type BudgetStatus } from "@/shared/budget"; +import { formatDate, formatMoney } from "@/shared/format"; +import { formatMoneyInput, parseMoney } from "@/shared/money"; +import { budgetFromActualsAction, copyPreviousBudgetAction, saveBudgetAction } from "../actions"; +import type { BudgetLineView, BudgetView as Data } from "../service"; + +const t = tr.budget; +const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`); +const monthFmt = new Intl.DateTimeFormat("tr-TR", { + month: "long", + year: "numeric", + timeZone: "Europe/Istanbul", +}); +const monthLabel = (m: string) => monthFmt.format(new Date(`${m}-15T12:00:00+03:00`)); + +const BAR: Record = { + ok: "bg-budget", + warning: "bg-amber-500", + over: "bg-expense", + none: "bg-muted-foreground/40", +}; + +/** Yüzde çubuğu; `marker` ayın geçen kısmını (yüzde) gösterir. */ +function Bar({ + ratioBp, + status, + marker, + label, +}: { + ratioBp: number | null; + status: BudgetStatus; + marker?: number; + label: string; +}) { + const pct = ratioBp === null ? 0 : Math.min(100, ratioBp / 100); + return ( +
+
+ {marker !== undefined && marker > 0 && marker < 100 ? ( + + ) : null} +
+ ); +} + +function StatusBadge({ + status, + forecastOver, + exceeded, +}: { + status: BudgetStatus; + forecastOver: boolean; + /** Limit aşıldı mı (tam %100'de "doldu"). */ + exceeded: boolean; +}) { + if (status === "over") + return ( + + + {exceeded ? t.status.over : t.status.full} + + ); + if (status === "warning") + return ( + + + {t.status.warning} + + ); + if (forecastOver) + return ( + + {t.status.forecastOver} + + ); + return null; +} + +function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +type Pending = { kind: "edit" } | { kind: "replace"; source: "copy" | "actuals" } | null; + +export function BudgetView({ data }: { data: Data }) { + const [pending, setPending] = useState(null); + const [busy, setBusy] = useState(false); + const close = () => setPending(null); + const tt = data.totals; + const monthPct = Math.round((data.elapsedDays * 100) / data.totalDays); + const warnings = data.lines.filter((l) => l.status === "warning" || l.status === "over"); + + async function generate(source: "copy" | "actuals") { + setBusy(true); + const r = + source === "copy" + ? await copyPreviousBudgetAction(data.month) + : await budgetFromActualsAction(data.month); + setBusy(false); + close(); + if (r.ok) toast.success(source === "copy" ? t.toast.copied : t.toast.created); + else toast.error(r.error); + } + const askOrRun = (source: "copy" | "actuals") => + data.hasBudget ? setPending({ kind: "replace", source }) : generate(source); + + return ( +
+
+
+ +
+

{monthLabel(data.month)}

+

+ {t.period(d(data.period.from), d(data.period.to))} +

+
+ + {data.month !== data.currentMonth ? ( + + ) : null} +
+ {data.canWrite ? ( +
+ + + + + + + askOrRun("copy")} + > + + {t.copyPrevious} + + + askOrRun("actuals")}> + + {t.fromActuals} + + + +
+ ) : null} +
+ + + +
+ + + + +
+ {data.hasBudget ? ( + + ) : null} +
+ + {t.totals.monthProgress(data.elapsedDays, data.totalDays, String(monthPct))} + {tt.ratioBp !== null ? ` · %${Math.round(tt.ratioBp / 100)}` : ""} + + {tt.plannedMinor ? {t.totals.planned(formatMoney(tt.plannedMinor))} : null} + {data.hasBudget && tt.unbudgetedMinor ? ( + + {t.totals.unbudgeted}: {formatMoney(tt.unbudgetedMinor)} + + ) : null} +
+ {data.includesHidden ? ( +

{t.includesHidden}

+ ) : null} +
+
+ + {warnings.length ? ( +
+ + {t.warnings(warnings.length)} +
+ ) : null} + + {!data.hasBudget ? ( +
+

{t.empty}

+

{t.emptyHint}

+ {data.canWrite ? ( +
+ + + +
+ ) : null} +
+ ) : null} + + {data.lines.length ? ( +
    + {data.lines.map((l) => ( + + ))} +
+ ) : data.hasBudget ? ( +

{t.noSpending}

+ ) : null} + + {pending?.kind === "edit" ? : null} + !o && close()}> + + + {t.replaceTitle} + + {t.replaceDescription} + {pending?.kind === "replace" && pending.source === "actuals" + ? ` ${t.fromActualsHint}` + : ""} + + + + {tr.common.cancel} + + + + +
+ ); +} + +function Line({ line: l, marker }: { line: BudgetLineView; marker: number }) { + const hasLimit = l.limitMinor !== null; + return ( +
  • + +
    +
    +

    + {l.name} + +

    +

    + {hasLimit + ? t.line.ofLimit(formatMoney(l.actualMinor), formatMoney(l.limitMinor!)) + : formatMoney(l.actualMinor)} +

    +
    + {hasLimit ? ( + + ) : null} +

    + {hasLimit ? ( + + {l.remainingMinor! < 0 + ? t.line.over(formatMoney(-l.remainingMinor!)) + : t.line.remaining(formatMoney(l.remainingMinor!))} + + ) : ( + {l.covered ? t.status.covered : t.status.none} + )} + {l.plannedMinor ? {t.line.planned(formatMoney(l.plannedMinor))} : null} + {l.forecastMinor !== l.actualMinor ? ( + + {t.line.forecast(formatMoney(l.forecastMinor))} + + ) : null} +

    +
    +
  • + ); +} + +function EditDialog({ data, onClose }: { data: Data; onClose: () => void }) { + const [values, setValues] = useState>(() => + Object.fromEntries( + data.editor.categories.map((c) => { + const v = data.editor.limits[c.id]; + return [c.id, v ? formatMoneyInput(v) : ""]; + }), + ), + ); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Toplam: limiti olan ve üstünde limit olmayan kategoriler (alt limitler iki kez sayılmaz). + let total = 0; + const stack: { depth: number; limited: boolean }[] = []; + for (const c of data.editor.categories) { + while (stack.length && stack.at(-1)!.depth >= c.depth) stack.pop(); + const covered = stack.some((s) => s.limited); + const v = parseMoney(values[c.id] ?? "") ?? 0; + if (v > 0 && !covered) total += v; + stack.push({ depth: c.depth, limited: v > 0 }); + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + const r = await saveBudgetAction({ + month: data.month, + limits: Object.entries(values).map(([categoryId, amount]) => ({ categoryId, amount })), + }); + setBusy(false); + if (r.ok) { + toast.success(t.toast.saved); + onClose(); + } else setError(r.error); + } + + return ( + !o && onClose()} + title={t.editTitle(monthLabel(data.month))} + description={t.editHint} + footer={ + <> + + {t.editTotal(formatMoney(total))} + + + + + } + > +
    + {data.editor.categories.map((c) => ( + + ))} + {error ?

    {error}

    : null} +
    +
    + ); +} diff --git a/src/features/budgets/components/warning-banner.tsx b/src/features/budgets/components/warning-banner.tsx new file mode 100644 index 0000000..07da75e --- /dev/null +++ b/src/features/budgets/components/warning-banner.tsx @@ -0,0 +1,29 @@ +import { AlertTriangle } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { tr } from "@/i18n/tr"; +import { countBudgetWarnings } from "../service"; + +/** Dashboard: bu ay %80'i geçilen veya aşılan bütçe kategorileri (uygulama içi uyarı). */ +export async function BudgetWarningBanner({ + userId, + householdId, +}: { + userId: string; + householdId: string; +}) { + const n = await countBudgetWarnings(userId, householdId); + if (!n) return null; + return ( +
    + + {tr.budget.warnings(n)} + +
    + ); +}