diff --git a/app/api/admin/reports/export/route.ts b/app/api/admin/reports/export/route.ts index 797cd79..f4f3911 100644 --- a/app/api/admin/reports/export/route.ts +++ b/app/api/admin/reports/export/route.ts @@ -12,7 +12,7 @@ import User from "@/models/User" import Vehicle from "@/models/Vehicle" import InvestmentPool from "@/models/InvestmentPool" -type ExportType = "deposits" | "investments" | "repayments" +type ExportType = "deposits" | "investments" | "repayments" | "kyc" | "fleet" | "users" type RangeType = "7d" | "30d" | "90d" | "all" | "custom" function csvEscape(value: unknown): string { @@ -97,13 +97,14 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url) const type = (searchParams.get("type") || "deposits") as ExportType - if (!["deposits", "investments", "repayments"].includes(type)) { + if (!["deposits", "investments", "repayments", "kyc", "fleet", "users"].includes(type)) { return NextResponse.json({ message: "Invalid export type" }, { status: 400 }) } const range = parseRange(searchParams.get("range")) const { startDate, endDate } = buildWindow(range, searchParams.get("from"), searchParams.get("to")) + // ── deposits ──────────────────────────────────────────────────────────── if (type === "deposits") { const deposits = await Transaction.find({ type: { $in: ["deposit", "wallet_funding"] }, @@ -146,6 +147,7 @@ export async function GET(request: Request) { return shouldRefreshSession ? withSessionRefresh(response, user) : response } + // ── investments ────────────────────────────────────────────────────────── if (type === "investments") { const [poolInvestments, legacyInvestments] = await Promise.all([ PoolInvestment.find({ @@ -228,51 +230,164 @@ export async function GET(request: Request) { return shouldRefreshSession ? withSessionRefresh(response, user) : response } - const repayments = await DriverPayment.find({ - status: "CONFIRMED", - ...dateMatch("createdAt", startDate, endDate), - }) - .select("driverUserId contractId amountNgn appliedAmountNgn method paystackRef status createdAt") - .sort({ createdAt: -1 }) - .lean() + // ── repayments ─────────────────────────────────────────────────────────── + if (type === "repayments") { + const repayments = await DriverPayment.find({ + status: "CONFIRMED", + ...dateMatch("createdAt", startDate, endDate), + }) + .select("driverUserId contractId amountNgn appliedAmountNgn method paystackRef status createdAt") + .sort({ createdAt: -1 }) + .lean() + + const userIds = collectObjectIds(repayments.map((item: any) => item.driverUserId)) + const contractIds = collectObjectIds(repayments.map((item: any) => item.contractId)) + + const [users, contracts] = await Promise.all([ + userIds.length ? User.find({ _id: { $in: userIds } }).select("name fullName email").lean() : Promise.resolve([]), + contractIds.length + ? HirePurchaseContract.find({ _id: { $in: contractIds } }).select("vehicleDisplayName").lean() + : Promise.resolve([]), + ]) + + const userById = new Map(users.map((entry: any) => [entry._id.toString(), entry])) + const contractById = new Map(contracts.map((entry: any) => [entry._id.toString(), entry])) + + const csv = toCsv( + ["Date", "Driver", "Email", "Vehicle/Contract", "Amount (NGN)", "Applied Amount (NGN)", "Method", "Reference", "Status"], + repayments.map((item: any) => { + const userEntry = userById.get(item.driverUserId?.toString?.() || "") + const contract = contractById.get(item.contractId?.toString?.() || "") + return [ + item.createdAt ? new Date(item.createdAt).toISOString() : "", + getUserName(userEntry), + userEntry?.email || "", + contract?.vehicleDisplayName || "Contract", + Number(item.amountNgn || 0), + Number(item.appliedAmountNgn || 0), + item.method || "PAYSTACK", + item.paystackRef || "", + item.status || "unknown", + ] + }), + ) + + const response = new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="repayments-${range}.csv"`, + }, + }) + return shouldRefreshSession ? withSessionRefresh(response, user) : response + } + + // ── kyc ────────────────────────────────────────────────────────────────── + if (type === "kyc") { + const kycStatusFilter = searchParams.get("status") || "" + const kycQuery: Record = { + kycStatus: { $ne: "none" }, + ...dateMatch("createdAt", startDate, endDate), + } + if (["pending", "approved", "rejected"].includes(kycStatusFilter)) { + kycQuery.kycStatus = kycStatusFilter + } + + const kycUsers = await User.find(kycQuery) + .select("name fullName email role kycStatus kycVerified createdAt") + .sort({ createdAt: -1 }) + .lean() + + const csv = toCsv( + ["Date Joined", "Name", "Email", "Role", "KYC Status", "KYC Verified"], + kycUsers.map((u: any) => [ + u.createdAt ? new Date(u.createdAt).toISOString() : "", + getUserName(u), + u.email || "", + u.role || "", + u.kycStatus || "none", + u.kycVerified ? "Yes" : "No", + ]), + ) - const userIds = collectObjectIds(repayments.map((item: any) => item.driverUserId)) - const contractIds = collectObjectIds(repayments.map((item: any) => item.contractId)) + const response = new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="kyc-report-${range}.csv"`, + }, + }) + return shouldRefreshSession ? withSessionRefresh(response, user) : response + } - const [users, contracts] = await Promise.all([ - userIds.length ? User.find({ _id: { $in: userIds } }).select("name fullName email").lean() : Promise.resolve([]), - contractIds.length - ? HirePurchaseContract.find({ _id: { $in: contractIds } }).select("vehicleDisplayName").lean() - : Promise.resolve([]), - ]) + // ── fleet ───────────────────────────────────────────────────────────────── + if (type === "fleet") { + const vehicleStatusFilter = searchParams.get("vstatus") || "" + const vehicleQuery: Record = {} + if (["Available", "Financed", "Reserved", "Maintenance", "Retired"].includes(vehicleStatusFilter)) { + vehicleQuery.status = vehicleStatusFilter + } + + const vehicles = await Vehicle.find(vehicleQuery) + .select("name identifier type year price status fundingStatus totalFundedAmount addedDate") + .sort({ addedDate: -1 }) + .lean() - const userById = new Map(users.map((entry: any) => [entry._id.toString(), entry])) - const contractById = new Map(contracts.map((entry: any) => [entry._id.toString(), entry])) + const csv = toCsv( + ["Date Added", "Name", "Identifier", "Type", "Year", "Price (NGN)", "Status", "Funding Status", "Total Funded (NGN)"], + vehicles.map((v: any) => [ + v.addedDate ? new Date(v.addedDate).toISOString() : "", + v.name || "", + v.identifier || "", + v.type || "", + v.year || "", + Number(v.price || 0), + v.status || "", + v.fundingStatus || "", + Number(v.totalFundedAmount || 0), + ]), + ) + + const response = new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="fleet-report-${range}.csv"`, + }, + }) + return shouldRefreshSession ? withSessionRefresh(response, user) : response + } + + // ── users ───────────────────────────────────────────────────────────────── + const roleFilter = searchParams.get("role") || "" + const userQuery: Record = { + ...dateMatch("createdAt", startDate, endDate), + } + if (["driver", "investor", "admin"].includes(roleFilter)) { + userQuery.role = roleFilter + } + + const platformUsers = await User.find(userQuery) + .select("name fullName email role kycVerified createdAt") + .sort({ createdAt: -1 }) + .lean() const csv = toCsv( - ["Date", "Driver", "Email", "Vehicle/Contract", "Amount (NGN)", "Applied Amount (NGN)", "Method", "Reference", "Status"], - repayments.map((item: any) => { - const userEntry = userById.get(item.driverUserId?.toString?.() || "") - const contract = contractById.get(item.contractId?.toString?.() || "") - return [ - item.createdAt ? new Date(item.createdAt).toISOString() : "", - getUserName(userEntry), - userEntry?.email || "", - contract?.vehicleDisplayName || "Contract", - Number(item.amountNgn || 0), - Number(item.appliedAmountNgn || 0), - item.method || "PAYSTACK", - item.paystackRef || "", - item.status || "unknown", - ] - }), + ["Date Joined", "Name", "Email", "Role", "KYC Verified"], + platformUsers.map((u: any) => [ + u.createdAt ? new Date(u.createdAt).toISOString() : "", + getUserName(u), + u.email || "", + u.role || "", + u.kycVerified ? "Yes" : "No", + ]), ) const response = new NextResponse(csv, { status: 200, headers: { "Content-Type": "text/csv; charset=utf-8", - "Content-Disposition": `attachment; filename="repayments-${range}.csv"`, + "Content-Disposition": `attachment; filename="users-report-${range}.csv"`, }, }) return shouldRefreshSession ? withSessionRefresh(response, user) : response @@ -281,4 +396,3 @@ export async function GET(request: Request) { return NextResponse.json({ message: "Failed to export report." }, { status: 500 }) } } - diff --git a/app/dashboard/admin/reports/loading.tsx b/app/dashboard/admin/reports/loading.tsx index 4988457..efa9b58 100644 --- a/app/dashboard/admin/reports/loading.tsx +++ b/app/dashboard/admin/reports/loading.tsx @@ -1,5 +1,5 @@ import { DashboardRouteLoading } from "@/components/dashboard/dashboard-route-loading" export default function Loading() { - return -} + return +} diff --git a/app/dashboard/admin/reports/page.tsx b/app/dashboard/admin/reports/page.tsx index df9e11d..8b545d7 100644 --- a/app/dashboard/admin/reports/page.tsx +++ b/app/dashboard/admin/reports/page.tsx @@ -1,8 +1,10 @@ import Link from "next/link" -import { Download } from "lucide-react" +import { Download, Printer } from "lucide-react" import { PageHeader } from "@/components/dashboard/admin/page-header" +import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { formatNaira } from "@/lib/currency" import dbConnect from "@/lib/dbConnect" @@ -12,6 +14,8 @@ import InvestmentPool from "@/models/InvestmentPool" import InvestorCredit from "@/models/InvestorCredit" import PoolInvestment from "@/models/PoolInvestment" import Transaction from "@/models/Transaction" +import User from "@/models/User" +import Vehicle from "@/models/Vehicle" import { requireAdminAccess } from "@/src/server/admin/require-admin" export const dynamic = "force-dynamic" @@ -21,6 +25,7 @@ interface ReportsPageProps { } type ReportRange = "7d" | "30d" | "90d" | "all" | "custom" +type ReportTab = "overview" | "kyc" | "fleet" | "users" function getParam(value: string | string[] | undefined, fallback = "") { if (Array.isArray(value)) return value[0] ?? fallback @@ -64,6 +69,31 @@ function hrefForRange(range: ReportRange, from: string, to: string) { return `/dashboard/admin/reports?${params.toString()}` } +function hrefForTab(tab: ReportTab, range: ReportRange, from: string, to: string) { + const params = new URLSearchParams() + params.set("tab", tab) + params.set("range", range) + if (range === "custom") { + if (from) params.set("from", from) + if (to) params.set("to", to) + } + return `/dashboard/admin/reports?${params.toString()}` +} + +function kycBadgeVariant(status: string) { + if (status === "approved") return "default" + if (status === "rejected") return "destructive" + if (status === "pending") return "secondary" + return "outline" +} + +function vehicleStatusVariant(status: string) { + if (status === "Available") return "default" + if (status === "Financed" || status === "Reserved") return "secondary" + if (status === "Maintenance") return "destructive" + return "outline" +} + export default async function AdminReportsPage({ searchParams }: ReportsPageProps) { await requireAdminAccess() await dbConnect() @@ -73,6 +103,11 @@ export default async function AdminReportsPage({ searchParams }: ReportsPageProp const range: ReportRange = ["7d", "30d", "90d", "all", "custom"].includes(rawRange) ? (rawRange as ReportRange) : "30d" const from = getParam(resolved.from) const to = getParam(resolved.to) + const rawTab = getParam(resolved.tab, "overview") + const tab: ReportTab = ["overview", "kyc", "fleet", "users"].includes(rawTab) ? (rawTab as ReportTab) : "overview" + const kycStatusFilter = getParam(resolved.status) + const vehicleStatusFilter = getParam(resolved.vstatus) + const roleFilter = getParam(resolved.role) const { startDate, endDate } = buildWindow(range, from, to) const txDateMatch = buildDateMatch("timestamp", startDate, endDate) @@ -80,6 +115,15 @@ export default async function AdminReportsPage({ searchParams }: ReportsPageProp const legacyInvestDateMatch = buildDateMatch("date", startDate, endDate) const paymentDateMatch = buildDateMatch("createdAt", startDate, endDate) const poolsDateMatch = buildDateMatch("createdAt", startDate, endDate) + const userDateMatch = buildDateMatch("createdAt", startDate, endDate) + + // Tab-specific data + let kycStatusCounts: Array<{ _id: string; count: number }> = [] + let recentKyc: any[] = [] + let vehicleStatusCounts: Array<{ _id: string; count: number }> = [] + let recentVehicles: any[] = [] + let userRoleCounts: Array<{ _id: string; count: number }> = [] + let recentUsers: any[] = [] const [depositsAgg, poolInvestAgg, legacyInvestAgg, repaymentsAgg, creditsAgg, poolSummary] = await Promise.all([ Transaction.aggregate([ @@ -136,6 +180,33 @@ export default async function AdminReportsPage({ searchParams }: ReportsPageProp const fundedPools = Number(poolSummary.find((entry: any) => String(entry._id).toUpperCase() === "FUNDED")?.count || 0) const activePools = openPools + fundedPools + if (tab === "users") { + const uQuery: Record = { ...userDateMatch } + if (["driver", "investor", "admin"].includes(roleFilter)) uQuery.role = roleFilter + ;[userRoleCounts, recentUsers] = await Promise.all([ + User.aggregate([{ $group: { _id: "$role", count: { $sum: 1 } } }]), + User.find(uQuery).select("name fullName email role kycVerified createdAt").sort({ createdAt: -1 }).limit(15).lean(), + ]) + } + + if (tab === "fleet") { + const vQuery: Record = {} + if (["Available", "Financed", "Reserved", "Maintenance", "Retired"].includes(vehicleStatusFilter)) vQuery.status = vehicleStatusFilter + ;[vehicleStatusCounts, recentVehicles] = await Promise.all([ + Vehicle.aggregate([{ $group: { _id: "$status", count: { $sum: 1 } } }]), + Vehicle.find(vQuery).select("name identifier type year price status fundingStatus totalFundedAmount addedDate").sort({ addedDate: -1 }).limit(15).lean(), + ]) + } + + if (tab === "kyc") { + const kycQuery: Record = { kycStatus: { $nin: ["none", null] } } + if (["pending", "approved", "rejected"].includes(kycStatusFilter)) kycQuery.kycStatus = kycStatusFilter + ;[kycStatusCounts, recentKyc] = await Promise.all([ + User.aggregate([{ $match: { kycStatus: { $nin: ["none", null] } } }, { $group: { _id: "$kycStatus", count: { $sum: 1 } } }]), + User.find(kycQuery).select("name fullName email role kycStatus kycVerified createdAt").sort({ createdAt: -1 }).limit(15).lean(), + ]) + } + const exportQuery = new URLSearchParams() exportQuery.set("range", range) if (range === "custom") { @@ -149,52 +220,50 @@ export default async function AdminReportsPage({ searchParams }: ReportsPageProp title="Reports" subtitle="Platform reporting hub and exports." actions={ -
- - - - -
+
+
+ + + + + +
+
} />
+
+ {(["overview", "kyc", "fleet", "users"] as const).map((t) => ( + + {t === "overview" ? "Overview" : t === "kyc" ? "KYC" : t === "fleet" ? "Fleet" : "Users"} + + ))} +
+