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:
parent
43d0eae01f
commit
4f6e64bf99
@ -8,104 +8,36 @@ import {
|
||||
Video,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
|
||||
interface User {
|
||||
ID: number;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
DeletedAt?: string | null;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
location: string;
|
||||
date_of_birth?: string;
|
||||
is_admin?: boolean;
|
||||
bookings?: null;
|
||||
}
|
||||
|
||||
interface Booking {
|
||||
ID: number;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
DeletedAt: string | null;
|
||||
user_id: number;
|
||||
user: User;
|
||||
scheduled_at: string;
|
||||
duration: number;
|
||||
status: string;
|
||||
jitsi_room_id: string;
|
||||
jitsi_room_url: string;
|
||||
payment_id: string;
|
||||
payment_status: string;
|
||||
amount: number;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface BookingsResponse {
|
||||
bookings: Booking[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
}
|
||||
import { listAppointments } from "@/lib/actions/appointments";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import type { Appointment } from "@/lib/models/appointments";
|
||||
|
||||
export default function Booking() {
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate API call
|
||||
const fetchBookings = async () => {
|
||||
setLoading(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Mock API response
|
||||
const mockData: BookingsResponse = {
|
||||
bookings: [
|
||||
{
|
||||
ID: 1,
|
||||
CreatedAt: "2025-11-06T11:33:45.704633Z",
|
||||
UpdatedAt: "2025-11-06T11:33:45.707543Z",
|
||||
DeletedAt: null,
|
||||
user_id: 3,
|
||||
user: {
|
||||
ID: 3,
|
||||
CreatedAt: "2025-11-06T10:43:01.299311Z",
|
||||
UpdatedAt: "2025-11-06T10:43:48.427284Z",
|
||||
DeletedAt: null,
|
||||
first_name: "John",
|
||||
last_name: "Smith",
|
||||
email: "john.doe@example.com",
|
||||
phone: "+1234567891",
|
||||
location: "Los Angeles, CA",
|
||||
date_of_birth: "0001-01-01T00:00:00Z",
|
||||
is_admin: true,
|
||||
bookings: null,
|
||||
},
|
||||
scheduled_at: "2025-11-07T10:00:00Z",
|
||||
duration: 60,
|
||||
status: "scheduled",
|
||||
jitsi_room_id: "booking-1-1762428825-22c92ced2870c17c",
|
||||
jitsi_room_url:
|
||||
"https://meet.jit.si/booking-1-1762428825-22c92ced2870c17c",
|
||||
payment_id: "",
|
||||
payment_status: "pending",
|
||||
amount: 52,
|
||||
notes: "Initial consultation session",
|
||||
},
|
||||
],
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
total: 1,
|
||||
};
|
||||
|
||||
setBookings(mockData.bookings);
|
||||
setLoading(false);
|
||||
try {
|
||||
const data = await listAppointments();
|
||||
console.log("Fetched appointments:", data);
|
||||
console.log("Appointments count:", data?.length);
|
||||
setAppointments(data || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch appointments:", error);
|
||||
toast.error("Failed to load appointments. Please try again.");
|
||||
setAppointments([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBookings();
|
||||
@ -137,8 +69,10 @@ export default function Booking() {
|
||||
return "bg-blue-500/20 text-blue-200";
|
||||
case "completed":
|
||||
return "bg-green-500/20 text-green-200";
|
||||
case "rejected":
|
||||
case "cancelled":
|
||||
return "bg-red-500/20 text-red-200";
|
||||
case "pending_review":
|
||||
case "pending":
|
||||
return "bg-yellow-500/20 text-yellow-200";
|
||||
default:
|
||||
@ -150,8 +84,10 @@ export default function Booking() {
|
||||
return "bg-blue-100 text-blue-700";
|
||||
case "completed":
|
||||
return "bg-green-100 text-green-700";
|
||||
case "rejected":
|
||||
case "cancelled":
|
||||
return "bg-red-100 text-red-700";
|
||||
case "pending_review":
|
||||
case "pending":
|
||||
return "bg-yellow-100 text-yellow-700";
|
||||
default:
|
||||
@ -159,41 +95,20 @@ export default function Booking() {
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentStatusColor = (status: string) => {
|
||||
const normalized = status.toLowerCase();
|
||||
if (isDark) {
|
||||
switch (normalized) {
|
||||
case "paid":
|
||||
return "bg-green-500/20 text-green-200";
|
||||
case "pending":
|
||||
return "bg-yellow-500/20 text-yellow-200";
|
||||
case "failed":
|
||||
return "bg-red-500/20 text-red-200";
|
||||
default:
|
||||
return "bg-gray-700 text-gray-200";
|
||||
}
|
||||
}
|
||||
switch (normalized) {
|
||||
case "paid":
|
||||
return "bg-green-100 text-green-700";
|
||||
case "pending":
|
||||
return "bg-yellow-100 text-yellow-700";
|
||||
case "failed":
|
||||
return "bg-red-100 text-red-700";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700";
|
||||
}
|
||||
const formatStatus = (status: string) => {
|
||||
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
};
|
||||
|
||||
const filteredBookings = bookings.filter(
|
||||
(booking) =>
|
||||
booking.user.first_name
|
||||
const filteredAppointments = appointments.filter(
|
||||
(appointment) =>
|
||||
appointment.first_name
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
booking.user.last_name
|
||||
appointment.last_name
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
booking.user.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
appointment.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(appointment.phone && appointment.phone.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
|
||||
return (
|
||||
@ -202,34 +117,42 @@ export default function Booking() {
|
||||
{/* Main Content */}
|
||||
<main className="p-3 sm:p-4 md:p-6 lg:p-8">
|
||||
{/* 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>
|
||||
<h1 className={`text-xl sm:text-2xl font-semibold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
Bookings
|
||||
</h1>
|
||||
<p className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Manage and view all appointment bookings
|
||||
</p>
|
||||
<div className="mb-4 sm:mb-6 flex flex-col gap-3 sm:gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<div>
|
||||
<h1 className={`text-xl sm:text-2xl font-semibold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
Bookings
|
||||
</h1>
|
||||
<p className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
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>
|
||||
<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>
|
||||
|
||||
{loading ? (
|
||||
<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>
|
||||
) : 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"}`}>
|
||||
<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={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{searchTerm
|
||||
? "Try adjusting your search terms"
|
||||
: "Create a new booking to get started"}
|
||||
: "No appointments have been created yet"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -251,10 +174,10 @@ export default function Booking() {
|
||||
Status
|
||||
</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"}`}>
|
||||
Payment
|
||||
Preferred Dates
|
||||
</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"}`}>
|
||||
Amount
|
||||
Created
|
||||
</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"}`}>
|
||||
Actions
|
||||
@ -262,9 +185,9 @@ export default function Booking() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`${isDark ? "bg-gray-800 divide-gray-700" : "bg-white divide-gray-200"}`}>
|
||||
{filteredBookings.map((booking) => (
|
||||
{filteredAppointments.map((appointment) => (
|
||||
<tr
|
||||
key={booking.ID}
|
||||
key={appointment.id}
|
||||
className={`transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
|
||||
>
|
||||
<td className="px-3 sm:px-4 md:px-6 py-4">
|
||||
@ -274,55 +197,75 @@ export default function Booking() {
|
||||
</div>
|
||||
<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"}`}>
|
||||
{booking.user.first_name} {booking.user.last_name}
|
||||
{appointment.first_name} {appointment.last_name}
|
||||
</div>
|
||||
<div className={`text-xs sm:text-sm truncate hidden sm:block ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{booking.user.email}
|
||||
</div>
|
||||
<div className={`text-xs sm:hidden mt-0.5 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{formatDate(booking.scheduled_at)}
|
||||
{appointment.email}
|
||||
</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>
|
||||
</td>
|
||||
<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"}`}>
|
||||
{formatDate(booking.scheduled_at)}
|
||||
</div>
|
||||
<div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTime(booking.scheduled_at)}
|
||||
</div>
|
||||
{appointment.scheduled_datetime ? (
|
||||
<>
|
||||
<div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{formatDate(appointment.scheduled_datetime)}
|
||||
</div>
|
||||
<div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
<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 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 className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
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>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden lg:table-cell">
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getPaymentStatusColor(
|
||||
booking.payment_status
|
||||
)}`}
|
||||
>
|
||||
{booking.payment_status}
|
||||
</span>
|
||||
<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"}`}>
|
||||
{appointment.preferred_dates && appointment.preferred_dates.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{appointment.preferred_dates.slice(0, 2).map((date, idx) => (
|
||||
<span key={idx}>{formatDate(date)}</span>
|
||||
))}
|
||||
{appointment.preferred_dates.length > 2 && (
|
||||
<span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</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"}`}>
|
||||
${booking.amount}
|
||||
<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"}`}>
|
||||
{formatDate(appointment.created_at)}
|
||||
</td>
|
||||
<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">
|
||||
{booking.jitsi_room_url && (
|
||||
{appointment.jitsi_meet_url && (
|
||||
<a
|
||||
href={booking.jitsi_room_url}
|
||||
href={appointment.jitsi_meet_url}
|
||||
target="_blank"
|
||||
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"}`}
|
||||
@ -331,10 +274,10 @@ export default function Booking() {
|
||||
<Video className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
{booking.notes && (
|
||||
{appointment.reason && (
|
||||
<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"}`}
|
||||
title="View Notes"
|
||||
title={appointment.reason}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@ -20,6 +20,11 @@ import {
|
||||
ArrowDownRight,
|
||||
} from "lucide-react";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { getAllUsers } from "@/lib/actions/auth";
|
||||
import { getAppointmentStats, listAppointments } from "@/lib/actions/appointments";
|
||||
import { toast } from "sonner";
|
||||
import type { User } from "@/lib/models/auth";
|
||||
import type { Appointment } from "@/lib/models/appointments";
|
||||
|
||||
interface DashboardStats {
|
||||
total_users: number;
|
||||
@ -30,6 +35,16 @@ interface DashboardStats {
|
||||
cancelled_bookings: number;
|
||||
total_revenue: number;
|
||||
monthly_revenue: number;
|
||||
trends: {
|
||||
total_users: string;
|
||||
active_users: string;
|
||||
total_bookings: string;
|
||||
upcoming_bookings: string;
|
||||
completed_bookings: string;
|
||||
cancelled_bookings: string;
|
||||
total_revenue: string;
|
||||
monthly_revenue: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
@ -40,86 +55,166 @@ export default function Dashboard() {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate API call
|
||||
const fetchStats = async () => {
|
||||
setLoading(true);
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Mock API response
|
||||
const mockData: DashboardStats = {
|
||||
total_users: 3,
|
||||
active_users: 3,
|
||||
total_bookings: 6,
|
||||
upcoming_bookings: 6,
|
||||
try {
|
||||
// Fetch all data in parallel
|
||||
const [users, appointmentStats, appointments] = await Promise.all([
|
||||
getAllUsers().catch(() => [] as User[]),
|
||||
getAppointmentStats().catch(() => null),
|
||||
listAppointments().catch(() => [] as Appointment[]),
|
||||
]);
|
||||
|
||||
// Calculate statistics
|
||||
// Use users count from appointment stats if available, otherwise use getAllUsers result
|
||||
const totalUsers = appointmentStats?.users ?? users.length;
|
||||
const activeUsers = users.filter(
|
||||
(user) => user.is_active === true || user.isActive === true
|
||||
).length;
|
||||
|
||||
const totalBookings = appointmentStats?.total_requests || appointments.length;
|
||||
const upcomingBookings = appointmentStats?.scheduled ||
|
||||
appointments.filter((apt) => apt.status === "scheduled").length;
|
||||
// Completed bookings - not in API status types, so set to 0
|
||||
const completedBookings = 0;
|
||||
const cancelledBookings = appointmentStats?.rejected ||
|
||||
appointments.filter((apt) => apt.status === "rejected").length;
|
||||
|
||||
// Calculate revenue (assuming appointments have amount field, defaulting to 0)
|
||||
const now = new Date();
|
||||
const currentMonth = now.getMonth();
|
||||
const currentYear = now.getFullYear();
|
||||
|
||||
const totalRevenue = appointments.reduce((sum, apt) => {
|
||||
// If appointment has amount field, use it, otherwise default to 0
|
||||
const amount = (apt as any).amount || 0;
|
||||
return sum + amount;
|
||||
}, 0);
|
||||
|
||||
const monthlyRevenue = appointments
|
||||
.filter((apt) => {
|
||||
if (!apt.scheduled_datetime) return false;
|
||||
const aptDate = new Date(apt.scheduled_datetime);
|
||||
return (
|
||||
aptDate.getMonth() === currentMonth &&
|
||||
aptDate.getFullYear() === currentYear
|
||||
);
|
||||
})
|
||||
.reduce((sum, apt) => {
|
||||
const amount = (apt as any).amount || 0;
|
||||
return sum + amount;
|
||||
}, 0);
|
||||
|
||||
// For now, use static trends (in a real app, you'd calculate these from historical data)
|
||||
const trends = {
|
||||
total_users: "+12%",
|
||||
active_users: "+8%",
|
||||
total_bookings: "+24%",
|
||||
upcoming_bookings: "+6",
|
||||
completed_bookings: "0%",
|
||||
cancelled_bookings: "0%",
|
||||
total_revenue: "+18%",
|
||||
monthly_revenue: "+32%",
|
||||
};
|
||||
|
||||
setStats({
|
||||
total_users: totalUsers,
|
||||
active_users: activeUsers,
|
||||
total_bookings: totalBookings,
|
||||
upcoming_bookings: upcomingBookings,
|
||||
completed_bookings: completedBookings,
|
||||
cancelled_bookings: cancelledBookings,
|
||||
total_revenue: totalRevenue,
|
||||
monthly_revenue: monthlyRevenue,
|
||||
trends,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dashboard stats:", error);
|
||||
toast.error("Failed to load dashboard statistics");
|
||||
// Set default values on error
|
||||
setStats({
|
||||
total_users: 0,
|
||||
active_users: 0,
|
||||
total_bookings: 0,
|
||||
upcoming_bookings: 0,
|
||||
completed_bookings: 0,
|
||||
cancelled_bookings: 0,
|
||||
total_revenue: 0,
|
||||
monthly_revenue: 0,
|
||||
};
|
||||
|
||||
setStats(mockData);
|
||||
trends: {
|
||||
total_users: "0%",
|
||||
active_users: "0%",
|
||||
total_bookings: "0%",
|
||||
upcoming_bookings: "0",
|
||||
completed_bookings: "0%",
|
||||
cancelled_bookings: "0%",
|
||||
total_revenue: "0%",
|
||||
monthly_revenue: "0%",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
}, [timePeriod]);
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: "Total Users",
|
||||
value: stats?.total_users ?? 0,
|
||||
icon: Users,
|
||||
trend: "+12%",
|
||||
trend: stats?.trends.total_users ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Active Users",
|
||||
value: stats?.active_users ?? 0,
|
||||
icon: UserCheck,
|
||||
trend: "+8%",
|
||||
trend: stats?.trends.active_users ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Total Bookings",
|
||||
value: stats?.total_bookings ?? 0,
|
||||
icon: Calendar,
|
||||
trend: "+24%",
|
||||
trend: stats?.trends.total_bookings ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Upcoming Bookings",
|
||||
value: stats?.upcoming_bookings ?? 0,
|
||||
icon: CalendarCheck,
|
||||
trend: "+6",
|
||||
trend: stats?.trends.upcoming_bookings ?? "0",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Completed Bookings",
|
||||
value: stats?.completed_bookings ?? 0,
|
||||
icon: CalendarCheck,
|
||||
trend: "0%",
|
||||
trend: stats?.trends.completed_bookings ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Cancelled Bookings",
|
||||
value: stats?.cancelled_bookings ?? 0,
|
||||
icon: CalendarX,
|
||||
trend: "0%",
|
||||
trend: stats?.trends.cancelled_bookings ?? "0%",
|
||||
trendUp: false,
|
||||
},
|
||||
{
|
||||
title: "Total Revenue",
|
||||
value: `$${stats?.total_revenue.toLocaleString() ?? 0}`,
|
||||
icon: DollarSign,
|
||||
trend: "+18%",
|
||||
trend: stats?.trends.total_revenue ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Monthly Revenue",
|
||||
value: `$${stats?.monthly_revenue.toLocaleString() ?? 0}`,
|
||||
icon: TrendingUp,
|
||||
trend: "+32%",
|
||||
trend: stats?.trends.monthly_revenue ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
];
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import {
|
||||
loginUser,
|
||||
registerUser,
|
||||
@ -17,6 +17,8 @@ import {
|
||||
storeTokens,
|
||||
storeUser,
|
||||
clearAuthData,
|
||||
isTokenExpired,
|
||||
hasValidAuth,
|
||||
} from "@/lib/actions/auth";
|
||||
import type {
|
||||
LoginInput,
|
||||
@ -28,6 +30,7 @@ import type {
|
||||
ResetPasswordInput,
|
||||
} from "@/lib/schema/auth";
|
||||
import type { User } from "@/lib/models/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function useAuth() {
|
||||
const router = useRouter();
|
||||
@ -40,8 +43,8 @@ export function useAuth() {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
// Check if user is authenticated
|
||||
const isAuthenticated = !!user && !!getStoredTokens().access;
|
||||
// Check if user is authenticated with valid token
|
||||
const isAuthenticated = !!user && hasValidAuth();
|
||||
|
||||
// Check if user is admin (check multiple possible field names)
|
||||
const isAdmin =
|
||||
@ -108,6 +111,12 @@ export function useAuth() {
|
||||
mutationFn: (refresh: string) => refreshToken({ refresh }),
|
||||
onSuccess: (tokens) => {
|
||||
storeTokens(tokens);
|
||||
queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
},
|
||||
onError: () => {
|
||||
// If refresh fails, logout
|
||||
clearAuthData();
|
||||
queryClient.clear();
|
||||
},
|
||||
});
|
||||
|
||||
@ -118,6 +127,42 @@ export function useAuth() {
|
||||
// Don't redirect here - let components handle redirect as needed
|
||||
}, [queryClient]);
|
||||
|
||||
// Auto-logout if token is expired or missing
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const tokens = getStoredTokens();
|
||||
const storedUser = getStoredUser();
|
||||
|
||||
// If user exists but no token or token is expired, logout
|
||||
if (storedUser && (!tokens.access || isTokenExpired(tokens.access))) {
|
||||
// Try to refresh token first if refresh token exists
|
||||
if (tokens.refresh && !isTokenExpired(tokens.refresh)) {
|
||||
refreshTokenMutation.mutate(tokens.refresh, {
|
||||
onError: () => {
|
||||
// If refresh fails, logout
|
||||
clearAuthData();
|
||||
queryClient.clear();
|
||||
toast.error("Your session has expired. Please log in again.");
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// No valid refresh token, logout immediately
|
||||
clearAuthData();
|
||||
queryClient.clear();
|
||||
toast.error("Your session has expired. Please log in again.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately
|
||||
checkAuth();
|
||||
|
||||
// Check every 30 seconds
|
||||
const interval = setInterval(checkAuth, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [queryClient, refreshTokenMutation]);
|
||||
|
||||
// Login function
|
||||
const login = useCallback(
|
||||
async (input: LoginInput) => {
|
||||
|
||||
@ -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) {
|
||||
const errorMessage = extractErrorMessage(data as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data.appointments || [];
|
||||
// Handle different response formats
|
||||
// API might return array directly or wrapped in an object
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
if (data.appointments && Array.isArray(data.appointments)) {
|
||||
return data.appointments;
|
||||
}
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
return data.results;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get user appointments
|
||||
@ -147,14 +159,26 @@ export async function getUserAppointments(): Promise<Appointment[]> {
|
||||
},
|
||||
});
|
||||
|
||||
const data: AppointmentsListResponse = await response.json();
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = extractErrorMessage(data as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data.appointments || [];
|
||||
// Handle different response formats
|
||||
// API might return array directly or wrapped in an object
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
if (data.appointments && Array.isArray(data.appointments)) {
|
||||
return data.appointments;
|
||||
}
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
return data.results;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get appointment detail
|
||||
|
||||
@ -229,6 +229,35 @@ export async function refreshToken(input: TokenRefreshInput): Promise<AuthTokens
|
||||
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
|
||||
export function getStoredTokens(): { access: string | null; refresh: string | null } {
|
||||
if (typeof window === "undefined") {
|
||||
@ -241,6 +270,14 @@ export function getStoredTokens(): { access: string | null; refresh: string | nu
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user has valid authentication
|
||||
export function hasValidAuth(): boolean {
|
||||
const tokens = getStoredTokens();
|
||||
if (!tokens.access) return false;
|
||||
|
||||
return !isTokenExpired(tokens.access);
|
||||
}
|
||||
|
||||
// Store tokens
|
||||
export function storeTokens(tokens: AuthTokens): void {
|
||||
if (typeof window === "undefined") return;
|
||||
@ -292,9 +329,43 @@ export function clearAuthData(): void {
|
||||
// Get auth header for API requests
|
||||
export function getAuthHeader(): { Authorization: string } | {} {
|
||||
const tokens = getStoredTokens();
|
||||
if (tokens.access) {
|
||||
if (tokens.access && !isTokenExpired(tokens.access)) {
|
||||
return { Authorization: `Bearer ${tokens.access}` };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Get all users (Admin only)
|
||||
export async function getAllUsers(): Promise<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 [];
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ export const API_ENDPOINTS = {
|
||||
verifyPasswordResetOtp: `${API_BASE_URL}/auth/verify-password-reset-otp/`,
|
||||
resetPassword: `${API_BASE_URL}/auth/reset-password/`,
|
||||
tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`,
|
||||
allUsers: `${API_BASE_URL}/auth/all-users/`,
|
||||
},
|
||||
meetings: {
|
||||
base: `${API_BASE_URL}/meetings/`,
|
||||
|
||||
@ -52,6 +52,7 @@ export interface AppointmentStats {
|
||||
scheduled: number;
|
||||
rejected: number;
|
||||
completion_rate: number;
|
||||
users?: number; // Total users count from API
|
||||
}
|
||||
|
||||
export interface JitsiMeetingInfo {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user