diff --git a/app/(admin)/admin/booking/[id]/page.tsx b/app/(admin)/admin/booking/[id]/page.tsx index eda1ac4..c4c6d10 100644 --- a/app/(admin)/admin/booking/[id]/page.tsx +++ b/app/(admin)/admin/booking/[id]/page.tsx @@ -541,9 +541,10 @@ export default function AppointmentDetailPage() { {appointment.jitsi_room_id}

@@ -555,22 +556,38 @@ export default function AppointmentDetailPage() { Meeting Link

- - {appointment.jitsi_meet_url} - - - - + {appointment.can_join_meeting ? ( + <> + + {appointment.jitsi_meet_url} + + + + + + ) : ( + <> +
+ {appointment.jitsi_meet_url} +
+ + + )}
{appointment.can_join_meeting !== undefined && ( diff --git a/app/(user)/user/appointments/[id]/page.tsx b/app/(user)/user/appointments/[id]/page.tsx new file mode 100644 index 0000000..9334a01 --- /dev/null +++ b/app/(user)/user/appointments/[id]/page.tsx @@ -0,0 +1,623 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { + Calendar, + Clock, + User, + Video, + CalendarCheck, + Loader2, + ArrowLeft, + Mail, + Phone as PhoneIcon, + MessageSquare, + CheckCircle2, + ExternalLink, + Copy, +} from "lucide-react"; +import { useAppTheme } from "@/components/ThemeProvider"; +import { getAppointmentDetail } from "@/lib/actions/appointments"; +import { Button } from "@/components/ui/button"; +import { Navbar } from "@/components/Navbar"; +import { toast } from "sonner"; +import type { Appointment } from "@/lib/models/appointments"; + +export default function UserAppointmentDetailPage() { + const params = useParams(); + const router = useRouter(); + const appointmentId = params.id as string; + + const [appointment, setAppointment] = useState(null); + const [loading, setLoading] = useState(true); + const { theme } = useAppTheme(); + const isDark = theme === "dark"; + + useEffect(() => { + const fetchAppointment = async () => { + if (!appointmentId) return; + + setLoading(true); + try { + const data = await getAppointmentDetail(appointmentId); + setAppointment(data); + } catch (error) { + toast.error("Failed to load appointment details"); + router.push("/user/dashboard"); + } finally { + setLoading(false); + } + }; + + fetchAppointment(); + }, [appointmentId, router]); + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }); + }; + + const formatTime = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + }; + + const formatShortDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }; + + const getStatusColor = (status: string) => { + const normalized = status.toLowerCase(); + if (isDark) { + switch (normalized) { + case "scheduled": + return "bg-blue-500/20 text-blue-300 border-blue-500/30"; + case "completed": + return "bg-green-500/20 text-green-300 border-green-500/30"; + case "rejected": + case "cancelled": + return "bg-red-500/20 text-red-300 border-red-500/30"; + case "pending_review": + case "pending": + return "bg-yellow-500/20 text-yellow-300 border-yellow-500/30"; + default: + return "bg-gray-700 text-gray-200 border-gray-600"; + } + } + switch (normalized) { + case "scheduled": + return "bg-blue-50 text-blue-700 border-blue-200"; + case "completed": + return "bg-green-50 text-green-700 border-green-200"; + case "rejected": + case "cancelled": + return "bg-red-50 text-red-700 border-red-200"; + case "pending_review": + case "pending": + return "bg-yellow-50 text-yellow-700 border-yellow-200"; + default: + return "bg-gray-100 text-gray-700 border-gray-300"; + } + }; + + const formatStatus = (status: string) => { + return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase()); + }; + + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied to clipboard`); + }; + + if (loading) { + return ( +
+ +
+
+ +

Loading appointment details...

+
+
+
+ ); + } + + if (!appointment) { + return ( +
+ +
+
+

Appointment not found

+ +
+
+
+ ); + } + + return ( +
+ +
+ {/* Page Header */} +
+ + +
+
+
+
+ +
+
+

+ Appointment Details +

+

+ Request #{appointment.id.slice(0, 8)} +

+
+
+
+ +
+ + {appointment.status === "scheduled" && } + {formatStatus(appointment.status)} + +
+
+
+ +
+ {/* Main Content - Left Column (2/3) */} +
+ {/* Appointment Information Card */} +
+
+

+ + Appointment Information +

+
+
+
+
+

+ Full Name +

+

+ {appointment.first_name} {appointment.last_name} +

+
+
+

+ + Email Address +

+
+

+ {appointment.email} +

+ +
+
+ {appointment.phone && ( +
+

+ + Phone Number +

+
+

+ {appointment.phone} +

+ +
+
+ )} +
+
+
+ + {/* Scheduled Appointment Details */} + {appointment.scheduled_datetime && ( +
+
+

+ + Scheduled Appointment +

+
+
+
+
+ +
+
+

+ {formatDate(appointment.scheduled_datetime)} +

+
+
+ +

+ {formatTime(appointment.scheduled_datetime)} +

+
+ {appointment.scheduled_duration && ( +
+ +

+ {appointment.meeting_duration_display || `${appointment.scheduled_duration} minutes`} +

+
+ )} +
+
+
+
+
+ )} + + {/* Preferred Dates & Times */} + {((appointment.preferred_dates && (Array.isArray(appointment.preferred_dates) ? appointment.preferred_dates.length > 0 : appointment.preferred_dates)) || + (appointment.preferred_time_slots && (Array.isArray(appointment.preferred_time_slots) ? appointment.preferred_time_slots.length > 0 : appointment.preferred_time_slots))) && ( +
+
+

+ Preferred Availability +

+
+
+ {appointment.preferred_dates && ( +
+

+ Preferred Dates +

+
+ {Array.isArray(appointment.preferred_dates) ? ( + (appointment.preferred_dates as string[]).map((date, idx) => ( + + {formatShortDate(date)} + + )) + ) : ( + + {appointment.preferred_dates_display || appointment.preferred_dates || 'N/A'} + + )} +
+
+ )} + {appointment.preferred_time_slots && ( +
+

+ Preferred Time Slots +

+
+ {Array.isArray(appointment.preferred_time_slots) ? ( + (appointment.preferred_time_slots as string[]).map((slot, idx) => { + const timeSlotLabels: Record = { + morning: "Morning", + afternoon: "Lunchtime", + evening: "Evening", + }; + const normalizedSlot = String(slot).toLowerCase().trim(); + return ( + + {timeSlotLabels[normalizedSlot] || slot} + + ); + }) + ) : ( + + {appointment.preferred_time_slots_display || appointment.preferred_time_slots || 'N/A'} + + )} +
+
+ )} +
+
+ )} + + {/* Matching Availability */} + {appointment.matching_availability && Array.isArray(appointment.matching_availability) && appointment.matching_availability.length > 0 && ( +
+
+

+ + Matching Availability + {appointment.are_preferences_available !== undefined && ( + + {appointment.are_preferences_available ? "Available" : "Partially Available"} + + )} +

+
+
+
+ {appointment.matching_availability.map((match: any, idx: number) => ( +
+
+
+

+ {match.day_name || "Unknown Day"} +

+

+ {formatShortDate(match.date || match.date_obj || "")} +

+
+
+ {match.available_slots && Array.isArray(match.available_slots) && match.available_slots.length > 0 && ( +
+ {match.available_slots.map((slot: string, slotIdx: number) => { + const timeSlotLabels: Record = { + morning: "Morning", + afternoon: "Lunchtime", + evening: "Evening", + }; + const normalizedSlot = String(slot).toLowerCase().trim(); + return ( + + {timeSlotLabels[normalizedSlot] || slot} + + ); + })} +
+ )} +
+ ))} +
+
+
+ )} + + {/* Reason */} + {appointment.reason && ( +
+
+

+ + Reason for Appointment +

+
+
+

+ {appointment.reason} +

+
+
+ )} + + {/* Rejection Reason */} + {appointment.rejection_reason && ( +
+
+

+ Rejection Reason +

+
+
+

+ {appointment.rejection_reason} +

+
+
+ )} + + {/* Meeting Information */} + {appointment.jitsi_meet_url && ( +
+
+

+

+
+
+ {appointment.jitsi_room_id && ( +
+

+ Meeting Room ID +

+
+

+ {appointment.jitsi_room_id} +

+ +
+
+ )} +
+

+ Meeting Link +

+
+ {appointment.can_join_meeting ? ( + <> + + {appointment.jitsi_meet_url} + + + + + + ) : ( + <> +
+ {appointment.jitsi_meet_url} +
+ + + )} +
+
+ {appointment.can_join_meeting !== undefined && ( +
+
+

+ {appointment.can_join_meeting ? "Meeting is active - You can join now" : "Meeting is not available yet"} +

+
+ )} +
+
+ )} +
+ + {/* Sidebar - Right Column (1/3) */} +
+ {/* Quick Info Card */} +
+
+

+ Quick Info +

+
+
+
+

+ Created +

+

+ {formatShortDate(appointment.created_at)} +

+

+ {formatTime(appointment.created_at)} +

+
+
+

+ Status +

+ + {appointment.status === "scheduled" && } + {formatStatus(appointment.status)} + +
+
+
+ + {/* Join Meeting Button */} + {appointment.status === "scheduled" && appointment.jitsi_meet_url && ( +
+
+ {appointment.can_join_meeting ? ( + + + ) : ( + + )} +
+
+ )} +
+
+
+
+ ); +} + diff --git a/app/(user)/user/dashboard/page.tsx b/app/(user)/user/dashboard/page.tsx index b51c275..d5217b9 100644 --- a/app/(user)/user/dashboard/page.tsx +++ b/app/(user)/user/dashboard/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Calendar, @@ -22,11 +23,12 @@ import Link from "next/link"; import { Navbar } from "@/components/Navbar"; import { useAppTheme } from "@/components/ThemeProvider"; import { useAuth } from "@/hooks/useAuth"; -import { listAppointments, getUserAppointmentStats } from "@/lib/actions/appointments"; +import { getUserAppointments, getUserAppointmentStats } from "@/lib/actions/appointments"; import type { Appointment, UserAppointmentStats } from "@/lib/models/appointments"; import { toast } from "sonner"; export default function UserDashboard() { + const router = useRouter(); const { theme } = useAppTheme(); const isDark = theme === "dark"; const { user } = useAuth(); @@ -35,12 +37,12 @@ export default function UserDashboard() { const [stats, setStats] = useState(null); const [loadingStats, setLoadingStats] = useState(true); - // Fetch appointments using the same endpoint as admin booking table + // Fetch user appointments from user-specific endpoint useEffect(() => { const fetchAppointments = async () => { setLoading(true); try { - const data = await listAppointments(); + const data = await getUserAppointments(); setAppointments(data || []); } catch (error) { toast.error("Failed to load appointments. Please try again."); @@ -53,21 +55,15 @@ export default function UserDashboard() { fetchAppointments(); }, []); - // Fetch stats from API using user email + // Fetch stats from API for authenticated user useEffect(() => { const fetchStats = async () => { - if (!user?.email) { - setLoadingStats(false); - return; - } - setLoadingStats(true); try { - const statsData = await getUserAppointmentStats(user.email); + const statsData = await getUserAppointmentStats(); setStats(statsData); } catch (error) { toast.error("Failed to load appointment statistics."); - // Set default stats on error setStats({ total_requests: 0, pending_review: 0, @@ -75,7 +71,6 @@ export default function UserDashboard() { rejected: 0, completed: 0, completion_rate: 0, - email: user.email, }); } finally { setLoadingStats(false); @@ -83,7 +78,7 @@ export default function UserDashboard() { }; fetchStats(); - }, [user?.email]); + }, []); const formatDate = (dateString: string) => { const date = new Date(dateString); @@ -221,17 +216,17 @@ export default function UserDashboard() { <> {/* Stats Grid */}
-
-
-
- -
+
+
+ +
+
- + {displayStats.scheduled > 0 ? `+${displayStats.scheduled}` : "0"}
@@ -256,7 +251,7 @@ export default function UserDashboard() {
- + {displayStats.completed > 0 ? `+${displayStats.completed}` : "0"}
@@ -268,8 +263,8 @@ export default function UserDashboard() { {displayStats.completed}

vs last month

-
-
+
+
{`${Math.round(displayStats.completion_rate || 0)}%`}
-
-

+
+

Total Appointments -

-

+

+

{displayStats.total_requests} -

-

vs last month

-
+

+

vs last month

+

All Appointments -

+
@@ -387,8 +382,9 @@ export default function UserDashboard() { return ( router.push(`/user/appointments/${appointment.id}`)} > ); diff --git a/hooks/useAppointments.ts b/hooks/useAppointments.ts index 0ab8800..728f586 100644 --- a/hooks/useAppointments.ts +++ b/hooks/useAppointments.ts @@ -138,17 +138,11 @@ export function useAppointments(options?: { staleTime: 1 * 60 * 1000, // 1 minute }); - // Get user appointment stats query - disabled because it requires email parameter - // Use getUserAppointmentStats(email) directly where email is available const userAppointmentStatsQuery = useQuery({ queryKey: ["appointments", "user", "stats"], - queryFn: async () => { - // This query is disabled - getUserAppointmentStats requires email parameter - // Use getUserAppointmentStats(email) directly in components where email is available - return {} as UserAppointmentStats; - }, - enabled: false, // Disabled - requires email parameter which hook doesn't have access to - staleTime: 1 * 60 * 1000, // 1 minute + queryFn: () => getUserAppointmentStats(), + enabled: enableStats, + staleTime: 1 * 60 * 1000, }); // Get Jitsi meeting info query diff --git a/lib/actions/appointments.ts b/lib/actions/appointments.ts index ba86a38..4fde1ae 100644 --- a/lib/actions/appointments.ts +++ b/lib/actions/appointments.ts @@ -624,45 +624,26 @@ export async function getAppointmentStats(): Promise { return data; } -export async function getUserAppointmentStats(email: string): Promise { +export async function getUserAppointmentStats(): Promise { const tokens = getStoredTokens(); if (!tokens.access) { throw new Error("Authentication required."); } - if (!email) { - throw new Error("Email is required to fetch user appointment stats."); - } - const response = await fetch(API_ENDPOINTS.meetings.userAppointmentStats, { - method: "POST", + method: "GET", headers: { "Content-Type": "application/json", Authorization: `Bearer ${tokens.access}`, }, - body: JSON.stringify({ email }), }); - const responseText = await response.text(); - + const data = await parseResponse(response); if (!response.ok) { - let errorData: any; - try { - errorData = JSON.parse(responseText); - } catch { - throw new Error(`Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`); - } - throw new Error(extractErrorMessage(errorData as unknown as ApiError)); + throw new Error(extractErrorMessage(data as unknown as ApiError)); } - try { - if (!responseText || responseText.trim().length === 0) { - throw new Error("Empty response from server"); - } - return JSON.parse(responseText); - } catch { - throw new Error("Failed to parse response: Invalid JSON format"); - } + return data; } export async function getJitsiMeetingInfo(id: string): Promise {
@@ -404,9 +400,9 @@ export default function UserDashboard() { {appointment.reason}
)} - {appointment.scheduled_datetime && ( + {appointment.scheduled_datetime && (
- {formatDate(appointment.scheduled_datetime)} + {formatDate(appointment.scheduled_datetime)}
)} @@ -420,9 +416,9 @@ export default function UserDashboard() {
- {formatTime(appointment.scheduled_datetime)} -
- + {formatTime(appointment.scheduled_datetime)} + + ) : (
Not scheduled @@ -451,8 +447,8 @@ export default function UserDashboard() { ))} {appointment.selected_slots.length > 2 && ( +{appointment.selected_slots.length - 2} more - )} -
+ )} + ) : ( "-" )} @@ -463,10 +459,10 @@ export default function UserDashboard() {
+ > +