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 {