feat/authentication #21
@ -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