From 4f6e64bf993b8b9afdb96ee0ec9a1de0f77f8b33 Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Sun, 23 Nov 2025 22:28:02 +0000 Subject: [PATCH] Refactor Booking and Dashboard components to integrate appointment management and enhance data fetching logic. Replace mock data with API calls for appointments and user statistics, improving error handling and user feedback. Update UI elements for better search functionality and display of appointment details. --- app/(admin)/admin/booking/page.tsx | 269 +++++++++++---------------- app/(admin)/admin/dashboard/page.tsx | 139 +++++++++++--- hooks/useAuth.ts | 51 ++++- lib/actions/appointments.ts | 32 +++- lib/actions/auth.ts | 73 +++++++- lib/api_urls.ts | 1 + lib/models/appointments.ts | 1 + 7 files changed, 373 insertions(+), 193 deletions(-) diff --git a/app/(admin)/admin/booking/page.tsx b/app/(admin)/admin/booking/page.tsx index e670d9f..3abe9f7 100644 --- a/app/(admin)/admin/booking/page.tsx +++ b/app/(admin)/admin/booking/page.tsx @@ -8,104 +8,36 @@ import { Video, FileText, MoreVertical, + Search, } from "lucide-react"; import { useAppTheme } from "@/components/ThemeProvider"; - -interface User { - ID: number; - CreatedAt?: string; - UpdatedAt?: string; - DeletedAt?: string | null; - first_name: string; - last_name: string; - email: string; - phone: string; - location: string; - date_of_birth?: string; - is_admin?: boolean; - bookings?: null; -} - -interface Booking { - ID: number; - CreatedAt: string; - UpdatedAt: string; - DeletedAt: string | null; - user_id: number; - user: User; - scheduled_at: string; - duration: number; - status: string; - jitsi_room_id: string; - jitsi_room_url: string; - payment_id: string; - payment_status: string; - amount: number; - notes: string; -} - -interface BookingsResponse { - bookings: Booking[]; - limit: number; - offset: number; - total: number; -} +import { listAppointments } from "@/lib/actions/appointments"; +import { Input } from "@/components/ui/input"; +import { toast } from "sonner"; +import type { Appointment } from "@/lib/models/appointments"; export default function Booking() { - const [bookings, setBookings] = useState([]); + const [appointments, setAppointments] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const { theme } = useAppTheme(); const isDark = theme === "dark"; useEffect(() => { - // Simulate API call const fetchBookings = async () => { setLoading(true); - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Mock API response - const mockData: BookingsResponse = { - bookings: [ - { - ID: 1, - CreatedAt: "2025-11-06T11:33:45.704633Z", - UpdatedAt: "2025-11-06T11:33:45.707543Z", - DeletedAt: null, - user_id: 3, - user: { - ID: 3, - CreatedAt: "2025-11-06T10:43:01.299311Z", - UpdatedAt: "2025-11-06T10:43:48.427284Z", - DeletedAt: null, - first_name: "John", - last_name: "Smith", - email: "john.doe@example.com", - phone: "+1234567891", - location: "Los Angeles, CA", - date_of_birth: "0001-01-01T00:00:00Z", - is_admin: true, - bookings: null, - }, - scheduled_at: "2025-11-07T10:00:00Z", - duration: 60, - status: "scheduled", - jitsi_room_id: "booking-1-1762428825-22c92ced2870c17c", - jitsi_room_url: - "https://meet.jit.si/booking-1-1762428825-22c92ced2870c17c", - payment_id: "", - payment_status: "pending", - amount: 52, - notes: "Initial consultation session", - }, - ], - limit: 50, - offset: 0, - total: 1, - }; - - setBookings(mockData.bookings); - setLoading(false); + try { + const data = await listAppointments(); + console.log("Fetched appointments:", data); + console.log("Appointments count:", data?.length); + setAppointments(data || []); + } catch (error) { + console.error("Failed to fetch appointments:", error); + toast.error("Failed to load appointments. Please try again."); + setAppointments([]); + } finally { + setLoading(false); + } }; fetchBookings(); @@ -137,8 +69,10 @@ export default function Booking() { return "bg-blue-500/20 text-blue-200"; case "completed": return "bg-green-500/20 text-green-200"; + case "rejected": case "cancelled": return "bg-red-500/20 text-red-200"; + case "pending_review": case "pending": return "bg-yellow-500/20 text-yellow-200"; default: @@ -150,8 +84,10 @@ export default function Booking() { return "bg-blue-100 text-blue-700"; case "completed": return "bg-green-100 text-green-700"; + case "rejected": case "cancelled": return "bg-red-100 text-red-700"; + case "pending_review": case "pending": return "bg-yellow-100 text-yellow-700"; default: @@ -159,41 +95,20 @@ export default function Booking() { } }; - const getPaymentStatusColor = (status: string) => { - const normalized = status.toLowerCase(); - if (isDark) { - switch (normalized) { - case "paid": - return "bg-green-500/20 text-green-200"; - case "pending": - return "bg-yellow-500/20 text-yellow-200"; - case "failed": - return "bg-red-500/20 text-red-200"; - default: - return "bg-gray-700 text-gray-200"; - } - } - switch (normalized) { - case "paid": - return "bg-green-100 text-green-700"; - case "pending": - return "bg-yellow-100 text-yellow-700"; - case "failed": - return "bg-red-100 text-red-700"; - default: - return "bg-gray-100 text-gray-700"; - } + const formatStatus = (status: string) => { + return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase()); }; - const filteredBookings = bookings.filter( - (booking) => - booking.user.first_name + const filteredAppointments = appointments.filter( + (appointment) => + appointment.first_name .toLowerCase() .includes(searchTerm.toLowerCase()) || - booking.user.last_name + appointment.last_name .toLowerCase() .includes(searchTerm.toLowerCase()) || - booking.user.email.toLowerCase().includes(searchTerm.toLowerCase()) + appointment.email.toLowerCase().includes(searchTerm.toLowerCase()) || + (appointment.phone && appointment.phone.toLowerCase().includes(searchTerm.toLowerCase())) ); return ( @@ -202,34 +117,42 @@ export default function Booking() { {/* Main Content */}
{/* Page Header */} -
-
-

- Bookings -

-

- Manage and view all appointment bookings -

+
+
+
+

+ Bookings +

+

+ Manage and view all appointment bookings +

+
+
+ {/* Search Bar */} +
+ + setSearchTerm(e.target.value)} + className={`pl-10 ${isDark ? "bg-gray-800 border-gray-700 text-white placeholder:text-gray-400" : "bg-white border-gray-200 text-gray-900 placeholder:text-gray-500"}`} + />
-
{loading ? (
- ) : filteredBookings.length === 0 ? ( + ) : filteredAppointments.length === 0 ? (

No bookings found

{searchTerm ? "Try adjusting your search terms" - : "Create a new booking to get started"} + : "No appointments have been created yet"}

) : ( @@ -251,10 +174,10 @@ export default function Booking() { Status - Payment + Preferred Dates - Amount + Created Actions @@ -262,9 +185,9 @@ export default function Booking() { - {filteredBookings.map((booking) => ( + {filteredAppointments.map((appointment) => ( @@ -274,55 +197,75 @@ export default function Booking() {
- {booking.user.first_name} {booking.user.last_name} + {appointment.first_name} {appointment.last_name}
-
- {formatDate(booking.scheduled_at)} + {appointment.email}
+ {appointment.phone && ( + + )} + {appointment.scheduled_datetime && ( +
+ {formatDate(appointment.scheduled_datetime)} +
+ )}
-
- {formatDate(booking.scheduled_at)} -
-
- - {formatTime(booking.scheduled_at)} -
+ {appointment.scheduled_datetime ? ( + <> +
+ {formatDate(appointment.scheduled_datetime)} +
+
+ + {formatTime(appointment.scheduled_datetime)} +
+ + ) : ( +
+ Not scheduled +
+ )} - {booking.duration} min + {appointment.scheduled_duration ? `${appointment.scheduled_duration} min` : "-"} - {booking.status} + {formatStatus(appointment.status)} - - - {booking.payment_status} - + + {appointment.preferred_dates && appointment.preferred_dates.length > 0 ? ( +
+ {appointment.preferred_dates.slice(0, 2).map((date, idx) => ( + {formatDate(date)} + ))} + {appointment.preferred_dates.length > 2 && ( + +{appointment.preferred_dates.length - 2} more + )} +
+ ) : ( + "-" + )} - - ${booking.amount} + + {formatDate(appointment.created_at)}
- {booking.jitsi_room_url && ( + {appointment.jitsi_meet_url && ( )} - {booking.notes && ( + {appointment.reason && ( diff --git a/app/(admin)/admin/dashboard/page.tsx b/app/(admin)/admin/dashboard/page.tsx index 2bb5fc4..d500554 100644 --- a/app/(admin)/admin/dashboard/page.tsx +++ b/app/(admin)/admin/dashboard/page.tsx @@ -20,6 +20,11 @@ import { ArrowDownRight, } from "lucide-react"; import { useAppTheme } from "@/components/ThemeProvider"; +import { getAllUsers } from "@/lib/actions/auth"; +import { getAppointmentStats, listAppointments } from "@/lib/actions/appointments"; +import { toast } from "sonner"; +import type { User } from "@/lib/models/auth"; +import type { Appointment } from "@/lib/models/appointments"; interface DashboardStats { total_users: number; @@ -30,6 +35,16 @@ interface DashboardStats { cancelled_bookings: number; total_revenue: number; monthly_revenue: number; + trends: { + total_users: string; + active_users: string; + total_bookings: string; + upcoming_bookings: string; + completed_bookings: string; + cancelled_bookings: string; + total_revenue: string; + monthly_revenue: string; + }; } export default function Dashboard() { @@ -40,86 +55,166 @@ export default function Dashboard() { const isDark = theme === "dark"; useEffect(() => { - // Simulate API call const fetchStats = async () => { setLoading(true); - // Simulate network delay - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Mock API response - const mockData: DashboardStats = { - total_users: 3, - active_users: 3, - total_bookings: 6, - upcoming_bookings: 6, + try { + // Fetch all data in parallel + const [users, appointmentStats, appointments] = await Promise.all([ + getAllUsers().catch(() => [] as User[]), + getAppointmentStats().catch(() => null), + listAppointments().catch(() => [] as Appointment[]), + ]); + + // Calculate statistics + // Use users count from appointment stats if available, otherwise use getAllUsers result + const totalUsers = appointmentStats?.users ?? users.length; + const activeUsers = users.filter( + (user) => user.is_active === true || user.isActive === true + ).length; + + const totalBookings = appointmentStats?.total_requests || appointments.length; + const upcomingBookings = appointmentStats?.scheduled || + appointments.filter((apt) => apt.status === "scheduled").length; + // Completed bookings - not in API status types, so set to 0 + const completedBookings = 0; + const cancelledBookings = appointmentStats?.rejected || + appointments.filter((apt) => apt.status === "rejected").length; + + // Calculate revenue (assuming appointments have amount field, defaulting to 0) + const now = new Date(); + const currentMonth = now.getMonth(); + const currentYear = now.getFullYear(); + + const totalRevenue = appointments.reduce((sum, apt) => { + // If appointment has amount field, use it, otherwise default to 0 + const amount = (apt as any).amount || 0; + return sum + amount; + }, 0); + + const monthlyRevenue = appointments + .filter((apt) => { + if (!apt.scheduled_datetime) return false; + const aptDate = new Date(apt.scheduled_datetime); + return ( + aptDate.getMonth() === currentMonth && + aptDate.getFullYear() === currentYear + ); + }) + .reduce((sum, apt) => { + const amount = (apt as any).amount || 0; + return sum + amount; + }, 0); + + // For now, use static trends (in a real app, you'd calculate these from historical data) + const trends = { + total_users: "+12%", + active_users: "+8%", + total_bookings: "+24%", + upcoming_bookings: "+6", + completed_bookings: "0%", + cancelled_bookings: "0%", + total_revenue: "+18%", + monthly_revenue: "+32%", + }; + + setStats({ + total_users: totalUsers, + active_users: activeUsers, + total_bookings: totalBookings, + upcoming_bookings: upcomingBookings, + completed_bookings: completedBookings, + cancelled_bookings: cancelledBookings, + total_revenue: totalRevenue, + monthly_revenue: monthlyRevenue, + trends, + }); + } catch (error) { + console.error("Failed to fetch dashboard stats:", error); + toast.error("Failed to load dashboard statistics"); + // Set default values on error + setStats({ + total_users: 0, + active_users: 0, + total_bookings: 0, + upcoming_bookings: 0, completed_bookings: 0, cancelled_bookings: 0, total_revenue: 0, monthly_revenue: 0, - }; - - setStats(mockData); + trends: { + total_users: "0%", + active_users: "0%", + total_bookings: "0%", + upcoming_bookings: "0", + completed_bookings: "0%", + cancelled_bookings: "0%", + total_revenue: "0%", + monthly_revenue: "0%", + }, + }); + } finally { setLoading(false); + } }; fetchStats(); - }, []); + }, [timePeriod]); const statCards = [ { title: "Total Users", value: stats?.total_users ?? 0, icon: Users, - trend: "+12%", + trend: stats?.trends.total_users ?? "0%", trendUp: true, }, { title: "Active Users", value: stats?.active_users ?? 0, icon: UserCheck, - trend: "+8%", + trend: stats?.trends.active_users ?? "0%", trendUp: true, }, { title: "Total Bookings", value: stats?.total_bookings ?? 0, icon: Calendar, - trend: "+24%", + trend: stats?.trends.total_bookings ?? "0%", trendUp: true, }, { title: "Upcoming Bookings", value: stats?.upcoming_bookings ?? 0, icon: CalendarCheck, - trend: "+6", + trend: stats?.trends.upcoming_bookings ?? "0", trendUp: true, }, { title: "Completed Bookings", value: stats?.completed_bookings ?? 0, icon: CalendarCheck, - trend: "0%", + trend: stats?.trends.completed_bookings ?? "0%", trendUp: true, }, { title: "Cancelled Bookings", value: stats?.cancelled_bookings ?? 0, icon: CalendarX, - trend: "0%", + trend: stats?.trends.cancelled_bookings ?? "0%", trendUp: false, }, { title: "Total Revenue", value: `$${stats?.total_revenue.toLocaleString() ?? 0}`, icon: DollarSign, - trend: "+18%", + trend: stats?.trends.total_revenue ?? "0%", trendUp: true, }, { title: "Monthly Revenue", value: `$${stats?.monthly_revenue.toLocaleString() ?? 0}`, icon: TrendingUp, - trend: "+32%", + trend: stats?.trends.monthly_revenue ?? "0%", trendUp: true, }, ]; diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts index cabc484..ab59df6 100644 --- a/hooks/useAuth.ts +++ b/hooks/useAuth.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { loginUser, registerUser, @@ -17,6 +17,8 @@ import { storeTokens, storeUser, clearAuthData, + isTokenExpired, + hasValidAuth, } from "@/lib/actions/auth"; import type { LoginInput, @@ -28,6 +30,7 @@ import type { ResetPasswordInput, } from "@/lib/schema/auth"; import type { User } from "@/lib/models/auth"; +import { toast } from "sonner"; export function useAuth() { const router = useRouter(); @@ -40,8 +43,8 @@ export function useAuth() { staleTime: Infinity, }); - // Check if user is authenticated - const isAuthenticated = !!user && !!getStoredTokens().access; + // Check if user is authenticated with valid token + const isAuthenticated = !!user && hasValidAuth(); // Check if user is admin (check multiple possible field names) const isAdmin = @@ -108,6 +111,12 @@ export function useAuth() { mutationFn: (refresh: string) => refreshToken({ refresh }), onSuccess: (tokens) => { storeTokens(tokens); + queryClient.invalidateQueries({ queryKey: ["auth"] }); + }, + onError: () => { + // If refresh fails, logout + clearAuthData(); + queryClient.clear(); }, }); @@ -118,6 +127,42 @@ export function useAuth() { // Don't redirect here - let components handle redirect as needed }, [queryClient]); + // Auto-logout if token is expired or missing + useEffect(() => { + const checkAuth = () => { + const tokens = getStoredTokens(); + const storedUser = getStoredUser(); + + // If user exists but no token or token is expired, logout + if (storedUser && (!tokens.access || isTokenExpired(tokens.access))) { + // Try to refresh token first if refresh token exists + if (tokens.refresh && !isTokenExpired(tokens.refresh)) { + refreshTokenMutation.mutate(tokens.refresh, { + onError: () => { + // If refresh fails, logout + clearAuthData(); + queryClient.clear(); + toast.error("Your session has expired. Please log in again."); + }, + }); + } else { + // No valid refresh token, logout immediately + clearAuthData(); + queryClient.clear(); + toast.error("Your session has expired. Please log in again."); + } + } + }; + + // Check immediately + checkAuth(); + + // Check every 30 seconds + const interval = setInterval(checkAuth, 30000); + + return () => clearInterval(interval); + }, [queryClient, refreshTokenMutation]); + // Login function const login = useCallback( async (input: LoginInput) => { diff --git a/lib/actions/appointments.ts b/lib/actions/appointments.ts index de02ce6..a5b5c46 100644 --- a/lib/actions/appointments.ts +++ b/lib/actions/appointments.ts @@ -121,14 +121,26 @@ export async function listAppointments(email?: string): Promise { }, }); - const data: AppointmentsListResponse = await response.json(); + const data = await response.json(); if (!response.ok) { const errorMessage = extractErrorMessage(data as ApiError); throw new Error(errorMessage); } - return data.appointments || []; + // Handle different response formats + // API might return array directly or wrapped in an object + if (Array.isArray(data)) { + return data; + } + if (data.appointments && Array.isArray(data.appointments)) { + return data.appointments; + } + if (data.results && Array.isArray(data.results)) { + return data.results; + } + + return []; } // Get user appointments @@ -147,14 +159,26 @@ export async function getUserAppointments(): Promise { }, }); - const data: AppointmentsListResponse = await response.json(); + const data = await response.json(); if (!response.ok) { const errorMessage = extractErrorMessage(data as ApiError); throw new Error(errorMessage); } - return data.appointments || []; + // Handle different response formats + // API might return array directly or wrapped in an object + if (Array.isArray(data)) { + return data; + } + if (data.appointments && Array.isArray(data.appointments)) { + return data.appointments; + } + if (data.results && Array.isArray(data.results)) { + return data.results; + } + + return []; } // Get appointment detail diff --git a/lib/actions/auth.ts b/lib/actions/auth.ts index 93a0bf2..4056bf1 100644 --- a/lib/actions/auth.ts +++ b/lib/actions/auth.ts @@ -229,6 +229,35 @@ export async function refreshToken(input: TokenRefreshInput): Promise(response); } +// Decode JWT token to check expiration +function decodeJWT(token: string): { exp?: number; [key: string]: any } | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + + const payload = parts[1]; + const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/"))); + return decoded; + } catch (error) { + return null; + } +} + +// Check if token is expired +export function isTokenExpired(token: string | null): boolean { + if (!token) return true; + + const decoded = decodeJWT(token); + if (!decoded || !decoded.exp) return true; + + // exp is in seconds, Date.now() is in milliseconds + const expirationTime = decoded.exp * 1000; + const currentTime = Date.now(); + + // Consider token expired if it expires within the next 5 seconds (buffer) + return currentTime >= (expirationTime - 5000); +} + // Get stored tokens export function getStoredTokens(): { access: string | null; refresh: string | null } { if (typeof window === "undefined") { @@ -241,6 +270,14 @@ export function getStoredTokens(): { access: string | null; refresh: string | nu }; } +// Check if user has valid authentication +export function hasValidAuth(): boolean { + const tokens = getStoredTokens(); + if (!tokens.access) return false; + + return !isTokenExpired(tokens.access); +} + // Store tokens export function storeTokens(tokens: AuthTokens): void { if (typeof window === "undefined") return; @@ -292,9 +329,43 @@ export function clearAuthData(): void { // Get auth header for API requests export function getAuthHeader(): { Authorization: string } | {} { const tokens = getStoredTokens(); - if (tokens.access) { + if (tokens.access && !isTokenExpired(tokens.access)) { return { Authorization: `Bearer ${tokens.access}` }; } return {}; } +// Get all users (Admin only) +export async function getAllUsers(): Promise { + const tokens = getStoredTokens(); + + if (!tokens.access) { + throw new Error("Authentication required."); + } + + const response = await fetch(API_ENDPOINTS.auth.allUsers, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens.access}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + const errorMessage = extractErrorMessage(data); + throw new Error(errorMessage); + } + + // Handle different response formats + if (data.users) { + return data.users; + } + if (Array.isArray(data)) { + return data; + } + + return []; +} + diff --git a/lib/api_urls.ts b/lib/api_urls.ts index 593e509..297fc83 100644 --- a/lib/api_urls.ts +++ b/lib/api_urls.ts @@ -20,6 +20,7 @@ export const API_ENDPOINTS = { verifyPasswordResetOtp: `${API_BASE_URL}/auth/verify-password-reset-otp/`, resetPassword: `${API_BASE_URL}/auth/reset-password/`, tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`, + allUsers: `${API_BASE_URL}/auth/all-users/`, }, meetings: { base: `${API_BASE_URL}/meetings/`, diff --git a/lib/models/appointments.ts b/lib/models/appointments.ts index 26cbd0e..7fef918 100644 --- a/lib/models/appointments.ts +++ b/lib/models/appointments.ts @@ -52,6 +52,7 @@ export interface AppointmentStats { scheduled: number; rejected: number; completion_rate: number; + users?: number; // Total users count from API } export interface JitsiMeetingInfo {