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,
|
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>
|
||||||
|
|||||||
@ -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([
|
||||||
// Mock API response
|
getAllUsers().catch(() => [] as User[]),
|
||||||
const mockData: DashboardStats = {
|
getAppointmentStats().catch(() => null),
|
||||||
total_users: 3,
|
listAppointments().catch(() => [] as Appointment[]),
|
||||||
active_users: 3,
|
]);
|
||||||
total_bookings: 6,
|
|
||||||
upcoming_bookings: 6,
|
// 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,
|
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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 [];
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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/`,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user