|
|
@@ -0,0 +1,475 @@
|
|
|
+"use client"
|
|
|
+
|
|
|
+import { useSession } from "next-auth/react"
|
|
|
+import { useRouter } from "next/navigation"
|
|
|
+import { useEffect, useState } from "react"
|
|
|
+import AuthenticatedLayout from "@/components/AuthenticatedLayout"
|
|
|
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
|
+import { Skeleton } from "@/components/ui/skeleton"
|
|
|
+import {
|
|
|
+ BarChart3,
|
|
|
+ Users,
|
|
|
+ Eye,
|
|
|
+ TrendingUp,
|
|
|
+ FileText,
|
|
|
+ Calendar,
|
|
|
+ Activity,
|
|
|
+} from "lucide-react"
|
|
|
+import { Button } from "@/components/ui/button"
|
|
|
+import {
|
|
|
+ Select,
|
|
|
+ SelectContent,
|
|
|
+ SelectItem,
|
|
|
+ SelectTrigger,
|
|
|
+ SelectValue,
|
|
|
+} from "@/components/ui/select"
|
|
|
+
|
|
|
+interface AnalyticsData {
|
|
|
+ period: number
|
|
|
+ startDate: string
|
|
|
+ endDate: string
|
|
|
+ analytics: {
|
|
|
+ totalVisits: number
|
|
|
+ uniqueUsers: number
|
|
|
+ uniqueSessions: number
|
|
|
+ topPages: Array<{ path: string; visits: number }>
|
|
|
+ visitsByDay: Array<{
|
|
|
+ date: string
|
|
|
+ totalVisits: number
|
|
|
+ uniqueUsers: number
|
|
|
+ }>
|
|
|
+ }
|
|
|
+ system: {
|
|
|
+ totalUsers: number
|
|
|
+ newUsers: number
|
|
|
+ totalRecords: number
|
|
|
+ appointments: Record<string, number>
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export default function AnalyticsPage() {
|
|
|
+ const { data: session, status } = useSession()
|
|
|
+ const router = useRouter()
|
|
|
+ const [data, setData] = useState<AnalyticsData | null>(null)
|
|
|
+ const [loading, setLoading] = useState(true)
|
|
|
+ const [period, setPeriod] = useState("30")
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (status === "unauthenticated") {
|
|
|
+ router.push("/auth/login")
|
|
|
+ }
|
|
|
+ }, [status, router])
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (session && session.user.role !== "ADMIN") {
|
|
|
+ router.push("/dashboard")
|
|
|
+ }
|
|
|
+ }, [session, router])
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (session?.user.role === "ADMIN") {
|
|
|
+ fetchAnalytics()
|
|
|
+ }
|
|
|
+ }, [session, period])
|
|
|
+
|
|
|
+ const fetchAnalytics = async () => {
|
|
|
+ try {
|
|
|
+ setLoading(true)
|
|
|
+ const response = await fetch(`/api/admin/analytics?period=${period}`)
|
|
|
+ if (response.ok) {
|
|
|
+ const result = await response.json()
|
|
|
+ setData(result)
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error("Error fetching analytics:", error)
|
|
|
+ } finally {
|
|
|
+ setLoading(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (status === "loading" || !session) {
|
|
|
+ return (
|
|
|
+ <div className="min-h-screen flex items-center justify-center">
|
|
|
+ <div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ if (session.user.role !== "ADMIN") {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+
|
|
|
+ const totalAppointments = data
|
|
|
+ ? Object.values(data.system.appointments).reduce((a, b) => a + b, 0)
|
|
|
+ : 0
|
|
|
+
|
|
|
+ return (
|
|
|
+ <AuthenticatedLayout>
|
|
|
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
|
+ {/* Header */}
|
|
|
+ <div className="mb-8 flex items-center justify-between">
|
|
|
+ <div>
|
|
|
+ <h1 className="text-3xl font-bold text-gray-900 mb-2">
|
|
|
+ Analíticas del Sistema
|
|
|
+ </h1>
|
|
|
+ <p className="text-gray-600">
|
|
|
+ Métricas de uso y estadísticas de la plataforma
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <Select value={period} onValueChange={setPeriod}>
|
|
|
+ <SelectTrigger className="w-[180px]">
|
|
|
+ <SelectValue placeholder="Periodo" />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ <SelectItem value="7">Últimos 7 días</SelectItem>
|
|
|
+ <SelectItem value="30">Últimos 30 días</SelectItem>
|
|
|
+ <SelectItem value="90">Últimos 90 días</SelectItem>
|
|
|
+ <SelectItem value="365">Último año</SelectItem>
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Métricas principales de visitas */}
|
|
|
+ <div className="grid md:grid-cols-3 gap-6 mb-8">
|
|
|
+ <Card>
|
|
|
+ <CardContent className="p-6">
|
|
|
+ <div className="flex items-center">
|
|
|
+ <div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mr-4">
|
|
|
+ <Eye className="w-6 h-6 text-blue-600" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <p className="text-sm font-medium text-gray-600">
|
|
|
+ Visitas Totales
|
|
|
+ </p>
|
|
|
+ {loading ? (
|
|
|
+ <Skeleton className="h-8 w-20" />
|
|
|
+ ) : (
|
|
|
+ <p className="text-2xl font-bold text-gray-900">
|
|
|
+ {data?.analytics.totalVisits.toLocaleString()}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card>
|
|
|
+ <CardContent className="p-6">
|
|
|
+ <div className="flex items-center">
|
|
|
+ <div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mr-4">
|
|
|
+ <Users className="w-6 h-6 text-green-600" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <p className="text-sm font-medium text-gray-600">
|
|
|
+ Usuarios Únicos
|
|
|
+ </p>
|
|
|
+ {loading ? (
|
|
|
+ <Skeleton className="h-8 w-20" />
|
|
|
+ ) : (
|
|
|
+ <p className="text-2xl font-bold text-gray-900">
|
|
|
+ {data?.analytics.uniqueUsers.toLocaleString()}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card>
|
|
|
+ <CardContent className="p-6">
|
|
|
+ <div className="flex items-center">
|
|
|
+ <div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mr-4">
|
|
|
+ <Activity className="w-6 h-6 text-purple-600" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <p className="text-sm font-medium text-gray-600">
|
|
|
+ Sesiones Únicas
|
|
|
+ </p>
|
|
|
+ {loading ? (
|
|
|
+ <Skeleton className="h-8 w-20" />
|
|
|
+ ) : (
|
|
|
+ <p className="text-2xl font-bold text-gray-900">
|
|
|
+ {data?.analytics.uniqueSessions.toLocaleString()}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Métricas del sistema */}
|
|
|
+ <div className="mb-6">
|
|
|
+ <h2 className="text-xl font-bold text-gray-900 mb-4">
|
|
|
+ Estadísticas del Sistema
|
|
|
+ </h2>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="grid md:grid-cols-4 gap-6 mb-8">
|
|
|
+ <Card>
|
|
|
+ <CardContent className="p-6">
|
|
|
+ <div className="flex items-center">
|
|
|
+ <div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center mr-3">
|
|
|
+ <Users className="w-5 h-5 text-orange-600" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <p className="text-xs font-medium text-gray-600">
|
|
|
+ Total Usuarios
|
|
|
+ </p>
|
|
|
+ {loading ? (
|
|
|
+ <Skeleton className="h-7 w-16" />
|
|
|
+ ) : (
|
|
|
+ <p className="text-xl font-bold text-gray-900">
|
|
|
+ {data?.system.totalUsers}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card>
|
|
|
+ <CardContent className="p-6">
|
|
|
+ <div className="flex items-center">
|
|
|
+ <div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center mr-3">
|
|
|
+ <TrendingUp className="w-5 h-5 text-green-600" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <p className="text-xs font-medium text-gray-600">
|
|
|
+ Nuevos Usuarios
|
|
|
+ </p>
|
|
|
+ {loading ? (
|
|
|
+ <Skeleton className="h-7 w-16" />
|
|
|
+ ) : (
|
|
|
+ <p className="text-xl font-bold text-gray-900">
|
|
|
+ {data?.system.newUsers}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card>
|
|
|
+ <CardContent className="p-6">
|
|
|
+ <div className="flex items-center">
|
|
|
+ <div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center mr-3">
|
|
|
+ <FileText className="w-5 h-5 text-blue-600" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <p className="text-xs font-medium text-gray-600">
|
|
|
+ Consultas
|
|
|
+ </p>
|
|
|
+ {loading ? (
|
|
|
+ <Skeleton className="h-7 w-16" />
|
|
|
+ ) : (
|
|
|
+ <p className="text-xl font-bold text-gray-900">
|
|
|
+ {data?.system.totalRecords}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card>
|
|
|
+ <CardContent className="p-6">
|
|
|
+ <div className="flex items-center">
|
|
|
+ <div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center mr-3">
|
|
|
+ <Calendar className="w-5 h-5 text-purple-600" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <p className="text-xs font-medium text-gray-600">Citas</p>
|
|
|
+ {loading ? (
|
|
|
+ <Skeleton className="h-7 w-16" />
|
|
|
+ ) : (
|
|
|
+ <p className="text-xl font-bold text-gray-900">
|
|
|
+ {totalAppointments}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Páginas más visitadas */}
|
|
|
+ <div className="grid md:grid-cols-2 gap-6">
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle className="flex items-center">
|
|
|
+ <BarChart3 className="w-5 h-5 mr-2 text-blue-600" />
|
|
|
+ Páginas Más Visitadas
|
|
|
+ </CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ {loading ? (
|
|
|
+ <div className="space-y-3">
|
|
|
+ {[1, 2, 3, 4, 5].map((i) => (
|
|
|
+ <Skeleton key={i} className="h-12 w-full" />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="space-y-2">
|
|
|
+ {data?.analytics.topPages.map((page, index) => (
|
|
|
+ <div
|
|
|
+ key={page.path}
|
|
|
+ className="flex items-center justify-between p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
|
|
|
+ >
|
|
|
+ <div className="flex items-center space-x-3">
|
|
|
+ <div className="w-6 h-6 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-sm font-semibold">
|
|
|
+ {index + 1}
|
|
|
+ </div>
|
|
|
+ <span className="text-sm font-medium text-gray-900">
|
|
|
+ {page.path}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <span className="text-sm font-semibold text-gray-600">
|
|
|
+ {page.visits.toLocaleString()} visitas
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ {!data?.analytics.topPages.length && (
|
|
|
+ <p className="text-gray-500 text-center py-8">
|
|
|
+ No hay datos de visitas aún
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle className="flex items-center">
|
|
|
+ <Calendar className="w-5 h-5 mr-2 text-purple-600" />
|
|
|
+ Estado de Citas
|
|
|
+ </CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ {loading ? (
|
|
|
+ <div className="space-y-3">
|
|
|
+ {[1, 2, 3, 4, 5].map((i) => (
|
|
|
+ <Skeleton key={i} className="h-12 w-full" />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="space-y-2">
|
|
|
+ {Object.entries(data?.system.appointments || {}).map(
|
|
|
+ ([status, count]) => {
|
|
|
+ const statusColors: Record<
|
|
|
+ string,
|
|
|
+ { bg: string; text: string }
|
|
|
+ > = {
|
|
|
+ PENDIENTE: { bg: "bg-yellow-100", text: "text-yellow-600" },
|
|
|
+ APROBADA: { bg: "bg-blue-100", text: "text-blue-600" },
|
|
|
+ RECHAZADA: { bg: "bg-red-100", text: "text-red-600" },
|
|
|
+ COMPLETADA: { bg: "bg-green-100", text: "text-green-600" },
|
|
|
+ CANCELADA: { bg: "bg-gray-100", text: "text-gray-600" },
|
|
|
+ }
|
|
|
+ const colors =
|
|
|
+ statusColors[status] || statusColors.PENDIENTE
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={status}
|
|
|
+ className="flex items-center justify-between p-3 rounded-lg bg-gray-50"
|
|
|
+ >
|
|
|
+ <div className="flex items-center space-x-3">
|
|
|
+ <div
|
|
|
+ className={`w-3 h-3 rounded-full ${colors.bg}`}
|
|
|
+ ></div>
|
|
|
+ <span className="text-sm font-medium text-gray-900">
|
|
|
+ {status}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <span
|
|
|
+ className={`text-sm font-semibold ${colors.text}`}
|
|
|
+ >
|
|
|
+ {count}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ )}
|
|
|
+ {!Object.keys(data?.system.appointments || {}).length && (
|
|
|
+ <p className="text-gray-500 text-center py-8">
|
|
|
+ No hay citas registradas aún
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Actividad por día */}
|
|
|
+ <div className="mt-6">
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle className="flex items-center">
|
|
|
+ <TrendingUp className="w-5 h-5 mr-2 text-green-600" />
|
|
|
+ Actividad Diaria
|
|
|
+ </CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ {loading ? (
|
|
|
+ <Skeleton className="h-64 w-full" />
|
|
|
+ ) : (
|
|
|
+ <div className="overflow-x-auto">
|
|
|
+ <table className="w-full">
|
|
|
+ <thead>
|
|
|
+ <tr className="border-b">
|
|
|
+ <th className="text-left py-3 px-4 text-sm font-semibold text-gray-600">
|
|
|
+ Fecha
|
|
|
+ </th>
|
|
|
+ <th className="text-right py-3 px-4 text-sm font-semibold text-gray-600">
|
|
|
+ Visitas Totales
|
|
|
+ </th>
|
|
|
+ <th className="text-right py-3 px-4 text-sm font-semibold text-gray-600">
|
|
|
+ Usuarios Únicos
|
|
|
+ </th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {data?.analytics.visitsByDay
|
|
|
+ .slice(0, 10)
|
|
|
+ .map((day, index) => (
|
|
|
+ <tr
|
|
|
+ key={day.date}
|
|
|
+ className={
|
|
|
+ index % 2 === 0 ? "bg-gray-50" : "bg-white"
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <td className="py-3 px-4 text-sm text-gray-900">
|
|
|
+ {new Date(day.date).toLocaleDateString("es-ES", {
|
|
|
+ weekday: "short",
|
|
|
+ year: "numeric",
|
|
|
+ month: "short",
|
|
|
+ day: "numeric",
|
|
|
+ })}
|
|
|
+ </td>
|
|
|
+ <td className="py-3 px-4 text-sm text-right font-medium text-gray-900">
|
|
|
+ {day.totalVisits.toLocaleString()}
|
|
|
+ </td>
|
|
|
+ <td className="py-3 px-4 text-sm text-right font-medium text-green-600">
|
|
|
+ {day.uniqueUsers.toLocaleString()}
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ ))}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ {!data?.analytics.visitsByDay.length && (
|
|
|
+ <p className="text-gray-500 text-center py-8">
|
|
|
+ No hay datos de actividad aún
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </AuthenticatedLayout>
|
|
|
+ )
|
|
|
+}
|