Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 7 additions & 51 deletions backend/src/dashboard/dashboard.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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')
Expand Down Expand Up @@ -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 };
}
}
89 changes: 83 additions & 6 deletions backend/src/dashboard/providers/member-dashboard.provider.ts
Original file line number Diff line number Diff line change
@@ -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<Booking>,
@InjectRepository(Payment)
private readonly paymentRepository: Repository<Payment>,
@InjectRepository(Invoice)
private readonly invoiceRepository: Repository<Invoice>,
@InjectRepository(Workspace)
private readonly workspaceRepository: Repository<Workspace>,
) {}

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,
};
}
}
159 changes: 157 additions & 2 deletions frontend/app/dashboard/DashboardContent.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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<Stats | null>(null);
const [activity, setActivity] = useState<ActivityItem[]>([]);
Expand All @@ -69,6 +95,8 @@ export default function DashboardContent() {
limit: 10,
totalPages: 0,
});
const [recentBookings, setRecentBookings] = useState<RecentBooking[]>([]);
const [recentPayments, setRecentPayments] = useState<RecentPayment[]>([]);
const [loading, setLoading] = useState(true);

const fetchData = useCallback(async () => {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -132,7 +173,121 @@ export default function DashboardContent() {
) : (
<div className="space-y-6">
{/* Stats cards */}
<StatsCards stats={stats} />
{isAdmin ? (
<StatsCards stats={stats} />
) : (
<MemberStatsCards />
)}

{/* Member recent activity sections */}
{!isAdmin && (
<>
{/* Recent Bookings */}
<div className="bg-white rounded-xl border border-gray-100 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">
Recent Bookings
</h2>
<Link
href="/bookings"
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
View all
</Link>
</div>
{recentBookings.length === 0 ? (
<p className="text-gray-500 text-sm py-4">
No recent bookings found.
</p>
) : (
<div className="space-y-3">
{recentBookings.map((booking) => (
<div
key={booking.id}
className="flex items-center justify-between py-3 border-b border-gray-50 last:border-0"
>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
{booking.workspace.name}
</p>
<p className="text-xs text-gray-500">
{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",
})}
</p>
</div>
<span
className={`px-2.5 py-1 rounded-full text-xs font-medium ${
booking.status === "CONFIRMED"
? "bg-green-50 text-green-700"
: booking.status === "PENDING"
? "bg-yellow-50 text-yellow-700"
: booking.status === "CANCELLED"
? "bg-red-50 text-red-700"
: "bg-gray-50 text-gray-700"
}`}
>
{booking.status}
</span>
</div>
))}
</div>
)}
</div>

{/* Recent Payments */}
<div className="bg-white rounded-xl border border-gray-100 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">
Recent Payments
</h2>
<Link
href="/payments"
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
View all
</Link>
</div>
{recentPayments.length === 0 ? (
<p className="text-gray-500 text-sm py-4">
No recent payments found.
</p>
) : (
<div className="space-y-3">
{recentPayments.map((payment) => (
<div
key={payment.id}
className="flex items-center justify-between py-3 border-b border-gray-50 last:border-0"
>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
{payment.booking?.workspace?.name || "Workspace Booking"}
</p>
<p className="text-xs text-gray-500">
{new Date(payment.createdAt).toLocaleDateString("en-NG", {
month: "short",
day: "numeric",
year: "numeric",
})}
</p>
</div>
<p className="text-sm font-semibold text-gray-900">
₦{(payment.amountKobo / 100).toLocaleString()}
</p>
</div>
))}
</div>
)}
</div>
</>
)}

{/* Middle row — activity + quick actions */}
<div className="grid lg:grid-cols-2 gap-6">
Expand Down
Loading
Loading