diff --git a/backend/src/dashboard/dashboard.controller.ts b/backend/src/dashboard/dashboard.controller.ts index 68de24f..1d775a5 100644 --- a/backend/src/dashboard/dashboard.controller.ts +++ b/backend/src/dashboard/dashboard.controller.ts @@ -8,6 +8,7 @@ import { } from '@nestjs/common'; import { DashboardService } from './dashboard.service'; import { AdminAnalyticsProvider } from './providers/admin-analytics.provider'; +import { MemberDashboardProvider } from './providers/member-dashboard.provider'; import { AnalyticsQueryDto } from './dto/analytics-query.dto'; import { JwtAuthGuard } from '../auth/guard/jwt.auth.guard'; import { RolesGuard } from '../auth/guard/roles.guard'; @@ -27,6 +28,7 @@ export class DashboardController { private readonly dashboardService: DashboardService, private readonly memberDashboardProvider: MemberDashboardProvider, private readonly adminAnalyticsProvider: AdminAnalyticsProvider, + private readonly memberDashboardProvider: MemberDashboardProvider, ) {} @Get('stats') @@ -80,57 +82,11 @@ export class DashboardController { return { success: true, data }; } - @Get('member/bookings') - @HttpCode(HttpStatus.OK) - async getMemberBookings( - @GetCurrentUser('id') userId: string, - @Query('page') page: string = '1', - @Query('limit') limit: string = '10', - ) { - const parsedPage = Math.max(1, parseInt(page, 10) || 1); - const parsedLimit = Math.min(50, Math.max(1, parseInt(limit, 10) || 10)); - - const data = await this.dashboardService.getMemberBookings( - userId, - parsedPage, - parsedLimit, - ); - return { success: true, ...data }; - } - - @Get('member/payments') - @HttpCode(HttpStatus.OK) - async getMemberPayments( - @GetCurrentUser('id') userId: string, - @Query('page') page: string = '1', - @Query('limit') limit: string = '10', - ) { - const parsedPage = Math.max(1, parseInt(page, 10) || 1); - const parsedLimit = Math.min(50, Math.max(1, parseInt(limit, 10) || 10)); - - const data = await this.dashboardService.getMemberPayments( - userId, - parsedPage, - parsedLimit, - ); - return { success: true, ...data }; - } - - @Get('member/invoices') + @Get('member') @HttpCode(HttpStatus.OK) - async getMemberInvoices( - @GetCurrentUser('id') userId: string, - @Query('page') page: string = '1', - @Query('limit') limit: string = '10', - ) { - const parsedPage = Math.max(1, parseInt(page, 10) || 1); - const parsedLimit = Math.min(50, Math.max(1, parseInt(limit, 10) || 10)); - - const data = await this.dashboardService.getMemberInvoices( - userId, - parsedPage, - parsedLimit, - ); - return { success: true, ...data }; + @UseGuards(JwtAuthGuard) + async getMemberDashboard(@CurrentUser() user: User) { + const data = await this.memberDashboardProvider.getMemberDashboard(user.id); + return { success: true, data }; } } diff --git a/backend/src/dashboard/providers/member-dashboard.provider.ts b/backend/src/dashboard/providers/member-dashboard.provider.ts index e72dfd2..46401c9 100644 --- a/backend/src/dashboard/providers/member-dashboard.provider.ts +++ b/backend/src/dashboard/providers/member-dashboard.provider.ts @@ -1,15 +1,92 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Payment } from '../../payments/entities/payment.entity'; +import { Invoice } from '../../payments/entities/invoice.entity'; +import { Workspace } from '../../workspaces/entities/workspace.entity'; +import { BookingStatus } from '../../bookings/enums/booking-status.enum'; +import { PaymentStatus } from '../../payments/entities/payment.entity'; @Injectable() export class MemberDashboardProvider { - // Stub implementation for MemberDashboardProvider - // To be implemented in Issue Parking Reservation System #74 - + constructor( + @InjectRepository(Booking) + private readonly bookingRepository: Repository, + @InjectRepository(Payment) + private readonly paymentRepository: Repository, + @InjectRepository(Invoice) + private readonly invoiceRepository: Repository, + @InjectRepository(Workspace) + private readonly workspaceRepository: Repository, + ) {} + async getMemberDashboard(userId: string) { - // Placeholder implementation + // Get active bookings (pending + confirmed) + const activeBookingsCount = await this.bookingRepository.count({ + where: { + userId, + status: In([BookingStatus.PENDING, BookingStatus.CONFIRMED]), + }, + }); + + // Get total spent (successful payments) + const successfulPayments = await this.paymentRepository.find({ + where: { + userId, + status: PaymentStatus.SUCCESSFUL, + }, + select: ['amountKobo'], + }); + + const totalSpentKobo = successfulPayments.reduce( + (sum, payment) => sum + payment.amountKobo, + 0, + ); + + // Get invoice count + const invoiceCount = await this.invoiceRepository.count({ + where: { userId }, + }); + + // Get last check-in (most recent workspace log) + const lastCheckIn = await this.workspaceRepository + .createQueryBuilder('workspace') + .innerJoin('workspace.workspaceLogs', 'log') + .where('log.userId = :userId', { userId }) + .andWhere('log.checkIn IS NOT NULL') + .orderBy('log.checkIn', 'DESC') + .select(['log.checkIn']) + .getOne(); + + // Get recent bookings (5 most recent) + const recentBookings = await this.bookingRepository.find({ + where: { userId }, + relations: ['workspace'], + order: { createdAt: 'DESC' }, + take: 5, + }); + + // Get recent successful payments (5 most recent) + const recentPayments = await this.paymentRepository.find({ + where: { + userId, + status: PaymentStatus.SUCCESSFUL, + }, + relations: ['booking'], + order: { createdAt: 'DESC' }, + take: 5, + }); + return { - message: 'MemberDashboardProvider stub - to be implemented', - userId, + stats: { + activeBookings: activeBookingsCount, + totalSpentKobo, + invoiceCount, + lastCheckIn: lastCheckIn ? lastCheckIn.createdAt : null, + }, + recentBookings, + recentPayments, }; } } diff --git a/frontend/app/dashboard/DashboardContent.tsx b/frontend/app/dashboard/DashboardContent.tsx index 2d9ec5e..07ce1a6 100644 --- a/frontend/app/dashboard/DashboardContent.tsx +++ b/frontend/app/dashboard/DashboardContent.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState, useCallback } from "react"; +import Link from "next/link"; import { apiClient } from "@/lib/apiClient"; import { useAuthState } from "@/lib/store/authStore"; import DashboardLayout from "@/components/dashboard/DashboardLayout"; @@ -10,6 +11,7 @@ import QuickActions from "@/components/dashboard/QuickActions"; import AnalyticsChart from "@/components/dashboard/AnalyticsChart"; import AdminOverview from "@/components/dashboard/AdminOverview"; import AdminUserTable from "@/components/dashboard/AdminUserTable"; +import MemberStatsCards from "@/components/dashboard/MemberStatsCards"; interface Stats { totalMembers: number; @@ -55,9 +57,33 @@ interface UserRow { profilePicture?: string; } +interface RecentBooking { + id: string; + workspace: { + id: string; + name: string; + type: string; + }; + status: string; + startDate: string; + endDate: string; +} + +interface RecentPayment { + id: string; + booking?: { + workspace?: { + id: string; + name: string; + }; + }; + amountKobo: number; + createdAt: string; +} + export default function DashboardContent() { const { user } = useAuthState(); - const isAdmin = user?.role === "admin"; + const isAdmin = user?.role === "admin" || user?.role === "super_admin" || user?.role === "staff"; const [stats, setStats] = useState(null); const [activity, setActivity] = useState([]); @@ -69,6 +95,8 @@ export default function DashboardContent() { limit: 10, totalPages: 0, }); + const [recentBookings, setRecentBookings] = useState([]); + const [recentPayments, setRecentPayments] = useState([]); const [loading, setLoading] = useState(true); const fetchData = useCallback(async () => { @@ -98,6 +126,19 @@ export default function DashboardContent() { setAdminUsers(adminUsersRes.data); setUsersMeta(adminUsersRes.meta); } + + // Fetch member dashboard data for non-admin users + if (!isAdmin) { + const memberDashboardRes = await apiClient.get<{ + success: boolean; + data: { + recentBookings: RecentBooking[]; + recentPayments: RecentPayment[]; + }; + }>("/dashboard/member"); + setRecentBookings(memberDashboardRes.data.recentBookings || []); + setRecentPayments(memberDashboardRes.data.recentPayments || []); + } } catch { // API unavailable — show empty state } finally { @@ -132,7 +173,121 @@ export default function DashboardContent() { ) : (
{/* Stats cards */} - + {isAdmin ? ( + + ) : ( + + )} + + {/* Member recent activity sections */} + {!isAdmin && ( + <> + {/* Recent Bookings */} +
+
+

+ Recent Bookings +

+ + View all + +
+ {recentBookings.length === 0 ? ( +

+ No recent bookings found. +

+ ) : ( +
+ {recentBookings.map((booking) => ( +
+
+

+ {booking.workspace.name} +

+

+ {booking.workspace.type} •{" "} + {new Date(booking.startDate).toLocaleDateString("en-NG", { + month: "short", + day: "numeric", + })}{" "} + -{" "} + {new Date(booking.endDate).toLocaleDateString("en-NG", { + month: "short", + day: "numeric", + })} +

+
+ + {booking.status} + +
+ ))} +
+ )} +
+ + {/* Recent Payments */} +
+
+

+ Recent Payments +

+ + View all + +
+ {recentPayments.length === 0 ? ( +

+ No recent payments found. +

+ ) : ( +
+ {recentPayments.map((payment) => ( +
+
+

+ {payment.booking?.workspace?.name || "Workspace Booking"} +

+

+ {new Date(payment.createdAt).toLocaleDateString("en-NG", { + month: "short", + day: "numeric", + year: "numeric", + })} +

+
+

+ ₦{(payment.amountKobo / 100).toLocaleString()} +

+
+ ))} +
+ )} +
+ + )} {/* Middle row — activity + quick actions */}
diff --git a/frontend/components/dashboard/MemberStatsCards.tsx b/frontend/components/dashboard/MemberStatsCards.tsx new file mode 100644 index 0000000..e592be5 --- /dev/null +++ b/frontend/components/dashboard/MemberStatsCards.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { Calendar, DollarSign, FileText, Clock } from "lucide-react"; +import { useGetMemberDashboard } from "@/lib/react-query/hooks/dashboard/useGetMemberDashboard"; + +export default function MemberStatsCards() { + const { data, isLoading } = useGetMemberDashboard(); + + if (isLoading) { + return ( +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); + } + + const stats = data?.data.stats; + + // Format currency from kobo to naira + const formatCurrency = (kobo: number) => { + const naira = kobo / 100; + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(naira); + }; + + // Format date for last check-in + const formatDate = (dateString: string | null) => { + if (!dateString) return "Never"; + + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return "Today"; + if (diffDays === 1) return "Yesterday"; + if (diffDays < 7) return `${diffDays} days ago`; + + return date.toLocaleDateString("en-NG", { + month: "short", + day: "numeric", + year: "numeric", + }); + }; + + const cards = [ + { + icon: Calendar, + label: "Active Bookings", + value: stats?.activeBookings ?? 0, + }, + { + icon: DollarSign, + label: "Total Spent", + value: formatCurrency(stats?.totalSpentKobo ?? 0), + }, + { + icon: FileText, + label: "Invoices", + value: stats?.invoiceCount ?? 0, + }, + { + icon: Clock, + label: "Last Check-in", + value: formatDate(stats?.lastCheckIn ?? null), + }, + ]; + + return ( +
+ {cards.map((card) => { + const Icon = card.icon; + return ( +
+
+ + + +
+

{card.value}

+

{card.label}

+
+ ); + })} +
+ ); +} diff --git a/frontend/lib/react-query/hooks/dashboard/useGetMemberDashboard.ts b/frontend/lib/react-query/hooks/dashboard/useGetMemberDashboard.ts new file mode 100644 index 0000000..d587e8a --- /dev/null +++ b/frontend/lib/react-query/hooks/dashboard/useGetMemberDashboard.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { MemberDashboardData } from "@/lib/types/dashboard"; + +interface MemberDashboardResponse { + success: boolean; + data: MemberDashboardData; +} + +export const useGetMemberDashboard = () => { + return useQuery({ + queryKey: ["member-dashboard"], + queryFn: () => + apiClient.get("/dashboard/member"), + }); +}; diff --git a/frontend/lib/types/dashboard.ts b/frontend/lib/types/dashboard.ts new file mode 100644 index 0000000..89b0fe3 --- /dev/null +++ b/frontend/lib/types/dashboard.ts @@ -0,0 +1,48 @@ +export interface Booking { + id: string; + userId: string; + workspaceId: string; + status: "PENDING" | "CONFIRMED" | "COMPLETED" | "CANCELLED"; + seatCount: number; + totalAmountKobo: number; + startDate: string; + endDate: string; + createdAt: string; + updatedAt: string; + workspace?: { + id: string; + name: string; + type: string; + }; +} + +export interface Payment { + id: string; + userId: string; + bookingId?: string; + amountKobo: number; + status: "PENDING" | "SUCCESSFUL" | "FAILED" | "REFUNDED"; + reference?: string; + provider?: string; + createdAt: string; + updatedAt: string; + booking?: { + id: string; + workspaceId: string; + workspace?: { + id: string; + name: string; + }; + }; +} + +export interface MemberDashboardData { + stats: { + activeBookings: number; + totalSpentKobo: number; + invoiceCount: number; + lastCheckIn: string | null; + }; + recentBookings: Booking[]; + recentPayments: Payment[]; +}