diff --git a/app/api/kyc-documents/route.ts b/app/api/kyc-documents/route.ts index d898a70..cd1b015 100644 --- a/app/api/kyc-documents/route.ts +++ b/app/api/kyc-documents/route.ts @@ -82,7 +82,7 @@ export async function GET(request: Request) { body = Buffer.from(await upstreamResponse.arrayBuffer()) } - const response = new NextResponse(body, { + const response = new NextResponse(body as any, { status: 200, headers: { "Cache-Control": "private, no-store, max-age=0", diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts index 952ac31..e55e815 100644 --- a/app/api/users/[id]/route.ts +++ b/app/api/users/[id]/route.ts @@ -147,7 +147,7 @@ export async function PUT(request: Request, { params }: RouteContext) { return NextResponse.json({ message: "No user changes were provided." }, { status: 400 }) } - if (params.id === auth.user._id.toString() && hasRole && role !== "admin") { + if (params.id === auth.user!._id.toString() && hasRole && role !== "admin") { return NextResponse.json({ message: "You cannot remove your own admin access." }, { status: 403 }) } @@ -294,7 +294,7 @@ export async function DELETE(request: Request, { params }: RouteContext) { await dbConnect() - if (params.id === auth.user._id.toString()) { + if (params.id === auth.user!._id.toString()) { return NextResponse.json({ message: "You cannot delete your own account." }, { status: 403 }) } diff --git a/app/dashboard/admin/admincomponents/MetricsCard.tsx b/app/dashboard/admin/admincomponents/MetricsCard.tsx index 8dcf89a..d0be50d 100644 --- a/app/dashboard/admin/admincomponents/MetricsCard.tsx +++ b/app/dashboard/admin/admincomponents/MetricsCard.tsx @@ -3,13 +3,12 @@ import React from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { LucideIcon } from "lucide-react"; interface MetricsCardProps { title: string; value: string; description: string; - icon: LucideIcon; + icon: React.ComponentType<{ className?: string }>; gradient: string; } diff --git a/app/dashboard/admin/adminfunctions/useAdminDashboard.ts b/app/dashboard/admin/adminfunctions/useAdminDashboard.ts index 49e9a99..f162e37 100644 --- a/app/dashboard/admin/adminfunctions/useAdminDashboard.ts +++ b/app/dashboard/admin/adminfunctions/useAdminDashboard.ts @@ -120,7 +120,7 @@ export const useAdminDashboard = () => { isTermSet: false, // New field }; - await addVehicle(vehicleData as Omit); + await addVehicle(vehicleData as unknown as Omit); toast({ title: "Vehicle Added", diff --git a/app/dashboard/admin/fleet-operations/[id]/page.tsx b/app/dashboard/admin/fleet-operations/[id]/page.tsx new file mode 100644 index 0000000..8db5d4c --- /dev/null +++ b/app/dashboard/admin/fleet-operations/[id]/page.tsx @@ -0,0 +1,252 @@ +import Link from "next/link" +import { notFound } from "next/navigation" +import mongoose from "mongoose" +import { ArrowLeft } from "lucide-react" + +import { MetricCard } from "@/components/dashboard/admin/metric-card" +import { PageHeader } from "@/components/dashboard/admin/page-header" +import { RepaymentBar } from "@/components/dashboard/admin/repayment-bar" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { cn } from "@/lib/utils" +import { formatNaira, formatPercent } from "@/lib/currency" +import dbConnect from "@/lib/dbConnect" +import DriverPayment from "@/models/DriverPayment" +import HirePurchaseContract from "@/models/HirePurchaseContract" +import Vehicle from "@/models/Vehicle" +import { requireAdminAccess } from "@/src/server/admin/require-admin" +import { + contractStatusBadgeClass, + normalizeVehicleStatus, + pickOperationalContract, + repaymentPercent, + vehicleStatusBadgeClass, +} from "@/src/server/admin/fleet" + +export const dynamic = "force-dynamic" + +interface VehicleDetailPageProps { + params: Promise<{ id: string }> +} + +function formatDate(value?: Date | string | null) { + if (!value) return "—" + const date = new Date(value) + if (Number.isNaN(date.getTime())) return "—" + return date.toLocaleDateString("en-NG", { day: "2-digit", month: "short", year: "numeric" }) +} + +function driverLabel(driver: any) { + if (!driver) return "Unassigned" + return driver.fullName || driver.name || driver.email || "Unnamed driver" +} + +function paymentStatusBadgeClass(status?: string | null) { + const value = (status || "").toUpperCase() + if (value === "CONFIRMED") return "bg-green-600 text-white hover:bg-green-600" + if (value === "FAILED") return "bg-red-600 text-white hover:bg-red-600" + return "bg-amber-600 text-white hover:bg-amber-600" +} + +const SPEC_FIELDS: Array<{ key: string; label: string }> = [ + { key: "engine", label: "Engine" }, + { key: "fuelType", label: "Fuel Type" }, + { key: "transmission", label: "Transmission" }, + { key: "mileage", label: "Mileage" }, + { key: "color", label: "Color" }, + { key: "vin", label: "VIN" }, +] + +export default async function AdminVehicleDetailPage({ params }: VehicleDetailPageProps) { + await requireAdminAccess() + + const { id } = await params + if (!mongoose.Types.ObjectId.isValid(id)) { + notFound() + } + + await dbConnect() + + const vehicle = await Vehicle.findById(id).populate("driverId", "name fullName email").lean() + if (!vehicle) { + notFound() + } + + const contractList: any[] = await HirePurchaseContract.find({ vehicleDisplayName: vehicle.name }) + .sort({ createdAt: -1 }) + .lean() + const contract = pickOperationalContract(contractList) + + const payments = contract + ? await DriverPayment.find({ contractId: contract._id }) + .select("amountNgn appliedAmountNgn method paystackRef status confirmedAt createdAt") + .sort({ createdAt: -1 }) + .limit(10) + .lean() + : [] + + const statusLabel = normalizeVehicleStatus(vehicle.status) + const percent = contract ? repaymentPercent(contract.totalPaidNgn, contract.totalPayableNgn) : null + const specifications = (vehicle.specifications || {}) as Record + + return ( +
+ + + + Back to fleet + + + } + /> + +
+ + {statusLabel} + + } + /> + {driverLabel(vehicle.driverId)}} /> + + +
+ +
+ {/* Specifications */} + + + Specifications + + +
+ {SPEC_FIELDS.map((field) => ( +
+
{field.label}
+
{specifications[field.key] || "Not provided"}
+
+ ))} +
+
+
+ + {/* Contract summary */} + + + Contract Summary + {contract ? ( + + {contract.status} + + ) : null} + + + {contract ? ( +
+ {percent !== null ? : null} +
+
+
Principal
+
{formatNaira(Number(contract.principalNgn || 0))}
+
+
+
Deposit
+
{formatNaira(Number(contract.depositNgn || 0))}
+
+
+
Total Payable
+
{formatNaira(Number(contract.totalPayableNgn || 0))}
+
+
+
Total Paid
+
{formatNaira(Number(contract.totalPaidNgn || 0))}
+
+
+
Weekly Payment
+
{formatNaira(Number(contract.weeklyPaymentNgn || 0))}
+
+
+
Duration
+
{contract.durationWeeks || 0} weeks
+
+
+
Start Date
+
{formatDate(contract.startDate)}
+
+
+
Next Due
+
{formatDate(contract.nextDueDate)}
+
+
+
+ ) : ( +

+ No hire-purchase contract is linked to this vehicle yet. +

+ )} +
+
+
+ + {/* Payment history */} + + + Recent Repayments + + +
+ + + + + + + + + + + + + {payments.length === 0 ? ( + + + + ) : ( + payments.map((payment: any) => ( + + + + + + + + + )) + )} + +
DateAmountAppliedMethodReferenceStatus
+ No repayments recorded yet. +
{formatDate(payment.confirmedAt || payment.createdAt)}{formatNaira(Number(payment.amountNgn || 0))}{formatNaira(Number(payment.appliedAmountNgn || 0))}{payment.method || "—"} + {payment.paystackRef || "—"} + + + {payment.status} + +
+
+
+
+
+ ) +} diff --git a/app/dashboard/admin/fleet-operations/error.tsx b/app/dashboard/admin/fleet-operations/error.tsx new file mode 100644 index 0000000..2374e2a --- /dev/null +++ b/app/dashboard/admin/fleet-operations/error.tsx @@ -0,0 +1,35 @@ +"use client" + +import { useEffect } from "react" + +import { PageHeader } from "@/components/dashboard/admin/page-header" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" + +export default function FleetOperationsError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error("Fleet operations dashboard failed to load:", error) + }, [error]) + + return ( +
+ + + +

+ We couldn't load fleet data right now. Please try again. +

+ +
+
+
+ ) +} diff --git a/app/dashboard/admin/fleet-operations/loading.tsx b/app/dashboard/admin/fleet-operations/loading.tsx new file mode 100644 index 0000000..014918f --- /dev/null +++ b/app/dashboard/admin/fleet-operations/loading.tsx @@ -0,0 +1,40 @@ +import { PageHeader } from "@/components/dashboard/admin/page-header" +import { Card, CardContent, CardHeader } from "@/components/ui/card" + +export default function FleetOperationsLoading() { + return ( +
+ + +
+ {Array.from({ length: 8 }).map((_, index) => ( + + +
+ + +
+ + + ))} +
+ +
+
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+
+
+ ) +} diff --git a/app/dashboard/admin/fleet-operations/page.tsx b/app/dashboard/admin/fleet-operations/page.tsx new file mode 100644 index 0000000..456dd9e --- /dev/null +++ b/app/dashboard/admin/fleet-operations/page.tsx @@ -0,0 +1,415 @@ +import Link from "next/link" +import { Eye, Search } from "lucide-react" + +import { MetricCard } from "@/components/dashboard/admin/metric-card" +import { PageHeader } from "@/components/dashboard/admin/page-header" +import { RepaymentBar } from "@/components/dashboard/admin/repayment-bar" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { cn } from "@/lib/utils" +import { formatNaira, formatPercent } from "@/lib/currency" +import dbConnect from "@/lib/dbConnect" +import DriverPayment from "@/models/DriverPayment" +import HirePurchaseContract from "@/models/HirePurchaseContract" +import Vehicle from "@/models/Vehicle" +import { requireAdminAccess } from "@/src/server/admin/require-admin" +import { + contractStatusBadgeClass, + normalizeVehicleStatus, + pickOperationalContract, + repaymentPercent, + vehicleStatusBadgeClass, +} from "@/src/server/admin/fleet" + +export const dynamic = "force-dynamic" + +interface FleetPageProps { + searchParams?: Promise> +} + +const PAGE_SIZE = 20 + +function getParam(value: string | string[] | undefined, fallback = "") { + if (Array.isArray(value)) return value[0] ?? fallback + return value ?? fallback +} + +function toInt(value: string, fallback: number) { + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed) || parsed < 1) return fallback + return parsed +} + +interface FleetFilters { + q: string + status: string + type: string + contract: string +} + +function buildFleetHref(filters: FleetFilters, page: number) { + const params = new URLSearchParams() + if (filters.q) params.set("q", filters.q) + if (filters.status !== "all") params.set("status", filters.status) + if (filters.type !== "all") params.set("type", filters.type) + if (filters.contract !== "all") params.set("contract", filters.contract) + if (page > 1) params.set("page", String(page)) + const query = params.toString() + return query ? `/dashboard/admin/fleet-operations?${query}` : "/dashboard/admin/fleet-operations" +} + +function formatDate(value?: Date | string | null) { + if (!value) return "—" + const date = new Date(value) + if (Number.isNaN(date.getTime())) return "—" + return date.toLocaleDateString("en-NG", { day: "2-digit", month: "short", year: "numeric" }) +} + +function driverLabel(driver: any) { + if (!driver) return "Unassigned" + return driver.fullName || driver.name || driver.email || "Unnamed driver" +} + +export default async function AdminFleetOperationsPage({ searchParams }: FleetPageProps) { + await requireAdminAccess() + await dbConnect() + + const resolved = (await searchParams) || {} + const filters: FleetFilters = { + q: getParam(resolved.q).trim(), + status: getParam(resolved.status, "all"), + type: getParam(resolved.type, "all"), + contract: getParam(resolved.contract, "all"), + } + const page = toInt(getParam(resolved.page, "1"), 1) + + // ---- Fleet-level metrics (computed across the whole fleet, not the page) ---- + const [statusCounts, fleetValueAgg, activeContractAgg, collectedAgg] = await Promise.all([ + Vehicle.aggregate([{ $group: { _id: "$status", count: { $sum: 1 } } }]), + Vehicle.aggregate([ + { $match: { status: { $ne: "Retired" } } }, + { $group: { _id: null, total: { $sum: "$price" } } }, + ]), + HirePurchaseContract.aggregate([ + { $match: { status: "ACTIVE" } }, + { + $group: { + _id: null, + count: { $sum: 1 }, + paid: { $sum: "$totalPaidNgn" }, + payable: { $sum: "$totalPayableNgn" }, + }, + }, + ]), + DriverPayment.aggregate([ + { $match: { status: "CONFIRMED" } }, + { + $group: { + _id: null, + total: { + $sum: { $cond: [{ $gt: ["$appliedAmountNgn", 0] }, "$appliedAmountNgn", "$amountNgn"] }, + }, + }, + }, + ]), + ]) + + const countByStatus = (status: string) => + Number(statusCounts.find((entry: any) => entry._id === status)?.count || 0) + + const availableCount = countByStatus("Available") + const assignedCount = countByStatus("Financed") + countByStatus("Reserved") + const maintenanceCount = countByStatus("Maintenance") + const retiredCount = countByStatus("Retired") + const totalVehicles = statusCounts.reduce((acc: number, entry: any) => acc + Number(entry.count || 0), 0) + const fleetSize = totalVehicles - retiredCount + + const fleetValue = Number(fleetValueAgg[0]?.total || 0) + const activeContracts = Number(activeContractAgg[0]?.count || 0) + const avgRepayment = repaymentPercent(activeContractAgg[0]?.paid, activeContractAgg[0]?.payable) + const totalCollected = Number(collectedAgg[0]?.total || 0) + + // ---- Vehicle table query (respects filters + pagination) ---- + const query: Record = {} + + if (filters.q) { + const regex = new RegExp(filters.q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i") + query.$or = [{ name: regex }, { type: regex }, { identifier: regex }, { "specifications.vin": regex }] + } + + if (filters.type !== "all") query.type = filters.type + + if (filters.status === "available") query.status = "Available" + else if (filters.status === "assigned") query.status = { $in: ["Financed", "Reserved"] } + else if (filters.status === "maintenance") query.status = "Maintenance" + else if (filters.status === "retired") query.status = "Retired" + + // Contract filter resolves vehicle display names by contract status, then + // narrows the vehicle query to those names. + if (["active", "completed", "defaulted"].includes(filters.contract)) { + const names = await HirePurchaseContract.distinct("vehicleDisplayName", { + status: filters.contract.toUpperCase(), + }) + query.name = { $in: names } + } else if (filters.contract === "none") { + const names = await HirePurchaseContract.distinct("vehicleDisplayName") + query.name = { $nin: names } + } + + const totalCount = await Vehicle.countDocuments(query) + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) + const currentPage = Math.min(page, totalPages) + + const vehicles = await Vehicle.find(query) + .populate("driverId", "name fullName email") + .sort({ addedDate: -1 }) + .skip((currentPage - 1) * PAGE_SIZE) + .limit(PAGE_SIZE) + .lean() + + // Resolve the operational contract for each vehicle on this page. + const vehicleNames = vehicles.map((vehicle: any) => vehicle.name).filter(Boolean) + const contracts = vehicleNames.length + ? await HirePurchaseContract.find({ vehicleDisplayName: { $in: vehicleNames } }) + .select("vehicleDisplayName status totalPaidNgn totalPayableNgn nextDueDate createdAt") + .sort({ createdAt: -1 }) + .lean() + : [] + + const contractsByName = new Map() + for (const contract of contracts) { + const list = contractsByName.get(contract.vehicleDisplayName) || [] + list.push(contract) + contractsByName.set(contract.vehicleDisplayName, list) + } + + const rows = vehicles.map((vehicle: any) => { + const statusLabel = normalizeVehicleStatus(vehicle.status) + const contract = pickOperationalContract(contractsByName.get(vehicle.name) || []) + const percent = contract ? repaymentPercent(contract.totalPaidNgn, contract.totalPayableNgn) : null + return { + id: vehicle._id.toString(), + name: vehicle.name as string, + type: (vehicle.type as string) || "N/A", + identifier: (vehicle.identifier || vehicle.specifications?.vin || "—") as string, + statusLabel, + driver: driverLabel(vehicle.driverId), + contractStatus: (contract?.status as string) || null, + percent, + nextDue: contract?.nextDueDate as Date | undefined, + } + }) + + const from = totalCount === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1 + const to = Math.min(currentPage * PAGE_SIZE, totalCount) + + return ( +
+ +
+ + +
+ + + + + + } + /> + +
+ + + + + + + + +
+ +
+
+

+ Showing {from} to {to} of {totalCount} vehicles +

+

+ Page {currentPage} of {totalPages} +

+
+ + {/* Mobile cards */} +
+ {rows.length === 0 ? ( +
+ No vehicles match these filters. +
+ ) : ( + rows.map((row) => ( +
+
+
+

{row.name}

+

+ {row.type} · {row.identifier} +

+
+ + {row.statusLabel} + +
+
+

Driver: {row.driver}

+

+ Contract:{" "} + {row.contractStatus ? ( + + {row.contractStatus} + + ) : ( + "No contract" + )} +

+

Next due: {formatDate(row.nextDue)}

+
+ {row.percent !== null ? : null} + +
+ )) + )} +
+ + {/* Desktop table */} +
+ + + + + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((row) => ( + + + + + + + + + + )) + )} + +
VehicleStatusAssigned DriverContractRepaymentNext DueActions
+ No vehicles match these filters. +
+

{row.name}

+

+ {row.type} · {row.identifier} +

+
+ + {row.statusLabel} + + {row.driver} + {row.contractStatus ? ( + + {row.contractStatus} + + ) : ( + No contract + )} + + {row.percent !== null ? ( + + ) : ( + + )} + {formatDate(row.nextDue)} + +
+
+ +
+ + +
+
+
+ ) +} diff --git a/app/dashboard/admin/loans/page.tsx b/app/dashboard/admin/loans/page.tsx index 74b79dd..cc44c91 100644 --- a/app/dashboard/admin/loans/page.tsx +++ b/app/dashboard/admin/loans/page.tsx @@ -20,13 +20,13 @@ export default function AdminLoanManagementPage() { // State for loan management const [isLoading, setIsLoading] = useState(true) const [activeTab, setActiveTab] = useState("pending") - const [selectedLoan, setSelectedLoan] = useState(null) + const [selectedLoan, setSelectedLoan] = useState(null) const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false) const [isActionDialogOpen, setIsActionDialogOpen] = useState(false) - const [actionType, setActionType] = useState(null) + const [actionType, setActionType] = useState(null) const [adminNotes, setAdminNotes] = useState("") const [isSubmitting, setIsSubmitting] = useState(false) - const [loans, setLoans] = useState([]) + const [loans, setLoans] = useState([]) // Fetch loans from database const fetchLoans = async () => { @@ -39,7 +39,7 @@ export default function AdminLoanManagementPage() { // Update the platform context for consistency - keep populated objects dispatch({ type: "SET_LOAN_APPLICATIONS", - payload: fetchedLoans.map((loan) => ({ + payload: fetchedLoans.map((loan: any) => ({ ...loan, id: loan._id, // Keep the populated objects intact: @@ -74,13 +74,13 @@ export default function AdminLoanManagementPage() { }) || [] // Handle opening the loan details dialog - const handleViewDetails = (loan) => { + const handleViewDetails = (loan: any) => { setSelectedLoan(loan) setIsDetailsDialogOpen(true) } // Handle opening the action dialog (approve/reject) - const handleAction = (loan, type) => { + const handleAction = (loan: any, type: any) => { setSelectedLoan(loan) setActionType(type) setAdminNotes("") @@ -140,7 +140,7 @@ export default function AdminLoanManagementPage() { priority: "high", actionUrl: "/dashboard/driver/loan-terms" }, - }) + } as any) // Persist notification to database try { @@ -203,7 +203,7 @@ export default function AdminLoanManagementPage() { } // Send email notification - const sendEmailNotification = async (email, subject, message) => { + const sendEmailNotification = async (email: any, subject: any, message: any) => { try { const htmlContent = `
diff --git a/app/dashboard/admin/reports/page.tsx b/app/dashboard/admin/reports/page.tsx index 8b545d7..fbe33ea 100644 --- a/app/dashboard/admin/reports/page.tsx +++ b/app/dashboard/admin/reports/page.tsx @@ -240,16 +240,16 @@ export default async function AdminReportsPage({ searchParams }: ReportsPageProp
diff --git a/app/dashboard/investor/kyc/status/page.tsx b/app/dashboard/investor/kyc/status/page.tsx index 6a06d2c..c8d527f 100644 --- a/app/dashboard/investor/kyc/status/page.tsx +++ b/app/dashboard/investor/kyc/status/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect } from "react" +import { useEffect, type ReactElement } from "react" import { useRouter } from "next/navigation" import { CheckCircle, Clock, Loader2 } from "lucide-react" @@ -47,7 +47,7 @@ export default function InvestorKycStatusPage() { let icon = let title = "Checking KYC status..." let description = "Please wait while we load your investor verification status." - let action: JSX.Element | null = null + let action: ReactElement | null = null if (status === "pending") { icon = diff --git a/components/about/timeline-section.tsx b/components/about/timeline-section.tsx index 85a9edd..db59825 100644 --- a/components/about/timeline-section.tsx +++ b/components/about/timeline-section.tsx @@ -71,7 +71,7 @@ export function TimelineSection() {
(itemRefs.current[index] = el)} + ref={el => { itemRefs.current[index] = el }} className={`relative grid grid-cols-3 gap-4 items-start transition-all duration-700 ease-out transform ${ visible[index] ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" }`} diff --git a/components/auth/AuthInput.tsx b/components/auth/AuthInput.tsx index d50f976..6481a68 100644 --- a/components/auth/AuthInput.tsx +++ b/components/auth/AuthInput.tsx @@ -1,5 +1,4 @@ -import type { LucideIcon } from "lucide-react" -import type { InputHTMLAttributes, ReactNode } from "react" +import type { ComponentType, InputHTMLAttributes, ReactNode } from "react" import { Input } from "@/components/ui/input" import { cn } from "@/lib/utils" @@ -7,7 +6,7 @@ import { cn } from "@/lib/utils" interface AuthInputProps extends InputHTMLAttributes { id: string label: string - icon: LucideIcon + icon: ComponentType<{ className?: string }> error?: string trailing?: ReactNode containerClassName?: string diff --git a/components/dashboard/admin/metric-card.tsx b/components/dashboard/admin/metric-card.tsx new file mode 100644 index 0000000..b6242d8 --- /dev/null +++ b/components/dashboard/admin/metric-card.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +interface MetricCardProps { + label: string + value: ReactNode + hint?: string +} + +export function MetricCard({ label, value, hint }: MetricCardProps) { + return ( + + + {label} + + +

{value}

+ {hint ?

{hint}

: null} +
+
+ ) +} diff --git a/components/dashboard/admin/repayment-bar.tsx b/components/dashboard/admin/repayment-bar.tsx new file mode 100644 index 0000000..1bb0ffa --- /dev/null +++ b/components/dashboard/admin/repayment-bar.tsx @@ -0,0 +1,21 @@ +import { repaymentBarClass } from "@/src/server/admin/fleet" + +interface RepaymentBarProps { + percent: number + status?: string | null + className?: string +} + +export function RepaymentBar({ percent, status, className }: RepaymentBarProps) { + return ( +
+
+
+
+ {percent}% +
+ ) +} diff --git a/components/dashboard/advanced-analytics.tsx b/components/dashboard/advanced-analytics.tsx index 4d92785..ef42a0f 100644 --- a/components/dashboard/advanced-analytics.tsx +++ b/components/dashboard/advanced-analytics.tsx @@ -15,7 +15,7 @@ interface AnalyticsProps { export function AdvancedAnalytics({ userRole, userId }: AnalyticsProps) { const { state } = usePlatform() - if (state.isLoading || !state.drivers || !state.investors || !state.vehicles || !state.loanApplications) { + if (state.isLoading || !(state as any).drivers || !(state as any).investors || !state.vehicles || !state.loanApplications) { return ( @@ -35,8 +35,8 @@ export function AdvancedAnalytics({ userRole, userId }: AnalyticsProps) { monthlyGrowth: 12.5, // Simulated userGrowth: { - drivers: state.drivers.length, - investors: state.investors.length, + drivers: (state as any).drivers.length, + investors: (state as any).investors.length, growth: 8.3, // Simulated }, @@ -89,17 +89,17 @@ export function AdvancedAnalytics({ userRole, userId }: AnalyticsProps) { const driverTransactions = state.transactions.filter((t) => t.userId === userId) return { - totalBorrowed: driverLoans.reduce((sum, loan) => sum + loan.totalFunded, 0), + totalBorrowed: driverLoans.reduce((sum, loan) => sum + (loan.totalFunded ?? 0), 0), activeLoans: driverLoans.filter((l) => l.status === "Active").length, - completedPayments: state.repaymentSchedules.filter( - (r) => driverLoans.some((l) => l.id === r.loanId) && r.status === "Paid", + completedPayments: (state as any).repaymentSchedules.filter( + (r: any) => driverLoans.some((l) => l.id === r.loanId) && r.status === "Paid", ).length, creditScore: driverLoans[0]?.creditScore || 0, paymentHistory: 96.5, // Simulated } } else if (userRole === "investor" && userId) { const investorInvestments = state.investments.filter((i) => i.investorId === userId) - const investor = state.investors.find((i) => i.id === userId) + const investor = (state as any).investors.find((i: any) => i.id === userId) return { totalInvested: investorInvestments.reduce((sum, inv) => sum + inv.amount, 0), diff --git a/components/dashboard/onboarding/document-upload.tsx b/components/dashboard/onboarding/document-upload.tsx index a6485fd..cc7996b 100644 --- a/components/dashboard/onboarding/document-upload.tsx +++ b/components/dashboard/onboarding/document-upload.tsx @@ -256,10 +256,10 @@ export function DocumentUpload({ onNext, onBack }: DocumentUploadProps) { onDragOver={handleDragOver} onDrop={handleDrop} onClick={() => { - const input = document.createElement("input") + const input = (document as any).createElement("input") input.type = "file" input.accept = "image/*,.pdf" - input.onchange = (e) => { + input.onchange = (e: any) => { const file = (e.target as HTMLInputElement).files?.[0] if (file) handleFileUpload(file) } diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx index 4ef0c4c..6f0a47e 100644 --- a/components/dashboard/sidebar.tsx +++ b/components/dashboard/sidebar.tsx @@ -10,6 +10,7 @@ import { Coins, Compass, FileText, + Gauge, Layers, LayoutDashboard, LogOut, @@ -138,6 +139,7 @@ const SIDEBAR_SECTIONS: Record = { { label: "Investors", href: "/dashboard/admin/investors", icon: Coins }, { label: "Drivers", href: "/dashboard/admin/drivers", icon: Car }, { label: "Vehicles", href: "/dashboard/admin/vehicles", icon: Car }, + { label: "Fleet Operations", href: "/dashboard/admin/fleet-operations", icon: Gauge }, ], }, { diff --git a/components/landing/Footer.tsx b/components/landing/Footer.tsx index 211c14a..c1a8182 100644 --- a/components/landing/Footer.tsx +++ b/components/landing/Footer.tsx @@ -62,8 +62,8 @@ export function Footer() { key={item.label} href={item.href} aria-label={item.label} - target={item.external ? "_blank" : undefined} - rel={item.external ? "noopener noreferrer" : undefined} + target={(item as any).external ? "_blank" : undefined} + rel={(item as any).external ? "noopener noreferrer" : undefined} className="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-white/25 text-white/85 transition-colors hover:border-white/45 hover:text-white" > diff --git a/components/marketplace/tabs-section.tsx b/components/marketplace/tabs-section.tsx index 03bb119..c98c8d1 100644 --- a/components/marketplace/tabs-section.tsx +++ b/components/marketplace/tabs-section.tsx @@ -30,13 +30,13 @@ export function TabsSection() { - + - + - + ); diff --git a/components/providers.tsx b/components/providers.tsx index 0f5e7bd..912f177 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -1,7 +1,9 @@ "use client" +import * as React from "react" import { ThemeProvider as NextThemesProvider } from "next-themes" -import { type ThemeProviderProps } from "next-themes/dist/types" + +type ThemeProviderProps = React.ComponentProps export function ThemeProvider({ children, ...props }: ThemeProviderProps) { return ( diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx index 16295c2..3a4b681 100644 --- a/components/ui/chart.tsx +++ b/components/ui/chart.tsx @@ -111,6 +111,8 @@ const ChartTooltipContent = React.forwardRef< indicator?: "line" | "dot" | "dashed" nameKey?: string labelKey?: string + payload?: any[] + label?: any } >( ( @@ -260,8 +262,9 @@ const ChartLegend = RechartsPrimitive.Legend const ChartLegendContent = React.forwardRef< HTMLDivElement, - React.ComponentProps<"div"> & - Pick & { + React.ComponentProps<"div"> & { + payload?: any[] + verticalAlign?: "top" | "bottom" | "middle" hideIcon?: boolean nameKey?: string } diff --git a/lib/api/route-guard.ts b/lib/api/route-guard.ts index 274da9c..0bd035e 100644 --- a/lib/api/route-guard.ts +++ b/lib/api/route-guard.ts @@ -43,7 +43,9 @@ export async function requireAuthenticatedUser( } } - return authContext + // `authContext.user` is guaranteed non-null past the guard above; re-assert it + // so callers don't have to null-check the authenticated user. + return { ...authContext, user: authContext.user } } export async function finalizeAuthenticatedResponse( diff --git a/lib/services/driver-contracts.service.ts b/lib/services/driver-contracts.service.ts index 545f6ad..87b58af 100644 --- a/lib/services/driver-contracts.service.ts +++ b/lib/services/driver-contracts.service.ts @@ -687,7 +687,7 @@ export async function confirmDriverPayment( contract.totalPaidNgn = clampToNonNegative(Number(contract.totalPaidNgn || 0) + appliedAmountNgn) const remainingAfterNgn = computeRemainingBalance(contract.totalPayableNgn, contract.totalPaidNgn) contract.status = remainingAfterNgn <= 0 ? "COMPLETED" : "ACTIVE" - contract.nextDueDate = contract.status === "COMPLETED" ? null : calculateNextDueDate(contract) + contract.nextDueDate = contract.status === "COMPLETED" ? null : calculateNextDueDate(contract as any) await contract.save({ session }) const existingRepaymentTx = await Transaction.findOne({ diff --git a/lib/services/dva-user-identity.service.ts b/lib/services/dva-user-identity.service.ts index 9cb1358..91933da 100644 --- a/lib/services/dva-user-identity.service.ts +++ b/lib/services/dva-user-identity.service.ts @@ -54,13 +54,13 @@ export async function resolveDvaUserIdentity( return { user, email: normalizeString(user.email)?.toLowerCase() || null, - fullName: resolveStoredFullName(user), + fullName: resolveStoredFullName(user as any), phoneNumber: normalizeString(user.phoneNumber), } } let email = normalizeString(user.email)?.toLowerCase() || null - let fullName = resolveStoredFullName(user) + let fullName = resolveStoredFullName(user as any) let phoneNumber = normalizeString(user.phoneNumber) const needsPrivyFallback = (!email || !fullName || !phoneNumber) && normalizeString(user.privyUserId) diff --git a/lib/services/paystack-dva.service.ts b/lib/services/paystack-dva.service.ts index 566462a..3966273 100644 --- a/lib/services/paystack-dva.service.ts +++ b/lib/services/paystack-dva.service.ts @@ -264,7 +264,7 @@ async function createOrUpdatePaystackCustomer(input: { }, ) - return updatedCustomer.data + return updatedCustomer!.data } const createdCustomer = await paystackRequest("/customer", { @@ -278,7 +278,7 @@ async function createOrUpdatePaystackCustomer(input: { }, }) - return createdCustomer.data + return createdCustomer!.data } async function listDedicatedAccountsForCustomer(customerId: number) { @@ -309,7 +309,7 @@ async function createDedicatedAccount(input: { }, }) - return response.data + return response!.data } async function resolveActiveContract(driverUserId: string, contractId?: string) { diff --git a/lib/services/paystack-investor-dva.service.ts b/lib/services/paystack-investor-dva.service.ts index ef2aabc..d49eae9 100644 --- a/lib/services/paystack-investor-dva.service.ts +++ b/lib/services/paystack-investor-dva.service.ts @@ -256,7 +256,7 @@ async function createOrUpdatePaystackCustomer(input: { }, ) - return updatedCustomer.data + return updatedCustomer!.data } const createdCustomer = await paystackRequest("/customer", { @@ -270,7 +270,7 @@ async function createOrUpdatePaystackCustomer(input: { }, }) - return createdCustomer.data + return createdCustomer!.data } async function listDedicatedAccountsForCustomer(customerId: number) { @@ -301,7 +301,7 @@ async function createDedicatedAccount(input: { }, }) - return response.data + return response!.data } export async function getInvestorVirtualAccount(input: ProvisionInvestorVirtualAccountInput) { diff --git a/models/AuditLog.ts b/models/AuditLog.ts index 3b112ec..7157c0d 100644 --- a/models/AuditLog.ts +++ b/models/AuditLog.ts @@ -55,4 +55,5 @@ const AuditLogSchema = new Schema( }, ) -export default mongoose.models.AuditLog || mongoose.model("AuditLog", AuditLogSchema) +export default (mongoose.models.AuditLog || + mongoose.model("AuditLog", AuditLogSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/DriverPayment.ts b/models/DriverPayment.ts index fc2547c..df6344b 100644 --- a/models/DriverPayment.ts +++ b/models/DriverPayment.ts @@ -83,4 +83,5 @@ const DriverPaymentSchema: Schema = new Schema( DriverPaymentSchema.index({ driverUserId: 1, createdAt: -1 }) DriverPaymentSchema.index({ contractId: 1, createdAt: -1 }) -export default mongoose.models.DriverPayment || mongoose.model("DriverPayment", DriverPaymentSchema) +export default (mongoose.models.DriverPayment || + mongoose.model("DriverPayment", DriverPaymentSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/DriverVirtualAccount.ts b/models/DriverVirtualAccount.ts index c66ef5e..2a95b17 100644 --- a/models/DriverVirtualAccount.ts +++ b/models/DriverVirtualAccount.ts @@ -100,5 +100,5 @@ DriverVirtualAccountSchema.index({ dedicatedAccountId: 1 }, { unique: true, spar DriverVirtualAccountSchema.index({ driverUserId: 1, status: 1, updatedAt: -1 }) DriverVirtualAccountSchema.index({ contractId: 1, status: 1, updatedAt: -1 }) -export default mongoose.models.DriverVirtualAccount || - mongoose.model("DriverVirtualAccount", DriverVirtualAccountSchema) +export default (mongoose.models.DriverVirtualAccount || + mongoose.model("DriverVirtualAccount", DriverVirtualAccountSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/HirePurchaseContract.ts b/models/HirePurchaseContract.ts index 3a53488..117182f 100644 --- a/models/HirePurchaseContract.ts +++ b/models/HirePurchaseContract.ts @@ -101,5 +101,5 @@ const HirePurchaseContractSchema: Schema = new Schema( HirePurchaseContractSchema.index({ driverUserId: 1, status: 1, createdAt: -1 }) HirePurchaseContractSchema.index({ poolId: 1, status: 1 }) -export default mongoose.models.HirePurchaseContract || - mongoose.model("HirePurchaseContract", HirePurchaseContractSchema) +export default (mongoose.models.HirePurchaseContract || + mongoose.model("HirePurchaseContract", HirePurchaseContractSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/Investment.ts b/models/Investment.ts index f564c22..d6c767a 100644 --- a/models/Investment.ts +++ b/models/Investment.ts @@ -24,4 +24,5 @@ const InvestmentSchema: Schema = new Schema({ date: { type: Date, default: Date.now }, }); -export default mongoose.models.Investment || mongoose.model('Investment', InvestmentSchema); \ No newline at end of file +export default (mongoose.models.Investment || + mongoose.model('Investment', InvestmentSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/InvestmentPool.ts b/models/InvestmentPool.ts index aea3465..bcc3bc9 100644 --- a/models/InvestmentPool.ts +++ b/models/InvestmentPool.ts @@ -71,4 +71,5 @@ const InvestmentPoolSchema: Schema = new Schema( { timestamps: true }, ) -export default mongoose.models.InvestmentPool || mongoose.model("InvestmentPool", InvestmentPoolSchema) +export default (mongoose.models.InvestmentPool || + mongoose.model("InvestmentPool", InvestmentPoolSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/InvestorCredit.ts b/models/InvestorCredit.ts index 34d2546..57272e5 100644 --- a/models/InvestorCredit.ts +++ b/models/InvestorCredit.ts @@ -56,4 +56,5 @@ const InvestorCreditSchema: Schema = new Schema( InvestorCreditSchema.index({ paymentId: 1, investorUserId: 1 }, { unique: true }) InvestorCreditSchema.index({ investorUserId: 1, createdAt: -1 }) -export default mongoose.models.InvestorCredit || mongoose.model("InvestorCredit", InvestorCreditSchema) +export default (mongoose.models.InvestorCredit || + mongoose.model("InvestorCredit", InvestorCreditSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/InvestorVirtualAccount.ts b/models/InvestorVirtualAccount.ts index be815f3..f7b847c 100644 --- a/models/InvestorVirtualAccount.ts +++ b/models/InvestorVirtualAccount.ts @@ -92,5 +92,5 @@ InvestorVirtualAccountSchema.index({ accountNumber: 1 }, { unique: true, sparse: InvestorVirtualAccountSchema.index({ dedicatedAccountId: 1 }, { unique: true, sparse: true }) InvestorVirtualAccountSchema.index({ investorUserId: 1, status: 1, updatedAt: -1 }) -export default mongoose.models.InvestorVirtualAccount || - mongoose.model("InvestorVirtualAccount", InvestorVirtualAccountSchema) +export default (mongoose.models.InvestorVirtualAccount || + mongoose.model("InvestorVirtualAccount", InvestorVirtualAccountSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/Issue.ts b/models/Issue.ts index 766ea10..9b0b10d 100644 --- a/models/Issue.ts +++ b/models/Issue.ts @@ -94,5 +94,5 @@ const IssueSchema = new Schema( IssueSchema.index({ createdAt: -1, status: 1 }) -export default mongoose.models.Issue || mongoose.model("Issue", IssueSchema) - +export default (mongoose.models.Issue || + mongoose.model("Issue", IssueSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/Loan.ts b/models/Loan.ts index 322db27..7678e87 100644 --- a/models/Loan.ts +++ b/models/Loan.ts @@ -65,4 +65,5 @@ LoanSchema.virtual('remainingAmount').get(function() { return Number(loan.requestedAmount || 0) - Number(loan.totalFunded || 0) }); -export default mongoose.models.Loan || mongoose.model('Loan', LoanSchema); +export default (mongoose.models.Loan || + mongoose.model('Loan', LoanSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; diff --git a/models/Notification.ts b/models/Notification.ts index 6722ee0..f1eff1c 100644 --- a/models/Notification.ts +++ b/models/Notification.ts @@ -31,4 +31,5 @@ const NotificationSchema: Schema = new Schema({ timestamp: { type: Date, default: Date.now, index: true }, }) -export default mongoose.models.Notification || mongoose.model("Notification", NotificationSchema) +export default (mongoose.models.Notification || + mongoose.model("Notification", NotificationSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/PlatformSetting.ts b/models/PlatformSetting.ts index 087bae4..4060ca3 100644 --- a/models/PlatformSetting.ts +++ b/models/PlatformSetting.ts @@ -50,6 +50,5 @@ const PlatformSettingSchema = new Schema( { timestamps: true }, ) -export default mongoose.models.PlatformSetting || - mongoose.model("PlatformSetting", PlatformSettingSchema) - +export default (mongoose.models.PlatformSetting || + mongoose.model("PlatformSetting", PlatformSettingSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/PoolInvestment.ts b/models/PoolInvestment.ts index e375c02..2154ede 100644 --- a/models/PoolInvestment.ts +++ b/models/PoolInvestment.ts @@ -62,4 +62,5 @@ const PoolInvestmentSchema: Schema = new Schema( PoolInvestmentSchema.index({ poolId: 1, userId: 1, createdAt: -1 }) -export default mongoose.models.PoolInvestment || mongoose.model("PoolInvestment", PoolInvestmentSchema) +export default (mongoose.models.PoolInvestment || + mongoose.model("PoolInvestment", PoolInvestmentSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/ProcessedGatewayEvent.ts b/models/ProcessedGatewayEvent.ts index ba8fe31..833b7f8 100644 --- a/models/ProcessedGatewayEvent.ts +++ b/models/ProcessedGatewayEvent.ts @@ -2,7 +2,7 @@ import mongoose, { Document, Schema } from "mongoose" export type GatewayPaymentType = "wallet_funding" | "down_payment" -export interface IProcessedGatewayEvent extends Document { +export interface IProcessedGatewayEvent extends Document { _id: string paymentType: GatewayPaymentType processedVia: "verify" | "webhook" @@ -31,5 +31,5 @@ const ProcessedGatewayEventSchema = new Schema( { _id: false, timestamps: true }, ) -export default mongoose.models.ProcessedGatewayEvent || - mongoose.model("ProcessedGatewayEvent", ProcessedGatewayEventSchema) +export default (mongoose.models.ProcessedGatewayEvent || + mongoose.model("ProcessedGatewayEvent", ProcessedGatewayEventSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/StellarIndexedEvent.ts b/models/StellarIndexedEvent.ts index 1bfa376..2545b59 100644 --- a/models/StellarIndexedEvent.ts +++ b/models/StellarIndexedEvent.ts @@ -104,5 +104,5 @@ const StellarIndexedEventSchema = new Schema( { _id: false, timestamps: true }, ) -export default mongoose.models.StellarIndexedEvent || - mongoose.model("StellarIndexedEvent", StellarIndexedEventSchema) +export default (mongoose.models.StellarIndexedEvent || + mongoose.model("StellarIndexedEvent", StellarIndexedEventSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/StellarIndexerCursor.ts b/models/StellarIndexerCursor.ts index 1320fcb..67fd10d 100644 --- a/models/StellarIndexerCursor.ts +++ b/models/StellarIndexerCursor.ts @@ -33,5 +33,5 @@ const StellarIndexerCursorSchema = new Schema( { timestamps: true }, ) -export default mongoose.models.StellarIndexerCursor || - mongoose.model("StellarIndexerCursor", StellarIndexerCursorSchema) +export default (mongoose.models.StellarIndexerCursor || + mongoose.model("StellarIndexerCursor", StellarIndexerCursorSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/Transaction.ts b/models/Transaction.ts index e58e801..66da4a7 100644 --- a/models/Transaction.ts +++ b/models/Transaction.ts @@ -72,4 +72,5 @@ const TransactionSchema: Schema = new Schema({ timestamp: { type: Date, default: Date.now }, }) -export default mongoose.models.Transaction || mongoose.model("Transaction", TransactionSchema) +export default (mongoose.models.Transaction || + mongoose.model("Transaction", TransactionSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file diff --git a/models/User.ts b/models/User.ts index eef2230..80d6df1 100644 --- a/models/User.ts +++ b/models/User.ts @@ -1,5 +1,14 @@ import mongoose from "mongoose"; +// Loose document type: keeps property access permissive while giving the model +// a concrete generic so `findById().lean()` resolves to a single document +// instead of mongoose's broken `Doc[] | Doc` overload union. `_id` is typed +// `any` (not Document's `unknown`) so `_id.toString()` stays valid. +export interface IUser { + _id: any; + [key: string]: any; +} + const NotificationSchema = new mongoose.Schema( { id: { @@ -89,12 +98,6 @@ const UserSchema = new mongoose.Schema( sparse: true, trim: true, }, - stellarPublicKey: { - type: String, - unique: true, - sparse: true, - trim: true, - }, role: { type: String, enum: ["driver", "investor", "admin"], @@ -169,6 +172,7 @@ const UserSchema = new mongoose.Schema( }, stellarPublicKey: { type: String, + unique: true, sparse: true, trim: true, index: true, @@ -190,4 +194,5 @@ const UserSchema = new mongoose.Schema( { timestamps: true }, ); -export default mongoose.models.User || mongoose.model("User", UserSchema); +export default (mongoose.models.User || + mongoose.model("User", UserSchema)) as mongoose.Model; diff --git a/models/Vehicle.ts b/models/Vehicle.ts index 33459cb..6a89094 100644 --- a/models/Vehicle.ts +++ b/models/Vehicle.ts @@ -58,4 +58,5 @@ const VehicleSchema: Schema = new Schema({ totalFundedAmount: { type: Number, default: 0 }, }); -export default mongoose.models.Vehicle || mongoose.model('Vehicle', VehicleSchema); +export default (mongoose.models.Vehicle || + mongoose.model('Vehicle', VehicleSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; diff --git a/src/server/admin/fleet.ts b/src/server/admin/fleet.ts new file mode 100644 index 0000000..559a379 --- /dev/null +++ b/src/server/admin/fleet.ts @@ -0,0 +1,74 @@ +// Shared, presentation-level helpers for the admin fleet operations dashboard. +// Centralised here so the fleet list view and the vehicle detail view share the +// same status normalisation and repayment maths instead of duplicating it. + +export type FleetStatusLabel = + | "Available" + | "Assigned" + | "Under Maintenance" + | "Retired" + +export type VehicleStatus = + | "Available" + | "Financed" + | "Reserved" + | "Maintenance" + | "Retired" + +export type ContractStatus = "ACTIVE" | "COMPLETED" | "DEFAULTED" + +// Map the raw Vehicle.status enum to an operations-facing label. +export function normalizeVehicleStatus(status?: string | null): FleetStatusLabel { + const normalized = (status || "").toLowerCase() + if (normalized === "maintenance") return "Under Maintenance" + if (normalized === "retired") return "Retired" + if (normalized === "financed" || normalized === "reserved") return "Assigned" + return "Available" +} + +export function vehicleStatusBadgeClass(label: FleetStatusLabel): string { + if (label === "Assigned") return "bg-blue-600 text-white hover:bg-blue-600" + if (label === "Under Maintenance") return "bg-amber-600 text-white hover:bg-amber-600" + if (label === "Retired") return "bg-zinc-600 text-white hover:bg-zinc-600" + return "bg-green-600 text-white hover:bg-green-600" +} + +export function contractStatusBadgeClass(status?: string | null): string { + const value = (status || "").toUpperCase() + if (value === "COMPLETED") return "bg-green-600 text-white hover:bg-green-600" + if (value === "DEFAULTED") return "bg-red-600 text-white hover:bg-red-600" + if (value === "ACTIVE") return "bg-blue-600 text-white hover:bg-blue-600" + return "bg-zinc-600 text-white hover:bg-zinc-600" +} + +// Repayment completion as an integer percentage, clamped to [0, 100]. +export function repaymentPercent( + paid?: number | null, + payable?: number | null, +): number { + const paidValue = Number(paid || 0) + const payableValue = Number(payable || 0) + if (payableValue <= 0) return 0 + const pct = (paidValue / payableValue) * 100 + if (!Number.isFinite(pct)) return 0 + return Math.min(100, Math.max(0, Math.round(pct))) +} + +// Colour for the repayment progress bar based on completion + contract health. +export function repaymentBarClass(percent: number, status?: string | null): string { + if ((status || "").toUpperCase() === "DEFAULTED") return "bg-red-500" + if (percent >= 100) return "bg-green-500" + if (percent >= 50) return "bg-blue-500" + return "bg-amber-500" +} + +// Pick the contract that best represents a vehicle's current operational state: +// prefer an ACTIVE contract, otherwise fall back to the most recent one. +// `contracts` is expected to be pre-sorted by createdAt descending. +export function pickOperationalContract( + contracts: T[], +): T | null { + if (contracts.length === 0) return null + const active = contracts.find((c) => (c.status || "").toUpperCase() === "ACTIVE") + return active ?? contracts[0] +} diff --git a/src/server/analytics/shared.ts b/src/server/analytics/shared.ts index c011f04..81059aa 100644 --- a/src/server/analytics/shared.ts +++ b/src/server/analytics/shared.ts @@ -80,7 +80,7 @@ async function aggregateTotal({ match, sumField, }: { - model: { aggregate: (pipeline: Record[]) => Promise> } + model: { aggregate: (pipeline: any[]) => Promise } match: Record sumField: string }) {