From 37cf745ca18306a411d8b97a2b4cb160f7fe91fb Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 30 Apr 2026 07:55:39 +0300 Subject: [PATCH] =?UTF-8?q?feat(finance):=20/finance/reports=20=E2=80=94?= =?UTF-8?q?=20single-page=20financial=20overview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../reports/components/report-client.tsx | 542 ++++++++++++++++++ .../reports/components/trend-chart.tsx | 89 +++ src/app/(dashboard)/finance/reports/page.tsx | 49 ++ src/components/app-sidebar.tsx | 5 + src/lib/appwrite/finance-report-queries.ts | 390 +++++++++++++ 5 files changed, 1075 insertions(+) create mode 100644 src/app/(dashboard)/finance/reports/components/report-client.tsx create mode 100644 src/app/(dashboard)/finance/reports/components/trend-chart.tsx create mode 100644 src/app/(dashboard)/finance/reports/page.tsx create mode 100644 src/lib/appwrite/finance-report-queries.ts diff --git a/src/app/(dashboard)/finance/reports/components/report-client.tsx b/src/app/(dashboard)/finance/reports/components/report-client.tsx new file mode 100644 index 0000000..7c3f5ed --- /dev/null +++ b/src/app/(dashboard)/finance/reports/components/report-client.tsx @@ -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 = { + 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 ( +
+
+ +
+ + {/* KPIs */} +
+ = 0 ? "positive" : "negative"} + icon={CircleDollarSign} + subtitle="Banka + alacaklar − borçlar" + /> + + + = 0 ? "positive" : "negative"} + icon={Wallet} + /> +
+ + {/* Cash composition */} + + + Nakit pozisyonu detayı + + Bugünkü gerçek nakit + tahsil edilebilir − ödenecek borçlar + + + + + + + +
+ Net pozisyon + = 0 + ? "text-emerald-600 dark:text-emerald-400" + : "text-red-600 dark:text-red-400", + )} + > + {formatTRY(data.kpi.cashPosition)} + +
+
+
+ + {/* Trend chart */} + + + {/* Top customers + Expense breakdown */} +
+ + + + + En çok ciro yapan müşteriler + + + {PERIOD_LABEL[data.period]} ödenmiş faturalara göre + + + + {data.topCustomers.length === 0 ? ( +

+ Bu dönemde ödenmiş fatura yok. +

+ ) : ( +
    + {data.topCustomers.map((c, i) => { + const max = data.topCustomers[0]?.total ?? 1; + const w = (c.total / max) * 100; + return ( +
  • +
    + + + {String(i + 1).padStart(2, "0")} + + {c.name} + + {formatTRY(c.total)} +
    +
    +
    +
    +
  • + ); + })} +
+ )} +
+
+ + + + Gider dağılımı + {PERIOD_LABEL[data.period]} kaynak bazında + + + + + + {data.kpi.expense === 0 && ( +

+ Bu dönemde gider yok. +

+ )} +
+
+
+ + {/* Loans + Cards summary */} +
+ + +
+ + + Aktif krediler + + Kalan ödeme tutarına göre +
+ + Tümü + +
+ + {data.loans.length === 0 ? ( +

+ Aktif kredi yok. +

+ ) : ( + + + + Kredi + Aylık + Kalan + Sonraki + + + + {data.loans.map((l) => ( + + + {l.bankName} + {l.loanName} + + + {formatTRY(l.monthlyPayment)} + + + {formatTRY(l.remaining)} + + + {l.nextDue ? formatDate(l.nextDue) : "—"} + + + ))} + +
+ )} +
+
+ + + +
+ + + Kart ekstreleri + + Bekleyen ve gecikmiş ödemeler +
+ + Tümü + +
+ + {data.cardStatements.length === 0 ? ( +

+ Açık ekstre yok. +

+ ) : ( + + + + Kart + Vade + Kalan + Durum + + + + {data.cardStatements.map((s) => ( + + + {s.cardLabel} + + {s.period} + + + + {formatDate(s.dueDate)} + + + {formatTRY(s.remaining)} + + + + {STATUS_LABEL[s.status]} + + + + ))} + +
+ )} +
+
+
+ + {/* Outstanding invoices */} + + +
+ + + Bekleyen faturalar + + + Tahsil edilmesi gereken — vadesi geçmiş olanlar üstte + +
+ + Tümü + +
+ + {data.outstandingInvoices.length === 0 ? ( +

+ Bekleyen fatura yok. +

+ ) : ( + + + + Numara + Müşteri + Vade + Tutar + + + + {data.outstandingInvoices.map((inv) => ( + + + + {inv.number} + + + {inv.customerName} + + {inv.overdue && } + {formatDate(inv.dueDate)} + + + {formatTRY(inv.total)} + + + ))} + +
+ )} +
+
+
+ ); +} + +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 ( + + +
+

{label}

+

{value}

+ {subtitle &&

{subtitle}

} +
+ +
+
+ ); +} + +function CompositionRow({ + icon: Icon, + label, + sign, + amount, + href, +}: { + icon: typeof Building2; + label: string; + sign: "+" | "−"; + amount: number; + href?: string; +}) { + const positive = sign === "+"; + const content = ( +
+
+ + {label} +
+ + {sign} {formatTRY(amount)} + +
+ ); + if (href) { + return ( + + {content} + + ); + } + 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 ( +
+
+ {label} + + {formatTRY(amount)}{" "} + ({pct.toFixed(1)}%) + +
+
+
+
+
+ ); +} + +// 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 }, +); diff --git a/src/app/(dashboard)/finance/reports/components/trend-chart.tsx b/src/app/(dashboard)/finance/reports/components/trend-chart.tsx new file mode 100644 index 0000000..5a07a42 --- /dev/null +++ b/src/app/(dashboard)/finance/reports/components/trend-chart.tsx @@ -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 ( + + + 12 aylık trend + Gelir, gider ve net kâr + + + + + + + + + + + + + + + + + (v >= 1000 ? `${(v / 1000).toFixed(0)}k` : String(v))} + /> + [ + formatTRY(Number(value) || 0), + name === "income" ? "Gelir" : name === "expense" ? "Gider" : "Net", + ]} + /> + (v === "income" ? "Gelir" : v === "expense" ? "Gider" : "Net")} + /> + + + + + + + ); +} diff --git a/src/app/(dashboard)/finance/reports/page.tsx b/src/app/(dashboard)/finance/reports/page.tsx new file mode 100644 index 0000000..cfe621a --- /dev/null +++ b/src/app/(dashboard)/finance/reports/page.tsx @@ -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 ( +
+
+

{ctx.settings?.companyName ?? "Çalışma alanı"}

+

Finansal rapor

+

+ İşletmenizin nakit pozisyonu, gelir/gider performansı ve borç yükünün tek bakışta özeti. +

+
+ + +
+ ); +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 4be05bf..9828983 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -100,6 +100,11 @@ const navGroups = [ url: "/invoices", icon: Receipt, }, + { + title: "Rapor", + url: "/finance/reports", + icon: FileText, + }, ], }, { diff --git a/src/lib/appwrite/finance-report-queries.ts b/src/lib/appwrite/finance-report-queries.ts new file mode 100644 index 0000000..155bdc7 --- /dev/null +++ b/src/lib/appwrite/finance-report-queries.ts @@ -0,0 +1,390 @@ +import "server-only"; + +import { Query } from "node-appwrite"; + +import { createAdminClient } from "./server"; +import { + DATABASE_ID, + TABLES, + type BankAccount, + type BankLoan, + type CreditCard, + type CreditCardStatement, + type Customer, + type FinanceEntry, + type Invoice, + type LoanInstallment, +} from "./schema"; + +export type ReportPeriod = "month" | "quarter" | "year" | "all"; + +export type FinancialReport = { + period: ReportPeriod; + periodStart: string | null; + periodEnd: string | null; + // KPIs + kpi: { + income: number; + expense: number; + net: number; + cashPosition: number; // bank balances + receivables - loan remaining - card outstanding + }; + // Cash composition (right now, not period-bound) + composition: { + bankBalances: number; + receivables: number; // unpaid invoices total (not paid, not cancelled) + loanRemaining: number; + cardOutstanding: number; + }; + // Trend (last 12 months income/expense) + trend: { month: string; income: number; expense: number; net: number }[]; + // Top customers by paid invoice total (period-bound when period != all) + topCustomers: { name: string; total: number }[]; + // Top expense buckets — auto-grouped by source + expenseBreakdown: { + invoices: number; // expenses linked to invoices? we don't track AP invoices yet — leave 0 + loans: number; // finance_entries linked through loan installments (description match heuristic) + cards: number; + other: number; + }; + // Active loans + loans: { + id: string; + bankName: string; + loanName: string; + principal: number; + remaining: number; + monthlyPayment: number; + nextDue: string | null; + }[]; + // Credit card outstanding statements + cardStatements: { + id: string; + cardLabel: string; + period: string; + dueDate: string; + remaining: number; + status: "pending" | "partial" | "overdue"; + }[]; + // Outstanding (unpaid) invoices + outstandingInvoices: { + id: string; + number: string; + customerName: string; + dueDate: string; + total: number; + overdue: boolean; + }[]; +}; + +const MONTH_SHORT = ["Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara"]; + +function periodBounds(p: ReportPeriod): { start: Date | null; end: Date | null } { + const now = new Date(); + if (p === "month") { + return { + start: new Date(now.getFullYear(), now.getMonth(), 1), + end: new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59), + }; + } + if (p === "quarter") { + const q = Math.floor(now.getMonth() / 3); + return { + start: new Date(now.getFullYear(), q * 3, 1), + end: new Date(now.getFullYear(), q * 3 + 3, 0, 23, 59, 59), + }; + } + if (p === "year") { + return { + start: new Date(now.getFullYear(), 0, 1), + end: new Date(now.getFullYear(), 11, 31, 23, 59, 59), + }; + } + return { start: null, end: null }; +} + +function inRange(iso: string, start: Date | null, end: Date | null): boolean { + if (!start || !end) return true; + const t = new Date(iso).getTime(); + return t >= start.getTime() && t <= end.getTime(); +} + +export async function getFinancialReport( + tenantId: string, + period: ReportPeriod = "month", +): Promise { + const { tablesDB } = createAdminClient(); + + const [ + customers, + invoices, + finance, + bankAccounts, + loans, + installments, + cards, + statements, + ] = await Promise.all([ + tablesDB + .listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.customers, + queries: [Query.equal("tenantId", tenantId), Query.limit(2000)], + }) + .catch(() => ({ rows: [] as unknown[] })), + tablesDB + .listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.invoices, + queries: [Query.equal("tenantId", tenantId), Query.limit(2000)], + }) + .catch(() => ({ rows: [] as unknown[] })), + tablesDB + .listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.financeEntries, + queries: [Query.equal("tenantId", tenantId), Query.limit(5000)], + }) + .catch(() => ({ rows: [] as unknown[] })), + tablesDB + .listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.bankAccounts, + queries: [Query.equal("tenantId", tenantId), Query.limit(200)], + }) + .catch(() => ({ rows: [] as unknown[] })), + tablesDB + .listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.bankLoans, + queries: [Query.equal("tenantId", tenantId), Query.limit(200)], + }) + .catch(() => ({ rows: [] as unknown[] })), + tablesDB + .listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.loanInstallments, + queries: [Query.equal("tenantId", tenantId), Query.limit(5000)], + }) + .catch(() => ({ rows: [] as unknown[] })), + tablesDB + .listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.creditCards, + queries: [Query.equal("tenantId", tenantId), Query.limit(200)], + }) + .catch(() => ({ rows: [] as unknown[] })), + tablesDB + .listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.creditCardStatements, + queries: [Query.equal("tenantId", tenantId), Query.limit(2000)], + }) + .catch(() => ({ rows: [] as unknown[] })), + ]); + + const customerList = customers.rows as unknown as Customer[]; + const invoiceList = invoices.rows as unknown as Invoice[]; + const entryList = finance.rows as unknown as FinanceEntry[]; + const bankList = bankAccounts.rows as unknown as BankAccount[]; + const loanList = loans.rows as unknown as BankLoan[]; + const installmentList = installments.rows as unknown as LoanInstallment[]; + const cardList = cards.rows as unknown as CreditCard[]; + const statementList = statements.rows as unknown as CreditCardStatement[]; + + const customerMap = new Map(customerList.map((c) => [c.$id, c.name])); + const cardMap = new Map( + cardList.map((c) => [c.$id, `${c.bankName} — ${c.cardName}${c.last4 ? ` **${c.last4}` : ""}`]), + ); + + const { start, end } = periodBounds(period); + + // ---------- KPIs (period-bound for income/expense, current for cash) ---------- + let income = 0; + let expense = 0; + for (const e of entryList) { + if (!inRange(e.date, start, end)) continue; + if (e.type === "income") income += e.amount; + else if (e.type === "expense") expense += e.amount; + } + + // bank balance (today, not period-bound) + const balances = new Map(); + for (const a of bankList) balances.set(a.$id, a.openingBalance ?? 0); + for (const e of entryList) { + if (!e.bankAccountId) continue; + const cur = balances.get(e.bankAccountId); + if (cur === undefined) continue; + if (e.type === "income") balances.set(e.bankAccountId, cur + e.amount); + else if (e.type === "expense") balances.set(e.bankAccountId, cur - e.amount); + } + const bankBalances = Array.from(balances.values()).reduce((s, n) => s + n, 0); + + // receivables = sum of unpaid invoices + const receivables = invoiceList.reduce((s, inv) => { + const st = inv.status ?? "draft"; + if (st === "paid" || st === "cancelled") return s; + return s + (inv.total ?? 0); + }, 0); + + // loan remaining = sum of unpaid installments + const loanRemaining = installmentList.reduce( + (s, i) => (i.paid ? s : s + (i.amount ?? 0)), + 0, + ); + + // card outstanding = sum of (totalDebt - paidAmount) for non-paid statements + const cardOutstanding = statementList.reduce( + (s, st) => + st.status === "paid" ? s : s + ((st.totalDebt ?? 0) - (st.paidAmount ?? 0)), + 0, + ); + + const cashPosition = bankBalances + receivables - loanRemaining - cardOutstanding; + + // ---------- Trend (last 12 months always) ---------- + const trend: FinancialReport["trend"] = []; + const now = new Date(); + for (let i = 11; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + trend.push({ + month: MONTH_SHORT[d.getMonth()], + income: 0, + expense: 0, + net: 0, + }); + } + for (const e of entryList) { + const ed = new Date(e.date); + const monthsAgo = + (now.getFullYear() - ed.getFullYear()) * 12 + (now.getMonth() - ed.getMonth()); + if (monthsAgo < 0 || monthsAgo > 11) continue; + const idx = 11 - monthsAgo; + if (e.type === "income") trend[idx].income += e.amount; + else if (e.type === "expense") trend[idx].expense += e.amount; + } + for (const t of trend) t.net = t.income - t.expense; + + // ---------- Top customers (period-bound paid invoices) ---------- + const customerRevenue = new Map(); + for (const inv of invoiceList) { + if (inv.status !== "paid") continue; + if (start && !inRange(inv.issueDate, start, end)) continue; + customerRevenue.set( + inv.customerId, + (customerRevenue.get(inv.customerId) ?? 0) + (inv.total ?? 0), + ); + } + const topCustomers = Array.from(customerRevenue.entries()) + .map(([id, total]) => ({ name: customerMap.get(id) ?? "—", total })) + .sort((a, b) => b.total - a.total) + .slice(0, 8); + + // ---------- Expense breakdown (period-bound) ---------- + let expLoans = 0; + let expCards = 0; + let expOther = 0; + const installmentEntryIds = new Set( + installmentList.map((i) => i.financeEntryId).filter(Boolean) as string[], + ); + const statementEntryIds = new Set( + statementList.map((s) => s.financeEntryId).filter(Boolean) as string[], + ); + for (const e of entryList) { + if (e.type !== "expense") continue; + if (!inRange(e.date, start, end)) continue; + if (installmentEntryIds.has(e.$id)) expLoans += e.amount; + else if (statementEntryIds.has(e.$id)) expCards += e.amount; + else expOther += e.amount; + } + + // ---------- Active loans summary ---------- + const installmentsByLoan = new Map(); + for (const i of installmentList) { + const arr = installmentsByLoan.get(i.loanId) ?? []; + arr.push(i); + installmentsByLoan.set(i.loanId, arr); + } + const loansSummary = loanList + .filter((l) => (l.status ?? "active") === "active") + .map((l) => { + const items = installmentsByLoan.get(l.$id) ?? []; + const remaining = items.filter((i) => !i.paid).reduce((s, i) => s + i.amount, 0); + const unpaid = items.filter((i) => !i.paid).sort( + (a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime(), + ); + return { + id: l.$id, + bankName: l.bankName, + loanName: l.loanName, + principal: l.principal, + remaining, + monthlyPayment: l.monthlyPayment ?? 0, + nextDue: unpaid[0]?.dueDate ?? null, + }; + }) + .sort((a, b) => b.remaining - a.remaining); + + // ---------- Credit card outstanding statements ---------- + const cardStmts = statementList + .filter((s) => s.status !== "paid") + .map((s) => ({ + id: s.$id, + cardLabel: cardMap.get(s.cardId) ?? "—", + period: s.period, + dueDate: s.dueDate, + remaining: (s.totalDebt ?? 0) - (s.paidAmount ?? 0), + status: (s.status ?? "pending") as "pending" | "partial" | "overdue", + })) + .sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()); + + // ---------- Outstanding invoices ---------- + const today = new Date(); + const outstandingInvoices = invoiceList + .filter((inv) => { + const st = inv.status ?? "draft"; + return st !== "paid" && st !== "cancelled"; + }) + .map((inv) => ({ + id: inv.$id, + number: inv.number, + customerName: customerMap.get(inv.customerId) ?? "—", + dueDate: inv.dueDate, + total: inv.total ?? 0, + overdue: new Date(inv.dueDate) < today, + })) + .sort((a, b) => { + if (a.overdue !== b.overdue) return a.overdue ? -1 : 1; + return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime(); + }) + .slice(0, 12); + + return { + period, + periodStart: start ? start.toISOString() : null, + periodEnd: end ? end.toISOString() : null, + kpi: { + income, + expense, + net: income - expense, + cashPosition, + }, + composition: { + bankBalances, + receivables, + loanRemaining, + cardOutstanding, + }, + trend, + topCustomers, + expenseBreakdown: { + invoices: 0, + loans: expLoans, + cards: expCards, + other: expOther, + }, + loans: loansSummary.slice(0, 8), + cardStatements: cardStmts.slice(0, 12), + outstandingInvoices, + }; +}