Explorar el Código

implement analytics

Matthew Trejo hace 1 mes
padre
commit
4e587d20ee

+ 3 - 1
.claude/settings.local.json

@@ -1,7 +1,9 @@
 {
   "permissions": {
     "allow": [
-      "Bash(npm run build:*)"
+      "Bash(npm run build:*)",
+      "Bash(cat:*)",
+      "Bash(npx prisma migrate dev:*)"
     ],
     "deny": [],
     "ask": []

+ 25 - 0
prisma/migrations/20251111050354_add_analytics_page_visits/migration.sql

@@ -0,0 +1,25 @@
+-- CreateTable
+CREATE TABLE "PageVisit" (
+    "id" TEXT NOT NULL,
+    "userId" TEXT,
+    "sessionId" VARCHAR(255) NOT NULL,
+    "path" VARCHAR(500) NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "PageVisit_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "PageVisit_userId_idx" ON "PageVisit"("userId");
+
+-- CreateIndex
+CREATE INDEX "PageVisit_sessionId_idx" ON "PageVisit"("sessionId");
+
+-- CreateIndex
+CREATE INDEX "PageVisit_createdAt_idx" ON "PageVisit"("createdAt");
+
+-- CreateIndex
+CREATE INDEX "PageVisit_path_idx" ON "PageVisit"("path");
+
+-- AddForeignKey
+ALTER TABLE "PageVisit" ADD CONSTRAINT "PageVisit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 20 - 4
prisma/schema.prisma

@@ -38,6 +38,7 @@ model User {
   patientAppointments Appointment[] @relation("PatientAppointments")
   doctorAppointments Appointment[] @relation("DoctorAppointments")
   dailyLogs    DailyLog[]
+  pageVisits   PageVisit[]
 }
 
 model PatientAssignment {
@@ -104,25 +105,40 @@ model DailyLog {
   userId       String
   user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
   date         DateTime @db.Date
-  
+
   // Métricas (1-5)
   mood         Int?
   energy       Int?
   sleepHours   Float?
   sleepQuality Int?
-  
+
   // Notas personales
   notes        String?  @db.Text
-  
+
   // Metadata
   createdAt    DateTime @default(now())
   updatedAt    DateTime @updatedAt
-  
+
   @@unique([userId, date])
   @@index([userId])
   @@index([date])
 }
 
+model PageVisit {
+  id        String   @id @default(cuid())
+  userId    String?  // null = usuario no autenticado
+  sessionId String   @db.VarChar(255)
+  path      String   @db.VarChar(500)
+  createdAt DateTime @default(now())
+
+  user      User?    @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  @@index([userId])
+  @@index([sessionId])
+  @@index([createdAt])
+  @@index([path])
+}
+
 enum Role {
   ADMIN
   DOCTOR

+ 13 - 0
public/stethoscope.svg

@@ -0,0 +1,13 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
+  <!-- Fondo azul con esquinas redondeadas -->
+  <rect width="32" height="32" rx="6" fill="#3b82f6"/>
+  
+  <!-- Estetoscopio en blanco, centrado -->
+  <g transform="translate(4, 4)">
+    <path d="M11 2v2" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
+    <path d="M5 2v2" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
+    <path d="M5 3H4a2 2 0 0 0-2 2v4a6 6 0 0 0 12 0V5a2 2 0 0 0-2-2h-1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
+    <path d="M8 15a6 6 0 0 0 12 0v-3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
+    <circle cx="20" cy="10" r="2" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></circle>
+  </g>
+</svg>

+ 475 - 0
src/app/admin/analytics/page.tsx

@@ -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>
+  )
+}

+ 100 - 0
src/app/api/admin/analytics/route.ts

@@ -0,0 +1,100 @@
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { prisma } from '@/lib/prisma'
+import {
+  getTotalVisits,
+  getUniqueUsers,
+  getUniqueSessions,
+  getTopPages,
+  getVisitsByDay,
+} from '@/lib/analytics'
+
+export async function GET(request: Request) {
+  try {
+    // Verificar autenticación y rol
+    const session = await getServerSession(authOptions)
+
+    if (!session || session.user.role !== 'ADMIN') {
+      return Response.json({ error: 'No autorizado' }, { status: 403 })
+    }
+
+    // Obtener parámetros de fecha
+    const { searchParams } = new URL(request.url)
+    const period = searchParams.get('period') || '30' // días
+
+    // Calcular fechas
+    const endDate = new Date()
+    const startDate = new Date()
+    startDate.setDate(startDate.getDate() - parseInt(period))
+
+    // Obtener métricas en paralelo
+    const [
+      totalVisits,
+      uniqueUsers,
+      uniqueSessions,
+      topPages,
+      visitsByDay,
+      totalUsers,
+      newUsers,
+      totalRecords,
+      appointmentsByStatus,
+    ] = await Promise.all([
+      // Analytics de visitas
+      getTotalVisits(startDate, endDate),
+      getUniqueUsers(startDate, endDate),
+      getUniqueSessions(startDate, endDate),
+      getTopPages(10, startDate, endDate),
+      getVisitsByDay(startDate, endDate),
+
+      // Métricas de usuarios
+      prisma.user.count({ where: { isActive: true } }),
+      prisma.user.count({
+        where: { createdAt: { gte: startDate } },
+      }),
+
+      // Métricas de consultas
+      prisma.record.count({
+        where: { createdAt: { gte: startDate } },
+      }),
+
+      // Métricas de citas
+      prisma.appointment.groupBy({
+        by: ['estado'],
+        _count: true,
+        where: { createdAt: { gte: startDate } },
+      }),
+    ])
+
+    // Formatear respuesta
+    return Response.json({
+      period: parseInt(period),
+      startDate,
+      endDate,
+      analytics: {
+        totalVisits,
+        uniqueUsers,
+        uniqueSessions,
+        topPages,
+        visitsByDay,
+      },
+      system: {
+        totalUsers,
+        newUsers,
+        totalRecords,
+        appointments: appointmentsByStatus.reduce(
+          (acc, curr) => {
+            acc[curr.estado] = curr._count
+            return acc
+          },
+          {} as Record<string, number>
+        ),
+      },
+    })
+  } catch (error) {
+    console.error('❌ Error fetching analytics:', error)
+    return Response.json(
+      { error: 'Error al obtener analíticas' },
+      { status: 500 }
+    )
+  }
+}

+ 30 - 0
src/app/api/analytics/track/route.ts

@@ -0,0 +1,30 @@
+import { trackPageVisit } from '@/lib/analytics'
+import { NextRequest } from 'next/server'
+
+export const runtime = 'nodejs' // Importante: usar Node.js runtime para acceder a Prisma
+
+export async function POST(request: NextRequest) {
+  try {
+    const body = await request.json()
+    const { userId, sessionId, path } = body
+
+    if (!sessionId || !path) {
+      return Response.json(
+        { error: 'Missing required fields' },
+        { status: 400 }
+      )
+    }
+
+    await trackPageVisit({
+      userId: userId || undefined,
+      sessionId,
+      path,
+    })
+
+    return Response.json({ success: true }, { status: 200 })
+  } catch (error) {
+    console.error('📊 Error in track API:', error)
+    // No fallar - solo loggear
+    return Response.json({ success: false }, { status: 200 })
+  }
+}

+ 3 - 3
src/app/auth/login/page.tsx

@@ -139,13 +139,13 @@ export default function LoginPage() {
                 <div>
                   <p className="font-semibold text-blue-900">Estudiantes:</p>
                   <p className="text-blue-800">
-                    Usa tus credenciales UTB (ejemplo: 1234567890-EST)
+                    Usa tus credenciales que usas para iniciar en el SAI (ejemplo: 1234567890-EST)
                   </p>
                 </div>
                 <div>
-                  <p className="font-semibold text-blue-900">Doctores</p>
+                  <p className="font-semibold text-blue-900">Doctores:</p>
                   <p className="text-blue-800">
-                    Usa tu email y contraseña otorgados para el sistema
+                    Usa tu usuario y contraseña otorgados para el sistema
                   </p>
                 </div>
               </div>

BIN
src/app/favicon.ico


+ 5 - 0
src/app/layout.tsx

@@ -4,6 +4,7 @@ import "./globals.css"
 import { NextAuthProvider } from "@/components/NextAuthProvider"
 import { ProfileImageProvider } from "@/contexts/ProfileImageContext"
 import { Toaster } from "@/components/ui/sonner"
+import AnalyticsTracker from "@/components/AnalyticsTracker"
 import "@/lib/init"
 
 const inter = Inter({ subsets: ["latin"] })
@@ -11,6 +12,9 @@ const inter = Inter({ subsets: ["latin"] })
 export const metadata: Metadata = {
   title: "Ani Assistant - Asistente Médico Virtual",
   description: "Plataforma de asistente virtual médico que conecta pacientes con información médica confiable y genera reportes médicos personalizados.",
+  icons: {
+    icon: "/stethoscope.svg",
+  },
 }
 
 export default function RootLayout({
@@ -23,6 +27,7 @@ export default function RootLayout({
       <body className={inter.className}>
         <NextAuthProvider>
           <ProfileImageProvider>
+            <AnalyticsTracker />
             {children}
             <Toaster />
           </ProfileImageProvider>

+ 57 - 0
src/components/AnalyticsTracker.tsx

@@ -0,0 +1,57 @@
+"use client"
+
+import { useEffect } from 'react'
+import { usePathname } from 'next/navigation'
+import { useSession } from 'next-auth/react'
+
+// Hook para obtener o crear session ID
+function useAnalyticsSessionId() {
+  useEffect(() => {
+    // Crear session ID si no existe
+    if (typeof window !== 'undefined' && !localStorage.getItem('analytics_session_id')) {
+      localStorage.setItem('analytics_session_id', crypto.randomUUID())
+    }
+  }, [])
+
+  return typeof window !== 'undefined'
+    ? localStorage.getItem('analytics_session_id') || crypto.randomUUID()
+    : null
+}
+
+export default function AnalyticsTracker() {
+  const pathname = usePathname()
+  const { data: session } = useSession()
+  const sessionId = useAnalyticsSessionId()
+
+  useEffect(() => {
+    // No trackear si no tenemos sessionId o si es una ruta de API
+    if (!sessionId || pathname.startsWith('/api')) {
+      return
+    }
+
+    // Track page visit
+    const trackVisit = async () => {
+      try {
+        await fetch('/api/analytics/track', {
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/json',
+          },
+          body: JSON.stringify({
+            userId: session?.user?.id || null,
+            sessionId,
+            path: pathname,
+          }),
+        })
+      } catch (error) {
+        // Silent fail - analytics no debe romper la app
+        console.error('Analytics tracking failed:', error)
+      }
+    }
+
+    trackVisit()
+  }, [pathname, sessionId, session?.user?.id])
+
+  // Este componente no renderiza nada
+  return null
+}

+ 12 - 6
src/components/sidebar/SidebarNavigation.tsx

@@ -4,10 +4,10 @@ import { useState, useEffect } from "react"
 import { useSession } from "next-auth/react"
 import Link from "next/link"
 import { usePathname } from "next/navigation"
-import { 
-  MessageSquare, 
-  FileText, 
-  Users, 
+import {
+  MessageSquare,
+  FileText,
+  Users,
   ChevronDown,
   ChevronRight,
   Home,
@@ -16,7 +16,8 @@ import {
   BookOpen,
   User,
   CalendarDays,
-  UserCog
+  UserCog,
+  BarChart3
 } from "lucide-react"
 import { COLOR_PALETTE } from "@/utils/palette"
 import { useAppointmentsBadge } from "@/hooks/useAppointmentsBadge"
@@ -88,7 +89,7 @@ export default function SidebarNavigation({ onItemClick, isCollapsed = false }:
   const isDoctor = session.user.role === "DOCTOR"
 
   // Definir las secciones del sidebar según el rol
-  const sidebarSections: SidebarSection[] = isAdmin 
+  const sidebarSections: SidebarSection[] = isAdmin
     ? [
         {
           title: "General",
@@ -108,6 +109,11 @@ export default function SidebarNavigation({ onItemClick, isCollapsed = false }:
               href: "/admin",
               icon: Users
             },
+            {
+              title: "Analíticas",
+              href: "/admin/analytics",
+              icon: BarChart3
+            },
             {
               title: "Gestión de Usuarios",
               href: "/admin/users",

+ 166 - 0
src/lib/analytics.ts

@@ -0,0 +1,166 @@
+import { prisma } from '@/lib/prisma'
+
+export interface TrackPageVisitParams {
+  userId?: string
+  sessionId: string
+  path: string
+}
+
+/**
+ * Registra una visita a una página
+ * @param params - Parámetros de la visita
+ */
+export async function trackPageVisit({
+  userId,
+  sessionId,
+  path,
+}: TrackPageVisitParams) {
+  try {
+    await prisma.pageVisit.create({
+      data: {
+        userId,
+        sessionId,
+        path,
+      },
+    })
+  } catch (error) {
+    // Silent fail - analytics no debe romper la app
+    console.error('📊 Error tracking page visit:', error)
+  }
+}
+
+/**
+ * Obtiene el total de visitas en un rango de fechas
+ */
+export async function getTotalVisits(startDate?: Date, endDate?: Date) {
+  return await prisma.pageVisit.count({
+    where: {
+      createdAt: {
+        gte: startDate,
+        lte: endDate,
+      },
+    },
+  })
+}
+
+/**
+ * Obtiene usuarios únicos que visitaron en un rango de fechas
+ */
+export async function getUniqueUsers(startDate?: Date, endDate?: Date) {
+  const result = await prisma.pageVisit.findMany({
+    where: {
+      userId: { not: null },
+      createdAt: {
+        gte: startDate,
+        lte: endDate,
+      },
+    },
+    select: {
+      userId: true,
+    },
+    distinct: ['userId'],
+  })
+
+  return result.length
+}
+
+/**
+ * Obtiene sesiones únicas en un rango de fechas
+ */
+export async function getUniqueSessions(startDate?: Date, endDate?: Date) {
+  const result = await prisma.pageVisit.findMany({
+    where: {
+      createdAt: {
+        gte: startDate,
+        lte: endDate,
+      },
+    },
+    select: {
+      sessionId: true,
+    },
+    distinct: ['sessionId'],
+  })
+
+  return result.length
+}
+
+/**
+ * Obtiene las páginas más visitadas
+ */
+export async function getTopPages(limit = 10, startDate?: Date, endDate?: Date) {
+  const visits = await prisma.pageVisit.groupBy({
+    by: ['path'],
+    _count: {
+      id: true,
+    },
+    where: {
+      createdAt: {
+        gte: startDate,
+        lte: endDate,
+      },
+    },
+    orderBy: {
+      _count: {
+        id: 'desc',
+      },
+    },
+    take: limit,
+  })
+
+  return visits.map((v) => ({
+    path: v.path,
+    visits: v._count.id,
+  }))
+}
+
+/**
+ * Obtiene visitas agrupadas por día
+ */
+export async function getVisitsByDay(startDate?: Date, endDate?: Date) {
+  // Obtener todas las visitas en el rango usando Prisma ORM
+  const visits = await prisma.pageVisit.findMany({
+    where: {
+      createdAt: {
+        gte: startDate,
+        lte: endDate,
+      },
+    },
+    select: {
+      createdAt: true,
+      userId: true,
+    },
+    orderBy: {
+      createdAt: 'desc',
+    },
+  })
+
+  // Agrupar por día en JavaScript
+  const groupedByDay = visits.reduce((acc, visit) => {
+    // Obtener solo la fecha (sin hora)
+    const dateKey = visit.createdAt.toISOString().split('T')[0]
+
+    if (!acc[dateKey]) {
+      acc[dateKey] = {
+        date: dateKey,
+        totalVisits: 0,
+        uniqueUsers: new Set<string>(),
+      }
+    }
+
+    acc[dateKey].totalVisits++
+    if (visit.userId) {
+      acc[dateKey].uniqueUsers.add(visit.userId)
+    }
+
+    return acc
+  }, {} as Record<string, { date: string; totalVisits: number; uniqueUsers: Set<string> }>)
+
+  // Convertir a array y formatear
+  return Object.values(groupedByDay)
+    .map((day) => ({
+      date: day.date,
+      totalVisits: day.totalVisits,
+      uniqueUsers: day.uniqueUsers.size,
+    }))
+    .sort((a, b) => b.date.localeCompare(a.date)) // Ordenar por fecha descendente
+}

+ 15 - 0
src/lib/chat-prompts.ts

@@ -31,6 +31,21 @@ export const MEDICAL_SYSTEM_PROMPT = `Eres un asistente médico virtual llamado
 5. Sé empático y profesional en tus respuestas
 6. Responde siempre en español
 7. Mantén el enfoque estrictamente en salud y medicina
+8. Si el usuario indica manualmente que quiere agendar una cita, dale la opción al usuario de hacerlo sin preguntarle por sus síntomas.
+
+**PROTOCOLO DE SEGURIDAD ANTES DE RECOMENDACIONES:**
+⚠️ MUY IMPORTANTE: Antes de recomendar cualquier alimento, bebida, medicamento de venta libre o tratamiento específico, SIEMPRE debes preguntar primero:
+- ¿Tienes alergias conocidas a alimentos, medicamentos o sustancias?
+- ¿Tienes alguna intolerancia alimentaria (lactosa, gluten, etc.)?
+- ¿Padeces alguna condición médica crónica (diabetes, hipertensión, etc.)?
+- ¿Estás tomando algún medicamento actualmente?
+- ¿Estás embarazada o en período de lactancia? (si es relevante)
+
+NUNCA des recomendaciones específicas de alimentos, bebidas o medicamentos sin confirmar primero que el paciente no tiene contraindicaciones. Esto es crucial para la seguridad del paciente.
+
+Ejemplo de cómo hacerlo:
+❌ MAL: "Te recomiendo tomar agua con limón para la hidratación"
+✅ BIEN: "Para darte recomendaciones seguras de hidratación, ¿tienes alguna alergia alimentaria, intolerancia o condición que deba tener en cuenta?"
 
 **FORMATO DE RESPUESTA REQUERIDO:**
 Debes responder SIEMPRE en formato JSON válido. Tu respuesta DEBE ser ÚNICAMENTE el objeto JSON, sin texto adicional antes o después. Ejemplo: