Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8879453
feat(export): extend ExportType union to include kyc, fleet, users
Jaydbrown Jun 20, 2026
ae147a0
feat(export): update type-guard validation to accept kyc, fleet, users
Jaydbrown Jun 20, 2026
01ab843
feat(export): add kyc handler placeholder
Jaydbrown Jun 20, 2026
0c4115d
feat(export): extract kycStatusFilter from searchParams
Jaydbrown Jun 20, 2026
7bd73b4
feat(export): build kycQuery with status and date filters
Jaydbrown Jun 20, 2026
2f5683f
feat(export): add kycUsers DB query
Jaydbrown Jun 20, 2026
9efae40
feat(export): generate KYC CSV and return response
Jaydbrown Jun 20, 2026
997302d
feat(export): add fleet handler placeholder
Jaydbrown Jun 20, 2026
5c0582a
feat(export): implement fleet handler — query, CSV, and response
Jaydbrown Jun 20, 2026
38795e8
feat(export): add users handler placeholder
Jaydbrown Jun 20, 2026
8afcffa
feat(export): add users query with role and date filters
Jaydbrown Jun 20, 2026
f07bf4d
feat(export): generate users CSV and return response
Jaydbrown Jun 20, 2026
b4b3e44
style(export): normalise trailing newline
Jaydbrown Jun 20, 2026
8eb49a6
feat(export): final export route with all 6 report types complete
Jaydbrown Jun 20, 2026
ab59d3a
feat(reports): add Printer icon import and ReportTab type
Jaydbrown Jun 20, 2026
2f88845
feat(reports): add hrefForTab URL helper
Jaydbrown Jun 20, 2026
e00d560
feat(reports): extract tab URL param in page component
Jaydbrown Jun 20, 2026
f64d002
feat(reports): extract per-tab filter params (status, vstatus, role)
Jaydbrown Jun 20, 2026
abdd61f
feat(reports): add userDateMatch for user-scoped date range queries
Jaydbrown Jun 20, 2026
3ecc0e0
feat(reports): add User and Vehicle model imports
Jaydbrown Jun 20, 2026
2c7286b
feat(reports): declare tab-specific data variables with empty defaults
Jaydbrown Jun 20, 2026
c95a33d
feat(reports): fetch KYC status counts and recent KYC records when ta…
Jaydbrown Jun 20, 2026
36ede46
feat(reports): fetch fleet status counts and vehicles when tab=fleet
Jaydbrown Jun 20, 2026
8f7737f
feat(reports): fetch user role counts and recent users when tab=users
Jaydbrown Jun 20, 2026
6c2f20d
feat(reports): add Badge and Table component imports
Jaydbrown Jun 20, 2026
ca5cb3c
feat(reports): add kycBadgeVariant and vehicleStatusVariant helpers
Jaydbrown Jun 20, 2026
70b157b
feat(reports): update PageHeader actions with tab-aware form and Prin…
Jaydbrown Jun 20, 2026
39edcb2
feat(reports): add tab navigation links to page
Jaydbrown Jun 20, 2026
a46af75
feat(reports): wire tab param through range quick-links
Jaydbrown Jun 20, 2026
bedb808
chore(reports): update loading page description to reflect expanded r…
Jaydbrown Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 151 additions & 37 deletions app/api/admin/reports/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"] },
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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<string, unknown> = {
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<string, unknown> = {}
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<string, unknown> = {
...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
Expand All @@ -281,4 +396,3 @@ export async function GET(request: Request) {
return NextResponse.json({ message: "Failed to export report." }, { status: 500 })
}
}

4 changes: 2 additions & 2 deletions app/dashboard/admin/reports/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DashboardRouteLoading } from "@/components/dashboard/dashboard-route-loading"

export default function Loading() {
return <DashboardRouteLoading title="Loading admin dashboard" description="Preparing metrics, records, and governance data." />
}
return <DashboardRouteLoading title="Loading admin dashboard" description="Loading reports, metrics, filters, and export options." />
}
Loading
Loading