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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -100,6 +100,11 @@ const navGroups = [
|
|||||||
url: "/invoices",
|
url: "/invoices",
|
||||||
icon: Receipt,
|
icon: Receipt,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Rapor",
|
||||||
|
url: "/finance/reports",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<FinancialReport> {
|
||||||
|
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<string, number>();
|
||||||
|
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<string, number>();
|
||||||
|
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<string, LoanInstallment[]>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user