Forráskód Böngészése

add initial appointment support

Matthew Trejo 2 hónapja
szülő
commit
3a1d303bea
36 módosított fájl, 3529 hozzáadás és 30 törlés
  1. 185 0
      docs/APPOINTMENTS_SYSTEM.md
  2. 313 13
      package-lock.json
  3. 6 1
      package.json
  4. 47 0
      prisma/migrations/20251008150007_add_appointments_system/migration.sql
  5. 40 0
      prisma/schema.prisma
  6. 86 0
      src/app/api/appointments/[id]/approve/route.ts
  7. 77 0
      src/app/api/appointments/[id]/complete/route.ts
  8. 89 0
      src/app/api/appointments/[id]/reject/route.ts
  9. 137 0
      src/app/api/appointments/[id]/route.ts
  10. 198 0
      src/app/api/appointments/route.ts
  11. 1 1
      src/app/api/chat/route.ts
  12. 129 0
      src/app/appointments/[id]/meet/page.tsx
  13. 460 0
      src/app/appointments/[id]/page.tsx
  14. 204 0
      src/app/appointments/doctor/page.tsx
  15. 118 0
      src/app/appointments/page.tsx
  16. 127 0
      src/components/appointments/AppointmentCard.tsx
  17. 132 0
      src/components/appointments/AppointmentForm.tsx
  18. 47 0
      src/components/appointments/AppointmentStatusBadge.tsx
  19. 71 0
      src/components/appointments/AppointmentsFilter.tsx
  20. 50 0
      src/components/appointments/AppointmentsGrid.tsx
  21. 54 0
      src/components/appointments/AppointmentsHeader.tsx
  22. 115 0
      src/components/appointments/AppointmentsList.tsx
  23. 118 0
      src/components/appointments/AppointmentsStats.tsx
  24. 10 0
      src/components/appointments/index.ts
  25. 32 11
      src/components/chatbot/MedicalAlert.tsx
  26. 15 1
      src/components/sidebar/SidebarNavigation.tsx
  27. 1 3
      src/components/sidebar/index.ts
  28. 50 0
      src/components/ui/avatar.tsx
  29. 213 0
      src/components/ui/calendar.tsx
  30. 48 0
      src/components/ui/popover.tsx
  31. 28 0
      src/components/ui/separator.tsx
  32. 66 0
      src/components/ui/tabs.tsx
  33. 25 0
      src/hooks/use-toast.ts
  34. 155 0
      src/hooks/useAppointments.ts
  35. 39 0
      src/hooks/useAppointmentsBadge.ts
  36. 43 0
      src/types/appointments.ts

+ 185 - 0
docs/APPOINTMENTS_SYSTEM.md

@@ -0,0 +1,185 @@
+# Sistema de Agendamiento de Citas Telemáticas
+
+## 📋 Checklist de Implementación
+
+### 1. Base de Datos (Prisma)
+- [x] Crear modelo `Appointment` en schema.prisma
+- [x] Agregar campos: pacienteId, medicoId, chatReportId, fecha, estado, roomName, motivoRechazo
+- [x] Estados: PENDIENTE, APROBADA, RECHAZADA, COMPLETADA, CANCELADA
+- [x] Relaciones con User (paciente), User (medico), ChatReport
+- [x] Migración de base de datos
+
+### 2. Backend APIs
+- [x] `POST /api/appointments` - Crear cita (paciente)
+- [x] `GET /api/appointments` - Listar citas (filtro por rol)
+- [x] `GET /api/appointments/[id]` - Detalle de cita
+- [x] `PATCH /api/appointments/[id]/approve` - Aprobar (médico)
+- [x] `PATCH /api/appointments/[id]/reject` - Rechazar (médico)
+- [x] `PATCH /api/appointments/[id]/complete` - Marcar completada
+- [x] `DELETE /api/appointments/[id]` - Cancelar (paciente)
+
+### 3. Frontend - Componentes
+- [x] `AppointmentCard` - Card para mostrar cita
+- [x] `AppointmentForm` - Formulario nueva cita
+- [x] `AppointmentsList` - Lista de citas
+- [x] `AppointmentStatusBadge` - Badge de estado
+- [x] `JitsiMeetRoom` - Componente para videollamada
+- [x] `AppointmentActions` - Botones aprobar/rechazar
+
+### 4. Frontend - Páginas
+- [x] `/appointments` - Vista paciente (crear/ver citas)
+- [x] `/appointments/doctor` - Vista médico (aprobar citas)
+- [x] `/appointments/[id]` - Detalle de cita
+- [x] `/appointments/[id]/meet` - Sala Jitsi
+
+### 5. UI/UX
+- [x] Agregar item "Citas" en Sidebar
+- [x] Badge con contador de citas pendientes (médico)
+- [x] Botón "Agendar Cita" en MedicalAlert (RECOMENDADO/URGENTE)
+- [x] Modal de confirmación para aprobar/rechazar
+- [x] Estados de loading y errores
+
+### 6. Integración Jitsi Meet
+- [x] Script external_api.js en layout
+- [x] Componente React con Jitsi
+- [x] Generar roomName único (UUID + timestamp)
+- [ ] Configuración: moderador para médico
+- [x] Controles: mute, video, compartir pantalla
+
+### 7. Validaciones y Seguridad
+- [x] Middleware: solo pacientes crean citas
+- [x] Middleware: solo médicos aprueban/rechazan
+- [ ] Validar acceso a sala: solo paciente y médico de esa cita
+- [ ] Rate limiting en creación de citas
+- [x] Validación de fechas (no pasadas)
+
+### 8. Notificaciones (Opcional)
+- [x] Toast al crear cita
+- [x] Toast al aprobar/rechazar
+- [x] Badge de notificaciones en sidebar
+
+---
+
+## 🗂️ Estructura de Archivos
+
+```
+prisma/
+  schema.prisma (+ modelo Appointment)
+
+src/app/api/appointments/
+  route.ts (GET, POST)
+  [id]/route.ts (GET, PATCH, DELETE)
+  [id]/approve/route.ts
+  [id]/reject/route.ts
+
+src/app/appointments/
+  page.tsx (paciente)
+  doctor/page.tsx (médico)
+  [id]/page.tsx (detalle)
+  [id]/meet/page.tsx (sala Jitsi)
+
+src/components/appointments/
+  AppointmentCard.tsx
+  AppointmentForm.tsx
+  AppointmentsList.tsx
+  AppointmentStatusBadge.tsx
+  AppointmentActions.tsx
+  JitsiMeetRoom.tsx
+
+src/hooks/
+  useAppointments.ts
+```
+
+---
+
+## 📊 Modelo de Datos
+
+```prisma
+model Appointment {
+  id            String   @id @default(cuid())
+  createdAt     DateTime @default(now())
+  updatedAt     DateTime @updatedAt
+  
+  // Relaciones
+  pacienteId    String
+  paciente      User     @relation("PatientAppointments", fields: [pacienteId], references: [id])
+  medicoId      String?
+  medico        User?    @relation("DoctorAppointments", fields: [medicoId], references: [id])
+  chatReportId  String?  @unique
+  chatReport    ChatReport? @relation(fields: [chatReportId], references: [id])
+  
+  // Info de la cita
+  fechaSolicitada DateTime
+  estado        String   // PENDIENTE, APROBADA, RECHAZADA, COMPLETADA, CANCELADA
+  motivoConsulta String
+  motivoRechazo String?
+  
+  // Jitsi
+  roomName      String?  @unique
+  
+  @@index([pacienteId])
+  @@index([medicoId])
+  @@index([estado])
+}
+```
+
+---
+
+## 🎯 Prioridades
+
+1. **FASE 1** - MVP (3-4h)
+   - Schema + migración
+   - APIs básicas (crear, listar, aprobar)
+   - Páginas paciente y médico (simple)
+   - Integración Jitsi básica
+
+2. **FASE 2** - Mejoras (2-3h)
+   - UI mejorada con estados
+   - Validaciones completas
+   - Badge de notificaciones
+   - Botón desde MedicalAlert
+
+3. **FASE 3** - Pulido (1-2h)
+   - Notificaciones toast
+   - Manejo de errores robusto
+   - Loading states
+   - Responsivo mobile
+
+---
+
+## 🔧 Configuración Jitsi
+
+```typescript
+const domain = 'meet.jit.si';
+const options = {
+  roomName: `appointment-${appointmentId}-${Date.now()}`,
+  width: '100%',
+  height: 700,
+  parentNode: document.querySelector('#jitsi-container'),
+  configOverwrite: {
+    startWithAudioMuted: true,
+    startWithVideoMuted: false,
+  },
+  interfaceConfigOverwrite: {
+    TOOLBAR_BUTTONS: [
+      'microphone', 'camera', 'closedcaptions', 'desktop',
+      'fullscreen', 'fodeviceselection', 'hangup',
+      'chat', 'recording', 'etherpad', 'settings', 'videoquality',
+    ],
+  },
+  userInfo: {
+    displayName: userName,
+  }
+};
+```
+
+---
+
+## 📝 Notas
+
+- Usar Jitsi público (meet.jit.si) inicialmente
+- Self-host después (requiere servidor propio)
+- RoomName único: `appointment-{id}-{timestamp}`
+- Moderador: asignar al médico
+- Considerar límite de citas simultáneas por médico
+- **Flujo de creación de citas**: Las citas se crean SOLO desde el chatbot cuando la IA recomienda una consulta médica (estados RECOMENDADO/URGENTE). Los pacientes no pueden crear citas manualmente desde la página de citas.

+ 313 - 13
package-lock.json

@@ -13,11 +13,15 @@
         "@auth/prisma-adapter": "^2.10.0",
         "@headlessui/react": "^2.2.7",
         "@prisma/client": "^6.12.0",
+        "@radix-ui/react-avatar": "^1.1.10",
         "@radix-ui/react-dialog": "^1.1.14",
         "@radix-ui/react-dropdown-menu": "^2.1.15",
         "@radix-ui/react-label": "^2.1.7",
+        "@radix-ui/react-popover": "^1.1.15",
         "@radix-ui/react-select": "^2.2.5",
+        "@radix-ui/react-separator": "^1.1.7",
         "@radix-ui/react-slot": "^1.2.3",
+        "@radix-ui/react-tabs": "^1.1.13",
         "@radix-ui/react-toast": "^1.2.14",
         "@react-pdf/renderer": "^4.3.0",
         "@types/bcryptjs": "^2.4.6",
@@ -38,6 +42,7 @@
         "openai": "^5.10.1",
         "prisma": "^6.12.0",
         "react": "19.1.0",
+        "react-day-picker": "^9.11.1",
         "react-dom": "19.1.0",
         "react-markdown": "^10.1.0",
         "react-paginate": "^8.3.0",
@@ -45,7 +50,7 @@
         "rehype-raw": "^7.0.0",
         "rehype-stringify": "^10.0.1",
         "remark-gfm": "^4.0.1",
-        "sonner": "^2.0.6",
+        "sonner": "^2.0.7",
         "tailwind-merge": "^3.3.1",
         "tsx": "^4.20.3"
       },
@@ -1027,6 +1032,12 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@date-fns/tz": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
+      "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
+      "license": "MIT"
+    },
     "node_modules/@emnapi/core": {
       "version": "1.4.5",
       "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
@@ -2592,6 +2603,33 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-avatar": {
+      "version": "1.1.10",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
+      "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-callback-ref": "1.1.1",
+        "@radix-ui/react-use-is-hydrated": "0.1.0",
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@radix-ui/react-collection": {
       "version": "1.1.7",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -2877,22 +2915,21 @@
       }
     },
     "node_modules/@radix-ui/react-popover": {
-      "version": "1.1.14",
-      "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz",
-      "integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==",
+      "version": "1.1.15",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
+      "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
-        "@radix-ui/primitive": "1.1.2",
+        "@radix-ui/primitive": "1.1.3",
         "@radix-ui/react-compose-refs": "1.1.2",
         "@radix-ui/react-context": "1.1.2",
-        "@radix-ui/react-dismissable-layer": "1.1.10",
-        "@radix-ui/react-focus-guards": "1.1.2",
+        "@radix-ui/react-dismissable-layer": "1.1.11",
+        "@radix-ui/react-focus-guards": "1.1.3",
         "@radix-ui/react-focus-scope": "1.1.7",
         "@radix-ui/react-id": "1.1.1",
-        "@radix-ui/react-popper": "1.2.7",
+        "@radix-ui/react-popper": "1.2.8",
         "@radix-ui/react-portal": "1.1.9",
-        "@radix-ui/react-presence": "1.1.4",
+        "@radix-ui/react-presence": "1.1.5",
         "@radix-ui/react-primitive": "2.1.3",
         "@radix-ui/react-slot": "1.2.3",
         "@radix-ui/react-use-controllable-state": "1.2.2",
@@ -2914,6 +2951,110 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/primitive": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+      "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+      "license": "MIT"
+    },
+    "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
+      "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-callback-ref": "1.1.1",
+        "@radix-ui/react-use-escape-keydown": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
+      "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
+      "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/react-dom": "^2.0.0",
+        "@radix-ui/react-arrow": "1.1.7",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-callback-ref": "1.1.1",
+        "@radix-ui/react-use-layout-effect": "1.1.1",
+        "@radix-ui/react-use-rect": "1.1.1",
+        "@radix-ui/react-use-size": "1.1.1",
+        "@radix-ui/rect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+      "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@radix-ui/react-popper": {
       "version": "1.2.7",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
@@ -3091,6 +3232,29 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-separator": {
+      "version": "1.1.7",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
+      "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-primitive": "2.1.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@radix-ui/react-slot": {
       "version": "1.2.3",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@@ -3109,6 +3273,97 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-tabs": {
+      "version": "1.1.13",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
+      "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-direction": "1.1.1",
+        "@radix-ui/react-id": "1.1.1",
+        "@radix-ui/react-presence": "1.1.5",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-roving-focus": "1.1.11",
+        "@radix-ui/react-use-controllable-state": "1.2.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+      "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+      "license": "MIT"
+    },
+    "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+      "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
+      "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-collection": "1.1.7",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-direction": "1.1.1",
+        "@radix-ui/react-id": "1.1.1",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-callback-ref": "1.1.1",
+        "@radix-ui/react-use-controllable-state": "1.2.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@radix-ui/react-toast": {
       "version": "1.2.14",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.14.tgz",
@@ -3213,6 +3468,24 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-use-is-hydrated": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
+      "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
+      "license": "MIT",
+      "dependencies": {
+        "use-sync-external-store": "^1.5.0"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@radix-ui/react-use-layout-effect": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
@@ -5667,6 +5940,12 @@
         "url": "https://github.com/sponsors/kossnocorp"
       }
     },
+    "node_modules/date-fns-jalali": {
+      "version": "4.1.0-0",
+      "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
+      "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
+      "license": "MIT"
+    },
     "node_modules/debug": {
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -10551,6 +10830,27 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/react-day-picker": {
+      "version": "9.11.1",
+      "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz",
+      "integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==",
+      "license": "MIT",
+      "dependencies": {
+        "@date-fns/tz": "^1.4.1",
+        "date-fns": "^4.1.0",
+        "date-fns-jalali": "^4.1.0-0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "individual",
+        "url": "https://github.com/sponsors/gpbl"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
     "node_modules/react-dom": {
       "version": "19.1.0",
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@@ -11320,9 +11620,9 @@
       }
     },
     "node_modules/sonner": {
-      "version": "2.0.6",
-      "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.6.tgz",
-      "integrity": "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==",
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
+      "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
       "license": "MIT",
       "peerDependencies": {
         "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",

+ 6 - 1
package.json

@@ -28,11 +28,15 @@
     "@auth/prisma-adapter": "^2.10.0",
     "@headlessui/react": "^2.2.7",
     "@prisma/client": "^6.12.0",
+    "@radix-ui/react-avatar": "^1.1.10",
     "@radix-ui/react-dialog": "^1.1.14",
     "@radix-ui/react-dropdown-menu": "^2.1.15",
     "@radix-ui/react-label": "^2.1.7",
+    "@radix-ui/react-popover": "^1.1.15",
     "@radix-ui/react-select": "^2.2.5",
+    "@radix-ui/react-separator": "^1.1.7",
     "@radix-ui/react-slot": "^1.2.3",
+    "@radix-ui/react-tabs": "^1.1.13",
     "@radix-ui/react-toast": "^1.2.14",
     "@react-pdf/renderer": "^4.3.0",
     "@types/bcryptjs": "^2.4.6",
@@ -53,6 +57,7 @@
     "openai": "^5.10.1",
     "prisma": "^6.12.0",
     "react": "19.1.0",
+    "react-day-picker": "^9.11.1",
     "react-dom": "19.1.0",
     "react-markdown": "^10.1.0",
     "react-paginate": "^8.3.0",
@@ -60,7 +65,7 @@
     "rehype-raw": "^7.0.0",
     "rehype-stringify": "^10.0.1",
     "remark-gfm": "^4.0.1",
-    "sonner": "^2.0.6",
+    "sonner": "^2.0.7",
     "tailwind-merge": "^3.3.1",
     "tsx": "^4.20.3"
   },

+ 47 - 0
prisma/migrations/20251008150007_add_appointments_system/migration.sql

@@ -0,0 +1,47 @@
+-- CreateEnum
+CREATE TYPE "AppointmentStatus" AS ENUM ('PENDIENTE', 'APROBADA', 'RECHAZADA', 'COMPLETADA', 'CANCELADA');
+
+-- CreateTable
+CREATE TABLE "Appointment" (
+    "id" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "pacienteId" TEXT NOT NULL,
+    "medicoId" TEXT,
+    "recordId" TEXT,
+    "fechaSolicitada" TIMESTAMP(3) NOT NULL,
+    "estado" "AppointmentStatus" NOT NULL DEFAULT 'PENDIENTE',
+    "motivoConsulta" TEXT NOT NULL,
+    "motivoRechazo" TEXT,
+    "notas" TEXT,
+    "roomName" TEXT,
+
+    CONSTRAINT "Appointment_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Appointment_recordId_key" ON "Appointment"("recordId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Appointment_roomName_key" ON "Appointment"("roomName");
+
+-- CreateIndex
+CREATE INDEX "Appointment_pacienteId_idx" ON "Appointment"("pacienteId");
+
+-- CreateIndex
+CREATE INDEX "Appointment_medicoId_idx" ON "Appointment"("medicoId");
+
+-- CreateIndex
+CREATE INDEX "Appointment_estado_idx" ON "Appointment"("estado");
+
+-- CreateIndex
+CREATE INDEX "Appointment_fechaSolicitada_idx" ON "Appointment"("fechaSolicitada");
+
+-- AddForeignKey
+ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_pacienteId_fkey" FOREIGN KEY ("pacienteId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_medicoId_fkey" FOREIGN KEY ("medicoId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_recordId_fkey" FOREIGN KEY ("recordId") REFERENCES "Record"("id") ON DELETE SET NULL ON UPDATE CASCADE;

+ 40 - 0
prisma/schema.prisma

@@ -33,6 +33,8 @@ model User {
   records      Record[]
   assignedPatients PatientAssignment[] @relation("DoctorPatients")
   assignedDoctor PatientAssignment[] @relation("PatientDoctor")
+  patientAppointments Appointment[] @relation("PatientAppointments")
+  doctorAppointments Appointment[] @relation("DoctorAppointments")
 }
 
 model PatientAssignment {
@@ -56,6 +58,36 @@ model Record {
   messages  Json?
   createdAt DateTime @default(now())
   user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
+  appointment Appointment?
+}
+
+model Appointment {
+  id              String    @id @default(cuid())
+  createdAt       DateTime  @default(now())
+  updatedAt       DateTime  @updatedAt
+  
+  // Relaciones
+  pacienteId      String
+  paciente        User      @relation("PatientAppointments", fields: [pacienteId], references: [id], onDelete: Cascade)
+  medicoId        String?
+  medico          User?     @relation("DoctorAppointments", fields: [medicoId], references: [id], onDelete: SetNull)
+  recordId        String?   @unique
+  record          Record?   @relation(fields: [recordId], references: [id], onDelete: SetNull)
+  
+  // Info de la cita
+  fechaSolicitada DateTime
+  estado          AppointmentStatus @default(PENDIENTE)
+  motivoConsulta  String
+  motivoRechazo   String?
+  notas           String?
+  
+  // Jitsi
+  roomName        String?   @unique
+  
+  @@index([pacienteId])
+  @@index([medicoId])
+  @@index([estado])
+  @@index([fechaSolicitada])
 }
 
 enum Role {
@@ -64,6 +96,14 @@ enum Role {
   PATIENT
 }
 
+enum AppointmentStatus {
+  PENDIENTE
+  APROBADA
+  RECHAZADA
+  COMPLETADA
+  CANCELADA
+}
+
 enum Gender {
   MALE
   FEMALE

+ 86 - 0
src/app/api/appointments/[id]/approve/route.ts

@@ -0,0 +1,86 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+
+// POST /api/appointments/[id]/approve - Aprobar cita (médico)
+export async function POST(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const { id } = await params;
+    const session = await getServerSession(authOptions);
+    
+    if (!session?.user?.email) {
+      return NextResponse.json({ error: "No autorizado" }, { status: 401 });
+    }
+
+    const user = await prisma.user.findUnique({
+      where: { email: session.user.email },
+    });
+
+    if (!user || user.role !== "DOCTOR") {
+      return NextResponse.json(
+        { error: "Solo los médicos pueden aprobar citas" },
+        { status: 403 }
+      );
+    }
+
+    const appointment = await prisma.appointment.findUnique({
+      where: { id },
+    });
+
+    if (!appointment) {
+      return NextResponse.json({ error: "Cita no encontrada" }, { status: 404 });
+    }
+
+    if (appointment.estado !== "PENDIENTE") {
+      return NextResponse.json(
+        { error: "Solo se pueden aprobar citas pendientes" },
+        { status: 400 }
+      );
+    }
+
+    const body = await request.json();
+    const { notas } = body;
+
+    // Generar roomName único para Jitsi
+    const roomName = `appointment-${id}-${Date.now()}`;
+
+    const updated = await prisma.appointment.update({
+      where: { id },
+      data: {
+        estado: "APROBADA",
+        medicoId: user.id,
+        roomName,
+        notas: notas || null,
+      },
+      include: {
+        paciente: {
+          select: {
+            id: true,
+            name: true,
+            lastname: true,
+            email: true,
+            profileImage: true,
+          },
+        },
+        medico: {
+          select: {
+            id: true,
+            name: true,
+            lastname: true,
+            email: true,
+            profileImage: true,
+          },
+        },
+      },
+    });
+
+    return NextResponse.json(updated);
+  } catch (error) {
+    console.error("Error al aprobar cita:", error);
+    return NextResponse.json({ error: "Error al aprobar cita" }, { status: 500 });
+  }
+}

+ 77 - 0
src/app/api/appointments/[id]/complete/route.ts

@@ -0,0 +1,77 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+
+// POST /api/appointments/[id]/complete - Marcar cita como completada
+export async function POST(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const { id } = await params;
+    const session = await getServerSession(authOptions);
+    
+    if (!session?.user?.email) {
+      return NextResponse.json({ error: "No autorizado" }, { status: 401 });
+    }
+
+    const user = await prisma.user.findUnique({
+      where: { email: session.user.email },
+    });
+
+    if (!user) {
+      return NextResponse.json({ error: "Usuario no encontrado" }, { status: 404 });
+    }
+
+    const appointment = await prisma.appointment.findUnique({
+      where: { id },
+    });
+
+    if (!appointment) {
+      return NextResponse.json({ error: "Cita no encontrada" }, { status: 404 });
+    }
+
+    // Solo el médico puede marcar como completada
+    if (appointment.medicoId !== user.id && user.role !== "ADMIN") {
+      return NextResponse.json({ error: "No autorizado" }, { status: 403 });
+    }
+
+    if (appointment.estado !== "APROBADA") {
+      return NextResponse.json(
+        { error: "Solo se pueden completar citas aprobadas" },
+        { status: 400 }
+      );
+    }
+
+    const updated = await prisma.appointment.update({
+      where: { id },
+      data: { estado: "COMPLETADA" },
+      include: {
+        paciente: {
+          select: {
+            id: true,
+            name: true,
+            lastname: true,
+            email: true,
+            profileImage: true,
+          },
+        },
+        medico: {
+          select: {
+            id: true,
+            name: true,
+            lastname: true,
+            email: true,
+            profileImage: true,
+          },
+        },
+      },
+    });
+
+    return NextResponse.json(updated);
+  } catch (error) {
+    console.error("Error al completar cita:", error);
+    return NextResponse.json({ error: "Error al completar cita" }, { status: 500 });
+  }
+}

+ 89 - 0
src/app/api/appointments/[id]/reject/route.ts

@@ -0,0 +1,89 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+
+// POST /api/appointments/[id]/reject - Rechazar cita (médico)
+export async function POST(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const { id } = await params;
+    const session = await getServerSession(authOptions);
+    
+    if (!session?.user?.email) {
+      return NextResponse.json({ error: "No autorizado" }, { status: 401 });
+    }
+
+    const user = await prisma.user.findUnique({
+      where: { email: session.user.email },
+    });
+
+    if (!user || user.role !== "DOCTOR") {
+      return NextResponse.json(
+        { error: "Solo los médicos pueden rechazar citas" },
+        { status: 403 }
+      );
+    }
+
+    const appointment = await prisma.appointment.findUnique({
+      where: { id },
+    });
+
+    if (!appointment) {
+      return NextResponse.json({ error: "Cita no encontrada" }, { status: 404 });
+    }
+
+    if (appointment.estado !== "PENDIENTE") {
+      return NextResponse.json(
+        { error: "Solo se pueden rechazar citas pendientes" },
+        { status: 400 }
+      );
+    }
+
+    const body = await request.json();
+    const { motivoRechazo } = body;
+
+    if (!motivoRechazo) {
+      return NextResponse.json(
+        { error: "Debe proporcionar un motivo de rechazo" },
+        { status: 400 }
+      );
+    }
+
+    const updated = await prisma.appointment.update({
+      where: { id },
+      data: {
+        estado: "RECHAZADA",
+        medicoId: user.id,
+        motivoRechazo,
+      },
+      include: {
+        paciente: {
+          select: {
+            id: true,
+            name: true,
+            lastname: true,
+            email: true,
+            profileImage: true,
+          },
+        },
+        medico: {
+          select: {
+            id: true,
+            name: true,
+            lastname: true,
+            email: true,
+            profileImage: true,
+          },
+        },
+      },
+    });
+
+    return NextResponse.json(updated);
+  } catch (error) {
+    console.error("Error al rechazar cita:", error);
+    return NextResponse.json({ error: "Error al rechazar cita" }, { status: 500 });
+  }
+}

+ 137 - 0
src/app/api/appointments/[id]/route.ts

@@ -0,0 +1,137 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+
+// GET /api/appointments/[id]
+export async function GET(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const { id } = await params;
+    const session = await getServerSession(authOptions);
+    
+    if (!session?.user?.email) {
+      return NextResponse.json({ error: "No autorizado" }, { status: 401 });
+    }
+
+    const user = await prisma.user.findUnique({
+      where: { email: session.user.email },
+    });
+
+    if (!user) {
+      return NextResponse.json({ error: "Usuario no encontrado" }, { status: 404 });
+    }
+
+    const appointment = await prisma.appointment.findUnique({
+      where: { id },
+      include: {
+        paciente: {
+          select: {
+            id: true,
+            name: true,
+            lastname: true,
+            email: true,
+            profileImage: true,
+            phone: true,
+          },
+        },
+        medico: {
+          select: {
+            id: true,
+            name: true,
+            lastname: true,
+            email: true,
+            profileImage: true,
+          },
+        },
+      },
+    });
+
+    if (!appointment) {
+      return NextResponse.json({ error: "Cita no encontrada" }, { status: 404 });
+    }
+
+    // Validar acceso
+    const canAccess =
+      appointment.pacienteId === user.id ||
+      appointment.medicoId === user.id ||
+      user.role === "ADMIN";
+
+    if (!canAccess) {
+      return NextResponse.json({ error: "No autorizado" }, { status: 403 });
+    }
+
+    return NextResponse.json(appointment);
+  } catch (error) {
+    console.error("Error al obtener cita:", error);
+    return NextResponse.json({ error: "Error al obtener cita" }, { status: 500 });
+  }
+}
+
+// PATCH /api/appointments/[id] - Cancelar cita (paciente)
+export async function PATCH(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const { id } = await params;
+    const session = await getServerSession(authOptions);
+    
+    if (!session?.user?.email) {
+      return NextResponse.json({ error: "No autorizado" }, { status: 401 });
+    }
+
+    const user = await prisma.user.findUnique({
+      where: { email: session.user.email },
+    });
+
+    if (!user) {
+      return NextResponse.json({ error: "Usuario no encontrado" }, { status: 404 });
+    }
+
+    const appointment = await prisma.appointment.findUnique({
+      where: { id },
+    });
+
+    if (!appointment) {
+      return NextResponse.json({ error: "Cita no encontrada" }, { status: 404 });
+    }
+
+    // Solo el paciente puede cancelar
+    if (appointment.pacienteId !== user.id) {
+      return NextResponse.json({ error: "No autorizado" }, { status: 403 });
+    }
+
+    const updated = await prisma.appointment.update({
+      where: { id },
+      data: { estado: "CANCELADA" },
+      include: {
+        paciente: {
+          select: {
+            id: true,
+            name: true,
+            lastname: true,
+            email: true,
+            profileImage: true,
+          },
+        },
+        medico: {
+          select: {
+            id: true,
+            name: true,
+            lastname: true,
+            email: true,
+            profileImage: true,
+          },
+        },
+      },
+    });
+
+    return NextResponse.json(updated);
+  } catch (error) {
+    console.error("Error al cancelar cita:", error);
+    return NextResponse.json({ error: "Error al cancelar cita" }, { status: 500 });
+  }
+}

+ 198 - 0
src/app/api/appointments/route.ts

@@ -0,0 +1,198 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+
+// GET /api/appointments - Listar citas
+export async function GET(request: NextRequest) {
+  try {
+    const session = await getServerSession(authOptions);
+    
+    if (!session?.user?.email) {
+      return NextResponse.json(
+        { error: "No autorizado" },
+        { status: 401 }
+      );
+    }
+
+    const user = await prisma.user.findUnique({
+      where: { email: session.user.email },
+    });
+
+    if (!user) {
+      return NextResponse.json(
+        { error: "Usuario no encontrado" },
+        { status: 404 }
+      );
+    }
+
+    const { searchParams} = new URL(request.url);
+    const estadoParam = searchParams.get("estado");
+    const estado = estadoParam as "PENDIENTE" | "APROBADA" | "RECHAZADA" | "COMPLETADA" | "CANCELADA" | null;
+
+    let appointments;
+
+    // Si es médico, ver citas donde es médico o citas pendientes
+    if (user.role === "DOCTOR") {
+      appointments = await prisma.appointment.findMany({
+        where: estado
+          ? {
+              OR: [
+                { medicoId: user.id, estado: estado },
+                { medicoId: null, estado: "PENDIENTE" as const },
+              ],
+            }
+          : {
+              OR: [
+                { medicoId: user.id },
+                { medicoId: null, estado: "PENDIENTE" as const },
+              ],
+            },
+        include: {
+          paciente: {
+            select: {
+              id: true,
+              name: true,
+              lastname: true,
+              email: true,
+              profileImage: true,
+            },
+          },
+          medico: {
+            select: {
+              id: true,
+              name: true,
+              lastname: true,
+              email: true,
+              profileImage: true,
+            },
+          },
+        },
+        orderBy: [
+          { estado: "asc" },
+          { fechaSolicitada: "asc" },
+        ],
+      });
+    } else {
+      // Si es paciente, ver solo sus citas
+      appointments = await prisma.appointment.findMany({
+        where: estado
+          ? { pacienteId: user.id, estado: estado }
+          : { pacienteId: user.id },
+        include: {
+          paciente: {
+            select: {
+              id: true,
+              name: true,
+              lastname: true,
+              email: true,
+              profileImage: true,
+            },
+          },
+          medico: {
+            select: {
+              id: true,
+              name: true,
+              lastname: true,
+              email: true,
+              profileImage: true,
+            },
+          },
+        },
+        orderBy: [
+          { estado: "asc" },
+          { fechaSolicitada: "asc" },
+        ],
+      });
+    }
+
+    return NextResponse.json(appointments);
+  } catch (error) {
+    console.error("Error al obtener citas:", error);
+    return NextResponse.json(
+      { error: "Error al obtener citas" },
+      { status: 500 }
+    );
+  }
+}
+
+// POST /api/appointments - Crear cita
+export async function POST(request: NextRequest) {
+  try {
+    const session = await getServerSession(authOptions);
+    
+    if (!session?.user?.email) {
+      return NextResponse.json(
+        { error: "No autorizado" },
+        { status: 401 }
+      );
+    }
+
+    const user = await prisma.user.findUnique({
+      where: { email: session.user.email },
+    });
+
+    if (!user) {
+      return NextResponse.json(
+        { error: "Usuario no encontrado" },
+        { status: 404 }
+      );
+    }
+
+    // Solo pacientes pueden crear citas
+    if (user.role !== "PATIENT") {
+      return NextResponse.json(
+        { error: "Solo los pacientes pueden crear citas" },
+        { status: 403 }
+      );
+    }
+
+    const body = await request.json();
+    const { fechaSolicitada, motivoConsulta, recordId } = body;
+
+    if (!fechaSolicitada || !motivoConsulta) {
+      return NextResponse.json(
+        { error: "Faltan campos requeridos" },
+        { status: 400 }
+      );
+    }
+
+    // Validar que la fecha no sea en el pasado
+    const fecha = new Date(fechaSolicitada);
+    if (fecha < new Date()) {
+      return NextResponse.json(
+        { error: "La fecha no puede ser en el pasado" },
+        { status: 400 }
+      );
+    }
+
+    const appointment = await prisma.appointment.create({
+      data: {
+        pacienteId: user.id,
+        fechaSolicitada: fecha,
+        motivoConsulta,
+        recordId: recordId || null,
+        estado: "PENDIENTE",
+      },
+      include: {
+        paciente: {
+          select: {
+            id: true,
+            name: true,
+            lastname: true,
+            email: true,
+            profileImage: true,
+          },
+        },
+      },
+    });
+
+    return NextResponse.json(appointment, { status: 201 });
+  } catch (error) {
+    console.error("Error al crear cita:", error);
+    return NextResponse.json(
+      { error: "Error al crear cita" },
+      { status: 500 }
+    );
+  }
+}

+ 1 - 1
src/app/api/chat/route.ts

@@ -190,7 +190,7 @@ RECUERDA: Eres un asistente médico virtual, NO un asistente general. Tu especia
           },
           ...conversationHistory,
         ],
-        max_tokens: 500,
+        max_tokens: 3000, // Espero no olvidarme de cambiar esto
         temperature: 0.7,
       });
 

+ 129 - 0
src/app/appointments/[id]/meet/page.tsx

@@ -0,0 +1,129 @@
+"use client";
+
+import { useEffect, useRef, useCallback } from "react";
+import { useSession } from "next-auth/react";
+import { useParams, redirect } from "next/navigation";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Loader2, Video } from "lucide-react";
+
+interface JitsiMeetExternalAPI {
+  dispose: () => void;
+  addEventListener: (event: string, handler: () => void) => void;
+}
+
+declare global {
+  interface Window {
+    JitsiMeetExternalAPI: new (domain: string, options: Record<string, unknown>) => JitsiMeetExternalAPI;
+  }
+}
+
+export default function MeetPage() {
+  const { data: session, status } = useSession();
+  const params = useParams();
+  const jitsiContainer = useRef<HTMLDivElement>(null);
+  const jitsiApi = useRef<JitsiMeetExternalAPI | null>(null);
+
+  const initJitsi = useCallback(() => {
+    if (!jitsiContainer.current || !session) return;
+
+    const domain = "meet.jit.si";
+    const options = {
+      roomName: `appointment-${params.id}`,
+      width: "100%",
+      height: 600,
+      parentNode: jitsiContainer.current,
+      configOverwrite: {
+        startWithAudioMuted: false,
+        startWithVideoMuted: false,
+        prejoinPageEnabled: false,
+      },
+      interfaceConfigOverwrite: {
+        TOOLBAR_BUTTONS: [
+          "microphone",
+          "camera",
+          "closedcaptions",
+          "desktop",
+          "fullscreen",
+          "fodeviceselection",
+          "hangup",
+          "chat",
+          "settings",
+          "videoquality",
+          "filmstrip",
+          "tileview",
+        ],
+        SHOW_JITSI_WATERMARK: false,
+        SHOW_WATERMARK_FOR_GUESTS: false,
+      },
+      userInfo: {
+        displayName: session.user?.name || "Usuario",
+        email: session.user?.email || undefined,
+      },
+    };
+
+    jitsiApi.current = new window.JitsiMeetExternalAPI(domain, options);
+
+    // Event listeners
+    jitsiApi.current.addEventListener("videoConferenceLeft", () => {
+      window.location.href = "/appointments";
+    });
+
+    jitsiApi.current.addEventListener("readyToClose", () => {
+      window.location.href = "/appointments";
+    });
+  }, [session, params.id]);
+
+  useEffect(() => {
+    if (status === "loading" || !session || !jitsiContainer.current) return;
+
+    // Cargar Jitsi script
+    const script = document.createElement("script");
+    script.src = "https://meet.jit.si/external_api.js";
+    script.async = true;
+    script.onload = () => initJitsi();
+    document.body.appendChild(script);
+
+    return () => {
+      if (jitsiApi.current) {
+        jitsiApi.current.dispose();
+      }
+      if (document.body.contains(script)) {
+        document.body.removeChild(script);
+      }
+    };
+  }, [status, session, initJitsi]);
+
+  if (status === "loading") {
+    return (
+      <div className="flex items-center justify-center min-h-screen">
+        <Loader2 className="h-8 w-8 animate-spin" />
+      </div>
+    );
+  }
+
+  if (!session) {
+    redirect("/auth/login");
+  }
+
+  return (
+    <div className="container mx-auto px-4 py-8 max-w-7xl">
+      <Card>
+        <CardHeader>
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-2">
+              <Video className="h-6 w-6" />
+              <CardTitle>Consulta Telemática</CardTitle>
+            </div>
+            <Button variant="outline" onClick={() => (window.location.href = "/appointments")}>
+              Salir
+            </Button>
+          </div>
+        </CardHeader>
+        <CardContent>
+          <div ref={jitsiContainer} className="w-full rounded-lg overflow-hidden bg-muted" />
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 460 - 0
src/app/appointments/[id]/page.tsx

@@ -0,0 +1,460 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useSession } from "next-auth/react";
+import { redirect, useRouter } from "next/navigation";
+import Link from "next/link";
+import AuthenticatedLayout from "@/components/AuthenticatedLayout";
+import { AppointmentStatusBadge } from "@/components/appointments/AppointmentStatusBadge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Separator } from "@/components/ui/separator";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import {
+  Calendar,
+  Clock,
+  User,
+  FileText,
+  Video,
+  CheckCircle2,
+  XCircle,
+  ArrowLeft,
+  Loader2,
+  AlertCircle,
+} from "lucide-react";
+import { format } from "date-fns";
+import { es } from "date-fns/locale";
+import { toast } from "sonner";
+import type { Appointment } from "@/types/appointments";
+
+interface PageProps {
+  params: Promise<{ id: string }>;
+}
+
+export default function AppointmentDetailPage({ params }: PageProps) {
+  const router = useRouter();
+  const { data: session, status } = useSession();
+  const [appointment, setAppointment] = useState<Appointment | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [rejectDialog, setRejectDialog] = useState(false);
+  const [motivoRechazo, setMotivoRechazo] = useState("");
+  const [actionLoading, setActionLoading] = useState(false);
+  const [appointmentId, setAppointmentId] = useState<string>("");
+
+  useEffect(() => {
+    const loadParams = async () => {
+      const resolvedParams = await params;
+      setAppointmentId(resolvedParams.id);
+    };
+    loadParams();
+  }, [params]);
+
+  useEffect(() => {
+    if (!appointmentId) return;
+
+    const fetchAppointment = async () => {
+      try {
+        const response = await fetch(`/api/appointments/${appointmentId}`);
+        if (!response.ok) {
+          throw new Error("No se pudo cargar la cita");
+        }
+        const data: Appointment = await response.json();
+        setAppointment(data);
+      } catch (error) {
+        toast.error("Error al cargar la cita");
+        console.error(error);
+      } finally {
+        setLoading(false);
+      }
+    };
+
+    fetchAppointment();
+  }, [appointmentId]);
+
+  if (status === "loading" || loading) {
+    return (
+      <AuthenticatedLayout>
+        <div className="flex items-center justify-center min-h-screen">
+          <Loader2 className="h-8 w-8 animate-spin" />
+        </div>
+      </AuthenticatedLayout>
+    );
+  }
+
+  if (!session) {
+    redirect("/auth/login");
+  }
+
+  if (!appointment) {
+    return (
+      <AuthenticatedLayout>
+        <div className="container mx-auto px-4 py-6">
+          <Card>
+            <CardContent className="flex flex-col items-center justify-center py-12">
+              <AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
+              <h3 className="text-lg font-semibold mb-2">Cita no encontrada</h3>
+              <p className="text-muted-foreground mb-4">
+                La cita que buscas no existe o no tienes permisos para verla.
+              </p>
+              <Button asChild>
+                <Link href="/appointments">Volver a mis citas</Link>
+              </Button>
+            </CardContent>
+          </Card>
+        </div>
+      </AuthenticatedLayout>
+    );
+  }
+
+  const userRole = session.user.role as "PATIENT" | "DOCTOR" | "ADMIN";
+  const isPatient = userRole === "PATIENT";
+  const isDoctor = userRole === "DOCTOR";
+  const otherUser = isPatient ? appointment.medico : appointment.paciente;
+  const fecha = new Date(appointment.fechaSolicitada);
+
+  const handleApprove = async () => {
+    setActionLoading(true);
+    try {
+      const response = await fetch(`/api/appointments/${appointment.id}/approve`, {
+        method: "POST",
+      });
+
+      if (!response.ok) throw new Error("Error al aprobar la cita");
+
+      const updated: Appointment = await response.json();
+      setAppointment(updated);
+      toast.success("Cita aprobada exitosamente");
+    } catch (error) {
+      toast.error("Error al aprobar la cita");
+      console.error(error);
+    } finally {
+      setActionLoading(false);
+    }
+  };
+
+  const handleRejectConfirm = async () => {
+    if (!motivoRechazo.trim()) return;
+
+    setActionLoading(true);
+    try {
+      const response = await fetch(`/api/appointments/${appointment.id}/reject`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ motivoRechazo }),
+      });
+
+      if (!response.ok) throw new Error("Error al rechazar la cita");
+
+      const updated: Appointment = await response.json();
+      setAppointment(updated);
+      setRejectDialog(false);
+      setMotivoRechazo("");
+      toast.success("Cita rechazada");
+    } catch (error) {
+      toast.error("Error al rechazar la cita");
+      console.error(error);
+    } finally {
+      setActionLoading(false);
+    }
+  };
+
+  const handleCancel = async () => {
+    setActionLoading(true);
+    try {
+      const response = await fetch(`/api/appointments/${appointment.id}`, {
+        method: "DELETE",
+      });
+
+      if (!response.ok) throw new Error("Error al cancelar la cita");
+
+      toast.success("Cita cancelada exitosamente");
+      router.push("/appointments");
+    } catch (error) {
+      toast.error("Error al cancelar la cita");
+      console.error(error);
+      setActionLoading(false);
+    }
+  };
+
+  const handleComplete = async () => {
+    setActionLoading(true);
+    try {
+      const response = await fetch(`/api/appointments/${appointment.id}/complete`, {
+        method: "POST",
+      });
+
+      if (!response.ok) throw new Error("Error al completar la cita");
+
+      const updated: Appointment = await response.json();
+      setAppointment(updated);
+      toast.success("Cita marcada como completada");
+    } catch (error) {
+      toast.error("Error al completar la cita");
+      console.error(error);
+    } finally {
+      setActionLoading(false);
+    }
+  };
+
+  return (
+    <AuthenticatedLayout>
+      <div className="container mx-auto px-4 py-6 max-w-4xl">
+        {/* Back Button */}
+        <Button
+          variant="ghost"
+          className="mb-4"
+          onClick={() => router.back()}
+        >
+          <ArrowLeft className="h-4 w-4 mr-2" />
+          Volver
+        </Button>
+
+        {/* Header Card */}
+        <Card className="mb-6">
+          <CardHeader>
+            <div className="flex items-center justify-between">
+              <div className="flex items-center gap-4">
+                {otherUser && (
+                  <Avatar className="h-16 w-16">
+                    <AvatarImage src={otherUser.profileImage || undefined} />
+                    <AvatarFallback className="text-lg">
+                      {otherUser.name[0]}{otherUser.lastname[0]}
+                    </AvatarFallback>
+                  </Avatar>
+                )}
+                <div>
+                  <CardTitle className="text-2xl">
+                    {otherUser
+                      ? `${otherUser.name} ${otherUser.lastname}`
+                      : isDoctor
+                      ? "Sin asignar"
+                      : "Médico por asignar"}
+                  </CardTitle>
+                  <CardDescription>
+                    {isPatient ? "Médico asignado" : "Paciente"}
+                  </CardDescription>
+                </div>
+              </div>
+              <AppointmentStatusBadge status={appointment.estado} />
+            </div>
+          </CardHeader>
+        </Card>
+
+        {/* Details Card */}
+        <Card className="mb-6">
+          <CardHeader>
+            <CardTitle>Detalles de la Cita</CardTitle>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+              <div className="flex items-start gap-3">
+                <Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
+                <div>
+                  <p className="text-sm font-medium">Fecha</p>
+                  <p className="text-sm text-muted-foreground">
+                    {format(fecha, "PPP", { locale: es })}
+                  </p>
+                </div>
+              </div>
+              <div className="flex items-start gap-3">
+                <Clock className="h-5 w-5 text-muted-foreground mt-0.5" />
+                <div>
+                  <p className="text-sm font-medium">Hora</p>
+                  <p className="text-sm text-muted-foreground">
+                    {format(fecha, "p", { locale: es })}
+                  </p>
+                </div>
+              </div>
+            </div>
+
+            <Separator />
+
+            <div className="flex items-start gap-3">
+              <FileText className="h-5 w-5 text-muted-foreground mt-0.5" />
+              <div className="flex-1">
+                <p className="text-sm font-medium mb-1">Motivo de consulta</p>
+                <p className="text-sm text-muted-foreground">
+                  {appointment.motivoConsulta}
+                </p>
+              </div>
+            </div>
+
+            {appointment.motivoRechazo && (
+              <>
+                <Separator />
+                <div className="bg-destructive/10 p-4 rounded-lg">
+                  <div className="flex items-start gap-3">
+                    <XCircle className="h-5 w-5 text-destructive mt-0.5" />
+                    <div className="flex-1">
+                      <p className="text-sm font-medium text-destructive mb-1">
+                        Motivo de rechazo
+                      </p>
+                      <p className="text-sm text-muted-foreground">
+                        {appointment.motivoRechazo}
+                      </p>
+                    </div>
+                  </div>
+                </div>
+              </>
+            )}
+
+            {appointment.roomName && (
+              <>
+                <Separator />
+                <div className="bg-primary/10 p-4 rounded-lg">
+                  <div className="flex items-start gap-3">
+                    <Video className="h-5 w-5 text-primary mt-0.5" />
+                    <div className="flex-1">
+                      <p className="text-sm font-medium mb-1">Sala de videollamada</p>
+                      <p className="text-sm text-muted-foreground mb-3">
+                        La sala está lista. Puedes unirte cuando llegue la hora de la cita.
+                      </p>
+                      <Button asChild size="sm">
+                        <Link href={`/appointments/${appointment.id}/meet`}>
+                          <Video className="h-4 w-4 mr-2" />
+                          Unirse a la consulta
+                        </Link>
+                      </Button>
+                    </div>
+                  </div>
+                </div>
+              </>
+            )}
+          </CardContent>
+        </Card>
+
+        {/* Actions Card */}
+        <Card>
+          <CardHeader>
+            <CardTitle>Acciones</CardTitle>
+          </CardHeader>
+          <CardContent>
+            <div className="flex flex-wrap gap-3">
+              {isDoctor && appointment.estado === "PENDIENTE" && (
+                <>
+                  <Button
+                    onClick={handleApprove}
+                    disabled={actionLoading}
+                    className="flex-1 min-w-[150px]"
+                  >
+                    {actionLoading ? (
+                      <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+                    ) : (
+                      <CheckCircle2 className="h-4 w-4 mr-2" />
+                    )}
+                    Aprobar Cita
+                  </Button>
+                  <Button
+                    onClick={() => setRejectDialog(true)}
+                    variant="destructive"
+                    disabled={actionLoading}
+                    className="flex-1 min-w-[150px]"
+                  >
+                    <XCircle className="h-4 w-4 mr-2" />
+                    Rechazar Cita
+                  </Button>
+                </>
+              )}
+
+              {isDoctor && appointment.estado === "APROBADA" && (
+                <Button
+                  onClick={handleComplete}
+                  disabled={actionLoading}
+                  className="flex-1 min-w-[150px]"
+                >
+                  {actionLoading ? (
+                    <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+                  ) : (
+                    <CheckCircle2 className="h-4 w-4 mr-2" />
+                  )}
+                  Marcar como Completada
+                </Button>
+              )}
+
+              {isPatient && appointment.estado === "PENDIENTE" && (
+                <Button
+                  onClick={handleCancel}
+                  variant="outline"
+                  disabled={actionLoading}
+                  className="flex-1 min-w-[150px]"
+                >
+                  {actionLoading ? (
+                    <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+                  ) : (
+                    <XCircle className="h-4 w-4 mr-2" />
+                  )}
+                  Cancelar Cita
+                </Button>
+              )}
+
+              {appointment.estado === "APROBADA" && (
+                <Button asChild className="flex-1 min-w-[150px]">
+                  <Link href={`/appointments/${appointment.id}/meet`}>
+                    <Video className="h-4 w-4 mr-2" />
+                    Unirse a la Consulta
+                  </Link>
+                </Button>
+              )}
+            </div>
+          </CardContent>
+        </Card>
+
+        {/* Reject Dialog */}
+        <Dialog open={rejectDialog} onOpenChange={setRejectDialog}>
+          <DialogContent>
+            <DialogHeader>
+              <DialogTitle>Rechazar Cita</DialogTitle>
+              <DialogDescription>
+                Por favor proporciona un motivo para rechazar esta cita. El paciente recibirá esta información.
+              </DialogDescription>
+            </DialogHeader>
+            <div className="space-y-2">
+              <Label htmlFor="motivo">Motivo del rechazo</Label>
+              <Textarea
+                id="motivo"
+                value={motivoRechazo}
+                onChange={(e) => setMotivoRechazo(e.target.value)}
+                placeholder="Ejemplo: No hay disponibilidad en esta fecha, por favor reagenda para la próxima semana..."
+                rows={4}
+                className="resize-none"
+              />
+            </div>
+            <DialogFooter>
+              <Button
+                variant="outline"
+                onClick={() => {
+                  setRejectDialog(false);
+                  setMotivoRechazo("");
+                }}
+              >
+                Cancelar
+              </Button>
+              <Button
+                variant="destructive"
+                onClick={handleRejectConfirm}
+                disabled={!motivoRechazo.trim() || actionLoading}
+              >
+                {actionLoading ? (
+                  <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+                ) : (
+                  <XCircle className="h-4 w-4 mr-2" />
+                )}
+                Rechazar Cita
+              </Button>
+            </DialogFooter>
+          </DialogContent>
+        </Dialog>
+      </div>
+    </AuthenticatedLayout>
+  );
+}

+ 204 - 0
src/app/appointments/doctor/page.tsx

@@ -0,0 +1,204 @@
+"use client";
+
+import { useState, useMemo } from "react";
+import { useSession } from "next-auth/react";
+import { redirect } from "next/navigation";
+import AuthenticatedLayout from "@/components/AuthenticatedLayout";
+import { AppointmentsHeader } from "@/components/appointments/AppointmentsHeader";
+import { AppointmentsStats } from "@/components/appointments/AppointmentsStats";
+import { AppointmentsFilter, type AppointmentFilter } from "@/components/appointments/AppointmentsFilter";
+import { AppointmentsGrid } from "@/components/appointments/AppointmentsGrid";
+import { useAppointments } from "@/hooks/useAppointments";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Loader2 } from "lucide-react";
+
+export default function DoctorAppointmentsPage() {
+  const { data: session, status } = useSession();
+  const [rejectDialog, setRejectDialog] = useState<{ open: boolean; id: string | null }>({
+    open: false,
+    id: null,
+  });
+  const [motivoRechazo, setMotivoRechazo] = useState("");
+  const [currentFilter, setCurrentFilter] = useState<AppointmentFilter>("pending");
+
+  const {
+    appointments,
+    isLoading,
+    approveAppointment,
+    rejectAppointment,
+  } = useAppointments();
+
+  // Calcular estadísticas
+  const stats = useMemo(() => {
+    return {
+      total: appointments.length,
+      pending: appointments.filter(a => a.estado === "PENDIENTE").length,
+      approved: appointments.filter(a => a.estado === "APROBADA").length,
+      completed: appointments.filter(a => a.estado === "COMPLETADA").length,
+      cancelled: appointments.filter(a => a.estado === "CANCELADA" || a.estado === "RECHAZADA").length,
+    };
+  }, [appointments]);
+
+  // Filtrar citas según el filtro seleccionado
+  const filteredAppointments = useMemo(() => {
+    if (currentFilter === "all") return appointments;
+    if (currentFilter === "pending") return appointments.filter(a => a.estado === "PENDIENTE");
+    if (currentFilter === "approved") return appointments.filter(a => a.estado === "APROBADA");
+    if (currentFilter === "completed") return appointments.filter(a => a.estado === "COMPLETADA");
+    if (currentFilter === "cancelled") return appointments.filter(a => a.estado === "CANCELADA" || a.estado === "RECHAZADA");
+    return appointments;
+  }, [appointments, currentFilter]);
+
+  const filterMessages: Record<AppointmentFilter, string> = {
+    all: "No hay citas registradas en el sistema.",
+    pending: "No hay citas pendientes de aprobación. ¡Buen trabajo!",
+    approved: "No hay citas aprobadas próximas.",
+    completed: "No hay citas completadas.",
+    cancelled: "No hay citas canceladas o rechazadas.",
+  };
+
+  if (status === "loading") {
+    return (
+      <AuthenticatedLayout>
+        <div className="flex items-center justify-center min-h-screen">
+          <Loader2 className="h-8 w-8 animate-spin" />
+        </div>
+      </AuthenticatedLayout>
+    );
+  }
+
+  if (!session) {
+    redirect("/auth/login");
+  }
+
+  // Verificar que sea médico
+  if (session.user.role !== "DOCTOR") {
+    redirect("/appointments");
+  }
+
+  const handleApprove = async (id: string) => {
+    await approveAppointment(id);
+  };
+
+  const handleRejectClick = (id: string) => {
+    setRejectDialog({ open: true, id });
+  };
+
+  const handleReject = async () => {
+    if (!rejectDialog.id || !motivoRechazo.trim()) return;
+
+    await rejectAppointment(rejectDialog.id, motivoRechazo);
+    setRejectDialog({ open: false, id: null });
+    setMotivoRechazo("");
+  };
+
+  return (
+    <AuthenticatedLayout>
+      <div className="container mx-auto px-4 py-6 space-y-6">
+        {/* Header */}
+        <AppointmentsHeader
+          title="Gestión de Citas"
+          description="Administra las solicitudes de citas telemáticas"
+          appointmentsCount={stats.total}
+          showNewButton={false}
+        />
+
+        {/* Stats */}
+        <AppointmentsStats
+          total={stats.total}
+          pending={stats.pending}
+          approved={stats.approved}
+          completed={stats.completed}
+          variant="doctor"
+        />
+
+        {/* Filter */}
+        <div className="flex items-center justify-between">
+          <AppointmentsFilter
+            currentFilter={currentFilter}
+            onFilterChange={setCurrentFilter}
+            counts={{
+              all: stats.total,
+              pending: stats.pending,
+              approved: stats.approved,
+              completed: stats.completed,
+              cancelled: stats.cancelled,
+            }}
+          />
+        </div>
+
+        {/* Appointments Grid */}
+        {isLoading ? (
+          <div className="flex items-center justify-center py-12">
+            <Loader2 className="h-8 w-8 animate-spin" />
+          </div>
+        ) : (
+          <AppointmentsGrid
+            appointments={filteredAppointments}
+            userRole="DOCTOR"
+            onApprove={handleApprove}
+            onReject={handleRejectClick}
+            emptyMessage={filterMessages[currentFilter]}
+          />
+        )}
+
+        {/* Reject Dialog */}
+        <Dialog
+          open={rejectDialog.open}
+          onOpenChange={(open) => {
+            setRejectDialog({ open, id: null });
+            setMotivoRechazo("");
+          }}
+        >
+          <DialogContent>
+            <DialogHeader>
+              <DialogTitle>Rechazar Cita</DialogTitle>
+              <DialogDescription>
+                Por favor proporciona un motivo para rechazar esta cita. El paciente recibirá esta información.
+              </DialogDescription>
+            </DialogHeader>
+            <div className="space-y-2">
+              <Label htmlFor="motivo">Motivo del rechazo</Label>
+              <Textarea
+                id="motivo"
+                value={motivoRechazo}
+                onChange={(e) => setMotivoRechazo(e.target.value)}
+                placeholder="Ejemplo: No hay disponibilidad en esta fecha, por favor reagenda para la próxima semana..."
+                rows={4}
+                className="resize-none"
+              />
+            </div>
+            <DialogFooter>
+              <Button
+                variant="outline"
+                onClick={() => {
+                  setRejectDialog({ open: false, id: null });
+                  setMotivoRechazo("");
+                }}
+              >
+                Cancelar
+              </Button>
+              <Button
+                variant="destructive"
+                onClick={handleReject}
+                disabled={!motivoRechazo.trim()}
+              >
+                Rechazar Cita
+              </Button>
+            </DialogFooter>
+          </DialogContent>
+        </Dialog>
+      </div>
+    </AuthenticatedLayout>
+  );
+}

+ 118 - 0
src/app/appointments/page.tsx

@@ -0,0 +1,118 @@
+"use client";
+
+import { useState, useMemo } from "react";
+import { useSession } from "next-auth/react";
+import { redirect } from "next/navigation";
+import AuthenticatedLayout from "@/components/AuthenticatedLayout";
+import { AppointmentsHeader } from "@/components/appointments/AppointmentsHeader";
+import { AppointmentsStats } from "@/components/appointments/AppointmentsStats";
+import { AppointmentsFilter, type AppointmentFilter } from "@/components/appointments/AppointmentsFilter";
+import { AppointmentsGrid } from "@/components/appointments/AppointmentsGrid";
+import { useAppointments } from "@/hooks/useAppointments";
+import { Loader2 } from "lucide-react";
+
+export default function AppointmentsPage() {
+  const { data: session, status } = useSession();
+  const [currentFilter, setCurrentFilter] = useState<AppointmentFilter>("all");
+
+  const {
+    appointments,
+    isLoading,
+    cancelAppointment,
+  } = useAppointments();
+
+  // Calcular estadísticas
+  const stats = useMemo(() => {
+    return {
+      total: appointments.length,
+      pending: appointments.filter(a => a.estado === "PENDIENTE").length,
+      approved: appointments.filter(a => a.estado === "APROBADA").length,
+      completed: appointments.filter(a => a.estado === "COMPLETADA").length,
+      cancelled: appointments.filter(a => a.estado === "CANCELADA" || a.estado === "RECHAZADA").length,
+    };
+  }, [appointments]);
+
+  // Filtrar citas según el filtro seleccionado
+  const filteredAppointments = useMemo(() => {
+    if (currentFilter === "all") return appointments;
+    if (currentFilter === "pending") return appointments.filter(a => a.estado === "PENDIENTE");
+    if (currentFilter === "approved") return appointments.filter(a => a.estado === "APROBADA");
+    if (currentFilter === "completed") return appointments.filter(a => a.estado === "COMPLETADA");
+    if (currentFilter === "cancelled") return appointments.filter(a => a.estado === "CANCELADA" || a.estado === "RECHAZADA");
+    return appointments;
+  }, [appointments, currentFilter]);
+
+  const filterMessages: Record<AppointmentFilter, string> = {
+    all: "No tienes citas registradas. Usa el chatbot para obtener recomendaciones médicas y agendar citas.",
+    pending: "No tienes citas pendientes de aprobación.",
+    approved: "No tienes citas aprobadas próximas.",
+    completed: "No tienes citas completadas.",
+    cancelled: "No tienes citas canceladas o rechazadas.",
+  };
+
+  if (status === "loading") {
+    return (
+      <AuthenticatedLayout>
+        <div className="flex items-center justify-center min-h-screen">
+          <Loader2 className="h-8 w-8 animate-spin" />
+        </div>
+      </AuthenticatedLayout>
+    );
+  }
+
+  if (!session) {
+    redirect("/auth/login");
+  }
+
+  return (
+    <AuthenticatedLayout>
+      <div className="container mx-auto px-4 py-6 space-y-6">
+        {/* Header */}
+        <AppointmentsHeader
+          title="Mis Citas Telemáticas"
+          description="Gestiona tus consultas médicas virtuales. Las citas se crean desde el chatbot."
+          appointmentsCount={stats.total}
+          showNewButton={false}
+        />
+
+        {/* Stats */}
+        <AppointmentsStats
+          total={stats.total}
+          pending={stats.pending}
+          approved={stats.approved}
+          completed={stats.completed}
+          variant="patient"
+        />
+
+        {/* Filter */}
+        <div className="flex items-center justify-between">
+          <AppointmentsFilter
+            currentFilter={currentFilter}
+            onFilterChange={setCurrentFilter}
+            counts={{
+              all: stats.total,
+              pending: stats.pending,
+              approved: stats.approved,
+              completed: stats.completed,
+              cancelled: stats.cancelled,
+            }}
+          />
+        </div>
+
+        {/* Appointments Grid */}
+        {isLoading ? (
+          <div className="flex items-center justify-center py-12">
+            <Loader2 className="h-8 w-8 animate-spin" />
+          </div>
+        ) : (
+          <AppointmentsGrid
+            appointments={filteredAppointments}
+            userRole="PATIENT"
+            onCancel={cancelAppointment}
+            emptyMessage={filterMessages[currentFilter]}
+          />
+        )}
+      </div>
+    </AuthenticatedLayout>
+  );
+}

+ 127 - 0
src/components/appointments/AppointmentCard.tsx

@@ -0,0 +1,127 @@
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { AppointmentStatusBadge } from "./AppointmentStatusBadge";
+import { Calendar, Clock, User, FileText } from "lucide-react";
+import { format } from "date-fns";
+import { es } from "date-fns/locale";
+import Link from "next/link";
+import type { Appointment } from "@/types/appointments";
+
+interface AppointmentCardProps {
+  appointment: Appointment;
+  userRole: "PATIENT" | "DOCTOR" | "ADMIN";
+  onApprove?: (id: string) => void;
+  onReject?: (id: string) => void;
+  onCancel?: (id: string) => void;
+}
+
+export const AppointmentCard = ({
+  appointment,
+  userRole,
+  onApprove,
+  onReject,
+  onCancel,
+}: AppointmentCardProps) => {
+  const fecha = new Date(appointment.fechaSolicitada);
+  const otherUser = userRole === "PATIENT" ? appointment.medico : appointment.paciente;
+  
+  return (
+    <Card className="hover:shadow-md transition-shadow">
+      <CardHeader className="pb-3">
+        <div className="flex items-start justify-between">
+          <div className="flex items-center gap-3">
+            {otherUser && (
+              <Avatar>
+                <AvatarImage src={otherUser.profileImage || undefined} />
+                <AvatarFallback>
+                  {otherUser.name[0]}{otherUser.lastname[0]}
+                </AvatarFallback>
+              </Avatar>
+            )}
+            <div>
+              <h3 className="font-semibold text-lg">
+                {otherUser
+                  ? `${otherUser.name} ${otherUser.lastname}`
+                  : userRole === "DOCTOR"
+                  ? "Sin asignar"
+                  : "Médico por asignar"}
+              </h3>
+              <div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
+                <Calendar className="h-4 w-4" />
+                <span>{format(fecha, "PPP", { locale: es })}</span>
+                <Clock className="h-4 w-4 ml-2" />
+                <span>{format(fecha, "p", { locale: es })}</span>
+              </div>
+            </div>
+          </div>
+          <AppointmentStatusBadge status={appointment.estado} />
+        </div>
+      </CardHeader>
+      
+      <CardContent className="space-y-3">
+        <div className="flex items-start gap-2 text-sm">
+          <FileText className="h-4 w-4 mt-0.5 text-muted-foreground" />
+          <div>
+            <p className="font-medium">Motivo de consulta:</p>
+            <p className="text-muted-foreground">{appointment.motivoConsulta}</p>
+          </div>
+        </div>
+
+        {appointment.motivoRechazo && (
+          <div className="bg-destructive/10 p-3 rounded-md text-sm">
+            <p className="font-medium text-destructive">Motivo de rechazo:</p>
+            <p className="text-muted-foreground mt-1">{appointment.motivoRechazo}</p>
+          </div>
+        )}
+
+        <div className="flex gap-2 pt-2">
+          {userRole === "DOCTOR" && appointment.estado === "PENDIENTE" && (
+            <>
+              <Button
+                onClick={() => onApprove?.(appointment.id)}
+                className="flex-1"
+                size="sm"
+              >
+                Aprobar
+              </Button>
+              <Button
+                onClick={() => onReject?.(appointment.id)}
+                variant="outline"
+                className="flex-1"
+                size="sm"
+              >
+                Rechazar
+              </Button>
+            </>
+          )}
+
+          {userRole === "PATIENT" && appointment.estado === "PENDIENTE" && (
+            <Button
+              onClick={() => onCancel?.(appointment.id)}
+              variant="outline"
+              className="flex-1"
+              size="sm"
+            >
+              Cancelar cita
+            </Button>
+          )}
+
+          {appointment.estado === "APROBADA" && (
+            <Button asChild className="flex-1" size="sm">
+              <Link href={`/appointments/${appointment.id}/meet`}>
+                Unirse a la consulta
+              </Link>
+            </Button>
+          )}
+
+          <Button asChild variant="outline" size="sm">
+            <Link href={`/appointments/${appointment.id}`}>
+              Ver detalles
+            </Link>
+          </Button>
+        </div>
+      </CardContent>
+    </Card>
+  );
+};

+ 132 - 0
src/components/appointments/AppointmentForm.tsx

@@ -0,0 +1,132 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Calendar } from "@/components/ui/calendar";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { CalendarIcon } from "lucide-react";
+import { format } from "date-fns";
+import { es } from "date-fns/locale";
+import { cn } from "@/lib/utils";
+import type { CreateAppointmentInput, Appointment } from "@/types/appointments";
+
+interface AppointmentFormProps {
+  open: boolean;
+  onClose: () => void;
+  onSubmit: (data: CreateAppointmentInput) => Promise<Appointment | void>;
+  recordId?: string;
+}
+
+export const AppointmentForm = ({ open, onClose, onSubmit, recordId }: AppointmentFormProps) => {
+  const [date, setDate] = useState<Date>();
+  const [time, setTime] = useState("09:00");
+  const [motivoConsulta, setMotivoConsulta] = useState("");
+  const [isSubmitting, setIsSubmitting] = useState(false);
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    
+    if (!date || !motivoConsulta.trim()) return;
+
+    // Combinar fecha y hora
+    const [hours, minutes] = time.split(":");
+    const fechaSolicitada = new Date(date);
+    fechaSolicitada.setHours(parseInt(hours), parseInt(minutes));
+
+    setIsSubmitting(true);
+    try {
+      await onSubmit({ fechaSolicitada, motivoConsulta });
+      setDate(undefined);
+      setTime("09:00");
+      setMotivoConsulta("");
+      onClose();
+    } catch (error) {
+      console.error("Error al crear cita:", error);
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onClose}>
+      <DialogContent className="sm:max-w-[500px]">
+        <DialogHeader>
+          <DialogTitle>Solicitar Cita Telemática</DialogTitle>
+          <DialogDescription>
+            Complete los detalles para solicitar una cita con un médico.
+          </DialogDescription>
+        </DialogHeader>
+        
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="date">Fecha y hora preferida</Label>
+            <div className="flex gap-2">
+              <Popover>
+                <PopoverTrigger asChild>
+                  <Button
+                    variant="outline"
+                    className={cn(
+                      "flex-1 justify-start text-left font-normal",
+                      !date && "text-muted-foreground"
+                    )}
+                  >
+                    <CalendarIcon className="mr-2 h-4 w-4" />
+                    {date ? format(date, "PPP", { locale: es }) : "Seleccionar fecha"}
+                  </Button>
+                </PopoverTrigger>
+                <PopoverContent className="w-auto p-0">
+                  <Calendar
+                    mode="single"
+                    selected={date}
+                    onSelect={setDate}
+                    disabled={(date) => date < new Date()}
+                    initialFocus
+                  />
+                </PopoverContent>
+              </Popover>
+              
+              <Input
+                type="time"
+                value={time}
+                onChange={(e) => setTime(e.target.value)}
+                className="w-32"
+              />
+            </div>
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="motivo">Motivo de la consulta</Label>
+            <Textarea
+              id="motivo"
+              placeholder="Describa brevemente el motivo de su consulta..."
+              value={motivoConsulta}
+              onChange={(e) => setMotivoConsulta(e.target.value)}
+              rows={4}
+              required
+            />
+          </div>
+
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={onClose}>
+              Cancelar
+            </Button>
+            <Button type="submit" disabled={!date || !motivoConsulta.trim() || isSubmitting}>
+              {isSubmitting ? "Solicitando..." : "Solicitar cita"}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+};

+ 47 - 0
src/components/appointments/AppointmentStatusBadge.tsx

@@ -0,0 +1,47 @@
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+
+type AppointmentStatus = "PENDIENTE" | "APROBADA" | "RECHAZADA" | "COMPLETADA" | "CANCELADA";
+
+interface AppointmentStatusBadgeProps {
+  status: AppointmentStatus;
+  className?: string;
+}
+
+const statusConfig = {
+  PENDIENTE: {
+    label: "Pendiente",
+    variant: "secondary" as const,
+    className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-100",
+  },
+  APROBADA: {
+    label: "Aprobada",
+    variant: "default" as const,
+    className: "bg-green-100 text-green-800 hover:bg-green-100",
+  },
+  RECHAZADA: {
+    label: "Rechazada",
+    variant: "destructive" as const,
+    className: "bg-red-100 text-red-800 hover:bg-red-100",
+  },
+  COMPLETADA: {
+    label: "Completada",
+    variant: "outline" as const,
+    className: "bg-blue-100 text-blue-800 hover:bg-blue-100",
+  },
+  CANCELADA: {
+    label: "Cancelada",
+    variant: "outline" as const,
+    className: "bg-gray-100 text-gray-800 hover:bg-gray-100",
+  },
+};
+
+export const AppointmentStatusBadge = ({ status, className }: AppointmentStatusBadgeProps) => {
+  const config = statusConfig[status];
+  
+  return (
+    <Badge variant={config.variant} className={cn(config.className, className)}>
+      {config.label}
+    </Badge>
+  );
+};

+ 71 - 0
src/components/appointments/AppointmentsFilter.tsx

@@ -0,0 +1,71 @@
+"use client";
+
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Badge } from "@/components/ui/badge";
+
+export type AppointmentFilter = "all" | "pending" | "approved" | "completed" | "cancelled";
+
+interface AppointmentsFilterProps {
+  currentFilter: AppointmentFilter;
+  onFilterChange: (filter: AppointmentFilter) => void;
+  counts: {
+    all: number;
+    pending: number;
+    approved: number;
+    completed: number;
+    cancelled: number;
+  };
+}
+
+export const AppointmentsFilter = ({
+  currentFilter,
+  onFilterChange,
+  counts,
+}: AppointmentsFilterProps) => {
+  return (
+    <Tabs value={currentFilter} onValueChange={(v) => onFilterChange(v as AppointmentFilter)}>
+      <TabsList className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
+        <TabsTrigger value="all" className="inline-flex items-center gap-2">
+          Todas
+          {counts.all > 0 && (
+            <Badge variant="secondary" className="ml-1 px-1.5 py-0 text-xs">
+              {counts.all}
+            </Badge>
+          )}
+        </TabsTrigger>
+        <TabsTrigger value="pending" className="inline-flex items-center gap-2">
+          Pendientes
+          {counts.pending > 0 && (
+            <Badge variant="secondary" className="ml-1 px-1.5 py-0 text-xs bg-yellow-100 text-yellow-800">
+              {counts.pending}
+            </Badge>
+          )}
+        </TabsTrigger>
+        <TabsTrigger value="approved" className="inline-flex items-center gap-2">
+          Aprobadas
+          {counts.approved > 0 && (
+            <Badge variant="secondary" className="ml-1 px-1.5 py-0 text-xs bg-green-100 text-green-800">
+              {counts.approved}
+            </Badge>
+          )}
+        </TabsTrigger>
+        <TabsTrigger value="completed" className="inline-flex items-center gap-2">
+          Completadas
+          {counts.completed > 0 && (
+            <Badge variant="secondary" className="ml-1 px-1.5 py-0 text-xs">
+              {counts.completed}
+            </Badge>
+          )}
+        </TabsTrigger>
+        <TabsTrigger value="cancelled" className="inline-flex items-center gap-2">
+          Canceladas
+          {counts.cancelled > 0 && (
+            <Badge variant="secondary" className="ml-1 px-1.5 py-0 text-xs">
+              {counts.cancelled}
+            </Badge>
+          )}
+        </TabsTrigger>
+      </TabsList>
+    </Tabs>
+  );
+};

+ 50 - 0
src/components/appointments/AppointmentsGrid.tsx

@@ -0,0 +1,50 @@
+"use client";
+
+import { AppointmentCard } from "./AppointmentCard";
+import type { Appointment } from "@/types/appointments";
+import { FileQuestion } from "lucide-react";
+
+interface AppointmentsGridProps {
+  appointments: Appointment[];
+  userRole: "PATIENT" | "DOCTOR" | "ADMIN";
+  onApprove?: (id: string) => void;
+  onReject?: (id: string) => void;
+  onCancel?: (id: string) => void;
+  emptyMessage?: string;
+}
+
+export const AppointmentsGrid = ({
+  appointments,
+  userRole,
+  onApprove,
+  onReject,
+  onCancel,
+  emptyMessage = "No hay citas para mostrar",
+}: AppointmentsGridProps) => {
+  if (appointments.length === 0) {
+    return (
+      <div className="flex flex-col items-center justify-center py-12 px-4 text-center">
+        <div className="rounded-full bg-muted p-3 mb-4">
+          <FileQuestion className="h-6 w-6 text-muted-foreground" />
+        </div>
+        <h3 className="text-lg font-semibold mb-1">Sin resultados</h3>
+        <p className="text-sm text-muted-foreground max-w-sm">{emptyMessage}</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="grid gap-4 md:grid-cols-1 lg:grid-cols-1">
+      {appointments.map((appointment) => (
+        <AppointmentCard
+          key={appointment.id}
+          appointment={appointment}
+          userRole={userRole}
+          onApprove={onApprove}
+          onReject={onReject}
+          onCancel={onCancel}
+        />
+      ))}
+    </div>
+  );
+};

+ 54 - 0
src/components/appointments/AppointmentsHeader.tsx

@@ -0,0 +1,54 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Plus, Calendar } from "lucide-react";
+
+interface AppointmentsHeaderProps {
+  title: string;
+  description: string;
+  appointmentsCount: number;
+  onNewAppointment?: () => void;
+  showNewButton?: boolean;
+}
+
+export const AppointmentsHeader = ({
+  title,
+  description,
+  appointmentsCount,
+  onNewAppointment,
+  showNewButton = true,
+}: AppointmentsHeaderProps) => {
+  return (
+    <div className="mb-6">
+      <div className="bg-card rounded-xl p-6 border shadow-sm">
+        <div className="flex items-center justify-between">
+          <div className="flex items-center space-x-3">
+            <div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center shadow-sm">
+              <Calendar className="w-5 h-5 text-primary-foreground" />
+            </div>
+            <div>
+              <h1 className="text-xl font-bold text-foreground">{title}</h1>
+              <p className="text-sm text-muted-foreground">{description}</p>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <div className="text-right">
+              <div className="bg-muted rounded-lg p-3 shadow-sm border">
+                <div className="text-2xl font-bold text-primary">{appointmentsCount}</div>
+                <div className="text-xs text-muted-foreground font-medium">
+                  Citas {showNewButton ? "registradas" : "totales"}
+                </div>
+              </div>
+            </div>
+            {showNewButton && onNewAppointment && (
+              <Button onClick={onNewAppointment} size="default" className="ml-2">
+                <Plus className="h-4 w-4 mr-2" />
+                Nueva Cita
+              </Button>
+            )}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};

+ 115 - 0
src/components/appointments/AppointmentsList.tsx

@@ -0,0 +1,115 @@
+"use client";
+
+import { AppointmentCard } from "./AppointmentCard";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import type { Appointment } from "@/types/appointments";
+
+interface AppointmentsListProps {
+  appointments: Appointment[];
+  userRole: "PATIENT" | "DOCTOR" | "ADMIN";
+  onApprove?: (id: string) => void;
+  onReject?: (id: string) => void;
+  onCancel?: (id: string) => void;
+}
+
+export const AppointmentsList = ({
+  appointments,
+  userRole,
+  onApprove,
+  onReject,
+  onCancel,
+}: AppointmentsListProps) => {
+  const pendientes = appointments.filter((a) => a.estado === "PENDIENTE");
+  const aprobadas = appointments.filter((a) => a.estado === "APROBADA");
+  const completadas = appointments.filter((a) => a.estado === "COMPLETADA");
+  const otras = appointments.filter(
+    (a) => a.estado === "RECHAZADA" || a.estado === "CANCELADA"
+  );
+
+  return (
+    <Tabs defaultValue="pendientes" className="w-full">
+      <TabsList className="grid w-full grid-cols-4">
+        <TabsTrigger value="pendientes">
+          Pendientes {pendientes.length > 0 && `(${pendientes.length})`}
+        </TabsTrigger>
+        <TabsTrigger value="aprobadas">
+          Aprobadas {aprobadas.length > 0 && `(${aprobadas.length})`}
+        </TabsTrigger>
+        <TabsTrigger value="completadas">
+          Completadas {completadas.length > 0 && `(${completadas.length})`}
+        </TabsTrigger>
+        <TabsTrigger value="otras">Otras</TabsTrigger>
+      </TabsList>
+
+      <TabsContent value="pendientes" className="space-y-4 mt-4">
+        {pendientes.length === 0 ? (
+          <p className="text-center text-muted-foreground py-8">
+            No hay citas pendientes
+          </p>
+        ) : (
+          pendientes.map((appointment) => (
+            <AppointmentCard
+              key={appointment.id}
+              appointment={appointment}
+              userRole={userRole}
+              onApprove={onApprove}
+              onReject={onReject}
+              onCancel={onCancel}
+            />
+          ))
+        )}
+      </TabsContent>
+
+      <TabsContent value="aprobadas" className="space-y-4 mt-4">
+        {aprobadas.length === 0 ? (
+          <p className="text-center text-muted-foreground py-8">
+            No hay citas aprobadas
+          </p>
+        ) : (
+          aprobadas.map((appointment) => (
+            <AppointmentCard
+              key={appointment.id}
+              appointment={appointment}
+              userRole={userRole}
+              onApprove={onApprove}
+              onReject={onReject}
+              onCancel={onCancel}
+            />
+          ))
+        )}
+      </TabsContent>
+
+      <TabsContent value="completadas" className="space-y-4 mt-4">
+        {completadas.length === 0 ? (
+          <p className="text-center text-muted-foreground py-8">
+            No hay citas completadas
+          </p>
+        ) : (
+          completadas.map((appointment) => (
+            <AppointmentCard
+              key={appointment.id}
+              appointment={appointment}
+              userRole={userRole}
+            />
+          ))
+        )}
+      </TabsContent>
+
+      <TabsContent value="otras" className="space-y-4 mt-4">
+        {otras.length === 0 ? (
+          <p className="text-center text-muted-foreground py-8">
+            No hay citas rechazadas o canceladas
+          </p>
+        ) : (
+          otras.map((appointment) => (
+            <AppointmentCard
+              key={appointment.id}
+              appointment={appointment}
+              userRole={userRole}
+            />
+          ))
+        )}
+      </TabsContent>
+    </Tabs>
+  );
+};

+ 118 - 0
src/components/appointments/AppointmentsStats.tsx

@@ -0,0 +1,118 @@
+"use client";
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Calendar, Clock, CheckCircle2, XCircle } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface StatCardProps {
+  title: string;
+  value: number;
+  icon: React.ReactNode;
+  description?: string;
+  trend?: {
+    value: number;
+    isPositive: boolean;
+  };
+  variant?: "default" | "warning" | "success" | "danger";
+}
+
+const StatCard = ({ title, value, icon, description, variant = "default" }: StatCardProps) => {
+  const variantStyles = {
+    default: "text-primary",
+    warning: "text-yellow-600",
+    success: "text-green-600",
+    danger: "text-red-600",
+  };
+
+  return (
+    <Card>
+      <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+        <CardTitle className="text-sm font-medium">{title}</CardTitle>
+        <div className={cn("text-muted-foreground", variantStyles[variant])}>{icon}</div>
+      </CardHeader>
+      <CardContent>
+        <div className="text-2xl font-bold">{value}</div>
+        {description && <p className="text-xs text-muted-foreground mt-1">{description}</p>}
+      </CardContent>
+    </Card>
+  );
+};
+
+interface AppointmentsStatsProps {
+  total: number;
+  pending: number;
+  approved: number;
+  completed: number;
+  rejected?: number;
+  cancelled?: number;
+  variant?: "patient" | "doctor";
+}
+
+export const AppointmentsStats = ({
+  total,
+  pending,
+  approved,
+  completed,
+  rejected = 0,
+  cancelled = 0,
+  variant = "patient",
+}: AppointmentsStatsProps) => {
+  if (variant === "doctor") {
+    return (
+      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
+        <StatCard
+          title="Total de Citas"
+          value={total}
+          icon={<Calendar className="h-4 w-4" />}
+          description="Todas las citas registradas"
+        />
+        <StatCard
+          title="Pendientes"
+          value={pending}
+          icon={<Clock className="h-4 w-4" />}
+          description="Requieren aprobación"
+          variant="warning"
+        />
+        <StatCard
+          title="Aprobadas"
+          value={approved}
+          icon={<CheckCircle2 className="h-4 w-4" />}
+          description="Listas para atender"
+          variant="success"
+        />
+        <StatCard
+          title="Completadas"
+          value={completed}
+          icon={<CheckCircle2 className="h-4 w-4" />}
+          description="Consultas finalizadas"
+          variant="default"
+        />
+      </div>
+    );
+  }
+
+  return (
+    <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+      <StatCard
+        title="Total de Citas"
+        value={total}
+        icon={<Calendar className="h-4 w-4" />}
+        description="Todas tus citas"
+      />
+      <StatCard
+        title="Pendientes"
+        value={pending}
+        icon={<Clock className="h-4 w-4" />}
+        description="En espera de aprobación"
+        variant="warning"
+      />
+      <StatCard
+        title="Próximas"
+        value={approved}
+        icon={<CheckCircle2 className="h-4 w-4" />}
+        description="Citas confirmadas"
+        variant="success"
+      />
+    </div>
+  );
+};

+ 10 - 0
src/components/appointments/index.ts

@@ -0,0 +1,10 @@
+export { AppointmentsHeader } from './AppointmentsHeader';
+export { AppointmentsStats } from './AppointmentsStats';
+export { AppointmentsFilter } from './AppointmentsFilter';
+export { AppointmentsGrid } from './AppointmentsGrid';
+export { AppointmentCard } from './AppointmentCard';
+export { AppointmentForm } from './AppointmentForm';
+export { AppointmentStatusBadge } from './AppointmentStatusBadge';
+export { AppointmentsList } from './AppointmentsList';
+
+export type { AppointmentFilter } from './AppointmentsFilter';

+ 32 - 11
src/components/chatbot/MedicalAlert.tsx

@@ -1,10 +1,15 @@
-import { AlertTriangle, Info, Clock } from "lucide-react";
+"use client";
+
+import { AlertTriangle, Info, Clock, Calendar } from "lucide-react";
 import { MedicalAlert as MedicalAlertType } from "./types";
 import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import Link from "next/link";
 
 interface MedicalAlertProps {
   alert: MedicalAlertType;
   className?: string;
+  showAppointmentButton?: boolean;
 }
 
 const alertConfig = {
@@ -19,33 +24,49 @@ const alertConfig = {
     icon: Clock,
     text: "Consulta recomendada",
     description: "Se recomienda agendar una cita médica",
-    className: "bg-warning border-warning/20 text-warning",
-    iconClassName: "text-warning"
+    className: "bg-yellow-50 border-yellow-200 text-yellow-800",
+    iconClassName: "text-yellow-600"
   },
   URGENTE: {
     icon: AlertTriangle,
     text: "Atención urgente",
     description: "Requiere atención médica inmediata",
-    className: "bg-destructive border-destructive/20 text-destructive",
-    iconClassName: "text-destructive"
+    className: "bg-red-500 border-red-600 text-white",
+    iconClassName: "text-white"
   }
 };
 
-export const MedicalAlert = ({ alert, className }: MedicalAlertProps) => {
+export const MedicalAlert = ({ alert, className, showAppointmentButton = true }: MedicalAlertProps) => {
   const config = alertConfig[alert];
   const Icon = config.icon;
+  const shouldShowButton = showAppointmentButton && (alert === "RECOMENDADO" || alert === "URGENTE");
 
   return (
     <div className={cn(
-      "flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium",
+      "flex items-center justify-between gap-3 px-4 py-3 rounded-lg border",
       config.className,
       className
     )}>
-      <Icon className={cn("h-4 w-4", config.iconClassName)} />
-      <div>
-        <div className="font-semibold">{config.text}</div>
-        <div className="text-xs opacity-80">{config.description}</div>
+      <div className="flex items-center gap-3 flex-1">
+        <Icon className={cn("h-5 w-5 flex-shrink-0", config.iconClassName)} />
+        <div className="flex-1">
+          <div className="font-semibold text-sm">{config.text}</div>
+          <div className="text-xs opacity-80 mt-0.5">{config.description}</div>
+        </div>
       </div>
+      {shouldShowButton && (
+        <Button
+          asChild
+          size="sm"
+          variant={alert === "URGENTE" ? "destructive" : "default"}
+          className="flex-shrink-0"
+        >
+          <Link href="/appointments">
+            <Calendar className="h-4 w-4 mr-2" />
+            Agendar Cita
+          </Link>
+        </Button>
+      )}
     </div>
   );
 };

+ 15 - 1
src/components/sidebar/SidebarNavigation.tsx

@@ -10,9 +10,11 @@ import {
   Users, 
   ChevronDown,
   ChevronRight,
-  Home
+  Home,
+  Calendar
 } from "lucide-react"
 import { COLOR_PALETTE } from "@/utils/palette"
+import { useAppointmentsBadge } from "@/hooks/useAppointmentsBadge"
 
 interface SidebarItem {
   title: string
@@ -35,6 +37,7 @@ export default function SidebarNavigation({ onItemClick, isCollapsed = false }:
   const { data: session } = useSession()
   const pathname = usePathname()
   const [expandedSections, setExpandedSections] = useState<string[]>([])
+  const { pendingCount } = useAppointmentsBadge()
 
   // Expandir automáticamente las secciones que contienen la página actual
   useEffect(() => {
@@ -120,6 +123,12 @@ export default function SidebarNavigation({ onItemClick, isCollapsed = false }:
               title: "Reportes Médicos",
               href: "/records",
               icon: FileText
+            },
+            {
+              title: "Gestión de Citas",
+              href: "/appointments/doctor",
+              icon: Calendar,
+              badge: pendingCount > 0 ? pendingCount.toString() : undefined
             }
           ]
         }
@@ -147,6 +156,11 @@ export default function SidebarNavigation({ onItemClick, isCollapsed = false }:
               title: "Mis Reportes",
               href: "/records",
               icon: FileText
+            },
+            {
+              title: "Mis Citas",
+              href: "/appointments",
+              icon: Calendar
             }
           ]
         }

+ 1 - 3
src/components/sidebar/index.ts

@@ -2,12 +2,10 @@ import SidebarHeader from './SidebarHeader'
 import SidebarUserInfo from './SidebarUserInfo'
 import SidebarNavigation from './SidebarNavigation'
 import SidebarFooter from './SidebarFooter'
-import MobileSidebar from './MobileSidebar'
 
 export {
   SidebarHeader,
   SidebarUserInfo,
   SidebarNavigation,
-  SidebarFooter,
-  MobileSidebar
+  SidebarFooter
 }

+ 50 - 0
src/components/ui/avatar.tsx

@@ -0,0 +1,50 @@
+"use client"
+
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+const Avatar = React.forwardRef<
+  React.ElementRef<typeof AvatarPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
+>(({ className, ...props }, ref) => (
+  <AvatarPrimitive.Root
+    ref={ref}
+    className={cn(
+      "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
+      className
+    )}
+    {...props}
+  />
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+  React.ElementRef<typeof AvatarPrimitive.Image>,
+  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
+>(({ className, ...props }, ref) => (
+  <AvatarPrimitive.Image
+    ref={ref}
+    className={cn("aspect-square h-full w-full", className)}
+    {...props}
+  />
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+  React.ElementRef<typeof AvatarPrimitive.Fallback>,
+  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
+>(({ className, ...props }, ref) => (
+  <AvatarPrimitive.Fallback
+    ref={ref}
+    className={cn(
+      "flex h-full w-full items-center justify-center rounded-full bg-muted",
+      className
+    )}
+    {...props}
+  />
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }

+ 213 - 0
src/components/ui/calendar.tsx

@@ -0,0 +1,213 @@
+"use client"
+
+import * as React from "react"
+import {
+  ChevronDownIcon,
+  ChevronLeftIcon,
+  ChevronRightIcon,
+} from "lucide-react"
+import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+
+function Calendar({
+  className,
+  classNames,
+  showOutsideDays = true,
+  captionLayout = "label",
+  buttonVariant = "ghost",
+  formatters,
+  components,
+  ...props
+}: React.ComponentProps<typeof DayPicker> & {
+  buttonVariant?: React.ComponentProps<typeof Button>["variant"]
+}) {
+  const defaultClassNames = getDefaultClassNames()
+
+  return (
+    <DayPicker
+      showOutsideDays={showOutsideDays}
+      className={cn(
+        "bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
+        String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
+        String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+        className
+      )}
+      captionLayout={captionLayout}
+      formatters={{
+        formatMonthDropdown: (date) =>
+          date.toLocaleString("default", { month: "short" }),
+        ...formatters,
+      }}
+      classNames={{
+        root: cn("w-fit", defaultClassNames.root),
+        months: cn(
+          "flex gap-4 flex-col md:flex-row relative",
+          defaultClassNames.months
+        ),
+        month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
+        nav: cn(
+          "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
+          defaultClassNames.nav
+        ),
+        button_previous: cn(
+          buttonVariants({ variant: buttonVariant }),
+          "size-8 aria-disabled:opacity-50 p-0 select-none",
+          defaultClassNames.button_previous
+        ),
+        button_next: cn(
+          buttonVariants({ variant: buttonVariant }),
+          "size-8 aria-disabled:opacity-50 p-0 select-none",
+          defaultClassNames.button_next
+        ),
+        month_caption: cn(
+          "flex items-center justify-center h-8 w-full px-8",
+          defaultClassNames.month_caption
+        ),
+        dropdowns: cn(
+          "w-full flex items-center text-sm font-medium justify-center h-8 gap-1.5",
+          defaultClassNames.dropdowns
+        ),
+        dropdown_root: cn(
+          "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
+          defaultClassNames.dropdown_root
+        ),
+        dropdown: cn(
+          "absolute bg-popover inset-0 opacity-0",
+          defaultClassNames.dropdown
+        ),
+        caption_label: cn(
+          "select-none font-medium",
+          captionLayout === "label"
+            ? "text-sm"
+            : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
+          defaultClassNames.caption_label
+        ),
+        table: "w-full border-collapse",
+        weekdays: cn("flex", defaultClassNames.weekdays),
+        weekday: cn(
+          "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
+          defaultClassNames.weekday
+        ),
+        week: cn("flex w-full mt-2", defaultClassNames.week),
+        week_number_header: cn(
+          "select-none w-8",
+          defaultClassNames.week_number_header
+        ),
+        week_number: cn(
+          "text-[0.8rem] select-none text-muted-foreground",
+          defaultClassNames.week_number
+        ),
+        day: cn(
+          "relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
+          defaultClassNames.day
+        ),
+        range_start: cn(
+          "rounded-l-md bg-accent",
+          defaultClassNames.range_start
+        ),
+        range_middle: cn("rounded-none", defaultClassNames.range_middle),
+        range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
+        today: cn(
+          "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
+          defaultClassNames.today
+        ),
+        outside: cn(
+          "text-muted-foreground aria-selected:text-muted-foreground",
+          defaultClassNames.outside
+        ),
+        disabled: cn(
+          "text-muted-foreground opacity-50",
+          defaultClassNames.disabled
+        ),
+        hidden: cn("invisible", defaultClassNames.hidden),
+        ...classNames,
+      }}
+      components={{
+        Root: ({ className, rootRef, ...props }) => {
+          return (
+            <div
+              data-slot="calendar"
+              ref={rootRef}
+              className={cn(className)}
+              {...props}
+            />
+          )
+        },
+        Chevron: ({ className, orientation, ...props }) => {
+          if (orientation === "left") {
+            return (
+              <ChevronLeftIcon className={cn("size-4", className)} {...props} />
+            )
+          }
+
+          if (orientation === "right") {
+            return (
+              <ChevronRightIcon
+                className={cn("size-4", className)}
+                {...props}
+              />
+            )
+          }
+
+          return (
+            <ChevronDownIcon className={cn("size-4", className)} {...props} />
+          )
+        },
+        DayButton: CalendarDayButton,
+        WeekNumber: ({ children, ...props }) => {
+          return (
+            <td {...props}>
+              <div className="flex size-8 items-center justify-center text-center">
+                {children}
+              </div>
+            </td>
+          )
+        },
+        ...components,
+      }}
+      {...props}
+    />
+  )
+}
+
+function CalendarDayButton({
+  className,
+  day,
+  modifiers,
+  ...props
+}: React.ComponentProps<typeof DayButton>) {
+  const defaultClassNames = getDefaultClassNames()
+
+  const ref = React.useRef<HTMLButtonElement>(null)
+  React.useEffect(() => {
+    if (modifiers.focused) ref.current?.focus()
+  }, [modifiers.focused])
+
+  return (
+    <Button
+      ref={ref}
+      variant="ghost"
+      size="icon"
+      data-day={day.date.toLocaleDateString()}
+      data-selected-single={
+        modifiers.selected &&
+        !modifiers.range_start &&
+        !modifiers.range_end &&
+        !modifiers.range_middle
+      }
+      data-range-start={modifiers.range_start}
+      data-range-end={modifiers.range_end}
+      data-range-middle={modifiers.range_middle}
+      className={cn(
+        "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-8 flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
+        defaultClassNames.day,
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Calendar, CalendarDayButton }

+ 48 - 0
src/components/ui/popover.tsx

@@ -0,0 +1,48 @@
+"use client"
+
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+
+import { cn } from "@/lib/utils"
+
+function Popover({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
+  return <PopoverPrimitive.Root data-slot="popover" {...props} />
+}
+
+function PopoverTrigger({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
+  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
+}
+
+function PopoverContent({
+  className,
+  align = "center",
+  sideOffset = 4,
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
+  return (
+    <PopoverPrimitive.Portal>
+      <PopoverPrimitive.Content
+        data-slot="popover-content"
+        align={align}
+        sideOffset={sideOffset}
+        className={cn(
+          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
+          className
+        )}
+        {...props}
+      />
+    </PopoverPrimitive.Portal>
+  )
+}
+
+function PopoverAnchor({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
+  return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
+}
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

+ 28 - 0
src/components/ui/separator.tsx

@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+  className,
+  orientation = "horizontal",
+  decorative = true,
+  ...props
+}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
+  return (
+    <SeparatorPrimitive.Root
+      data-slot="separator"
+      decorative={decorative}
+      orientation={orientation}
+      className={cn(
+        "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Separator }

+ 66 - 0
src/components/ui/tabs.tsx

@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Root>) {
+  return (
+    <TabsPrimitive.Root
+      data-slot="tabs"
+      className={cn("flex flex-col gap-2", className)}
+      {...props}
+    />
+  )
+}
+
+function TabsList({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.List>) {
+  return (
+    <TabsPrimitive.List
+      data-slot="tabs-list"
+      className={cn(
+        "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TabsTrigger({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
+  return (
+    <TabsPrimitive.Trigger
+      data-slot="tabs-trigger"
+      className={cn(
+        "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TabsContent({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Content>) {
+  return (
+    <TabsPrimitive.Content
+      data-slot="tabs-content"
+      className={cn("flex-1 outline-none", className)}
+      {...props}
+    />
+  )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }

+ 25 - 0
src/hooks/use-toast.ts

@@ -0,0 +1,25 @@
+import { toast as sonnerToast } from "sonner";
+
+export const useToast = () => {
+  return {
+    toast: ({
+      title,
+      description,
+      variant,
+    }: {
+      title: string;
+      description?: string;
+      variant?: "default" | "destructive";
+    }) => {
+      if (variant === "destructive") {
+        sonnerToast.error(title, {
+          description,
+        });
+      } else {
+        sonnerToast.success(title, {
+          description,
+        });
+      }
+    },
+  };
+};

+ 155 - 0
src/hooks/useAppointments.ts

@@ -0,0 +1,155 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useToast } from "@/hooks/use-toast";
+import type { Appointment, CreateAppointmentInput } from "@/types/appointments";
+
+export const useAppointments = () => {
+  const [appointments, setAppointments] = useState<Appointment[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+  const { toast } = useToast();
+
+  const fetchAppointments = async (estado?: string) => {
+    try {
+      const url = estado
+        ? `/api/appointments?estado=${estado}`
+        : "/api/appointments";
+      
+      const response = await fetch(url);
+      
+      if (!response.ok) throw new Error("Error al cargar citas");
+      
+      const data: Appointment[] = await response.json();
+      setAppointments(data);
+    } catch (error) {
+      console.error("Error fetching appointments:", error);
+      toast({
+        title: "Error",
+        description: "No se pudieron cargar las citas",
+        variant: "destructive",
+      });
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  const createAppointment = async (data: CreateAppointmentInput) => {
+    try {
+      const response = await fetch("/api/appointments", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(data),
+      });
+
+      if (!response.ok) {
+        const error = await response.json();
+        throw new Error(error.error || "Error al crear cita");
+      }
+
+      const newAppointment: Appointment = await response.json();
+      
+      toast({
+        title: "¡Cita solicitada!",
+        description: "Un médico revisará tu solicitud pronto",
+      });
+
+      await fetchAppointments();
+      return newAppointment;
+    } catch (error) {
+      const message = error instanceof Error ? error.message : "No se pudo crear la cita";
+      toast({
+        title: "Error",
+        description: message,
+        variant: "destructive",
+      });
+      throw error;
+    }
+  };
+
+  const approveAppointment = async (id: string, notas?: string) => {
+    try {
+      const response = await fetch(`/api/appointments/${id}/approve`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ notas }),
+      });
+
+      if (!response.ok) throw new Error("Error al aprobar cita");
+
+      toast({
+        title: "Cita aprobada",
+        description: "El paciente ha sido notificado",
+      });
+
+      await fetchAppointments();
+    } catch (error) {
+      toast({
+        title: "Error",
+        description: "No se pudo aprobar la cita",
+        variant: "destructive",
+      });
+    }
+  };
+
+  const rejectAppointment = async (id: string, motivoRechazo: string) => {
+    try {
+      const response = await fetch(`/api/appointments/${id}/reject`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ motivoRechazo }),
+      });
+
+      if (!response.ok) throw new Error("Error al rechazar cita");
+
+      toast({
+        title: "Cita rechazada",
+        description: "El paciente ha sido notificado",
+      });
+
+      await fetchAppointments();
+    } catch (error) {
+      toast({
+        title: "Error",
+        description: "No se pudo rechazar la cita",
+        variant: "destructive",
+      });
+    }
+  };
+
+  const cancelAppointment = async (id: string) => {
+    try {
+      const response = await fetch(`/api/appointments/${id}`, {
+        method: "PATCH",
+      });
+
+      if (!response.ok) throw new Error("Error al cancelar cita");
+
+      toast({
+        title: "Cita cancelada",
+        description: "La cita ha sido cancelada exitosamente",
+      });
+
+      await fetchAppointments();
+    } catch (error) {
+      toast({
+        title: "Error",
+        description: "No se pudo cancelar la cita",
+        variant: "destructive",
+      });
+    }
+  };
+
+  useEffect(() => {
+    fetchAppointments();
+  }, []);
+
+  return {
+    appointments,
+    isLoading,
+    createAppointment,
+    approveAppointment,
+    rejectAppointment,
+    cancelAppointment,
+    refetch: fetchAppointments,
+  };
+};

+ 39 - 0
src/hooks/useAppointmentsBadge.ts

@@ -0,0 +1,39 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useSession } from "next-auth/react";
+
+export const useAppointmentsBadge = () => {
+  const { data: session } = useSession();
+  const [pendingCount, setPendingCount] = useState(0);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    const fetchPendingCount = async () => {
+      if (!session || session.user.role !== "DOCTOR") {
+        setIsLoading(false);
+        return;
+      }
+
+      try {
+        const response = await fetch("/api/appointments?estado=PENDIENTE");
+        if (response.ok) {
+          const appointments: unknown[] = await response.json();
+          setPendingCount(appointments.length);
+        }
+      } catch (error) {
+        console.error("Error fetching pending appointments:", error);
+      } finally {
+        setIsLoading(false);
+      }
+    };
+
+    fetchPendingCount();
+    
+    // Refetch cada 30 segundos
+    const interval = setInterval(fetchPendingCount, 30000);
+    return () => clearInterval(interval);
+  }, [session]);
+
+  return { pendingCount, isLoading };
+};

+ 43 - 0
src/types/appointments.ts

@@ -0,0 +1,43 @@
+import { AppointmentStatus } from "@prisma/client";
+
+export interface Appointment {
+  id: string;
+  createdAt: Date | string;
+  updatedAt: Date | string;
+  pacienteId: string;
+  medicoId: string | null;
+  recordId: string | null;
+  fechaSolicitada: Date | string;
+  estado: "PENDIENTE" | "APROBADA" | "RECHAZADA" | "COMPLETADA" | "CANCELADA";
+  motivoConsulta: string;
+  motivoRechazo: string | null;
+  notas: string | null;
+  roomName: string | null;
+  paciente?: {
+    id: string;
+    name: string;
+    lastname: string;
+    email: string | null;
+    profileImage: string | null;
+  };
+  medico?: {
+    id: string;
+    name: string;
+    lastname: string;
+    email: string | null;
+    profileImage: string | null;
+  } | null;
+}
+
+export interface CreateAppointmentInput {
+  fechaSolicitada: Date;
+  motivoConsulta: string;
+  recordId?: string;
+}
+
+export interface UpdateAppointmentInput {
+  medicoId?: string;
+  estado?: "PENDIENTE" | "APROBADA" | "RECHAZADA" | "COMPLETADA" | "CANCELADA";
+  motivoRechazo?: string;
+  notas?: string;
+}