From cea4747da50cc688fdc8f98507747d63120ac4a2 Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Thu, 27 Nov 2025 20:35:26 +0000 Subject: [PATCH] Enhance appointment detail and user dashboard components to conditionally render meeting links and buttons based on availability. Update appointment stats fetching logic to remove email dependency, improving user experience and API interaction. Refactor UI elements for better accessibility and clarity. --- app/(admin)/admin/booking/[id]/page.tsx | 55 +- app/(user)/user/appointments/[id]/page.tsx | 623 +++++++++++++++++++++ app/(user)/user/dashboard/page.tsx | 97 ++-- hooks/useAppointments.ts | 12 +- lib/actions/appointments.ts | 29 +- 5 files changed, 714 insertions(+), 102 deletions(-) create mode 100644 app/(user)/user/appointments/[id]/page.tsx 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() {
+ > +