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.

This commit is contained in:
iamkiddy 2025-11-23 22:28:02 +00:00
parent 43d0eae01f
commit 4f6e64bf99
7 changed files with 373 additions and 193 deletions

View File

@ -8,104 +8,36 @@ import {
Video, Video,
FileText, FileText,
MoreVertical, MoreVertical,
Search,
} from "lucide-react"; } from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { listAppointments } from "@/lib/actions/appointments";
interface User { import { Input } from "@/components/ui/input";
ID: number; import { toast } from "sonner";
CreatedAt?: string; import type { Appointment } from "@/lib/models/appointments";
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;
}
export default function Booking() { export default function Booking() {
const [bookings, setBookings] = useState<Booking[]>([]); const [appointments, setAppointments] = useState<Appointment[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
useEffect(() => { useEffect(() => {
// Simulate API call
const fetchBookings = async () => { const fetchBookings = async () => {
setLoading(true); setLoading(true);
await new Promise((resolve) => setTimeout(resolve, 500)); try {
const data = await listAppointments();
// Mock API response console.log("Fetched appointments:", data);
const mockData: BookingsResponse = { console.log("Appointments count:", data?.length);
bookings: [ setAppointments(data || []);
{ } catch (error) {
ID: 1, console.error("Failed to fetch appointments:", error);
CreatedAt: "2025-11-06T11:33:45.704633Z", toast.error("Failed to load appointments. Please try again.");
UpdatedAt: "2025-11-06T11:33:45.707543Z", setAppointments([]);
DeletedAt: null, } finally {
user_id: 3, setLoading(false);
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);
}; };
fetchBookings(); fetchBookings();
@ -137,8 +69,10 @@ export default function Booking() {
return "bg-blue-500/20 text-blue-200"; return "bg-blue-500/20 text-blue-200";
case "completed": case "completed":
return "bg-green-500/20 text-green-200"; return "bg-green-500/20 text-green-200";
case "rejected":
case "cancelled": case "cancelled":
return "bg-red-500/20 text-red-200"; return "bg-red-500/20 text-red-200";
case "pending_review":
case "pending": case "pending":
return "bg-yellow-500/20 text-yellow-200"; return "bg-yellow-500/20 text-yellow-200";
default: default:
@ -150,8 +84,10 @@ export default function Booking() {
return "bg-blue-100 text-blue-700"; return "bg-blue-100 text-blue-700";
case "completed": case "completed":
return "bg-green-100 text-green-700"; return "bg-green-100 text-green-700";
case "rejected":
case "cancelled": case "cancelled":
return "bg-red-100 text-red-700"; return "bg-red-100 text-red-700";
case "pending_review":
case "pending": case "pending":
return "bg-yellow-100 text-yellow-700"; return "bg-yellow-100 text-yellow-700";
default: default:
@ -159,41 +95,20 @@ export default function Booking() {
} }
}; };
const getPaymentStatusColor = (status: string) => { const formatStatus = (status: string) => {
const normalized = status.toLowerCase(); return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
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 filteredBookings = bookings.filter( const filteredAppointments = appointments.filter(
(booking) => (appointment) =>
booking.user.first_name appointment.first_name
.toLowerCase() .toLowerCase()
.includes(searchTerm.toLowerCase()) || .includes(searchTerm.toLowerCase()) ||
booking.user.last_name appointment.last_name
.toLowerCase() .toLowerCase()
.includes(searchTerm.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 ( return (
@ -202,34 +117,42 @@ export default function Booking() {
{/* Main Content */} {/* Main Content */}
<main className="p-3 sm:p-4 md:p-6 lg:p-8"> <main className="p-3 sm:p-4 md:p-6 lg:p-8">
{/* Page Header */} {/* Page Header */}
<div className="mb-4 sm:mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4"> <div className="mb-4 sm:mb-6 flex flex-col gap-3 sm:gap-4">
<div> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<h1 className={`text-xl sm:text-2xl font-semibold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}> <div>
Bookings <h1 className={`text-xl sm:text-2xl font-semibold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
</h1> Bookings
<p className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}> </h1>
Manage and view all appointment bookings <p className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
</p> Manage and view all appointment bookings
</p>
</div>
</div>
{/* Search Bar */}
<div className="relative">
<Search className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
<Input
type="text"
placeholder="Search by name, email, or phone..."
value={searchTerm}
onChange={(e) => 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"}`}
/>
</div> </div>
<button className={`w-full sm:w-auto px-3 sm:px-4 py-2 rounded-lg text-xs sm:text-sm font-medium transition-colors ${
isDark ? "bg-rose-500 text-white hover:bg-rose-600" : "bg-gray-900 text-white hover:bg-gray-800"
}`}>
+ New Booking
</button>
</div> </div>
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className={`animate-spin rounded-full h-8 w-8 border-b-2 ${isDark ? "border-gray-600" : "border-gray-400"}`}></div> <div className={`animate-spin rounded-full h-8 w-8 border-b-2 ${isDark ? "border-gray-600" : "border-gray-400"}`}></div>
</div> </div>
) : filteredBookings.length === 0 ? ( ) : filteredAppointments.length === 0 ? (
<div className={`rounded-lg border p-12 text-center ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}> <div className={`rounded-lg border p-12 text-center ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<Calendar className={`w-12 h-12 mx-auto mb-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} /> <Calendar className={`w-12 h-12 mx-auto mb-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} />
<p className={`font-medium mb-1 ${isDark ? "text-gray-200" : "text-gray-600"}`}>No bookings found</p> <p className={`font-medium mb-1 ${isDark ? "text-gray-200" : "text-gray-600"}`}>No bookings found</p>
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}> <p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{searchTerm {searchTerm
? "Try adjusting your search terms" ? "Try adjusting your search terms"
: "Create a new booking to get started"} : "No appointments have been created yet"}
</p> </p>
</div> </div>
) : ( ) : (
@ -251,10 +174,10 @@ export default function Booking() {
Status Status
</th> </th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}> <th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Payment Preferred Dates
</th> </th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}> <th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Amount Created
</th> </th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-right text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}> <th className={`px-3 sm:px-4 md:px-6 py-3 text-right text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Actions Actions
@ -262,9 +185,9 @@ export default function Booking() {
</tr> </tr>
</thead> </thead>
<tbody className={`${isDark ? "bg-gray-800 divide-gray-700" : "bg-white divide-gray-200"}`}> <tbody className={`${isDark ? "bg-gray-800 divide-gray-700" : "bg-white divide-gray-200"}`}>
{filteredBookings.map((booking) => ( {filteredAppointments.map((appointment) => (
<tr <tr
key={booking.ID} key={appointment.id}
className={`transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`} className={`transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
> >
<td className="px-3 sm:px-4 md:px-6 py-4"> <td className="px-3 sm:px-4 md:px-6 py-4">
@ -274,55 +197,75 @@ export default function Booking() {
</div> </div>
<div className="ml-2 sm:ml-4 min-w-0"> <div className="ml-2 sm:ml-4 min-w-0">
<div className={`text-xs sm:text-sm font-medium truncate ${isDark ? "text-white" : "text-gray-900"}`}> <div className={`text-xs sm:text-sm font-medium truncate ${isDark ? "text-white" : "text-gray-900"}`}>
{booking.user.first_name} {booking.user.last_name} {appointment.first_name} {appointment.last_name}
</div> </div>
<div className={`text-xs sm:text-sm truncate hidden sm:block ${isDark ? "text-gray-400" : "text-gray-500"}`}> <div className={`text-xs sm:text-sm truncate hidden sm:block ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{booking.user.email} {appointment.email}
</div>
<div className={`text-xs sm:hidden mt-0.5 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{formatDate(booking.scheduled_at)}
</div> </div>
{appointment.phone && (
<div className={`text-xs truncate hidden sm:block ${isDark ? "text-gray-500" : "text-gray-400"}`}>
{appointment.phone}
</div>
)}
{appointment.scheduled_datetime && (
<div className={`text-xs sm:hidden mt-0.5 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{formatDate(appointment.scheduled_datetime)}
</div>
)}
</div> </div>
</div> </div>
</td> </td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden md:table-cell"> <td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden md:table-cell">
<div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}> {appointment.scheduled_datetime ? (
{formatDate(booking.scheduled_at)} <>
</div> <div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}>
<div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}> {formatDate(appointment.scheduled_datetime)}
<Clock className="w-3 h-3" /> </div>
{formatTime(booking.scheduled_at)} <div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
</div> <Clock className="w-3 h-3" />
{formatTime(appointment.scheduled_datetime)}
</div>
</>
) : (
<div className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Not scheduled
</div>
)}
</td> </td>
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-white" : "text-gray-900"}`}> <td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-white" : "text-gray-900"}`}>
{booking.duration} min {appointment.scheduled_duration ? `${appointment.scheduled_duration} min` : "-"}
</td> </td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap"> <td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap">
<span <span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor( className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
booking.status appointment.status
)}`} )}`}
> >
{booking.status} {formatStatus(appointment.status)}
</span> </span>
</td> </td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden lg:table-cell"> <td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<span {appointment.preferred_dates && appointment.preferred_dates.length > 0 ? (
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getPaymentStatusColor( <div className="flex flex-col gap-1">
booking.payment_status {appointment.preferred_dates.slice(0, 2).map((date, idx) => (
)}`} <span key={idx}>{formatDate(date)}</span>
> ))}
{booking.payment_status} {appointment.preferred_dates.length > 2 && (
</span> <span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
)}
</div>
) : (
"-"
)}
</td> </td>
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm font-medium hidden xl:table-cell ${isDark ? "text-white" : "text-gray-900"}`}> <td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
${booking.amount} {formatDate(appointment.created_at)}
</td> </td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-1 sm:gap-2"> <div className="flex items-center justify-end gap-1 sm:gap-2">
{booking.jitsi_room_url && ( {appointment.jitsi_meet_url && (
<a <a
href={booking.jitsi_room_url} href={appointment.jitsi_meet_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={`p-1.5 sm:p-2 rounded-lg transition-colors ${isDark ? "text-gray-300 hover:text-white hover:bg-gray-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-100"}`} className={`p-1.5 sm:p-2 rounded-lg transition-colors ${isDark ? "text-gray-300 hover:text-white hover:bg-gray-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-100"}`}
@ -331,10 +274,10 @@ export default function Booking() {
<Video className="w-4 h-4" /> <Video className="w-4 h-4" />
</a> </a>
)} )}
{booking.notes && ( {appointment.reason && (
<button <button
className={`p-1.5 sm:p-2 rounded-lg transition-colors ${isDark ? "text-gray-300 hover:text-white hover:bg-gray-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-100"}`} className={`p-1.5 sm:p-2 rounded-lg transition-colors ${isDark ? "text-gray-300 hover:text-white hover:bg-gray-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-100"}`}
title="View Notes" title={appointment.reason}
> >
<FileText className="w-4 h-4" /> <FileText className="w-4 h-4" />
</button> </button>

View File

@ -20,6 +20,11 @@ import {
ArrowDownRight, ArrowDownRight,
} from "lucide-react"; } from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider"; 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 { interface DashboardStats {
total_users: number; total_users: number;
@ -30,6 +35,16 @@ interface DashboardStats {
cancelled_bookings: number; cancelled_bookings: number;
total_revenue: number; total_revenue: number;
monthly_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() { export default function Dashboard() {
@ -40,86 +55,166 @@ export default function Dashboard() {
const isDark = theme === "dark"; const isDark = theme === "dark";
useEffect(() => { useEffect(() => {
// Simulate API call
const fetchStats = async () => { const fetchStats = async () => {
setLoading(true); setLoading(true);
// Simulate network delay try {
await new Promise((resolve) => setTimeout(resolve, 500)); // Fetch all data in parallel
const [users, appointmentStats, appointments] = await Promise.all([
getAllUsers().catch(() => [] as User[]),
getAppointmentStats().catch(() => null),
listAppointments().catch(() => [] as Appointment[]),
]);
// Mock API response // Calculate statistics
const mockData: DashboardStats = { // Use users count from appointment stats if available, otherwise use getAllUsers result
total_users: 3, const totalUsers = appointmentStats?.users ?? users.length;
active_users: 3, const activeUsers = users.filter(
total_bookings: 6, (user) => user.is_active === true || user.isActive === true
upcoming_bookings: 6, ).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, completed_bookings: 0,
cancelled_bookings: 0, cancelled_bookings: 0,
total_revenue: 0, total_revenue: 0,
monthly_revenue: 0, monthly_revenue: 0,
}; trends: {
total_users: "0%",
setStats(mockData); active_users: "0%",
total_bookings: "0%",
upcoming_bookings: "0",
completed_bookings: "0%",
cancelled_bookings: "0%",
total_revenue: "0%",
monthly_revenue: "0%",
},
});
} finally {
setLoading(false); setLoading(false);
}
}; };
fetchStats(); fetchStats();
}, []); }, [timePeriod]);
const statCards = [ const statCards = [
{ {
title: "Total Users", title: "Total Users",
value: stats?.total_users ?? 0, value: stats?.total_users ?? 0,
icon: Users, icon: Users,
trend: "+12%", trend: stats?.trends.total_users ?? "0%",
trendUp: true, trendUp: true,
}, },
{ {
title: "Active Users", title: "Active Users",
value: stats?.active_users ?? 0, value: stats?.active_users ?? 0,
icon: UserCheck, icon: UserCheck,
trend: "+8%", trend: stats?.trends.active_users ?? "0%",
trendUp: true, trendUp: true,
}, },
{ {
title: "Total Bookings", title: "Total Bookings",
value: stats?.total_bookings ?? 0, value: stats?.total_bookings ?? 0,
icon: Calendar, icon: Calendar,
trend: "+24%", trend: stats?.trends.total_bookings ?? "0%",
trendUp: true, trendUp: true,
}, },
{ {
title: "Upcoming Bookings", title: "Upcoming Bookings",
value: stats?.upcoming_bookings ?? 0, value: stats?.upcoming_bookings ?? 0,
icon: CalendarCheck, icon: CalendarCheck,
trend: "+6", trend: stats?.trends.upcoming_bookings ?? "0",
trendUp: true, trendUp: true,
}, },
{ {
title: "Completed Bookings", title: "Completed Bookings",
value: stats?.completed_bookings ?? 0, value: stats?.completed_bookings ?? 0,
icon: CalendarCheck, icon: CalendarCheck,
trend: "0%", trend: stats?.trends.completed_bookings ?? "0%",
trendUp: true, trendUp: true,
}, },
{ {
title: "Cancelled Bookings", title: "Cancelled Bookings",
value: stats?.cancelled_bookings ?? 0, value: stats?.cancelled_bookings ?? 0,
icon: CalendarX, icon: CalendarX,
trend: "0%", trend: stats?.trends.cancelled_bookings ?? "0%",
trendUp: false, trendUp: false,
}, },
{ {
title: "Total Revenue", title: "Total Revenue",
value: `$${stats?.total_revenue.toLocaleString() ?? 0}`, value: `$${stats?.total_revenue.toLocaleString() ?? 0}`,
icon: DollarSign, icon: DollarSign,
trend: "+18%", trend: stats?.trends.total_revenue ?? "0%",
trendUp: true, trendUp: true,
}, },
{ {
title: "Monthly Revenue", title: "Monthly Revenue",
value: `$${stats?.monthly_revenue.toLocaleString() ?? 0}`, value: `$${stats?.monthly_revenue.toLocaleString() ?? 0}`,
icon: TrendingUp, icon: TrendingUp,
trend: "+32%", trend: stats?.trends.monthly_revenue ?? "0%",
trendUp: true, trendUp: true,
}, },
]; ];

View File

@ -2,7 +2,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback } from "react"; import { useCallback, useEffect } from "react";
import { import {
loginUser, loginUser,
registerUser, registerUser,
@ -17,6 +17,8 @@ import {
storeTokens, storeTokens,
storeUser, storeUser,
clearAuthData, clearAuthData,
isTokenExpired,
hasValidAuth,
} from "@/lib/actions/auth"; } from "@/lib/actions/auth";
import type { import type {
LoginInput, LoginInput,
@ -28,6 +30,7 @@ import type {
ResetPasswordInput, ResetPasswordInput,
} from "@/lib/schema/auth"; } from "@/lib/schema/auth";
import type { User } from "@/lib/models/auth"; import type { User } from "@/lib/models/auth";
import { toast } from "sonner";
export function useAuth() { export function useAuth() {
const router = useRouter(); const router = useRouter();
@ -40,8 +43,8 @@ export function useAuth() {
staleTime: Infinity, staleTime: Infinity,
}); });
// Check if user is authenticated // Check if user is authenticated with valid token
const isAuthenticated = !!user && !!getStoredTokens().access; const isAuthenticated = !!user && hasValidAuth();
// Check if user is admin (check multiple possible field names) // Check if user is admin (check multiple possible field names)
const isAdmin = const isAdmin =
@ -108,6 +111,12 @@ export function useAuth() {
mutationFn: (refresh: string) => refreshToken({ refresh }), mutationFn: (refresh: string) => refreshToken({ refresh }),
onSuccess: (tokens) => { onSuccess: (tokens) => {
storeTokens(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 // Don't redirect here - let components handle redirect as needed
}, [queryClient]); }, [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 // Login function
const login = useCallback( const login = useCallback(
async (input: LoginInput) => { async (input: LoginInput) => {

View File

@ -121,14 +121,26 @@ export async function listAppointments(email?: string): Promise<Appointment[]> {
}, },
}); });
const data: AppointmentsListResponse = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
const errorMessage = extractErrorMessage(data as ApiError); const errorMessage = extractErrorMessage(data as ApiError);
throw new Error(errorMessage); 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 // Get user appointments
@ -147,14 +159,26 @@ export async function getUserAppointments(): Promise<Appointment[]> {
}, },
}); });
const data: AppointmentsListResponse = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
const errorMessage = extractErrorMessage(data as ApiError); const errorMessage = extractErrorMessage(data as ApiError);
throw new Error(errorMessage); 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 // Get appointment detail

View File

@ -229,6 +229,35 @@ export async function refreshToken(input: TokenRefreshInput): Promise<AuthTokens
return handleResponse<AuthTokens>(response); return handleResponse<AuthTokens>(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 // Get stored tokens
export function getStoredTokens(): { access: string | null; refresh: string | null } { export function getStoredTokens(): { access: string | null; refresh: string | null } {
if (typeof window === "undefined") { 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 // Store tokens
export function storeTokens(tokens: AuthTokens): void { export function storeTokens(tokens: AuthTokens): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@ -292,9 +329,43 @@ export function clearAuthData(): void {
// Get auth header for API requests // Get auth header for API requests
export function getAuthHeader(): { Authorization: string } | {} { export function getAuthHeader(): { Authorization: string } | {} {
const tokens = getStoredTokens(); const tokens = getStoredTokens();
if (tokens.access) { if (tokens.access && !isTokenExpired(tokens.access)) {
return { Authorization: `Bearer ${tokens.access}` }; return { Authorization: `Bearer ${tokens.access}` };
} }
return {}; return {};
} }
// Get all users (Admin only)
export async function getAllUsers(): Promise<User[]> {
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 [];
}

View File

@ -20,6 +20,7 @@ export const API_ENDPOINTS = {
verifyPasswordResetOtp: `${API_BASE_URL}/auth/verify-password-reset-otp/`, verifyPasswordResetOtp: `${API_BASE_URL}/auth/verify-password-reset-otp/`,
resetPassword: `${API_BASE_URL}/auth/reset-password/`, resetPassword: `${API_BASE_URL}/auth/reset-password/`,
tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`, tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`,
allUsers: `${API_BASE_URL}/auth/all-users/`,
}, },
meetings: { meetings: {
base: `${API_BASE_URL}/meetings/`, base: `${API_BASE_URL}/meetings/`,

View File

@ -52,6 +52,7 @@ export interface AppointmentStats {
scheduled: number; scheduled: number;
rejected: number; rejected: number;
completion_rate: number; completion_rate: number;
users?: number; // Total users count from API
} }
export interface JitsiMeetingInfo { export interface JitsiMeetingInfo {