From b109de59817bc9aa251d370d3a81e06bbb1e0196 Mon Sep 17 00:00:00 2001 From: ekwe7 Date: Sun, 21 Jun 2026 10:46:54 +0100 Subject: [PATCH] feat(investor): implement advanced investor analytics dashboard - Add front-end layout with performance cards, charts, and transaction tables - Implement clean, responsive UI layouts optimized for mobile and desktop screens - Create secure backend aggregation API routes to fetch scoped investor metrics - Integrate MongoDB model calculations for total returns, active pools, and payouts - Add granular state handling for loading, API errors, and new investor empty states - Enforce strict authorization to ensure data is scoped only to authenticated users - Ensure clean verification via npm run lint and successful production build runs --- app/api/investor/dashboard/route.ts | 280 ++++++++ app/dashboard/investor/page.tsx | 997 ++++++++++++++++++++++------ 2 files changed, 1059 insertions(+), 218 deletions(-) create mode 100644 app/api/investor/dashboard/route.ts diff --git a/app/api/investor/dashboard/route.ts b/app/api/investor/dashboard/route.ts new file mode 100644 index 0000000..d677a36 --- /dev/null +++ b/app/api/investor/dashboard/route.ts @@ -0,0 +1,280 @@ +import { NextResponse } from "next/server" +import mongoose from "mongoose" +import { z } from "zod" + +import { finalizeAuthenticatedResponse, requireAuthenticatedUser } from "@/lib/api/route-guard" +import { parseSearchParams } from "@/lib/api/validation" +import dbConnect from "@/lib/dbConnect" +import User from "@/models/User" +import InvestmentPool from "@/models/InvestmentPool" +import PoolInvestment from "@/models/PoolInvestment" +import HirePurchaseContract from "@/models/HirePurchaseContract" +import Transaction from "@/models/Transaction" +import Investment from "@/models/Investment" +import Vehicle from "@/models/Vehicle" + +const querySchema = z.object({ + investorId: z.string().trim().regex(/^[a-f\d]{24}$/i, "Invalid investorId.").optional(), +}) + +export async function GET(request: Request) { + try { + const authContext = await requireAuthenticatedUser(request, ["admin", "investor"], { + forbiddenMessage: "Investor or admin access required", + }) + if ("response" in authContext) return authContext.response + + const query = parseSearchParams(request, querySchema) + if ("response" in query) return query.response + + await dbConnect() + + const investorId = + authContext.user.role === "admin" && query.data.investorId + ? query.data.investorId + : authContext.user._id.toString() + + const userObjectId = new mongoose.Types.ObjectId(investorId) + + // 1. Fetch User details for available balance and baseline info + const user = await User.findById(userObjectId).lean() + if (!user) { + return NextResponse.json({ error: "Investor not found" }, { status: 404 }) + } + + const availableBalance = user.availableBalance || 0 + + // 2. Fetch all CONFIRMED Pool Investments for this investor + const poolInvestments = await PoolInvestment.find({ + userId: userObjectId, + status: "CONFIRMED", + }).lean() + + // 3. Fetch all direct legacy investments for this investor + const legacyInvestments = await Investment.find({ + investorId: userObjectId, + status: { $in: ["Active", "Completed"] }, + }).lean() + + // Retrieve unique pool IDs and vehicle IDs + const poolIds = poolInvestments.map((pi) => pi.poolId) + const directVehicleIds = legacyInvestments.map((li) => li.vehicleId) + + // Fetch referenced Investment Pools and Vehicles + const pools = await InvestmentPool.find({ _id: { $in: poolIds } }).lean() + const legacyVehicles = await Vehicle.find({ _id: { $in: directVehicleIds } }).lean() + + // Fetch all HirePurchaseContracts for these pools + const contracts = await HirePurchaseContract.find({ + poolId: { $in: poolIds }, + }).lean() + + // Fetch return transactions to compute actual return per pool/investment + const returnTransactions = await Transaction.find({ + userId: userObjectId, + type: "return", + status: "Completed", + }).lean() + + // Helper map of returns by relatedId + const returnsByRelatedId = new Map() + returnTransactions.forEach((tx) => { + const relId = tx.relatedId?.toString() + if (relId) { + returnsByRelatedId.set(relId, (returnsByRelatedId.get(relId) || 0) + tx.amount) + } + }) + + const poolMap = new Map(pools.map((p) => [p._id.toString(), p])) + const vehicleMap = new Map(legacyVehicles.map((v) => [v._id.toString(), v])) + + let totalInvested = 0 + let totalReturnsEarned = 0 + let totalExpectedLifetime = 0 + let totalExpectedToDate = 0 + + const now = new Date() + + // Group contracts by poolId + const contractsByPool = new Map() + contracts.forEach((c) => { + const pid = c.poolId.toString() + if (!contractsByPool.has(pid)) { + contractsByPool.set(pid, []) + } + contractsByPool.get(pid)!.push(c) + }) + + // Build Active Pool Positions + const activePositions = poolInvestments.map((pi) => { + const poolIdStr = pi.poolId.toString() + const pool = poolMap.get(poolIdStr) + const poolContracts = contractsByPool.get(poolIdStr) || [] + const actualReturns = returnsByRelatedId.get(poolIdStr) || 0 + + totalInvested += pi.amountNgn + totalReturnsEarned += actualReturns + + let expectedReturnsLifetime = 0 + let expectedReturnsToDate = 0 + let poolTotalPayable = 0 + let poolTotalPaid = 0 + + const formattedContracts = poolContracts.map((c) => { + const principal = c.principalNgn || 0 + const totalPayable = c.totalPayableNgn || 0 + const totalPaid = c.totalPaidNgn || 0 + const markup = Math.max(0, totalPayable - principal) + + // Calculate weeks elapsed + const startDate = new Date(c.startDate) + const msElapsed = now.getTime() - startDate.getTime() + const weeksElapsed = msElapsed / (1000 * 60 * 60 * 24 * 7) + const durationWeeks = c.durationWeeks || 1 + const fractionElapsed = Math.min(1, Math.max(0, weeksElapsed / durationWeeks)) + + const contractExpectedLifetime = markup * (pi.ownershipBps / 10000) + const contractExpectedToDate = contractExpectedLifetime * fractionElapsed + + expectedReturnsLifetime += contractExpectedLifetime + expectedReturnsToDate += contractExpectedToDate + poolTotalPayable += totalPayable + poolTotalPaid += totalPaid + + return { + id: c._id.toString(), + vehicleDisplayName: c.vehicleDisplayName, + principal, + totalPayable, + totalPaid, + status: c.status, + startDate: c.startDate.toISOString(), + progressPercent: totalPayable > 0 ? (totalPaid / totalPayable) * 100 : 0, + } + }) + + // If no contract exists yet, use a default 24% annual ROI assumption + if (poolContracts.length === 0) { + const creationDate = new Date(pi.createdAt || (pool ? pool.createdAt : now)) + const msElapsed = now.getTime() - creationDate.getTime() + const daysElapsed = msElapsed / (1000 * 60 * 60 * 24) + + // Assume standard 2-year tenure (104 weeks) for estimation + expectedReturnsLifetime = pi.amountNgn * 0.24 * 2 + expectedReturnsToDate = pi.amountNgn * 0.24 * (daysElapsed / 365) + } + + totalExpectedLifetime += expectedReturnsLifetime + totalExpectedToDate += expectedReturnsToDate + + const repaymentProgressPercent = + poolTotalPayable > 0 ? (poolTotalPaid / poolTotalPayable) * 100 : 0 + + return { + id: pi._id.toString(), + poolId: poolIdStr, + assetType: pool?.assetType || "KEKE", + status: pool?.status || "OPEN", + targetAmountNgn: pool?.targetAmountNgn || 0, + currentRaisedNgn: pool?.currentRaisedNgn || 0, + userInvestedNgn: pi.amountNgn, + userOwnershipBps: pi.ownershipBps, + expectedReturnsLifetime, + expectedReturnsToDate, + actualReturnsToDate: actualReturns, + repaymentProgressPercent, + contractsCount: poolContracts.length, + contracts: formattedContracts, + createdAt: pi.createdAt.toISOString(), + } + }) + + // Build Legacy Direct Positions + const legacyPositions = legacyInvestments.map((li) => { + const vehicleIdStr = li.vehicleId.toString() + const vehicle = vehicleMap.get(vehicleIdStr) + const actualReturns = returnsByRelatedId.get(li._id.toString()) || returnsByRelatedId.get(vehicleIdStr) || 0 + + totalInvested += li.amount + totalReturnsEarned += actualReturns + + const startDate = new Date(li.date) + const msElapsed = now.getTime() - startDate.getTime() + const monthsElapsed = Math.floor(msElapsed / (1000 * 60 * 60 * 24 * 30.44)) + + // Direct investment has explicit monthlyReturn. Assume a typical 24-month lifespan. + const monthlyReturn = li.monthlyReturn || (li.amount * 0.24 / 12) + const expectedReturnsLifetime = monthlyReturn * 24 + const expectedReturnsToDate = monthlyReturn * Math.min(24, Math.max(0, monthsElapsed)) + + totalExpectedLifetime += expectedReturnsLifetime + totalExpectedToDate += expectedReturnsToDate + + return { + id: li._id.toString(), + vehicleId: vehicleIdStr, + vehicleName: vehicle?.name || "Direct Vehicle Investment", + assetType: vehicle?.type || "SHUTTLE", + status: li.status, + amount: li.amount, + monthlyReturn, + expectedReturnsLifetime, + expectedReturnsToDate, + actualReturnsToDate: actualReturns, + repaymentProgressPercent: li.status === "Completed" ? 100 : Math.min(100, (monthsElapsed / 24) * 100), + startDate: startDate.toISOString(), + } + }) + + // 4. Fetch recent transactions for this investor (limit 20) + const transactions = await Transaction.find({ + userId: userObjectId, + }) + .sort({ timestamp: -1 }) + .limit(20) + .lean() + + const formattedTransactions = transactions.map((t) => ({ + id: t._id.toString(), + type: t.type, + amount: t.amount, + currency: t.currency || "NGN", + method: t.method || "system", + status: t.status || "Completed", + description: t.description || "", + timestamp: t.timestamp.toISOString(), + })) + + // 5. Portfolio stats + const totalPortfolioValue = availableBalance + totalInvested + totalReturnsEarned + const averageRoi = totalInvested > 0 ? (totalReturnsEarned / totalInvested) * 100 : 0 + + const isEmptyState = + poolInvestments.length === 0 && + legacyInvestments.length === 0 && + availableBalance === 0 && + transactions.length === 0 + + const response = NextResponse.json({ + success: true, + totals: { + totalInvested, + availableBalance, + totalReturns: totalReturnsEarned, + totalExpectedToDate, + totalExpectedLifetime, + totalPortfolioValue, + averageRoi, + }, + activePositions, + legacyPositions, + transactions: formattedTransactions, + emptyState: isEmptyState, + }) + + return finalizeAuthenticatedResponse(response, authContext) + } catch (error) { + console.error("INVESTOR_DASHBOARD_API_ERROR", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/app/dashboard/investor/page.tsx b/app/dashboard/investor/page.tsx index ad6ed55..06413ff 100644 --- a/app/dashboard/investor/page.tsx +++ b/app/dashboard/investor/page.tsx @@ -2,30 +2,132 @@ import { useCallback, useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" -import { CalendarDays, ChevronDown, PlusCircle } from "lucide-react" -import { formatEther } from "viem" +import { + CalendarDays, + ChevronDown, + PlusCircle, + TrendingUp, + Wallet, + Percent, + CheckCircle2, + Clock, + Bus, + Activity, + AlertCircle, + RefreshCw, + Search, + ArrowUpRight, + ArrowDownLeft, + ChevronRight, + Info, + DollarSign +} from "lucide-react" import { DashboardShell } from "@/components/dashboard/dashboard-shell" import { DashboardHeader } from "@/components/dashboard/investor-overview/dashboard-header" import { DashboardBanner } from "@/components/dashboard/investor-overview/dashboard-banner" -import { MetricsRow } from "@/components/dashboard/investor-overview/metrics-row" -import { PortfolioActivityCard } from "@/components/dashboard/investor-overview/portfolio-activity-card" -import { InvestorStellarActivityPanel } from "@/components/dashboard/investor-overview/stellar-activity-panel" -import { WalletsCard } from "@/components/dashboard/investor-overview/wallets-card" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { Skeleton } from "@/components/ui/skeleton" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { DashboardRouteLoading } from "@/components/dashboard/dashboard-route-loading" import { getUserDisplayName, useAuth } from "@/hooks/use-auth" import { useToast } from "@/hooks/use-toast" import { getPrivyFundingErrorMessage, startPrivyFunding } from "@/lib/auth/privy-funding" import { useFundWallet, useWallets } from "@/lib/privy/react-auth" -import { formatNaira } from "@/lib/currency" +import { formatNaira, formatPercent } from "@/lib/currency" import { isMockStellar } from "@/lib/mock-stellar/mockConfig" import { mockAccount } from "@/lib/mock-stellar/mockAccount" -import { mockActivity } from "@/lib/mock-stellar/mockActivity" import { CURRENT_EMBEDDED_WALLET } from "@/lib/wallet/config" +import { InvestorStellarActivityPanel } from "@/components/dashboard/investor-overview/stellar-activity-panel" + +// Recharts imports for comparison chart +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer +} from "recharts" +import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart" + +type PoolContractPreview = { + id: string + vehicleDisplayName: string + principal: number + totalPayable: number + totalPaid: number + status: string + startDate: string + progressPercent: number +} + +type ActivePosition = { + id: string + poolId: string + assetType: "SHUTTLE" | "KEKE" + status: "OPEN" | "FUNDED" | "CLOSED" + targetAmountNgn: number + currentRaisedNgn: number + userInvestedNgn: number + userOwnershipBps: number + expectedReturnsLifetime: number + expectedReturnsToDate: number + actualReturnsToDate: number + repaymentProgressPercent: number + contractsCount: number + contracts: PoolContractPreview[] + createdAt: string +} + +type LegacyPosition = { + id: string + vehicleId: string + vehicleName: string + assetType: string + status: string + amount: number + monthlyReturn: number + expectedReturnsLifetime: number + expectedReturnsToDate: number + actualReturnsToDate: number + repaymentProgressPercent: number + startDate: string +} + +type DashboardTransaction = { + id: string + type: string + amount: number + currency: string + method: string + status: string + description: string + timestamp: string +} -type PoolPreview = { +type DashboardData = { + totals: { + totalInvested: number + availableBalance: number + totalReturns: number + totalExpectedToDate: number + totalExpectedLifetime: number + totalPortfolioValue: number + averageRoi: number + } + activePositions: ActivePosition[] + legacyPositions: LegacyPosition[] + transactions: DashboardTransaction[] + emptyState: boolean +} + +type OpenPoolPreview = { id: string assetType: "SHUTTLE" | "KEKE" targetAmountNgn: number @@ -47,67 +149,30 @@ function truncateAddress(address: string) { return `${address.slice(0, 4)}...${address.slice(-4)}` } -function formatEthForUi(balanceEth: number | null) { - const asset = CURRENT_EMBEDDED_WALLET.network.nativeAsset - if (!Number.isFinite(balanceEth) || balanceEth === null) return `0 ${asset}` - if (balanceEth < 0.01) return `0 ${asset}` - if (balanceEth < 1) return `${balanceEth.toFixed(2)} ${asset}` - return `${balanceEth.toFixed(1)} ${asset}` -} - -async function resolveOnchainBalanceEth(address: string) { - const rpcUrl = CURRENT_EMBEDDED_WALLET.network.rpcUrl - if (!rpcUrl) return null - const response = await fetch(rpcUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "eth_getBalance", - params: [address, "latest"], - }), - }) - - const payload = await response.json() - const balanceHex = payload?.result - if (typeof balanceHex !== "string") return null - - const parsed = Number.parseFloat(formatEther(BigInt(balanceHex))) - if (!Number.isFinite(parsed)) return null - return parsed -} - function isKycComplete(user: KycAwareAuthUser | null | undefined) { if (!user) return true - if (typeof user.isKycVerified === "boolean") return user.isKycVerified if (typeof user.kycVerified === "boolean") return user.kycVerified - const rawStatus = typeof user.kycStatus === "string" ? user.kycStatus.toLowerCase() : null if (!rawStatus) return true - return ["approved", "approved_stage2", "verified", "complete", "completed"].includes(rawStatus) } -function formatStartedDate(dateString: string) { - const parsedDate = new Date(dateString) - if (Number.isNaN(parsedDate.getTime())) return "Recently" - return parsedDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) -} - -export default function InvestorOverviewPage() { +export default function InvestorDashboardPage() { const router = useRouter() const { toast } = useToast() - const { user: authUser, loading: authLoading, refetch } = useAuth() + const { user: authUser, loading: authLoading, refetch: refetchAuth } = useAuth() const { wallets } = useWallets() const { fundWallet } = useFundWallet() - const [openPools, setOpenPools] = useState([]) - const [isPoolsLoading, setIsPoolsLoading] = useState(true) + const [dashboardData, setDashboardData] = useState(null) + const [openPools, setOpenPools] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) const [isRefreshing, setIsRefreshing] = useState(false) const [onchainBalanceEth, setOnchainBalanceEth] = useState(null) const [isDepositingCrypto, setIsDepositingCrypto] = useState(false) + const [txFilter, setTxFilter] = useState("all") const embeddedWallet = useMemo( () => wallets.find((wallet) => wallet.walletClientType === "privy" || wallet.walletClientType === "privy-v2"), @@ -116,29 +181,42 @@ export default function InvestorOverviewPage() { const walletAddress = isMockStellar ? mockAccount.publicKey : (embeddedWallet?.address || authUser?.walletAddress || "") const isWalletConnected = Boolean(walletAddress) const investorKycComplete = isKycComplete((authUser as KycAwareAuthUser | null | undefined) ?? null) - const investorName = getUserDisplayName(authUser, "Investor") - const loadOpenPools = useCallback(async () => { - setIsPoolsLoading(true) - try { - const response = await fetch("/api/pools?status=OPEN") - const payload = await response.json() - if (!response.ok) { - throw new Error(payload.message || "Unable to load opportunities.") - } - setOpenPools((payload.pools || []).slice(0, 4)) - } catch (error) { - toast({ - title: "Unable to load opportunities", - description: error instanceof Error ? error.message : "Try again in a moment.", - variant: "destructive", - }) - } finally { - setIsPoolsLoading(false) + const fetchDashboardData = useCallback(async (silent = false) => { + if (!silent) setIsLoading(true) + setError(null) + try { + const response = await fetch("/api/investor/dashboard") + const payload = await response.json() + if (!response.ok) { + throw new Error(payload.error || "Failed to load dashboard data.") + } + setDashboardData(payload) + } catch (err) { + setError(err instanceof Error ? err.message : "An unexpected error occurred.") + toast({ + title: "Unable to load dashboard", + description: err instanceof Error ? err.message : "Try again in a moment.", + variant: "destructive", + }) + } finally { + setIsLoading(false) } }, [toast]) + const fetchOpenPools = useCallback(async () => { + try { + const response = await fetch("/api/pools?status=OPEN") + const payload = await response.json() + if (response.ok && payload.pools) { + setOpenPools(payload.pools.slice(0, 3)) + } + } catch (err) { + console.error("Failed to load open pools:", err) + } + }, []) + const refreshOnchainBalance = useCallback(async () => { if (isMockStellar) { setOnchainBalanceEth(Number.parseFloat(mockAccount.balance)) @@ -151,96 +229,54 @@ export default function InvestorOverviewPage() { } try { - const balance = await resolveOnchainBalanceEth(walletAddress) - setOnchainBalanceEth(balance) + const rpcUrl = CURRENT_EMBEDDED_WALLET.network.rpcUrl + if (!rpcUrl) return + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "eth_getBalance", + params: [walletAddress, "latest"], + }), + }) + const payload = await response.json() + const balanceHex = payload?.result + if (typeof balanceHex === "string") { + const parsed = Number.parseFloat(window.BigInt ? String(Number(balanceHex) / 1e18) : "0") + setOnchainBalanceEth(parsed) + } } catch { setOnchainBalanceEth(null) } }, [walletAddress]) - const refreshOverview = async () => { + const handleRefresh = async () => { setIsRefreshing(true) - await Promise.all([loadOpenPools(), refetch?.(), refreshOnchainBalance()]) + await Promise.all([ + fetchDashboardData(true), + fetchOpenPools(), + refetchAuth?.(), + refreshOnchainBalance() + ]) setIsRefreshing(false) + toast({ + title: "Data refreshed", + description: "Your investment portfolio has been updated.", + }) } useEffect(() => { - void loadOpenPools() - }, [loadOpenPools]) + void fetchDashboardData() + void fetchOpenPools() + }, [fetchDashboardData, fetchOpenPools]) useEffect(() => { void refreshOnchainBalance() }, [refreshOnchainBalance]) - const ethLabel = isMockStellar ? `${mockAccount.balance} XLM` : formatEthForUi(onchainBalanceEth) - - const metrics = useMemo(() => { - const availableBalance = authUser?.availableBalance || 0 - const totalInvested = authUser?.totalInvested || 0 - const totalReturns = authUser?.totalReturns || 0 - const totalPortfolioValue = availableBalance + totalInvested + totalReturns - const annualRoi = totalInvested > 0 ? (totalReturns / totalInvested) * 100 : 0 - - return [ - { - id: "portfolio-value", - label: "Total Portfolio Value", - value: formatNaira(totalPortfolioValue), - accentValue: `+ ${ethLabel}`, - hint: "Combined value of active vehicle investments.", - }, - { - id: "capital-invested", - label: "Total Capital Invested", - value: formatNaira(totalInvested), - hint: "Total amount deployed into vehicle assets.", - }, - { - id: "returns-earned", - label: "Total Returns Earned", - value: formatNaira(totalReturns), - accentValue: `+ ${ethLabel}`, - hint: "Net income distributed to your wallets.", - }, - { - id: "annual-roi", - label: "Annual ROI", - value: `${formatNaira(totalReturns)} / ${annualRoi.toFixed(1)}%`, - hint: "Calculated after operational and platform expenses.", - }, - ] - }, [authUser?.availableBalance, authUser?.totalInvested, authUser?.totalReturns, ethLabel]) - - const activityItems = useMemo(() => { - if (isMockStellar) { - return mockActivity.map((activity) => ({ - id: activity.id, - title: activity.type, - startedLabel: `Date: ${formatStartedDate(activity.timestamp)}`, - amountLabel: formatNaira(Number.parseFloat(activity.amount)), - monthlyReturnsLabel: `Status: ${activity.status}`, - progressLabel: "Completed", - progressPercent: 100, - })) - } - - return openPools.slice(0, 2).map((pool) => { - const principalAmount = pool.currentRaisedNgn > 0 ? pool.currentRaisedNgn : pool.targetAmountNgn - const monthlyReturns = Math.round(principalAmount * 0.1) - const progressMonths = Math.max(1, Math.round(Math.min(pool.progressRatio, 1) * 24)) - - return { - id: pool.id, - title: pool.assetType === "KEKE" ? "Keke Napep" : "Shuttle Bus", - startedLabel: `Started ${formatStartedDate(pool.createdAt)}`, - amountLabel: formatNaira(principalAmount), - monthlyReturnsLabel: `Estimated Monthly Returns: ${formatNaira(monthlyReturns)}`, - progressLabel: `${progressMonths}/24`, - progressPercent: Math.min(Math.max(pool.progressRatio * 100, 6), 100), - } - }) - }, [openPools]) - + const ethLabel = isMockStellar ? `${mockAccount.balance} XLM` : onchainBalanceEth !== null ? `${onchainBalanceEth.toFixed(2)} ETH` : "0 ETH" const walletChipLabel = isWalletConnected ? `${truncateAddress(walletAddress)} (${ethLabel})` : null const bannerVariant = !isWalletConnected ? "connect-wallet" : !investorKycComplete ? "kyc" : null @@ -281,26 +317,63 @@ export default function InvestorOverviewPage() { } } - if (authLoading) { - return - } - - if (!authUser || authUser.role !== "investor") { - return ( -
- - - Access denied - You need an investor account to access this dashboard. - - - - - -
- ) + // Chart data preparing expected vs actual returns + const returnsChartData = useMemo(() => { + if (!dashboardData) return [] + const data: { name: string; expected: number; actual: number }[] = [] + + dashboardData.activePositions.forEach((pos) => { + data.push({ + name: `${pos.assetType === "SHUTTLE" ? "Shuttle" : "Keke"} Pool`, + expected: Math.round(pos.expectedReturnsToDate), + actual: Math.round(pos.actualReturnsToDate) + }) + }) + + dashboardData.legacyPositions.forEach((pos) => { + data.push({ + name: pos.vehicleName, + expected: Math.round(pos.expectedReturnsToDate), + actual: Math.round(pos.actualReturnsToDate) + }) + }) + + return data + }, [dashboardData]) + + // Filtered transactions list + const filteredTransactions = useMemo(() => { + if (!dashboardData) return [] + return dashboardData.transactions.filter((tx) => { + if (txFilter === "all") return true + if (txFilter === "investment") return tx.type.includes("investment") + if (txFilter === "return") return tx.type === "return" + if (txFilter === "deposit") return tx.type === "deposit" || tx.type === "wallet_funding" + if (txFilter === "withdrawal") return tx.type === "withdrawal" + return true + }) + }, [dashboardData, txFilter]) + + if (authLoading || (isLoading && !dashboardData)) { + return + } + + if (!authUser || authUser.role !== "investor") { + return ( +
+ + + Access denied + You need an investor account to access this dashboard. + + + + + +
+ ) } return ( @@ -315,7 +388,7 @@ export default function InvestorOverviewPage() { /> } > -
+
{bannerVariant ? ( ) : null} -
-
-
-

Portfolio Overview

-

- Monitor your mobility investments across fiat and crypto funding. -

-
+ {/* Dashboard Title & Actions */} +
+
+

Investor Dashboard

+

+ Real-time portfolio metrics, vehicle pool performance, and earnings tracking. +

+
-
- - -
+
+ +
- - -
- router.push("/dashboard/investor/investments")} - /> + {error ? ( + +
+ +
+

Dashboard error

+

{error}

+
+
+ +
+ ) : dashboardData?.emptyState ? ( + /* EMPTY STATE FOR NEW INVESTORS */ +
+
+ + + Total Portfolio Value + {formatNaira(0)} + + + + + Capital Invested + {formatNaira(0)} + + + + + Wallet Balance + {formatNaira(authUser.availableBalance || 0)} + + + + + Total Returns Earned + {formatNaira(0)} + + +
- router.push("/dashboard/investor/wallet")} - onDepositCrypto={handleDepositCrypto} - onWithdrawToBank={() => - toast({ - title: "Withdrawals are managed in Wallet", - description: "Open Wallet to continue bank withdrawal flow.", - }) - } - /> -
+ + + + Welcome to ChainMove Investments + + You haven't invested in any vehicle pools yet. Start earning up to 24% annual returns on asset-backed logistics vehicles. + + + + + + + + + {openPools.length > 0 && ( +
+

Featured Opportunities

+
+ {openPools.map((pool) => { + const progress = pool.progressRatio * 100 + return ( + + +
+ + {pool.assetType} POOL + + OPEN +
+ + {pool.assetType === "KEKE" ? "Keke Napep Fleet Pool" : "Shuttle Bus Transit Pool"} + + + Targeting {formatNaira(pool.targetAmountNgn)} + +
+ +
+
+ Raised: {formatNaira(pool.currentRaisedNgn)} + {progress.toFixed(0)}% +
+ +
+ +
+
+ ) + })} +
+
+ )} +
+ ) : ( + /* POPULATED DASHBOARD STATE */ +
+ {/* Core Metrics Row */} +
+ + + + + Total Portfolio Value + + + {formatNaira(dashboardData?.totals.totalPortfolioValue)} + + + + Combined wallet balance, active capital, and earned returns. + + + + + + + + Capital Invested + + + {formatNaira(dashboardData?.totals.totalInvested)} + + + + Total principal funds active across vehicle pools. + + + + + + + + Available Balance + + + {formatNaira(dashboardData?.totals.availableBalance)} + + + + Fiat wallet funds available for withdrawal or new investments. + + + + + + + + Total Returns Earned + + + {formatNaira(dashboardData?.totals.totalReturns)} + + + + Payouts distributed from driver contracts. Avg ROI: {formatPercent(dashboardData?.totals.averageRoi)} + + +
- + {/* Performance charts section */} +
+ {/* Expected vs Actual Returns comparison */} + + + + + Expected vs Actual Returns + + + Compare expected payout returns to-date with actual returns paid to your wallet. + + + + {returnsChartData.length > 0 ? ( +
+ + + + + + `₦${val.toLocaleString()}`} /> + } /> + + + + + +
+ ) : ( +
+ Waiting for active vehicle payouts to populate returns tracking. +
+ )} +
+
+ + {/* Connected Wallets summary */} +
+ + + + Wallets Overview + + + Manage your balances and withdrawal routes + + +
+

Internal Fiat Wallet

+

+ {formatNaira(dashboardData?.totals.availableBalance)} +

+

+ + Available for buying new pool shares. +

+
+ + +
+
+ +
+

Crypto Stellar Wallet

+

{ethLabel}

+

+ Addr: {walletAddress ? truncateAddress(walletAddress) : "Not connected"} +

+
+ +
+
+
+
+
+
+ + {/* Active Pool positions (Pool-by-pool cards) */} +
+
+
+

Active Pool Positions

+

+ Your ownership progress, vehicle metrics, and contract payment details. +

+
+ + {dashboardData?.activePositions.length || 0} Positions Active + +
+ +
+ {dashboardData?.activePositions.map((pos) => { + return ( + + +
+ + {pos.assetType} POOL + + + {pos.status} + +
+ + {pos.assetType === "KEKE" ? "Keke Napep Transit Pool" : "Shuttle Bus Transit Pool"} + ID: {pos.poolId.slice(-6)} + +
+ + + {/* Position Financials */} +
+
+

Invested

+

+ {formatNaira(pos.userInvestedNgn)} +

+
+
+

Share %

+

+ {(pos.userOwnershipBps / 100).toFixed(2)}% +

+
+
+

Returns Earned

+

+ {formatNaira(pos.actualReturnsToDate)} +

+
+
+ + {/* Return Progress Estimates */} +
+
+ + Expected lifetime returns: + + {formatNaira(pos.expectedReturnsLifetime)} +
+
+ Expected returns to-date: + {formatNaira(pos.expectedReturnsToDate)} +
+
+ + {/* Repayment Progress per Pool */} +
+
+ Repayment Progress (Pool-wide) + {pos.repaymentProgressPercent.toFixed(1)}% +
+ +
+ + {/* Individual vehicle contracts */} +
+

+ + Financed Vehicles ({pos.contractsCount}) +

+ {pos.contractsCount > 0 ? ( +
+ {pos.contracts.map((contract) => ( +
+
+ {contract.vehicleDisplayName} + + {contract.status} + +
+
+ Principal: {formatNaira(contract.principal)} + Paid: {formatNaira(contract.totalPaid)} +
+
+
+ Repayment progress + {contract.progressPercent.toFixed(0)}% +
+ +
+
+ ))} +
+ ) : ( +

+ No driver contracts active for this pool. Waiting for vehicle financing placement. +

+ )} +
+
+
+ ) + })} +
+
+ + {/* Recent transactions (filters and payouts) */} +
+
+
+

Transaction History

+

+ Filter and audit your investment transactions, returns, and wallet flows. +

+
+ +
+ +
+
+ + + + {filteredTransactions.length === 0 ? ( +
+ No transactions match the selected filter. +
+ ) : ( +
+ + + + + + + + + + + + {filteredTransactions.map((tx) => { + const isCredit = tx.type === "return" || tx.type === "deposit" || tx.type === "wallet_funding" + return ( + + + + + + + + ) + })} + +
TypeDescriptionDateAmountStatus
+ + {isCredit ? ( + + ) : ( + + )} + {tx.type.replace("_", " ")} + + + {tx.description} + + {new Date(tx.timestamp).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric" + })} + + {isCredit ? "+" : "-"}{formatNaira(tx.amount)} + + + {tx.status} + +
+
+ )} +
+
+
+ + {/* Stellar link dashboard element */} + +
+ )}
)