feat/authentication #21
@ -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,
|
|
||||||
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);
|
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,7 +117,8 @@ 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 className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className={`text-xl sm:text-2xl font-semibold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
|
<h1 className={`text-xl sm:text-2xl font-semibold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
Bookings
|
Bookings
|
||||||
@ -211,25 +127,32 @@ export default function Booking() {
|
|||||||
Manage and view all appointment bookings
|
Manage and view all appointment bookings
|
||||||
</p>
|
</p>
|
||||||
</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 ${
|
</div>
|
||||||
isDark ? "bg-rose-500 text-white hover:bg-rose-600" : "bg-gray-900 text-white hover:bg-gray-800"
|
{/* Search Bar */}
|
||||||
}`}>
|
<div className="relative">
|
||||||
+ New Booking
|
<Search className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
|
||||||
</button>
|
<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>
|
</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>
|
||||||
|
{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"}`}>
|
<div className={`text-xs sm:hidden mt-0.5 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
{formatDate(booking.scheduled_at)}
|
{formatDate(appointment.scheduled_datetime)}
|
||||||
</div>
|
</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">
|
||||||
|
{appointment.scheduled_datetime ? (
|
||||||
|
<>
|
||||||
<div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}>
|
<div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
{formatDate(booking.scheduled_at)}
|
{formatDate(appointment.scheduled_datetime)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
<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" />
|
<Clock className="w-3 h-3" />
|
||||||
{formatTime(booking.scheduled_at)}
|
{formatTime(appointment.scheduled_datetime)}
|
||||||
</div>
|
</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([
|
||||||
|
getAllUsers().catch(() => [] as User[]),
|
||||||
|
getAppointmentStats().catch(() => null),
|
||||||
|
listAppointments().catch(() => [] as Appointment[]),
|
||||||
|
]);
|
||||||
|
|
||||||
// Mock API response
|
// Calculate statistics
|
||||||
const mockData: DashboardStats = {
|
// Use users count from appointment stats if available, otherwise use getAllUsers result
|
||||||
total_users: 3,
|
const totalUsers = appointmentStats?.users ?? users.length;
|
||||||
active_users: 3,
|
const activeUsers = users.filter(
|
||||||
total_bookings: 6,
|
(user) => user.is_active === true || user.isActive === true
|
||||||
upcoming_bookings: 6,
|
).length;
|
||||||
|
|
||||||
|
const totalBookings = appointmentStats?.total_requests || appointments.length;
|
||||||
|
const upcomingBookings = appointmentStats?.scheduled ||
|
||||||
|
appointments.filter((apt) => apt.status === "scheduled").length;
|
||||||
|
// Completed bookings - not in API status types, so set to 0
|
||||||
|
const completedBookings = 0;
|
||||||
|
const cancelledBookings = appointmentStats?.rejected ||
|
||||||
|
appointments.filter((apt) => apt.status === "rejected").length;
|
||||||
|
|
||||||
|
// Calculate revenue (assuming appointments have amount field, defaulting to 0)
|
||||||
|
const now = new Date();
|
||||||
|
const currentMonth = now.getMonth();
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
|
||||||
|
const totalRevenue = appointments.reduce((sum, apt) => {
|
||||||
|
// If appointment has amount field, use it, otherwise default to 0
|
||||||
|
const amount = (apt as any).amount || 0;
|
||||||
|
return sum + amount;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const monthlyRevenue = appointments
|
||||||
|
.filter((apt) => {
|
||||||
|
if (!apt.scheduled_datetime) return false;
|
||||||
|
const aptDate = new Date(apt.scheduled_datetime);
|
||||||
|
return (
|
||||||
|
aptDate.getMonth() === currentMonth &&
|
||||||
|
aptDate.getFullYear() === currentYear
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce((sum, apt) => {
|
||||||
|
const amount = (apt as any).amount || 0;
|
||||||
|
return sum + amount;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// For now, use static trends (in a real app, you'd calculate these from historical data)
|
||||||
|
const trends = {
|
||||||
|
total_users: "+12%",
|
||||||
|
active_users: "+8%",
|
||||||
|
total_bookings: "+24%",
|
||||||
|
upcoming_bookings: "+6",
|
||||||
|
completed_bookings: "0%",
|
||||||
|
cancelled_bookings: "0%",
|
||||||
|
total_revenue: "+18%",
|
||||||
|
monthly_revenue: "+32%",
|
||||||
|
};
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
total_users: totalUsers,
|
||||||
|
active_users: activeUsers,
|
||||||
|
total_bookings: totalBookings,
|
||||||
|
upcoming_bookings: upcomingBookings,
|
||||||
|
completed_bookings: completedBookings,
|
||||||
|
cancelled_bookings: cancelledBookings,
|
||||||
|
total_revenue: totalRevenue,
|
||||||
|
monthly_revenue: monthlyRevenue,
|
||||||
|
trends,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch dashboard stats:", error);
|
||||||
|
toast.error("Failed to load dashboard statistics");
|
||||||
|
// Set default values on error
|
||||||
|
setStats({
|
||||||
|
total_users: 0,
|
||||||
|
active_users: 0,
|
||||||
|
total_bookings: 0,
|
||||||
|
upcoming_bookings: 0,
|
||||||
completed_bookings: 0,
|
completed_bookings: 0,
|
||||||
cancelled_bookings: 0,
|
cancelled_bookings: 0,
|
||||||
total_revenue: 0,
|
total_revenue: 0,
|
||||||
monthly_revenue: 0,
|
monthly_revenue: 0,
|
||||||
};
|
trends: {
|
||||||
|
total_users: "0%",
|
||||||
setStats(mockData);
|
active_users: "0%",
|
||||||
|
total_bookings: "0%",
|
||||||
|
upcoming_bookings: "0",
|
||||||
|
completed_bookings: "0%",
|
||||||
|
cancelled_bookings: "0%",
|
||||||
|
total_revenue: "0%",
|
||||||
|
monthly_revenue: "0%",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchStats();
|
fetchStats();
|
||||||
}, []);
|
}, [timePeriod]);
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{
|
{
|
||||||
title: "Total Users",
|
title: "Total Users",
|
||||||
value: stats?.total_users ?? 0,
|
value: stats?.total_users ?? 0,
|
||||||
icon: Users,
|
icon: Users,
|
||||||
trend: "+12%",
|
trend: stats?.trends.total_users ?? "0%",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Active Users",
|
title: "Active Users",
|
||||||
value: stats?.active_users ?? 0,
|
value: stats?.active_users ?? 0,
|
||||||
icon: UserCheck,
|
icon: UserCheck,
|
||||||
trend: "+8%",
|
trend: stats?.trends.active_users ?? "0%",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Total Bookings",
|
title: "Total Bookings",
|
||||||
value: stats?.total_bookings ?? 0,
|
value: stats?.total_bookings ?? 0,
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
trend: "+24%",
|
trend: stats?.trends.total_bookings ?? "0%",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Upcoming Bookings",
|
title: "Upcoming Bookings",
|
||||||
value: stats?.upcoming_bookings ?? 0,
|
value: stats?.upcoming_bookings ?? 0,
|
||||||
icon: CalendarCheck,
|
icon: CalendarCheck,
|
||||||
trend: "+6",
|
trend: stats?.trends.upcoming_bookings ?? "0",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Completed Bookings",
|
title: "Completed Bookings",
|
||||||
value: stats?.completed_bookings ?? 0,
|
value: stats?.completed_bookings ?? 0,
|
||||||
icon: CalendarCheck,
|
icon: CalendarCheck,
|
||||||
trend: "0%",
|
trend: stats?.trends.completed_bookings ?? "0%",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Cancelled Bookings",
|
title: "Cancelled Bookings",
|
||||||
value: stats?.cancelled_bookings ?? 0,
|
value: stats?.cancelled_bookings ?? 0,
|
||||||
icon: CalendarX,
|
icon: CalendarX,
|
||||||
trend: "0%",
|
trend: stats?.trends.cancelled_bookings ?? "0%",
|
||||||
trendUp: false,
|
trendUp: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Total Revenue",
|
title: "Total Revenue",
|
||||||
value: `$${stats?.total_revenue.toLocaleString() ?? 0}`,
|
value: `$${stats?.total_revenue.toLocaleString() ?? 0}`,
|
||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
trend: "+18%",
|
trend: stats?.trends.total_revenue ?? "0%",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Monthly Revenue",
|
title: "Monthly Revenue",
|
||||||
value: `$${stats?.monthly_revenue.toLocaleString() ?? 0}`,
|
value: `$${stats?.monthly_revenue.toLocaleString() ?? 0}`,
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
trend: "+32%",
|
trend: stats?.trends.monthly_revenue ?? "0%",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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