feat(ui): günlük plan kartı, gelir planı bölümü ve dashboard özet kartları

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 01:22:38 +03:00
parent 39aa19d168
commit d8f442c1aa
6 changed files with 564 additions and 7 deletions
+19
View File
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { BudgetView } from "@/features/budgets/components/budget-view"; 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 { getBudget } from "@/features/budgets/service";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal"; 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 ay = budgetMonthSchema.safeParse((await searchParams).ay);
const data = await getBudget(user.id, household.id, ay.success ? ay.data : undefined); const data = await getBudget(user.id, household.id, ay.success ? ay.data : undefined);
if (!data) notFound(); 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 ( 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} />
<IncomePlanSection
key={`income-${data.month}`}
month={data.month}
monthLabel={monthLabel}
income={income}
limitMinor={data.totals.limitMinor}
forecastMinor={data.totals.forecastMinor}
hasBudget={data.hasBudget}
canWrite={data.canWrite}
/>
</> </>
); );
} }
+22 -6
View File
@@ -4,7 +4,10 @@ import { Suspense } from "react";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; 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 { 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 { ContactsDueBanner } from "@/features/contacts/components/due-banner";
import { getNetWorth } from "@/features/net-worth/service"; import { getNetWorth } from "@/features/net-worth/service";
import { OverdueBanner } from "@/features/recurring/components/overdue-banner"; 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 * Özet kartları (içinde bulunulan bütçe ayı): gerçekleşen gelir, gider ve farkı; hesap bakiyesi,
* kartları Faz 14'te dolar. * kart borcu ve net varlık.
*/ */
async function getSummary(userId: string, householdId: string): Promise<SummaryCard[]> { async function getSummary(userId: string, householdId: string): Promise<SummaryCard[]> {
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 [ return [
{ key: "monthIncome", tone: "text-income", amountMinor: null }, { key: "monthIncome", tone: "text-income", amountMinor: inc, href: "/butce" },
{ key: "monthExpense", tone: "text-expense", amountMinor: null }, { key: "monthExpense", tone: "text-expense", amountMinor: exp, href: "/butce" },
{ key: "monthRemaining", tone: "text-budget", amountMinor: null }, {
key: "monthRemaining",
tone: "text-budget",
amountMinor: inc !== null && exp !== null ? inc - exp : null,
},
{ key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" }, { key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" },
{ key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null }, { key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null },
{ key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" }, { key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" },
@@ -89,6 +102,9 @@ export default async function DashboardPage() {
<Suspense fallback={<SummaryCardsSkeleton />}> <Suspense fallback={<SummaryCardsSkeleton />}>
<SummaryCards userId={user.id} householdId={household.id} /> <SummaryCards userId={user.id} householdId={household.id} />
</Suspense> </Suspense>
<Suspense fallback={null}>
<DailyPlanCard userId={user.id} householdId={household.id} />
</Suspense>
</> </>
); );
} }
+1 -1
View File
@@ -27,7 +27,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 = 10.1; export const CURRENT_PHASE = 10.2;
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
@@ -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 <DailyPlanView data={data} />;
}
@@ -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 (
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={cn("text-lg font-semibold tabular-nums", tone)}>{value}</p>
{hint ? <p className="text-[11px] leading-tight text-muted-foreground">{hint}</p> : null}
</div>
);
}
/** 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 (
<Card size="sm">
<CardHeader>
<CardTitle className="text-base">{t.title}</CardTitle>
<CardDescription>{t.noBudget}</CardDescription>
</CardHeader>
<CardContent>
<Button size="sm" variant="outline" asChild>
<Link href="/butce">{tr.budget.goToBudget}</Link>
</Button>
</CardContent>
</Card>
);
}
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 (
<Card size="sm">
<CardHeader>
<CardTitle className="text-base">{t.title}</CardTitle>
<CardDescription>
{p.spentPct !== null ? t.headline(p.monthPct, p.spentPct) : null}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Stat
label={t.today}
value={signed(p.todayAllowanceMinor)}
tone={p.todayAllowanceMinor < 0 ? "text-expense" : "text-budget"}
hint={t.todayHint}
/>
<Stat label={t.plannedToDate} value={formatMoney(p.plannedToDateMinor)} />
<Stat
label={t.actualToDate}
value={formatMoney(p.actualToDateMinor)}
tone={p.actualToDateMinor > p.plannedToDateMinor ? "text-expense" : undefined}
/>
<Stat
label={t.projectedLeft}
value={signed(data.projectedLeftMinor)}
tone={data.projectedLeftMinor < 0 ? "text-expense" : "text-income"}
hint={tr.budget.income.projectedLeftHint}
/>
</div>
<ChartContainer
config={config}
className="aspect-auto h-56 w-full"
aria-label={t.chartLabel}
role="img"
>
<LineChart data={chart} margin={{ left: 4, right: 12, top: 8 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={24}
/>
<YAxis
tickLine={false}
axisLine={false}
width={48}
tickFormatter={(v: number) => compact.format(v)}
/>
{income > 0 ? (
<ReferenceLine
y={income}
stroke="var(--income)"
strokeDasharray="4 4"
label={{
value: `${t.income} ${compact.format(income)}`,
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 11,
}}
/>
) : null}
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const date = payload?.[0]?.payload?.date as string | undefined;
return date ? d(date) : "";
}}
formatter={(value, name) => (
<div className="flex w-full items-center justify-between gap-3">
<span className="text-muted-foreground">
{config[name as keyof typeof config]?.label}
</span>
<span className="font-medium text-foreground tabular-nums">
{formatMoney(Math.round(Number(value) * 100))}
</span>
</div>
)}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
<Line
dataKey="planned"
type="linear"
stroke="var(--color-planned)"
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Line
dataKey="actual"
type="linear"
stroke="var(--color-actual)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
connectNulls={false}
isAnimationActive={false}
/>
</LineChart>
</ChartContainer>
<p className="text-xs text-muted-foreground">
{t.modes[data.mode]} {p.fixedMinor ? t.fixedNote(formatMoney(p.fixedMinor)) : ""}
</p>
<div className="rounded-lg border p-3 text-sm">
<p className="mb-2 font-medium capitalize">
{t.next(monthFmt.format(new Date(`${data.next.month}-15T12:00:00+03:00`)))}
</p>
<div className="grid grid-cols-3 gap-2">
<Stat label={t.nextIncome} value={formatMoney(data.next.expectedIncomeMinor)} />
<Stat
label={t.nextBudget}
value={
data.next.budgetMinor === null ? t.nextNoBudget : formatMoney(data.next.budgetMinor)
}
/>
<Stat
label={t.nextDiff}
value={nextDiff === null ? "—" : signed(nextDiff)}
tone={nextDiff !== null && nextDiff < 0 ? "text-expense" : undefined}
/>
</div>
</div>
<details className="text-sm">
<summary className="cursor-pointer text-xs text-muted-foreground">{t.table}</summary>
<div className="mt-2 max-h-80 overflow-y-auto">
<table className="w-full text-right tabular-nums">
<thead className="sticky top-0 bg-card text-xs text-muted-foreground">
<tr>
<th className="py-1 text-left font-normal">{t.day}</th>
<th className="py-1 font-normal">{t.planned}</th>
<th className="py-1 font-normal">{t.actual}</th>
<th className="py-1 font-normal">{t.cumPlanned}</th>
<th className="py-1 font-normal">{t.cumActual}</th>
</tr>
</thead>
<tbody className="divide-y">
{p.days.map((day, i) => (
<tr key={day.date} className={cn(i === p.todayIndex && "bg-muted font-medium")}>
<td className="py-1 text-left">{d(day.date)}</td>
<td className="py-1">{formatMoney(day.plannedMinor)}</td>
<td className="py-1">
{p.todayIndex === -1 || i <= p.todayIndex
? formatMoney(day.actualMinor)
: "—"}
</td>
<td className="py-1">{formatMoney(day.cumPlannedMinor)}</td>
<td className="py-1">
{p.todayIndex === -1 || i <= p.todayIndex
? formatMoney(day.cumActualMinor)
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</CardContent>
</Card>
);
}
@@ -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 (
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={cn("font-semibold tabular-nums", tone)}>{value}</p>
{hint ? <p className="text-[11px] text-muted-foreground">{hint}</p> : null}
</div>
);
}
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 (
<Card size="sm">
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<CardTitle className="text-base">{t.title}</CardTitle>
<CardDescription>{t.description}</CardDescription>
</div>
{canWrite ? (
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
<Plus />
{t.edit}
</Button>
) : null}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label={t.actual} value={formatMoney(income.actualMinor)} tone="text-income" />
<Stat label={t.pending} value={formatMoney(income.pendingMinor)} />
<Stat label={t.manual} value={formatMoney(income.manualMinor)} />
<Stat label={t.expected} value={formatMoney(income.expectedMinor)} tone="text-income" />
{hasBudget ? (
<Stat
label={t.plannedLeft}
value={signed(planned)}
tone={planned < 0 ? "text-expense" : undefined}
hint={t.plannedLeftHint}
/>
) : null}
<Stat
label={t.projectedLeft}
value={signed(projected)}
tone={projected < 0 ? "text-expense" : undefined}
hint={t.projectedLeftHint}
/>
</div>
{income.entries.length || income.manual.length ? (
<ul className="divide-y rounded-lg border text-sm">
{income.entries.map((e) => (
<li key={e.id} className="flex items-center justify-between gap-2 p-2">
<span className="min-w-0 truncate">
{d(e.date)} · {e.label}
</span>
<span className="flex shrink-0 items-center gap-2">
<Badge variant={e.status === "posted" ? "secondary" : "outline"}>
{e.status === "posted" ? t.received : t.planned}
</Badge>
<span className="tabular-nums">{formatMoney(e.amountMinor)}</span>
</span>
</li>
))}
{income.manual.map((m) => (
<li key={m.id} className="flex items-center justify-between gap-2 p-2">
<span className="min-w-0 truncate">{m.name}</span>
<span className="flex shrink-0 items-center gap-2">
<Badge variant="outline">{t.manual}</Badge>
<span className="tabular-nums">{formatMoney(m.amountMinor)}</span>
</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">{t.empty}</p>
)}
</CardContent>
{editing ? (
<ManualDialog
month={month}
monthLabel={monthLabel}
items={income.manual}
onClose={() => setEditing(false)}
/>
) : null}
</Card>
);
}
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<Record<string, string>>({});
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 (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={t.editTitle(monthLabel)}
description={t.editHint}
footer={
<>
<Button variant="outline" type="button" onClick={onClose}>
{tr.common.cancel}
</Button>
<Button type="submit" form="income-form" disabled={busy}>
{tr.common.save}
</Button>
</>
}
>
<form id="income-form" onSubmit={submit} noValidate className="flex flex-col gap-2">
{rows.map((row, i) => (
<div key={row.key} className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<Input
value={row.name}
placeholder={t.itemName}
aria-label={t.itemName}
aria-invalid={Boolean(errors[`items.${i}.name`])}
onChange={(e) =>
setRows((s) => s.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))
}
/>
{errors[`items.${i}.name`] ? (
<p className="text-xs text-destructive">{errors[`items.${i}.name`]}</p>
) : null}
</div>
<div className="w-36 shrink-0">
<MoneyInput
value={row.amount}
aria-label={t.itemAmount}
onChange={(v) =>
setRows((s) => s.map((x, j) => (j === i ? { ...x, amount: v } : x)))
}
/>
{errors[`items.${i}.amount`] ? (
<p className="text-xs text-destructive">{errors[`items.${i}.amount`]}</p>
) : null}
</div>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={t.removeItem}
onClick={() => setRows((s) => s.filter((_, j) => j !== i))}
>
<Trash2 />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
disabled={rows.length >= 30}
onClick={() => setRows((s) => [...s, { key: `new-${Date.now()}`, name: "", amount: "" }])}
>
<Plus />
{t.addItem}
</Button>
{errors.root ? <p className="text-sm text-destructive">{errors.root}</p> : null}
</form>
</ResponsiveDialog>
);
}