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.

This commit is contained in:
iamkiddy 2025-11-27 20:35:26 +00:00
parent a864d909a8
commit cea4747da5
5 changed files with 714 additions and 102 deletions

View File

@ -541,9 +541,10 @@ export default function AppointmentDetailPage() {
{appointment.jitsi_room_id}
</p>
<button
onClick={() => copyToClipboard(appointment.jitsi_room_id!, "Room ID")}
className={`p-2 rounded-lg hover:bg-opacity-80 transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-100"}`}
title="Copy room ID"
onClick={() => appointment.can_join_meeting && copyToClipboard(appointment.jitsi_room_id!, "Room ID")}
disabled={!appointment.can_join_meeting}
className={`p-2 rounded-lg transition-colors ${appointment.can_join_meeting ? (isDark ? "hover:bg-gray-700" : "hover:bg-gray-100") : (isDark ? "opacity-50 cursor-not-allowed" : "opacity-50 cursor-not-allowed")}`}
title={appointment.can_join_meeting ? "Copy room ID" : "Meeting not available"}
>
<Copy className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
</button>
@ -555,6 +556,8 @@ export default function AppointmentDetailPage() {
Meeting Link
</p>
<div className="flex items-center gap-2">
{appointment.can_join_meeting ? (
<>
<a
href={appointment.jitsi_meet_url}
target="_blank"
@ -571,6 +574,20 @@ export default function AppointmentDetailPage() {
>
<ExternalLink className="w-4 h-4" />
</a>
</>
) : (
<>
<div className={`flex-1 text-sm px-3 py-2 rounded-lg truncate ${isDark ? "bg-gray-800/50 text-gray-500 border border-gray-700" : "bg-gray-100 text-gray-400 border border-gray-300"}`}>
{appointment.jitsi_meet_url}
</div>
<button
disabled
className={`px-4 py-2 rounded-lg font-medium cursor-not-allowed ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
>
<ExternalLink className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
{appointment.can_join_meeting !== undefined && (

View File

@ -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<Appointment | null>(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 (
<div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
<Navbar />
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center">
<div className="text-center">
<Loader2 className={`w-12 h-12 animate-spin mx-auto mb-4 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-600"}`}>Loading appointment details...</p>
</div>
</div>
</div>
);
}
if (!appointment) {
return (
<div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
<Navbar />
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center">
<div className="text-center">
<p className={`text-lg mb-4 ${isDark ? "text-gray-400" : "text-gray-600"}`}>Appointment not found</p>
<Button
onClick={() => router.push("/user/dashboard")}
className="bg-rose-600 hover:bg-rose-700 text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Dashboard
</Button>
</div>
</div>
</div>
);
}
return (
<div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
<Navbar />
<main className="container mx-auto px-4 sm:px-6 lg:px-8 space-y-6 pt-20 sm:pt-24 pb-8">
{/* Page Header */}
<div className="flex flex-col gap-3 sm:gap-4">
<Button
variant="ghost"
onClick={() => router.push("/user/dashboard")}
className={`flex items-center gap-2 w-fit ${isDark ? "text-gray-300 hover:bg-gray-800 hover:text-white" : "text-gray-600 hover:bg-gray-100"}`}
>
<ArrowLeft className="w-4 h-4" />
Back to Dashboard
</Button>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-2">
<div className={`h-12 w-12 sm:h-16 sm:w-16 rounded-full flex items-center justify-center text-xl sm:text-2xl font-bold ${isDark ? "bg-gradient-to-br from-rose-500 to-pink-600 text-white" : "bg-gradient-to-br from-rose-100 to-pink-100 text-rose-600"}`}>
<CalendarCheck className="w-6 h-6 sm:w-8 sm:h-8" />
</div>
<div>
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${isDark ? "text-white" : "text-gray-900"}`}>
Appointment Details
</h1>
<p className={`text-xs sm:text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Request #{appointment.id.slice(0, 8)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<span
className={`px-3 sm:px-4 py-2 inline-flex items-center gap-2 text-xs sm:text-sm font-semibold rounded-full border ${getStatusColor(
appointment.status
)}`}
>
{appointment.status === "scheduled" && <CheckCircle2 className="w-3 h-3 sm:w-4 sm:h-4" />}
{formatStatus(appointment.status)}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content - Left Column (2/3) */}
<div className="lg:col-span-2 space-y-6">
{/* Appointment Information Card */}
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<User className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
Appointment Information
</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-1">
<p className={`text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Full Name
</p>
<p className={`text-base font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.first_name} {appointment.last_name}
</p>
</div>
<div className="space-y-1">
<p className={`text-xs font-medium uppercase tracking-wider flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<Mail className="w-3 h-3" />
Email Address
</p>
<div className="flex items-center gap-2">
<p className={`text-base font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.email}
</p>
<button
onClick={() => copyToClipboard(appointment.email, "Email")}
className={`p-1.5 rounded-lg hover:bg-opacity-80 transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-100"}`}
title="Copy email"
>
<Copy className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
</button>
</div>
</div>
{appointment.phone && (
<div className="space-y-1">
<p className={`text-xs font-medium uppercase tracking-wider flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<PhoneIcon className="w-3 h-3" />
Phone Number
</p>
<div className="flex items-center gap-2">
<p className={`text-base font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.phone}
</p>
<button
onClick={() => copyToClipboard(appointment.phone!, "Phone")}
className={`p-1.5 rounded-lg hover:bg-opacity-80 transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-100"}`}
title="Copy phone"
>
<Copy className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Scheduled Appointment Details */}
{appointment.scheduled_datetime && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<Calendar className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
Scheduled Appointment
</h2>
</div>
<div className="p-6">
<div className="flex items-start gap-4">
<div className={`p-4 rounded-xl ${isDark ? "bg-blue-500/10 border border-blue-500/20" : "bg-blue-50 border border-blue-100"}`}>
<Calendar className={`w-6 h-6 ${isDark ? "text-blue-400" : "text-blue-600"}`} />
</div>
<div className="flex-1">
<p className={`text-2xl font-bold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
{formatDate(appointment.scheduled_datetime)}
</p>
<div className="flex items-center gap-4 mt-2">
<div className="flex items-center gap-2">
<Clock className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
<p className={`text-base ${isDark ? "text-gray-300" : "text-gray-700"}`}>
{formatTime(appointment.scheduled_datetime)}
</p>
</div>
{appointment.scheduled_duration && (
<div className="flex items-center gap-2">
<span className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}></span>
<p className={`text-base ${isDark ? "text-gray-300" : "text-gray-700"}`}>
{appointment.meeting_duration_display || `${appointment.scheduled_duration} minutes`}
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* 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))) && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
Preferred Availability
</h2>
</div>
<div className="p-6 space-y-6">
{appointment.preferred_dates && (
<div>
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Preferred Dates
</p>
<div className="flex flex-wrap gap-2">
{Array.isArray(appointment.preferred_dates) ? (
(appointment.preferred_dates as string[]).map((date, idx) => (
<span
key={idx}
className={`px-4 py-2 rounded-lg text-sm font-medium ${isDark ? "bg-gray-700 text-gray-200 border border-gray-600" : "bg-gray-100 text-gray-700 border border-gray-200"}`}
>
{formatShortDate(date)}
</span>
))
) : (
<span
className={`px-4 py-2 rounded-lg text-sm font-medium ${isDark ? "bg-gray-700 text-gray-200 border border-gray-600" : "bg-gray-100 text-gray-700 border border-gray-200"}`}
>
{appointment.preferred_dates_display || appointment.preferred_dates || 'N/A'}
</span>
)}
</div>
</div>
)}
{appointment.preferred_time_slots && (
<div>
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Preferred Time Slots
</p>
<div className="flex flex-wrap gap-2">
{Array.isArray(appointment.preferred_time_slots) ? (
(appointment.preferred_time_slots as string[]).map((slot, idx) => {
const timeSlotLabels: Record<string, string> = {
morning: "Morning",
afternoon: "Lunchtime",
evening: "Evening",
};
const normalizedSlot = String(slot).toLowerCase().trim();
return (
<span
key={idx}
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${isDark ? "bg-rose-500/20 text-rose-300 border border-rose-500/30" : "bg-rose-50 text-rose-700 border border-rose-200"}`}
>
{timeSlotLabels[normalizedSlot] || slot}
</span>
);
})
) : (
<span
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${isDark ? "bg-rose-500/20 text-rose-300 border border-rose-500/30" : "bg-rose-50 text-rose-700 border border-rose-200"}`}
>
{appointment.preferred_time_slots_display || appointment.preferred_time_slots || 'N/A'}
</span>
)}
</div>
</div>
)}
</div>
</div>
)}
{/* Matching Availability */}
{appointment.matching_availability && Array.isArray(appointment.matching_availability) && appointment.matching_availability.length > 0 && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-green-400" : "text-green-600"}`} />
Matching Availability
{appointment.are_preferences_available !== undefined && (
<span className={`ml-auto px-3 py-1 text-xs font-medium rounded-full ${appointment.are_preferences_available ? (isDark ? "bg-green-500/20 text-green-300 border border-green-500/30" : "bg-green-50 text-green-700 border border-green-200") : (isDark ? "bg-yellow-500/20 text-yellow-300 border border-yellow-500/30" : "bg-yellow-50 text-yellow-700 border border-yellow-200")}`}>
{appointment.are_preferences_available ? "Available" : "Partially Available"}
</span>
)}
</h2>
</div>
<div className="p-6">
<div className="space-y-4">
{appointment.matching_availability.map((match: any, idx: number) => (
<div
key={idx}
className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}
>
<div className="flex items-start justify-between mb-3">
<div>
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
{match.day_name || "Unknown Day"}
</p>
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{formatShortDate(match.date || match.date_obj || "")}
</p>
</div>
</div>
{match.available_slots && Array.isArray(match.available_slots) && match.available_slots.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{match.available_slots.map((slot: string, slotIdx: number) => {
const timeSlotLabels: Record<string, string> = {
morning: "Morning",
afternoon: "Lunchtime",
evening: "Evening",
};
const normalizedSlot = String(slot).toLowerCase().trim();
return (
<span
key={slotIdx}
className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? "bg-green-500/20 text-green-300 border border-green-500/30" : "bg-green-50 text-green-700 border border-green-200"}`}
>
{timeSlotLabels[normalizedSlot] || slot}
</span>
);
})}
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
{/* Reason */}
{appointment.reason && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<MessageSquare className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
Reason for Appointment
</h2>
</div>
<div className="p-6">
<p className={`text-base leading-relaxed ${isDark ? "text-gray-300" : "text-gray-700"}`}>
{appointment.reason}
</p>
</div>
</div>
)}
{/* Rejection Reason */}
{appointment.rejection_reason && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-red-900/20 border-red-800/50" : "bg-red-50 border-red-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-red-800/50" : "border-red-200"}`}>
<h2 className={`text-lg font-semibold ${isDark ? "text-red-300" : "text-red-900"}`}>
Rejection Reason
</h2>
</div>
<div className="p-6">
<p className={`text-base leading-relaxed ${isDark ? "text-red-200" : "text-red-800"}`}>
{appointment.rejection_reason}
</p>
</div>
</div>
)}
{/* Meeting Information */}
{appointment.jitsi_meet_url && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-blue-800/30" : "border-blue-200"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<Video className={`w-5 h-5 ${isDark ? "text-blue-400" : "text-blue-600"}`} />
Video Meeting
</h2>
</div>
<div className="p-6 space-y-4">
{appointment.jitsi_room_id && (
<div>
<p className={`text-xs font-medium mb-2 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Meeting Room ID
</p>
<div className="flex items-center gap-2">
<p className={`text-sm font-mono px-3 py-2 rounded-lg ${isDark ? "bg-gray-800 text-gray-200" : "bg-white text-gray-900 border border-gray-200"}`}>
{appointment.jitsi_room_id}
</p>
<button
onClick={() => appointment.can_join_meeting && copyToClipboard(appointment.jitsi_room_id!, "Room ID")}
disabled={!appointment.can_join_meeting}
className={`p-2 rounded-lg transition-colors ${appointment.can_join_meeting ? (isDark ? "hover:bg-gray-700" : "hover:bg-gray-100") : (isDark ? "opacity-50 cursor-not-allowed" : "opacity-50 cursor-not-allowed")}`}
title={appointment.can_join_meeting ? "Copy room ID" : "Meeting not available"}
>
<Copy className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
</button>
</div>
</div>
)}
<div>
<p className={`text-xs font-medium mb-2 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Meeting Link
</p>
<div className="flex items-center gap-2">
{appointment.can_join_meeting ? (
<>
<a
href={appointment.jitsi_meet_url}
target="_blank"
rel="noopener noreferrer"
className={`flex-1 text-sm px-3 py-2 rounded-lg truncate ${isDark ? "bg-gray-800 text-blue-400 hover:bg-gray-700" : "bg-white text-blue-600 hover:bg-gray-50 border border-gray-200"}`}
>
{appointment.jitsi_meet_url}
</a>
<a
href={appointment.jitsi_meet_url}
target="_blank"
rel="noopener noreferrer"
className={`px-4 py-2 rounded-lg font-medium transition-colors ${isDark ? "bg-blue-600 hover:bg-blue-700 text-white" : "bg-blue-600 hover:bg-blue-700 text-white"}`}
>
<ExternalLink className="w-4 h-4" />
</a>
</>
) : (
<>
<div className={`flex-1 text-sm px-3 py-2 rounded-lg truncate ${isDark ? "bg-gray-800/50 text-gray-500 border border-gray-700" : "bg-gray-100 text-gray-400 border border-gray-300"}`}>
{appointment.jitsi_meet_url}
</div>
<button
disabled
className={`px-4 py-2 rounded-lg font-medium cursor-not-allowed ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
>
<ExternalLink className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
{appointment.can_join_meeting !== undefined && (
<div className={`flex items-center gap-2 px-4 py-3 rounded-lg ${appointment.can_join_meeting ? (isDark ? "bg-green-500/20 border border-green-500/30" : "bg-green-50 border border-green-200") : (isDark ? "bg-gray-800 border border-gray-700" : "bg-gray-50 border border-gray-200")}`}>
<div className={`h-2 w-2 rounded-full ${appointment.can_join_meeting ? (isDark ? "bg-green-400" : "bg-green-600") : (isDark ? "bg-gray-500" : "bg-gray-400")}`} />
<p className={`text-sm font-medium ${appointment.can_join_meeting ? (isDark ? "text-green-300" : "text-green-700") : (isDark ? "text-gray-400" : "text-gray-500")}`}>
{appointment.can_join_meeting ? "Meeting is active - You can join now" : "Meeting is not available yet"}
</p>
</div>
)}
</div>
</div>
)}
</div>
{/* Sidebar - Right Column (1/3) */}
<div className="space-y-6">
{/* Quick Info Card */}
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
Quick Info
</h2>
</div>
<div className="p-6 space-y-4">
<div>
<p className={`text-xs font-medium mb-1 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Created
</p>
<p className={`text-sm font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
{formatShortDate(appointment.created_at)}
</p>
<p className={`text-xs mt-0.5 ${isDark ? "text-gray-500" : "text-gray-500"}`}>
{formatTime(appointment.created_at)}
</p>
</div>
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<p className={`text-xs font-medium mb-1 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Status
</p>
<span
className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm font-semibold rounded-lg border ${getStatusColor(
appointment.status
)}`}
>
{appointment.status === "scheduled" && <CheckCircle2 className="w-4 h-4" />}
{formatStatus(appointment.status)}
</span>
</div>
</div>
</div>
{/* Join Meeting Button */}
{appointment.status === "scheduled" && appointment.jitsi_meet_url && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
<div className="p-6">
{appointment.can_join_meeting ? (
<a
href={appointment.jitsi_meet_url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 text-white h-12 rounded-lg text-base font-medium transition-colors`}
>
<Video className="w-5 h-5" />
Join Meeting
</a>
) : (
<button
disabled
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
>
<Video className="w-5 h-5" />
Meeting Not Available Yet
</button>
)}
</div>
</div>
)}
</div>
</div>
</main>
</div>
);
}

View File

@ -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<UserAppointmentStats | null>(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);
@ -388,7 +383,8 @@ export default function UserDashboard() {
return (
<tr
key={appointment.id}
className={`transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
className={`transition-colors cursor-pointer ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
onClick={() => router.push(`/user/appointments/${appointment.id}`)}
>
<td className="px-3 sm:px-4 md:px-6 py-4">
<div className="flex items-center">
@ -478,6 +474,7 @@ export default function UserDashboard() {
}`}
title={appointment.can_join_meeting ? "Join Meeting" : "Meeting Not Available"}
onClick={(e) => {
e.stopPropagation();
if (!appointment.can_join_meeting) {
e.preventDefault();
}

View File

@ -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<UserAppointmentStats>({
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

View File

@ -624,45 +624,26 @@ export async function getAppointmentStats(): Promise<AppointmentStats> {
return data;
}
export async function getUserAppointmentStats(email: string): Promise<UserAppointmentStats> {
export async function getUserAppointmentStats(): Promise<UserAppointmentStats> {
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<JitsiMeetingInfo> {