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:
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user