feat(finance): /finance/reports — single-page financial overview

CFO-style summary that pulls everything together: cash position, P&L by
period, 12-month trend, top customers, expense breakdown, loans, cards,
outstanding invoices.

Aggregator (lib/appwrite/finance-report-queries.ts):
- getFinancialReport(tenantId, period). Period: month / quarter / year /
  all. Pulls all relevant tables in parallel (customers, invoices, finance
  entries, bank accounts, loans, installments, cards, statements).
- KPIs: period income, period expense, period net, current cash position
  (bank balances + receivables − loan remaining − card outstanding).
- Cash composition: separates the four pieces of net cash position with
  links to drill in.
- Trend: 12-month income/expense series.
- Top customers: paid invoice totals, period-bound.
- Expense breakdown: heuristic split using financeEntryId backrefs from
  loan_installments and credit_card_statements — lets us bucket auto-
  generated expenses (loan vs card vs manual) without needing a new
  category column.
- Active loans summary: top 8 by remaining balance.
- Card statements: pending + overdue, sorted by due date.
- Outstanding invoices: top 12 unpaid (overdue first).

Page (/finance/reports):
- Period selector (?period=month|quarter|year|all). Default 'month';
  default URL drops the param.
- KPI row → composition card → 12-month trend → top customers + expense
  breakdown → loans + cards tables → outstanding invoices.
- Trend chart loaded via next/dynamic with ssr: false (recharts is heavy
  and only runs client-side anyway).

Sidebar Finans submenu: added Rapor → /finance/reports.
This commit is contained in:
kovakmedya
2026-04-30 07:55:39 +03:00
parent 121fbdba9d
commit 37cf745ca1
5 changed files with 1075 additions and 0 deletions
@@ -0,0 +1,542 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
AlertCircle,
ArrowDownRight,
ArrowUpRight,
Banknote,
Building2,
CircleDollarSign,
CreditCard,
Crown,
ExternalLink,
Receipt,
Wallet,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { FinancialReport, ReportPeriod } from "@/lib/appwrite/finance-report-queries";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
const PERIOD_LABEL: Record<ReportPeriod, string> = {
month: "Bu ay",
quarter: "Bu çeyrek",
year: "Bu yıl",
all: "Tüm zamanlar",
};
const STATUS_LABEL: Record<"pending" | "partial" | "overdue", string> = {
pending: "Bekliyor",
partial: "Kısmi",
overdue: "Gecikti",
};
const STATUS_COLOR: Record<"pending" | "partial" | "overdue", string> = {
pending: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
partial: "bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30",
overdue: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
};
export function ReportClient({ data }: { data: FinancialReport }) {
const router = useRouter();
const setPeriod = (p: ReportPeriod) => {
const params = new URLSearchParams();
if (p !== "month") params.set("period", p);
router.push(`/finance/reports${params.size ? `?${params}` : ""}`);
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Select value={data.period} onValueChange={(v) => setPeriod(v as ReportPeriod)}>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="month">{PERIOD_LABEL.month}</SelectItem>
<SelectItem value="quarter">{PERIOD_LABEL.quarter}</SelectItem>
<SelectItem value="year">{PERIOD_LABEL.year}</SelectItem>
<SelectItem value="all">{PERIOD_LABEL.all}</SelectItem>
</SelectContent>
</Select>
</div>
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-4">
<KpiCard
label="Nakit pozisyonu"
value={formatTRY(data.kpi.cashPosition)}
tone={data.kpi.cashPosition >= 0 ? "positive" : "negative"}
icon={CircleDollarSign}
subtitle="Banka + alacaklar borçlar"
/>
<KpiCard
label={`${PERIOD_LABEL[data.period]} geliri`}
value={formatTRY(data.kpi.income)}
tone="positive"
icon={ArrowUpRight}
/>
<KpiCard
label={`${PERIOD_LABEL[data.period]} gideri`}
value={formatTRY(data.kpi.expense)}
tone="negative"
icon={ArrowDownRight}
/>
<KpiCard
label="Net"
value={formatTRY(data.kpi.net)}
tone={data.kpi.net >= 0 ? "positive" : "negative"}
icon={Wallet}
/>
</div>
{/* Cash composition */}
<Card>
<CardHeader>
<CardTitle>Nakit pozisyonu detayı</CardTitle>
<CardDescription>
Bugünkü gerçek nakit + tahsil edilebilir ödenecek borçlar
</CardDescription>
</CardHeader>
<CardContent>
<CompositionRow
icon={Building2}
label="Banka hesapları"
sign="+"
amount={data.composition.bankBalances}
href="/finance/banks"
/>
<CompositionRow
icon={Receipt}
label="Bekleyen tahsilatlar"
sign="+"
amount={data.composition.receivables}
href="/invoices"
/>
<CompositionRow
icon={Banknote}
label="Kredi kalan ödemeler"
sign=""
amount={data.composition.loanRemaining}
href="/finance/loans"
/>
<CompositionRow
icon={CreditCard}
label="Kart ekstre borçları"
sign=""
amount={data.composition.cardOutstanding}
href="/finance/cards"
/>
<div className="mt-3 flex items-center justify-between border-t pt-3">
<span className="text-sm font-semibold">Net pozisyon</span>
<span
className={cn(
"text-lg font-semibold tabular-nums",
data.kpi.cashPosition >= 0
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400",
)}
>
{formatTRY(data.kpi.cashPosition)}
</span>
</div>
</CardContent>
</Card>
{/* Trend chart */}
<TrendChartLazy data={data.trend} />
{/* Top customers + Expense breakdown */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Crown className="size-4" />
En çok ciro yapan müşteriler
</CardTitle>
<CardDescription>
{PERIOD_LABEL[data.period]} ödenmiş faturalara göre
</CardDescription>
</CardHeader>
<CardContent>
{data.topCustomers.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Bu dönemde ödenmiş fatura yok.
</p>
) : (
<ul className="space-y-3">
{data.topCustomers.map((c, i) => {
const max = data.topCustomers[0]?.total ?? 1;
const w = (c.total / max) * 100;
return (
<li key={c.name + i} className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm">
<span className="text-muted-foreground mr-2 tabular-nums">
{String(i + 1).padStart(2, "0")}
</span>
{c.name}
</span>
<span className="text-sm tabular-nums">{formatTRY(c.total)}</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className="bg-emerald-500 h-full"
style={{ width: `${w}%` }}
/>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Gider dağılımı</CardTitle>
<CardDescription>{PERIOD_LABEL[data.period]} kaynak bazında</CardDescription>
</CardHeader>
<CardContent>
<ExpenseRow
label="Kredi taksit ödemeleri"
amount={data.expenseBreakdown.loans}
total={data.kpi.expense}
color="bg-amber-500"
/>
<ExpenseRow
label="Kredi kartı ödemeleri"
amount={data.expenseBreakdown.cards}
total={data.kpi.expense}
color="bg-violet-500"
/>
<ExpenseRow
label="Diğer (manuel) gider"
amount={data.expenseBreakdown.other}
total={data.kpi.expense}
color="bg-red-500"
/>
{data.kpi.expense === 0 && (
<p className="text-muted-foreground py-2 text-center text-sm">
Bu dönemde gider yok.
</p>
)}
</CardContent>
</Card>
</div>
{/* Loans + Cards summary */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Banknote className="size-4" />
Aktif krediler
</CardTitle>
<CardDescription>Kalan ödeme tutarına göre</CardDescription>
</div>
<Link
href="/finance/loans"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
Tümü <ExternalLink className="size-3" />
</Link>
</CardHeader>
<CardContent>
{data.loans.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Aktif kredi yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Kredi</TableHead>
<TableHead className="text-right">Aylık</TableHead>
<TableHead className="text-right">Kalan</TableHead>
<TableHead>Sonraki</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.loans.map((l) => (
<TableRow key={l.id}>
<TableCell>
<span className="block font-medium">{l.bankName}</span>
<span className="text-muted-foreground text-xs">{l.loanName}</span>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(l.monthlyPayment)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(l.remaining)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{l.nextDue ? formatDate(l.nextDue) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<CreditCard className="size-4" />
Kart ekstreleri
</CardTitle>
<CardDescription>Bekleyen ve gecikmiş ödemeler</CardDescription>
</div>
<Link
href="/finance/cards"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
Tümü <ExternalLink className="size-3" />
</Link>
</CardHeader>
<CardContent>
{data.cardStatements.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Açık ekstre yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Kart</TableHead>
<TableHead>Vade</TableHead>
<TableHead className="text-right">Kalan</TableHead>
<TableHead>Durum</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.cardStatements.map((s) => (
<TableRow key={s.id}>
<TableCell>
<span className="block text-sm font-medium">{s.cardLabel}</span>
<span className="text-muted-foreground font-mono text-xs">
{s.period}
</span>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDate(s.dueDate)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(s.remaining)}
</TableCell>
<TableCell>
<Badge variant="outline" className={cn("border-0", STATUS_COLOR[s.status])}>
{STATUS_LABEL[s.status]}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
{/* Outstanding invoices */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Receipt className="size-4" />
Bekleyen faturalar
</CardTitle>
<CardDescription>
Tahsil edilmesi gereken vadesi geçmiş olanlar üstte
</CardDescription>
</div>
<Link
href="/invoices"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
Tümü <ExternalLink className="size-3" />
</Link>
</CardHeader>
<CardContent>
{data.outstandingInvoices.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Bekleyen fatura yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Numara</TableHead>
<TableHead>Müşteri</TableHead>
<TableHead>Vade</TableHead>
<TableHead className="text-right">Tutar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.outstandingInvoices.map((inv) => (
<TableRow key={inv.id}>
<TableCell>
<Link
href={`/invoices/${inv.id}`}
className="hover:text-primary inline-flex items-center gap-1 font-mono text-sm"
>
{inv.number}
</Link>
</TableCell>
<TableCell>{inv.customerName}</TableCell>
<TableCell
className={cn(
"text-sm",
inv.overdue
? "text-destructive flex items-center gap-1 font-medium"
: "text-muted-foreground",
)}
>
{inv.overdue && <AlertCircle className="size-3" />}
{formatDate(inv.dueDate)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(inv.total)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function KpiCard({
label,
value,
tone,
icon: Icon,
subtitle,
}: {
label: string;
value: string;
tone: "positive" | "negative" | "neutral";
icon: typeof Wallet;
subtitle?: string;
}) {
const cls = {
positive: "text-emerald-600 dark:text-emerald-400",
negative: "text-red-600 dark:text-red-400",
neutral: "text-muted-foreground",
}[tone];
return (
<Card>
<CardContent className="flex items-start justify-between p-5">
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wide">{label}</p>
<p className={cn("mt-2 text-2xl font-semibold tabular-nums", cls)}>{value}</p>
{subtitle && <p className="text-muted-foreground mt-1 text-xs">{subtitle}</p>}
</div>
<Icon className={cn("size-5", cls)} />
</CardContent>
</Card>
);
}
function CompositionRow({
icon: Icon,
label,
sign,
amount,
href,
}: {
icon: typeof Building2;
label: string;
sign: "+" | "";
amount: number;
href?: string;
}) {
const positive = sign === "+";
const content = (
<div className="flex items-center justify-between border-b py-2 last:border-b-0">
<div className="flex items-center gap-2">
<Icon className="text-muted-foreground size-4" />
<span className="text-sm">{label}</span>
</div>
<span
className={cn(
"font-medium tabular-nums",
positive ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400",
)}
>
{sign} {formatTRY(amount)}
</span>
</div>
);
if (href) {
return (
<Link href={href} className="hover:bg-muted/30 block rounded -mx-2 px-2 transition-colors">
{content}
</Link>
);
}
return content;
}
function ExpenseRow({
label,
amount,
total,
color,
}: {
label: string;
amount: number;
total: number;
color: string;
}) {
const pct = total > 0 ? (amount / total) * 100 : 0;
return (
<div className="space-y-1.5 py-1.5">
<div className="flex items-center justify-between text-sm">
<span>{label}</span>
<span className="tabular-nums">
{formatTRY(amount)}{" "}
<span className="text-muted-foreground text-xs">({pct.toFixed(1)}%)</span>
</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div className={cn("h-full", color)} style={{ width: `${pct}%` }} />
</div>
</div>
);
}
// Pull TrendChart in via dynamic import only on client. Recharts is heavy.
import dynamic from "next/dynamic";
const TrendChartLazy = dynamic(
() => import("./trend-chart").then((m) => ({ default: m.TrendChart })),
{ ssr: false },
);
@@ -0,0 +1,89 @@
"use client";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { formatTRY } from "@/lib/format";
type Point = { month: string; income: number; expense: number; net: number };
export function TrendChart({ data }: { data: Point[] }) {
return (
<Card className="@container">
<CardHeader>
<CardTitle>12 aylık trend</CardTitle>
<CardDescription>Gelir, gider ve net kâr</CardDescription>
</CardHeader>
<CardContent className="h-[280px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<defs>
<linearGradient id="rIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
<linearGradient id="rExpense" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
fontSize={11}
stroke="hsl(var(--muted-foreground))"
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={11}
stroke="hsl(var(--muted-foreground))"
tickFormatter={(v) => (v >= 1000 ? `${(v / 1000).toFixed(0)}k` : String(v))}
/>
<Tooltip
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: 8,
fontSize: 12,
}}
formatter={(value: unknown, name) => [
formatTRY(Number(value) || 0),
name === "income" ? "Gelir" : name === "expense" ? "Gider" : "Net",
]}
/>
<Legend
wrapperStyle={{ fontSize: 11 }}
formatter={(v) => (v === "income" ? "Gelir" : v === "expense" ? "Gider" : "Net")}
/>
<Area
type="monotone"
dataKey="income"
stroke="#10b981"
strokeWidth={2}
fill="url(#rIncome)"
/>
<Area
type="monotone"
dataKey="expense"
stroke="#ef4444"
strokeWidth={2}
fill="url(#rExpense)"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import {
getFinancialReport,
type ReportPeriod,
} from "@/lib/appwrite/finance-report-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { ReportClient } from "./components/report-client";
export const metadata: Metadata = {
title: "İşletmem — Finansal rapor",
};
const ALLOWED: ReportPeriod[] = ["month", "quarter", "year", "all"];
export default async function ReportsPage({
searchParams,
}: {
searchParams: Promise<{ period?: string }>;
}) {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const sp = await searchParams;
const period: ReportPeriod = (ALLOWED as string[]).includes(sp.period ?? "")
? (sp.period as ReportPeriod)
: "month";
const data = await getFinancialReport(ctx.tenantId, period);
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Finansal rapor</h1>
<p className="text-muted-foreground text-sm">
İşletmenizin nakit pozisyonu, gelir/gider performansı ve borç yükünün tek bakışta özeti.
</p>
</div>
<ReportClient data={data} />
</div>
);
}