diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 2d29186..ed67087 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -3,6 +3,8 @@ import Link from "next/link"; import { Suspense } from "react"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { OverviewCard } from "@/features/dashboard/components/overview-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"; @@ -84,7 +86,7 @@ async function SummaryCards({ userId, householdId }: { userId: string; household ); } -export default async function DashboardPage() { +export default async function DashboardPage({ searchParams }: PageProps<"/dashboard">) { const { user, household } = await requireActiveHousehold("viewer"); return ( <> @@ -104,6 +106,9 @@ export default async function DashboardPage() { }> + }> + + diff --git a/src/app/(app)/raporlar/butce/csv/route.ts b/src/app/(app)/raporlar/butce/csv/route.ts new file mode 100644 index 0000000..00d04ea --- /dev/null +++ b/src/app/(app)/raporlar/butce/csv/route.ts @@ -0,0 +1,23 @@ +import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv"; +import { getBudgetReport } from "@/features/reports/service"; +import { tr } from "@/i18n/tr"; +import { csvMoney, toCsv } from "@/shared/csv"; + +/** GET /raporlar/butce/csv?ay=YYYY-MM — kategori bazında limit ve gerçekleşen. */ +export async function GET(request: Request) { + const c = await csvContext(request); + if (!c) return unauthorized(); + const r = await getBudgetReport(c.userId, c.householdId, c.month); + if (!r) return forbidden(); + const csv = toCsv( + tr.reports.csvHeaders.budget, + r.lines.map((l) => [ + l.path, + l.limitMinor === null ? "" : csvMoney(l.limitMinor), + csvMoney(l.actualMinor), + l.remainingMinor === null ? "" : csvMoney(l.remainingMinor), + csvPct(l.ratioBp === null ? null : Math.round(l.ratioBp / 10) / 10), + ]), + ); + return csvResponse(csv, tr.reports.csvFiles.budget(r.month)); +} diff --git a/src/app/(app)/raporlar/butce/page.tsx b/src/app/(app)/raporlar/butce/page.tsx new file mode 100644 index 0000000..721b758 --- /dev/null +++ b/src/app/(app)/raporlar/butce/page.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { BudgetReportView } from "@/features/reports/components/budget-report-view"; +import { + CsvButton, + ToolsEnd, + MonthNav, + ReportFrame, + monthLabel, +} from "@/features/reports/components/report-frame"; +import { getBudgetReport } from "@/features/reports/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; +import { budgetMonthSchema } from "@/shared/schemas/budget"; + +const t = tr.reports.budget; +export const metadata: Metadata = { title: t.title }; + +/** /raporlar/butce?ay=YYYY-MM — bütçe ve gerçekleşen. */ +export default async function BudgetReportPage({ searchParams }: PageProps<"/raporlar/butce">) { + const { user, household } = await requireActiveHousehold("viewer"); + const ay = budgetMonthSchema.safeParse((await searchParams).ay); + const data = await getBudgetReport(user.id, household.id, ay.success ? ay.data : undefined); + if (!data) notFound(); + return ( + + + + + + + } + > + + + ); +} diff --git a/src/app/(app)/raporlar/kategoriler/csv/route.ts b/src/app/(app)/raporlar/kategoriler/csv/route.ts new file mode 100644 index 0000000..b4c26f5 --- /dev/null +++ b/src/app/(app)/raporlar/kategoriler/csv/route.ts @@ -0,0 +1,29 @@ +import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv"; +import { getCategoryReport } from "@/features/reports/service"; +import { tr } from "@/i18n/tr"; +import { csvMoney, toCsv } from "@/shared/csv"; + +/** GET /raporlar/kategoriler/csv?ay=YYYY-MM — üst kategori ve alt kategori satırları. */ +export async function GET(request: Request) { + const c = await csvContext(request); + if (!c) return unauthorized(); + const r = await getCategoryReport(c.userId, c.householdId, c.month); + if (!r) return forbidden(); + const rows = r.breakdown.rows.flatMap((top) => [ + top, + ...top.children.map((k) => ({ ...k, parent: top.name })), + ]); + const csv = toCsv( + tr.reports.csvHeaders.categories, + rows.map((x) => [ + "parent" in x ? x.parent : x.name, + "parent" in x ? x.name : "", + csvMoney(x.amountMinor), + csvPct(x.sharePct), + csvMoney(x.prevMinor), + csvMoney(x.changeMinor), + csvPct(x.changePct), + ]), + ); + return csvResponse(csv, tr.reports.csvFiles.categories(r.month)); +} diff --git a/src/app/(app)/raporlar/kategoriler/page.tsx b/src/app/(app)/raporlar/kategoriler/page.tsx new file mode 100644 index 0000000..b8e279d --- /dev/null +++ b/src/app/(app)/raporlar/kategoriler/page.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { CategoriesView } from "@/features/reports/components/categories-view"; +import { + CsvButton, + ToolsEnd, + MonthNav, + ReportFrame, + monthLabel, +} from "@/features/reports/components/report-frame"; +import { getCategoryReport } from "@/features/reports/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; +import { budgetMonthSchema } from "@/shared/schemas/budget"; + +const t = tr.reports.categories; +export const metadata: Metadata = { title: t.title }; + +/** /raporlar/kategoriler?ay=YYYY-MM — kategori dağılımı. */ +export default async function CategoryReportPage({ + searchParams, +}: PageProps<"/raporlar/kategoriler">) { + const { user, household } = await requireActiveHousehold("viewer"); + const ay = budgetMonthSchema.safeParse((await searchParams).ay); + const data = await getCategoryReport(user.id, household.id, ay.success ? ay.data : undefined); + if (!data) notFound(); + return ( + + + + + + + } + > + {data.current ?

{t.partialMonth}

: null} + {data.includesHidden ? ( +

{tr.reports.includesHidden}

+ ) : null} + +
+ ); +} diff --git a/src/app/(app)/raporlar/kisi-hesap/csv/route.ts b/src/app/(app)/raporlar/kisi-hesap/csv/route.ts new file mode 100644 index 0000000..f9086a9 --- /dev/null +++ b/src/app/(app)/raporlar/kisi-hesap/csv/route.ts @@ -0,0 +1,36 @@ +import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv"; +import { getPeopleAccountsReport } from "@/features/reports/service"; +import { tr } from "@/i18n/tr"; +import { csvMoney, toCsv } from "@/shared/csv"; + +/** GET /raporlar/kisi-hesap/csv?ay=YYYY-MM&tablo=kisi|hesap */ +export async function GET(request: Request) { + const c = await csvContext(request); + if (!c) return unauthorized(); + const r = await getPeopleAccountsReport(c.userId, c.householdId, c.month); + if (!r) return forbidden(); + const h = tr.reports.csvHeaders; + if (c.params.get("tablo") === "hesap") + return csvResponse( + toCsv( + h.accounts, + r.accounts.map((a) => [ + a.name, + csvMoney(a.incomeMinor), + csvMoney(a.expenseMinor), + csvMoney(a.transferInMinor), + csvMoney(a.transferOutMinor), + csvMoney(a.netMinor), + a.count, + ]), + ), + tr.reports.csvFiles.accounts(r.month), + ); + return csvResponse( + toCsv( + h.people, + r.people.map((p) => [p.name, csvMoney(p.expenseMinor), p.count, csvPct(p.sharePct)]), + ), + tr.reports.csvFiles.people(r.month), + ); +} diff --git a/src/app/(app)/raporlar/kisi-hesap/page.tsx b/src/app/(app)/raporlar/kisi-hesap/page.tsx new file mode 100644 index 0000000..fea216c --- /dev/null +++ b/src/app/(app)/raporlar/kisi-hesap/page.tsx @@ -0,0 +1,61 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PeopleView } from "@/features/reports/components/people-view"; +import { + CsvButton, + ToolsEnd, + MonthNav, + ReportFrame, + monthLabel, +} from "@/features/reports/components/report-frame"; +import { getPeopleAccountsReport } from "@/features/reports/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; +import { budgetMonthSchema } from "@/shared/schemas/budget"; + +const t = tr.reports.people; +export const metadata: Metadata = { title: t.title }; + +/** /raporlar/kisi-hesap?ay=YYYY-MM — kişi bazlı harcama ve hesap bazlı hareket. */ +export default async function PeopleReportPage({ + searchParams, +}: PageProps<"/raporlar/kisi-hesap">) { + const { user, household } = await requireActiveHousehold("viewer"); + const ay = budgetMonthSchema.safeParse((await searchParams).ay); + const data = await getPeopleAccountsReport( + 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 new file mode 100644 index 0000000..6a06ea7 --- /dev/null +++ b/src/app/(app)/raporlar/page.tsx @@ -0,0 +1,101 @@ +import { ChartPie, ChevronRight, Target, TrendingUp, Users } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { monthLabel, formatPct } from "@/features/reports/components/report-frame"; +import { getCategoryReport, getTrendReport } from "@/features/reports/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; +import { cn } from "@/lib/utils"; +import { formatMoney } from "@/shared/format"; + +const t = tr.reports; +export const metadata: Metadata = { title: t.title }; + +const REPORTS = [ + { href: "/raporlar/kategoriler", key: "categories", icon: ChartPie }, + { href: "/raporlar/trend", key: "trend", icon: TrendingUp }, + { href: "/raporlar/kisi-hesap", key: "people", icon: Users }, + { href: "/raporlar/butce", key: "budget", icon: Target }, +] as const; + +/** /raporlar — bu bütçe ayının özeti ve raporlar. */ +export default async function ReportsPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const [trend, cats] = await Promise.all([ + getTrendReport(user.id, household.id, 1), + getCategoryReport(user.id, household.id), + ]); + if (!trend || !cats) notFound(); + const m = trend.months[0]!; + const top = cats.breakdown.rows.filter((r) => r.amountMinor > 0).slice(0, 3); + const stats = [ + { label: t.hub.income, value: formatMoney(m.incomeMinor), tone: "text-income" }, + { label: t.hub.expense, value: formatMoney(m.expenseMinor), tone: "text-expense" }, + { + label: t.hub.net, + value: `${m.netMinor < 0 ? "−" : ""}${formatMoney(Math.abs(m.netMinor))}`, + tone: m.netMinor < 0 ? "text-expense" : undefined, + }, + { label: t.hub.savings, value: formatPct(m.savingsPct) }, + ]; + return ( + <> + +
+

+ {t.hub.thisMonth}: {monthLabel(m.month)}{" "} + ({t.trend.ongoing}) +

+
+ {stats.map((s) => ( +
+
{s.label}
+
{s.value}
+
+ ))} +
+ {top.length ? ( +
+

{t.hub.topCategories}

+
    + {top.map((r) => ( +
  • + {r.name} + + {formatMoney(r.amountMinor)}{" "} + · {formatPct(r.sharePct)} + +
  • + ))} +
+
+ ) : null} +
+ + + ); +} diff --git a/src/app/(app)/raporlar/trend/csv/route.ts b/src/app/(app)/raporlar/trend/csv/route.ts new file mode 100644 index 0000000..8bab3fe --- /dev/null +++ b/src/app/(app)/raporlar/trend/csv/route.ts @@ -0,0 +1,24 @@ +import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv"; +import { getTrendReport } from "@/features/reports/service"; +import { tr } from "@/i18n/tr"; +import { csvMoney, toCsv } from "@/shared/csv"; +import { todayIstanbul } from "@/shared/dates"; + +/** GET /raporlar/trend/csv — son 12 bütçe ayı. */ +export async function GET(request: Request) { + const c = await csvContext(request); + if (!c) return unauthorized(); + const r = await getTrendReport(c.userId, c.householdId); + if (!r) return forbidden(); + const csv = toCsv( + tr.reports.csvHeaders.trend, + r.months.map((m) => [ + m.month, + csvMoney(m.incomeMinor), + csvMoney(m.expenseMinor), + csvMoney(m.netMinor), + csvPct(m.savingsPct), + ]), + ); + return csvResponse(csv, tr.reports.csvFiles.trend(todayIstanbul())); +} diff --git a/src/app/(app)/raporlar/trend/page.tsx b/src/app/(app)/raporlar/trend/page.tsx new file mode 100644 index 0000000..9727e27 --- /dev/null +++ b/src/app/(app)/raporlar/trend/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { CsvButton, ToolsEnd, ReportFrame } from "@/features/reports/components/report-frame"; +import { TrendView } from "@/features/reports/components/trend-view"; +import { getTrendReport } from "@/features/reports/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +const t = tr.reports.trend; +export const metadata: Metadata = { title: t.title }; + +/** /raporlar/trend — son 12 bütçe ayı gelir, gider, net ve tasarruf oranı. */ +export default async function TrendReportPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const data = await getTrendReport(user.id, household.id); + if (!data) notFound(); + return ( + + + + } + > + + + ); +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index d5d5d74..fd2012e 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 = 13.2; +export const CURRENT_PHASE = 14.1; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; @@ -44,7 +44,7 @@ export const MAIN_NAV: NavItem[] = [ { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 }, { href: "/magazalar", label: tr.nav.stores, icon: Store, phase: 12.1 }, { href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13.1 }, - { href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 }, + { href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14.1 }, { href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 }, ]; diff --git a/src/features/budgets/components/daily-plan-view.tsx b/src/features/budgets/components/daily-plan-view.tsx index 407594e..6d840a3 100644 --- a/src/features/budgets/components/daily-plan-view.tsx +++ b/src/features/budgets/components/daily-plan-view.tsx @@ -1,17 +1,8 @@ "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"; @@ -19,24 +10,13 @@ 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, @@ -57,7 +37,7 @@ function Stat({ ); } -/** Dashboard: ayın günlük planı, kümülatif grafik ve gelirle karşılaştırma. */ +/** Dashboard: ayın günlük planı ve gelirle karşılaştırma (grafik ana grafikte: Aylık). */ export function DailyPlanView({ data }: { data: Data }) { const p = data.plan; if (!data.hasBudget) { @@ -75,14 +55,6 @@ export function DailyPlanView({ data }: { data: Data }) { ); } - 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; @@ -116,82 +88,6 @@ export function DailyPlanView({ data }: { data: Data }) { /> - - - - - 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)) : ""}

diff --git a/src/features/dashboard/components/overview-card.tsx b/src/features/dashboard/components/overview-card.tsx new file mode 100644 index 0000000..e280780 --- /dev/null +++ b/src/features/dashboard/components/overview-card.tsx @@ -0,0 +1,24 @@ +import { OVERVIEW_PERIODS, type OverviewPeriod } from "@/shared/overview"; +import { getOverview } from "../service"; +import { OverviewChart } from "./overview-chart"; + +type Search = Record; + +const periodOf = (x: unknown): OverviewPeriod => + OVERVIEW_PERIODS.includes(x as OverviewPeriod) ? (x as OverviewPeriod) : "month"; + +/** Dashboard ana grafiği (sunucu): ?periyot=week|month|half|year&gorunum=kumulatif|donemsel. */ +export async function OverviewCard({ + userId, + householdId, + searchParams, +}: { + userId: string; + householdId: string; + searchParams: Promise; +}) { + const q = await searchParams; + const data = await getOverview(userId, householdId, periodOf(q.periyot)); + if (!data) return null; + return ; +} diff --git a/src/features/dashboard/components/overview-chart.tsx b/src/features/dashboard/components/overview-chart.tsx new file mode 100644 index 0000000..23ce3a5 --- /dev/null +++ b/src/features/dashboard/components/overview-chart.tsx @@ -0,0 +1,209 @@ +"use client"; + +import Link from "next/link"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +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 { cumulative, OVERVIEW_PERIODS, type OverviewPeriod } from "@/shared/overview"; +import type { OverviewData } from "../service"; + +const t = tr.dashboard.overview; +export type OverviewView = "cumulative" | "periodic"; + +const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 }); +const dayShort = new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" }); +const weekdayShort = new Intl.DateTimeFormat("tr-TR", { weekday: "short", day: "numeric" }); +const monthShort = new Intl.DateTimeFormat("tr-TR", { month: "short", year: "2-digit" }); +const monthLong = new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" }); + +const config = { + plannedIncome: { label: t.series.plannedIncome, color: "var(--income)" }, + budget: { label: t.series.budget, color: "var(--budget)" }, + actual: { label: t.series.actual, color: "var(--expense)" }, +} satisfies ChartConfig; +const SERIES = ["plannedIncome", "budget", "actual"] as const; + +const asDate = (key: string) => + new Date(key.length === 7 ? `${key}-15T12:00:00+03:00` : `${key}T12:00:00+03:00`); + +function bucketLabel(key: string, period: OverviewPeriod) { + const d = asDate(key); + if (key.length === 7) return monthShort.format(d); + return period === "week" ? weekdayShort.format(d) : dayShort.format(d); +} + +/** Bölüm 6.13 ana çizgi grafik: periyot seçici ve kümülatif / dönemsel görünüm (URL'de). */ +export function OverviewChart({ data, view }: { data: OverviewData; view: OverviewView }) { + const points = view === "cumulative" ? cumulative(data.points) : data.points; + const chart = points.map((p) => ({ + key: p.key, + label: bucketLabel(p.key, data.period), + plannedIncome: p.plannedIncome === null ? null : p.plannedIncome / 100, + budget: p.budget === null ? null : p.budget / 100, + actual: p.actual === null ? null : p.actual / 100, + })); + const hasGap = data.points.some((p) => p.budget === null); + const href = (period: OverviewPeriod, v: OverviewView) => + `?periyot=${period}&gorunum=${v === "cumulative" ? "kumulatif" : "donemsel"}`; + const rangeText = + data.range.granularity === "month" + ? `${monthLong.format(asDate(data.range.buckets[0]!))} – ${monthLong.format(asDate(data.range.buckets.at(-1)!))}` + : `${formatDate(asDate(data.range.from))} – ${formatDate(asDate(data.range.to))}`; + const pill = (active: boolean) => + cn( + "rounded-md px-2.5 py-1 text-sm transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", + active + ? "bg-background font-medium shadow-sm" + : "text-muted-foreground hover:text-foreground", + ); + + return ( + + +
+ {t.title} + {t.description(rangeText)} +
+
+ + +
+
+ + + + + + compact.format(v)} + /> + { + const key = payload?.[0]?.payload?.key as string | undefined; + if (!key) return ""; + return key.length === 7 + ? monthLong.format(asDate(key)) + : formatDate(asDate(key)); + }} + formatter={(value, name) => ( +
+ + {config[name as keyof typeof config]?.label} + + + {formatMoney(Math.round(Number(value) * 100))} + +
+ )} + /> + } + /> + } /> + {SERIES.map((k) => ( + + ))} +
+
+ {hasGap ?

{t.noBudget}

: null} +
+ {t.table} +
+ + + + + {SERIES.map((k) => ( + + ))} + + + + {points.map((p) => ( + + + {SERIES.map((k) => ( + + ))} + + ))} + +
{t.bucket[data.range.granularity]} + {t.series[k]} +
+ {p.key.length === 7 + ? monthLong.format(asDate(p.key)) + : formatDate(asDate(p.key))} + + {p[k] === null ? "—" : formatMoney(p[k])} +
+
+
+
+
+ ); +} diff --git a/src/features/reports/components/budget-report-view.tsx b/src/features/reports/components/budget-report-view.tsx new file mode 100644 index 0000000..3bb3afe --- /dev/null +++ b/src/features/reports/components/budget-report-view.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { formatMoney } from "@/shared/format"; +import type { BudgetReport } from "../service"; +import { monthLabel, monthShort } from "./report-frame"; + +const t = tr.reports.budget; +const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 }); +const ratio = (bp: number | null) => (bp === null ? "—" : `%${Math.round(bp / 100)}`); +const STATUS_TONE: Record = { + ok: "bg-income", + warning: "bg-amber-500", + over: "bg-expense", + none: "bg-muted-foreground", +}; + +const config = { + limit: { label: t.limit, color: "var(--budget)" }, + actual: { label: t.actual, color: "var(--expense)" }, +} satisfies ChartConfig; + +export function BudgetReportView({ data }: { data: BudgetReport }) { + const chart = data.history.map((h) => ({ + month: h.month, + label: monthShort(h.month), + limit: h.limitMinor === null ? null : h.limitMinor / 100, + actual: h.actualMinor / 100, + })); + const { totals, hasBudget } = data.budget; + + return ( +
+ {!hasBudget ?

{t.noBudget}

: null} +
+ + + + + + + + + + + + {data.lines.map((l) => ( + + + + + + + + ))} + + + + + + + + + + +
{t.category}{t.limit}{t.actual}{t.remaining}{t.ratio}
+ {l.name} + + {l.limitMinor === null ? ( + + {l.covered ? t.covered : "—"} + + ) : ( + formatMoney(l.limitMinor) + )} + {formatMoney(l.actualMinor)} + {l.remainingMinor === null + ? "—" + : `${l.remainingMinor < 0 ? "−" : ""}${formatMoney(Math.abs(l.remainingMinor))}`} + + {l.ratioBp === null ? ( + — + ) : ( + + + + + {ratio(l.ratioBp)} + + )} +
{tr.reports.categories.total} + {hasBudget ? formatMoney(totals.limitMinor) : "—"} + {formatMoney(totals.actualMinor)} + {hasBudget + ? `${totals.remainingMinor < 0 ? "−" : ""}${formatMoney(Math.abs(totals.remainingMinor))}` + : "—"} + {ratio(totals.ratioBp)}
+
+ +
+

+ {t.historyTitle} +

+ + + + + compact.format(v)} + /> + { + const m = payload?.[0]?.payload?.month as string | undefined; + return m ? monthLabel(m) : ""; + }} + formatter={(value, name) => ( +
+ + {config[name as keyof typeof config]?.label} + + + {formatMoney(Math.round(Number(value) * 100))} + +
+ )} + /> + } + /> + } /> + + +
+
+ + + + + + + + + + + {data.history.map((h) => ( + + + + + + + ))} + +
{t.month}{t.limit}{t.actual}{t.ratio}
{monthLabel(h.month)} + {h.limitMinor === null ? t.noLimit : formatMoney(h.limitMinor)} + {formatMoney(h.actualMinor)}{ratio(h.ratioBp)}
+
+
+ ); +} diff --git a/src/features/reports/components/categories-view.tsx b/src/features/reports/components/categories-view.tsx new file mode 100644 index 0000000..5da1e8d --- /dev/null +++ b/src/features/reports/components/categories-view.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { Cell, Pie, PieChart } from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import { IconBadge } from "@/components/appearance/swatch"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { formatMoney } from "@/shared/format"; +import type { CategoryReport } from "../service"; +import { Change, formatPct } from "./report-frame"; + +const t = tr.reports.categories; +/** Halkada en fazla bu kadar dilim; kalanlar "Diğer". */ +const MAX_SLICES = 7; + +export function CategoriesView({ data }: { data: CategoryReport }) { + const { breakdown, appearance } = data; + const positive = breakdown.rows.filter((r) => r.amountMinor > 0); + const head = positive.slice(0, MAX_SLICES); + const rest = positive.slice(MAX_SLICES); + const slices = [ + ...head.map((r) => ({ + key: r.id, + name: r.name, + value: r.amountMinor / 100, + share: r.sharePct, + color: `var(--swatch-${appearance[r.id]?.color ?? "slate"})`, + })), + ...(rest.length + ? [ + { + key: "__other__", + name: t.other(rest.length), + value: rest.reduce((a, r) => a + r.amountMinor, 0) / 100, + share: Math.round(rest.reduce((a, r) => a + r.sharePct, 0) * 10) / 10, + color: "var(--muted-foreground)", + }, + ] + : []), + ]; + const config = Object.fromEntries( + slices.map((s) => [s.key, { label: s.name, color: s.color }]), + ) satisfies ChartConfig; + + if (breakdown.totalMinor === 0 && breakdown.rows.length === 0) + return ( +

+ {tr.reports.noData} +

+ ); + + return ( +
+
+
+ + + ( +
+ {item.payload.name} + + {formatMoney(Math.round(Number(value) * 100))} ·{" "} + {formatPct(item.payload.share)} + +
+ )} + /> + } + /> + + {slices.map((s) => ( + + ))} + +
+
+
+ {t.total} + + {formatMoney(breakdown.totalMinor)} + +
+
+
    + {slices.map((s) => ( +
  • + + {s.name} + {formatPct(s.share)} +
  • + ))} +
+
+ +
+ + + + + + + + + + + + {breakdown.rows.map((r) => { + const a = appearance[r.id]; + return [ + + + + + + + , + ...(r.children.length && r.ownMinor + ? [ + + + + , + ] + : []), + ...r.children.map((c) => ( + + + + + + + + )), + ]; + })} + + + + + + + + + + +
{t.category}{t.amount}{t.share}{t.previous}{t.change}
+ + {a ? : null} + {r.name} + + + {formatMoney(r.amountMinor)} + {formatPct(r.sharePct)} + {formatMoney(r.prevMinor)} + + +
{t.own} + {formatMoney(r.ownMinor)} + +
1 ? "pl-14" : "pl-10")}>{c.name} + {formatMoney(c.amountMinor)} + + {formatPct(c.sharePct)} + + {formatMoney(c.prevMinor)} + + +
{t.total} + {formatMoney(breakdown.totalMinor)} + %100 + {formatMoney(breakdown.prevTotalMinor)} + + +
+
+
+ ); +} diff --git a/src/features/reports/components/people-view.tsx b/src/features/reports/components/people-view.tsx new file mode 100644 index 0000000..549a867 --- /dev/null +++ b/src/features/reports/components/people-view.tsx @@ -0,0 +1,108 @@ +import { Badge } from "@/components/ui/badge"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { formatMoney } from "@/shared/format"; +import type { PeopleAccountsReport } from "../service"; +import { formatPct } from "./report-frame"; + +const t = tr.reports.people; +const signed = (n: number) => `${n > 0 ? "+" : n < 0 ? "−" : ""}${formatMoney(Math.abs(n))}`; + +/** Kişi bazlı harcama (pay çubuğu + tablo) ve hesap bazlı hareket tablosu. */ +export function PeopleView({ data }: { data: PeopleAccountsReport }) { + return ( +
+
+
+

+ {t.peopleTitle} +

+

{t.peopleHint}

+
+ {data.people.length ? ( +
    + {data.people.map((p) => ( +
  • +
    + {p.name} + + {p.count} {t.count.toLocaleLowerCase("tr-TR")} + + + {formatMoney(p.expenseMinor)} + + + {formatPct(p.sharePct)} + +
    +
    +
    +
    +
  • + ))} +
  • + {tr.reports.categories.total} + {formatMoney(data.peopleTotalMinor)} +
  • +
+ ) : ( +

+ {tr.reports.noData} +

+ )} +
+ +
+
+

+ {t.accountsTitle} +

+

{t.accountsHint}

+
+
+ + + + + + + + + + + + + + {data.accounts.map((a) => ( + + + + + + + + + + ))} + +
{t.account}{t.income}{t.expense}{t.transferIn}{t.transferOut}{t.net}{t.count}
+ + {a.name} + {a.archived ? {t.archived} : null} + + {formatMoney(a.incomeMinor)}{formatMoney(a.expenseMinor)}{formatMoney(a.transferInMinor)}{formatMoney(a.transferOutMinor)} 0 ? "text-income" : "", + )} + > + {signed(a.netMinor)} + {a.count}
+
+
+
+ ); +} diff --git a/src/features/reports/components/report-frame.tsx b/src/features/reports/components/report-frame.tsx new file mode 100644 index 0000000..8eaf5c3 --- /dev/null +++ b/src/features/reports/components/report-frame.tsx @@ -0,0 +1,127 @@ +import { ArrowLeft, ChevronLeft, ChevronRight, Download } from "lucide-react"; +import Link from "next/link"; +import { PageHeader } from "@/components/page-header"; +import { Button } from "@/components/ui/button"; +import { tr } from "@/i18n/tr"; +import { shiftMonth } from "@/shared/budget"; + +const t = tr.reports; + +const monthFmt = new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" }); +export const monthLabel = (month: string) => + monthFmt.format(new Date(`${month}-15T12:00:00+03:00`)); +const monthShortFmt = new Intl.DateTimeFormat("tr-TR", { month: "short", year: "2-digit" }); +export const monthShort = (month: string) => + monthShortFmt.format(new Date(`${month}-15T12:00:00+03:00`)); + +/** 14,3 → "%14,3"; −20 → "−%20"; null → "—". */ +export function formatPct(n: number | null): string { + if (n === null) return "—"; + const abs = Math.abs(n).toLocaleString("tr-TR", { maximumFractionDigits: 1 }); + return n < 0 ? `−%${abs}` : `%${abs}`; +} + +/** Önceki / sonraki bütçe ayı (?ay=). Gelecek aya gidilmez. */ +export function MonthNav({ + month, + currentMonth, + path, +}: { + month: string; + currentMonth: string; + path: string; +}) { + const next = shiftMonth(month, 1); + return ( + + ); +} + +/** Rapor sayfası çerçevesi: geri bağlantısı, başlık, araçlar (ay seçici, CSV). */ +export function ReportFrame({ + title, + description, + tools, + children, +}: { + title: string; + description: React.ReactNode; + tools?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+ + + {tools ?
{tools}
: null} + {children} +
+ ); +} + +export function CsvButton({ href, label }: { href: string; label?: string }) { + return ( + + ); +} + +/** Araç çubuğunun sağ tarafı (CSV düğmeleri). */ +export function ToolsEnd({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +/** İşaretli tutar ve yüzde değişim; renk tek başına anlam taşımaz (işaret metinde). */ +export function Change({ + minor, + pct, + format, +}: { + minor: number; + pct: number | null; + format: (n: number) => string; +}) { + if (minor === 0) return 0; + const up = minor > 0; + return ( + + {up ? "+" : "−"} + {format(Math.abs(minor))} + {pct !== null ? ` (${up ? "+" : "−"}%${Math.abs(pct).toLocaleString("tr-TR")})` : ""} + + ); +} diff --git a/src/features/reports/components/trend-view.tsx b/src/features/reports/components/trend-view.tsx new file mode 100644 index 0000000..a24aece --- /dev/null +++ b/src/features/reports/components/trend-view.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + ReferenceLine, + XAxis, + YAxis, +} from "recharts"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { formatMoney } from "@/shared/format"; +import type { TrendReport } from "../service"; +import { monthLabel, monthShort, formatPct } from "./report-frame"; + +const t = tr.reports.trend; +const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 }); + +const barConfig = { + income: { label: t.income, color: "var(--income)" }, + expense: { label: t.expense, color: "var(--expense)" }, +} satisfies ChartConfig; +const rateConfig = { rate: { label: t.savings, color: "var(--budget)" } } satisfies ChartConfig; + +export function TrendView({ data }: { data: TrendReport }) { + const chart = data.months.map((m) => ({ + month: m.month, + label: monthShort(m.month), + income: m.incomeMinor / 100, + expense: m.expenseMinor / 100, + // Devam eden ayın oranı (maaş henüz yatmamış olabilir) ölçeği bozmasın: grafikte yok. + rate: m.month === data.currentMonth ? null : m.savingsPct, + })); + const moneyTip = ( + { + const m = payload?.[0]?.payload?.month as string | undefined; + return m ? monthLabel(m) : ""; + }} + formatter={(value, name) => ( +
+ + {barConfig[name as keyof typeof barConfig]?.label} + + + {formatMoney(Math.round(Number(value) * 100))} + +
+ )} + /> + ); + + return ( +
+
+ + + + + compact.format(v)} + /> + + } /> + + + + +
+ +
+

{t.rateTitle}

+

+ {t.rateHint} {t.partialHint} +

+ + + + + `%${v}`} + /> + + { + const m = payload?.[0]?.payload?.month as string | undefined; + return m ? monthLabel(m) : ""; + }} + formatter={(value) => ( + + {formatPct(Number(value))} + + )} + /> + } + /> + + + +
+ +
+ + + + + + + + + + + + {[...data.months].reverse().map((m) => ( + + + + + + + + ))} + + + + + + + + + + +
{t.month}{t.income}{t.expense}{t.net}{t.savings}
+ {monthLabel(m.month)} + {m.month === data.currentMonth ? ( + ({t.ongoing}) + ) : null} + {formatMoney(m.incomeMinor)}{formatMoney(m.expenseMinor)} + {m.netMinor < 0 ? "−" : ""} + {formatMoney(Math.abs(m.netMinor))} + {formatPct(m.savingsPct)}
{t.total}{formatMoney(data.totals.incomeMinor)}{formatMoney(data.totals.expenseMinor)} + {data.totals.netMinor < 0 ? "−" : ""} + {formatMoney(Math.abs(data.totals.netMinor))} + {formatPct(data.totals.savingsPct)}
+
+
+ ); +}