diff --git a/src/app/(app)/butce/page.tsx b/src/app/(app)/butce/page.tsx index 9ebe6f3..94c8fc3 100644 --- a/src/app/(app)/butce/page.tsx +++ b/src/app/(app)/butce/page.tsx @@ -2,6 +2,8 @@ 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 { IncomePlanSection } from "@/features/budgets/components/income-plan"; +import { getIncomePlan } from "@/features/budgets/plan"; import { getBudget } from "@/features/budgets/service"; import { tr } from "@/i18n/tr"; import { requireActiveHousehold } from "@/lib/dal"; @@ -15,10 +17,27 @@ export default async function BudgetPage({ searchParams }: PageProps<"/butce">) const ay = budgetMonthSchema.safeParse((await searchParams).ay); const data = await getBudget(user.id, household.id, ay.success ? ay.data : undefined); if (!data) notFound(); + const income = await getIncomePlan(user.id, household.id, data.month); + if (!income) notFound(); + const monthLabel = new Intl.DateTimeFormat("tr-TR", { + month: "long", + year: "numeric", + timeZone: "Europe/Istanbul", + }).format(new Date(`${data.month}-15T12:00:00+03:00`)); return ( <> + ); } diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index a6b41a0..b36d3cc 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -4,7 +4,10 @@ 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 { DailyPlanCard } from "@/features/budgets/components/daily-plan-card"; import { BudgetWarningBanner } from "@/features/budgets/components/warning-banner"; +import { getIncomePlan } from "@/features/budgets/plan"; +import { getBudget } from "@/features/budgets/service"; import { ContactsDueBanner } from "@/features/contacts/components/due-banner"; import { getNetWorth } from "@/features/net-worth/service"; import { OverdueBanner } from "@/features/recurring/components/overdue-banner"; @@ -23,15 +26,25 @@ type SummaryCard = { }; /** - * Özet kartları. Bakiye, kart borcu ve net varlık gerçek (Faz 9); bu ayın gelir/gider/kalan - * kartları Faz 14'te dolar. + * Özet kartları (içinde bulunulan bütçe ayı): gerçekleşen gelir, gider ve farkı; hesap bakiyesi, + * kart borcu ve net varlık. */ async function getSummary(userId: string, householdId: string): Promise { - const nw = await getNetWorth(userId, householdId); + const [nw, budget] = await Promise.all([ + getNetWorth(userId, householdId), + getBudget(userId, householdId), + ]); + const income = budget ? await getIncomePlan(userId, householdId, budget.month) : null; + const inc = income?.actualMinor ?? null; + const exp = budget?.totals.actualMinor ?? null; return [ - { key: "monthIncome", tone: "text-income", amountMinor: null }, - { key: "monthExpense", tone: "text-expense", amountMinor: null }, - { key: "monthRemaining", tone: "text-budget", amountMinor: null }, + { key: "monthIncome", tone: "text-income", amountMinor: inc, href: "/butce" }, + { key: "monthExpense", tone: "text-expense", amountMinor: exp, href: "/butce" }, + { + key: "monthRemaining", + tone: "text-budget", + amountMinor: inc !== null && exp !== null ? inc - exp : null, + }, { key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" }, { key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null }, { key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" }, @@ -89,6 +102,9 @@ export default async function DashboardPage() { }> + + + ); } diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index e5daebc..d5f42f2 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 = 10.1; +export const CURRENT_PHASE = 10.2; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; diff --git a/src/features/budgets/components/daily-plan-card.tsx b/src/features/budgets/components/daily-plan-card.tsx new file mode 100644 index 0000000..c976c90 --- /dev/null +++ b/src/features/budgets/components/daily-plan-card.tsx @@ -0,0 +1,15 @@ +import { getDailyPlan } from "../plan"; +import { DailyPlanView } from "./daily-plan-view"; + +/** Dashboard günlük plan kartı (sunucu): veriyi yükler, görünümü istemciye verir. */ +export async function DailyPlanCard({ + userId, + householdId, +}: { + userId: string; + householdId: string; +}) { + const data = await getDailyPlan(userId, householdId); + if (!data) return null; + return ; +} diff --git a/src/features/budgets/components/daily-plan-view.tsx b/src/features/budgets/components/daily-plan-view.tsx new file mode 100644 index 0000000..407594e --- /dev/null +++ b/src/features/budgets/components/daily-plan-view.tsx @@ -0,0 +1,257 @@ +"use client"; + +import Link from "next/link"; +import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { formatDate, formatMoney } from "@/shared/format"; +import type { DailyPlanView as Data } from "../plan"; + +const t = tr.budget.daily; +const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`); +const dayFmt = new Intl.DateTimeFormat("tr-TR", { + day: "numeric", + month: "short", + timeZone: "Europe/Istanbul", +}); +const monthFmt = new Intl.DateTimeFormat("tr-TR", { + month: "long", + year: "numeric", + timeZone: "Europe/Istanbul", +}); +const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 }); +const signed = (minor: number) => `${minor < 0 ? "−" : ""}${formatMoney(Math.abs(minor))}`; + +const config = { + planned: { label: t.cumPlanned, color: "var(--budget)" }, + actual: { label: t.cumActual, color: "var(--expense)" }, +} satisfies ChartConfig; + +function Stat({ + label, + value, + tone, + hint, +}: { + label: string; + value: string; + tone?: string; + hint?: string; +}) { + return ( +
+

{label}

+

{value}

+ {hint ?

{hint}

: null} +
+ ); +} + +/** Dashboard: ayın günlük planı, kümülatif grafik ve gelirle karşılaştırma. */ +export function DailyPlanView({ data }: { data: Data }) { + const p = data.plan; + if (!data.hasBudget) { + return ( + + + {t.title} + {t.noBudget} + + + + + + ); + } + const chart = p.days.map((day, i) => ({ + date: day.date, + label: dayFmt.format(new Date(`${day.date}T12:00:00+03:00`)), + planned: day.cumPlannedMinor / 100, + // Gerçekleşen yalnızca bugüne kadar çizilir. + actual: p.todayIndex === -1 || i <= p.todayIndex ? day.cumActualMinor / 100 : null, + })); + const income = data.income.expectedMinor / 100; + const nextDiff = + data.next.budgetMinor === null ? null : data.next.expectedIncomeMinor - data.next.budgetMinor; + + return ( + + + {t.title} + + {p.spentPct !== null ? t.headline(p.monthPct, p.spentPct) : null} + + + +
+ + + p.plannedToDateMinor ? "text-expense" : undefined} + /> + +
+ + + + + + compact.format(v)} + /> + {income > 0 ? ( + + ) : null} + { + const date = payload?.[0]?.payload?.date as string | undefined; + return date ? d(date) : ""; + }} + formatter={(value, name) => ( +
+ + {config[name as keyof typeof config]?.label} + + + {formatMoney(Math.round(Number(value) * 100))} + +
+ )} + /> + } + /> + } /> + + +
+
+ +

+ {t.modes[data.mode]} {p.fixedMinor ? t.fixedNote(formatMoney(p.fixedMinor)) : ""} +

+ +
+

+ {t.next(monthFmt.format(new Date(`${data.next.month}-15T12:00:00+03:00`)))} +

+
+ + + +
+
+ +
+ {t.table} +
+ + + + + + + + + + + + {p.days.map((day, i) => ( + + + + + + + + ))} + +
{t.day}{t.planned}{t.actual}{t.cumPlanned}{t.cumActual}
{d(day.date)}{formatMoney(day.plannedMinor)} + {p.todayIndex === -1 || i <= p.todayIndex + ? formatMoney(day.actualMinor) + : "—"} + {formatMoney(day.cumPlannedMinor)} + {p.todayIndex === -1 || i <= p.todayIndex + ? formatMoney(day.cumActualMinor) + : "—"} +
+
+
+
+
+ ); +} diff --git a/src/features/budgets/components/income-plan.tsx b/src/features/budgets/components/income-plan.tsx new file mode 100644 index 0000000..dc4ad1d --- /dev/null +++ b/src/features/budgets/components/income-plan.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { MoneyInput } from "@/components/money-input"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { formatDate, formatMoney } from "@/shared/format"; +import { formatMoneyInput } from "@/shared/money"; +import { saveIncomeItemsAction } from "../actions"; +import type { IncomePlan } from "../plan"; + +const t = tr.budget.income; +const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`); + +function Stat({ + label, + value, + tone, + hint, +}: { + label: string; + value: string; + tone?: string; + hint?: string; +}) { + return ( +
+

{label}

+

{value}

+ {hint ?

{hint}

: null} +
+ ); +} + +const signed = (minor: number) => `${minor < 0 ? "−" : ""}${formatMoney(Math.abs(minor))}`; + +/** /butce: ayın gelir planı ve gelir − gider karşılaştırması. */ +export function IncomePlanSection({ + month, + monthLabel, + income, + limitMinor, + forecastMinor, + hasBudget, + canWrite, +}: { + month: string; + monthLabel: string; + income: IncomePlan; + limitMinor: number; + forecastMinor: number; + hasBudget: boolean; + canWrite: boolean; +}) { + const [editing, setEditing] = useState(false); + const projected = income.expectedMinor - forecastMinor; + const planned = income.expectedMinor - limitMinor; + return ( + + +
+
+ {t.title} + {t.description} +
+ {canWrite ? ( + + ) : null} +
+
+ +
+ + + + + {hasBudget ? ( + + ) : null} + +
+ {income.entries.length || income.manual.length ? ( +
    + {income.entries.map((e) => ( +
  • + + {d(e.date)} · {e.label} + + + + {e.status === "posted" ? t.received : t.planned} + + {formatMoney(e.amountMinor)} + +
  • + ))} + {income.manual.map((m) => ( +
  • + {m.name} + + {t.manual} + {formatMoney(m.amountMinor)} + +
  • + ))} +
+ ) : ( +

{t.empty}

+ )} +
+ {editing ? ( + setEditing(false)} + /> + ) : null} +
+ ); +} + +function ManualDialog({ + month, + monthLabel, + items, + onClose, +}: { + month: string; + monthLabel: string; + items: IncomePlan["manual"]; + onClose: () => void; +}) { + const [rows, setRows] = useState(() => + items.length + ? items.map((i) => ({ key: i.id, name: i.name, amount: formatMoneyInput(i.amountMinor) })) + : [{ key: "new-0", name: "", amount: "" }], + ); + const [errors, setErrors] = useState>({}); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + const r = await saveIncomeItemsAction({ + month, + items: rows + .filter((x) => x.name.trim() || x.amount.trim()) + .map((x) => ({ name: x.name, amount: x.amount })), + }); + setBusy(false); + if (r.ok) { + toast.success(t.saved); + onClose(); + } else setErrors({ ...(r.fieldErrors ?? {}), root: r.fieldErrors ? "" : r.error }); + } + + return ( + !o && onClose()} + title={t.editTitle(monthLabel)} + description={t.editHint} + footer={ + <> + + + + } + > +
+ {rows.map((row, i) => ( +
+
+ + setRows((s) => s.map((x, j) => (j === i ? { ...x, name: e.target.value } : x))) + } + /> + {errors[`items.${i}.name`] ? ( +

{errors[`items.${i}.name`]}

+ ) : null} +
+
+ + setRows((s) => s.map((x, j) => (j === i ? { ...x, amount: v } : x))) + } + /> + {errors[`items.${i}.amount`] ? ( +

{errors[`items.${i}.amount`]}

+ ) : null} +
+ +
+ ))} + + {errors.root ?

{errors.root}

: null} +
+
+ ); +}