浏览代码

almost finish appointment system

Matthew Trejo 2 月之前
父节点
当前提交
5a4d4f52b5
共有 37 个文件被更改,包括 2327 次插入362 次删除
  1. 6 3
      docs/APPOINTMENTS_SYSTEM.md
  2. 298 0
      docs/CHATBOT_APPOINTMENTS_REFACTOR.md
  3. 62 0
      package-lock.json
  4. 1 0
      package.json
  5. 2 0
      prisma/migrations/20251008165451_make_fecha_solicitada_optional/migration.sql
  6. 4 0
      prisma/migrations/20251008181125_add_consultation_notes/migration.sql
  7. 6 1
      prisma/schema.prisma
  8. 22 6
      src/app/api/appointments/[id]/approve/route.ts
  9. 2 2
      src/app/api/appointments/[id]/complete/route.ts
  10. 135 0
      src/app/api/appointments/[id]/consultation-notes/route.ts
  11. 2 2
      src/app/api/appointments/[id]/reject/route.ts
  12. 8 7
      src/app/api/appointments/[id]/route.ts
  13. 137 0
      src/app/api/appointments/[id]/start-meeting/route.ts
  14. 20 17
      src/app/api/appointments/route.ts
  15. 138 29
      src/app/appointments/[id]/meet/page.tsx
  16. 207 33
      src/app/appointments/[id]/page.tsx
  17. 31 3
      src/app/appointments/doctor/page.tsx
  18. 347 211
      src/app/dashboard/page.tsx
  19. 1 1
      src/components/account/PasswordChangeSection.tsx
  20. 16 8
      src/components/appointments/AppointmentCard.tsx
  21. 140 0
      src/components/appointments/ApproveAppointmentModal.tsx
  22. 217 0
      src/components/appointments/ConsultationNotes.tsx
  23. 151 0
      src/components/chatbot/AppointmentModalFromChat.tsx
  24. 17 0
      src/components/chatbot/ChatInterface.tsx
  25. 6 2
      src/components/chatbot/ChatMessage.tsx
  26. 12 2
      src/components/chatbot/ChatMessages.tsx
  27. 6 9
      src/components/chatbot/MedicalAlert.tsx
  28. 8 8
      src/components/chatbot/SuggestedPrompts.tsx
  29. 1 0
      src/components/chatbot/index.ts
  30. 2 2
      src/components/sidebar/SidebarUserInfo.tsx
  31. 161 0
      src/components/ui/datetime-picker.tsx
  32. 7 8
      src/components/ui/profile-image.tsx
  33. 58 0
      src/components/ui/scroll-area.tsx
  34. 13 2
      src/contexts/ProfileImageContext.tsx
  35. 12 4
      src/hooks/useAppointments.ts
  36. 5 2
      src/types/appointments.ts
  37. 66 0
      src/utils/appointments.ts

+ 6 - 3
docs/APPOINTMENTS_SYSTEM.md

@@ -13,7 +13,8 @@
 - [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]/approve` - Aprobar (médico) - NO genera roomName
+- [x] `POST /api/appointments/[id]/start-meeting` - Iniciar videollamada (genera roomName)
 - [x] `PATCH /api/appointments/[id]/reject` - Rechazar (médico)
 - [x] `PATCH /api/appointments/[id]/complete` - Marcar completada
 - [x] `DELETE /api/appointments/[id]` - Cancelar (paciente)
@@ -42,7 +43,8 @@
 ### 6. Integración Jitsi Meet
 - [x] Script external_api.js en layout
 - [x] Componente React con Jitsi
-- [x] Generar roomName único (UUID + timestamp)
+- [x] Generar roomName único cuando se inicia videollamada (NO al aprobar)
+- [x] Validación de horario: permitir unirse 15 min antes hasta 1 hora después
 - [ ] Configuración: moderador para médico
 - [x] Controles: mute, video, compartir pantalla
 
@@ -179,7 +181,8 @@ const options = {
 
 - Usar Jitsi público (meet.jit.si) inicialmente
 - Self-host después (requiere servidor propio)
-- RoomName único: `appointment-{id}-{timestamp}`
+- RoomName único: `appointment-{id}-{timestamp}` (generado al iniciar videollamada)
 - 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.
+- **Flujo de videollamadas**: El `roomName` NO se genera al aprobar la cita, solo cuando un usuario intenta unirse en el horario válido (15 min antes hasta 1 hora después). Esto evita salas creadas prematuramente.

+ 298 - 0
docs/CHATBOT_APPOINTMENTS_REFACTOR.md

@@ -0,0 +1,298 @@
+# Refactor: Agendamiento de Citas desde Chatbot
+
+## 🎯 Objetivo
+
+Cambiar el flujo de agendamiento para que las citas se creen desde el chatbot (sin fecha/hora específica) y el doctor asigne la fecha al aprobar.
+
+## 📋 Tareas
+
+###### 🎯 Flujo Actualizado
+1. **Paciente**: Chatbot recomienda → Modal → Crea cita SIN fecha
+2. **Doctor**: Ve cita pendiente → Aprobar → Selecciona fecha/hora → Confirma (SIN roomName)
+3. **Ambos**: Esperan hasta el horario asignado
+4. **15 min antes o durante**: Aparece botón "Unirse a Videollamada"
+5. **Al hacer click**: Se genera `roomName` y se redirige a sala Jitsi
+6. **Durante videollamada**: Doctor puede tomar notas médicas
+7. **Finalizar consulta**: Doctor guarda y comparte notas con el paciente
+8. **Después**: Paciente puede ver las notas en el detalle de la cita
+
+### 📝 Sistema de Notas de Consulta
+
+#### ✅ Implementado
+1. **Schema actualizado** (`prisma/schema.prisma`):
+   - `notasConsulta`: String? - Texto de las notas médicas
+   - `notasGuardadas`: Boolean - Si están compartidas con el paciente
+   - `notasGuardadasAt`: DateTime? - Fecha de cuando se compartieron
+
+2. **API de notas** (`/api/appointments/[id]/consultation-notes`):
+   - `GET`: Obtener notas (doctor ve siempre, paciente solo si están guardadas)
+   - `POST`: Guardar notas (solo doctor)
+     - `guardar: false` → Borrador privado
+     - `guardar: true` → Compartidas con paciente
+
+3. **Componente `ConsultationNotes`**:
+   - Vista doctor: Editor con 12 líneas, botones "Guardar Borrador" y "Guardar y Compartir"
+   - Vista paciente: Muestra notas si están compartidas, mensaje de espera si no
+   - Auto-carga notas existentes al montar
+   - Feedback visual cuando están compartidas
+
+4. **Integración en `/meet`**:
+   - Layout de 2 columnas en pantallas grandes (videollamada + notas)
+   - Responsive: apila en móviles
+   - Detección automática de rol (isDoctor)
+   - **Modal de confirmación al salir**: Previene salidas accidentales
+   - **Intercepta beforeunload**: Alerta al cerrar pestaña o navegar fuera
+   - Recordatorio para guardar notas (solo doctores)
+
+5. **Visualización en detalle**:
+   - Sección verde con las notas guardadas
+   - Muestra fecha de guardado
+   - Solo visible si `notasGuardadas === true`
+   - **Citas completadas**: No muestra botón de videollamada, solo estado final
+   - **Acciones para completadas**: Mensaje que indica que las notas están arriba
+
+#### 🎯 Flujo de Notas
+1. **Durante videollamada**: Doctor escribe notas en tiempo real
+2. **Guardar borrador**: Notas privadas, solo el doctor las ve
+3. **Guardar y compartir**: Notas visibles para el paciente
+4. **Después de la consulta**: Paciente puede ver las notas en detalle de la cita
+
+### 🔧 Próximos Pasos Opcionales
+- [x] Modelo Prisma: `fechaSolicitada` ahora es `DateTime?`
+- [x] Migración aplicada: `20251008165451_make_fecha_solicitada_optional`
+- [x] API actualizada: `fechaSolicitada` es opcional en POST
+- [x] Tipos actualizados: `CreateAppointmentInput` sin fecha requerida
+
+### Frontend - Nuevo Componente
+- [x] `AppointmentModalFromChat.tsx`
+  - Modal simplificado dentro del chat
+  - Solo pide: motivo de consulta
+  - Llama a POST `/api/appointments` sin fecha
+  - Toast de confirmación
+
+### Frontend - Modificar Componentes
+- [x] `MedicalAlert.tsx`
+  - Cambiar botón Link por onClick handler
+  - Agregar prop `onAppointmentClick?: () => void`
+  - Mantener tipado estricto con `MedicalAlertType`
+
+- [x] `ChatMessage.tsx`
+  - Agregar prop `onAppointmentClick?: () => void`
+  - Pasar callback a `MedicalAlert`
+
+- [x] `ChatInterface.tsx`
+  - Estado: `showAppointmentModal: boolean`
+  - Handler: `handleOpenAppointmentModal`
+  - Renderizar `AppointmentModalFromChat`
+  - Pasar callback a `ChatMessages`
+
+- [x] `ChatMessages.tsx`
+  - Pasar callback a `ChatMessage`
+
+- [x] `AppointmentCard.tsx`
+  - Manejar `fechaSolicitada: null`
+  - Mostrar "Fecha por asignar" cuando no hay fecha
+
+- [x] `/appointments/[id]/page.tsx`
+  - Manejar `fechaSolicitada: null` en detalle
+  - Mostrar mensaje contextual cuando no hay fecha
+
+### Tipos
+- [x] `appointments.ts`
+  - `fechaSolicitada` ahora es `Date | string | null`
+  - `CreateAppointmentInput.fechaSolicitada` ahora es opcional
+
+### Opcional
+- [ ] Ocultar botón "Nueva Cita" en `/appointments/page.tsx`
+- [ ] Actualizar `APPOINTMENTS_SYSTEM.md`
+
+## 🔧 Implementación
+
+### 1. Crear Modal (`AppointmentModalFromChat.tsx`)
+```tsx
+interface Props {
+  open: boolean;
+  onClose: () => void;
+  onSuccess?: () => void;
+}
+```
+
+### 2. Props en Componentes
+```tsx
+// MedicalAlert
+interface MedicalAlertProps {
+  alert: MedicalAlertType;
+  className?: string;
+  onAppointmentClick?: () => void; // Nuevo
+}
+
+// ChatMessage
+interface ChatMessageProps {
+  message: Message;
+  onAppointmentClick?: () => void; // Nuevo
+}
+```
+
+### 3. Estado en ChatInterface
+```tsx
+const [showAppointmentModal, setShowAppointmentModal] = useState(false);
+
+const handleOpenAppointmentModal = () => {
+  setShowAppointmentModal(true);
+};
+```
+
+## 📦 Componentes Reutilizados
+- `Dialog` de shadcn/ui
+- `Textarea` para motivo
+- `Button` para acciones
+- Lógica de API call de `useAppointments`
+
+## ✅ Verificación
+- [x] Tipado TypeScript sin `any`
+- [x] Modal se abre desde alerta en chat
+- [x] Cita se crea sin fecha
+- [x] Toast de éxito aparece
+- [x] Modal se cierra correctamente
+- [x] No hay errores en consola
+- [x] Componente exportado en index.ts
+- [x] OrderBy cambiado de `fechaSolicitada` a `createdAt`
+- [x] AppointmentCard maneja fechas null correctamente
+- [x] Página de detalle maneja fechas null correctamente
+
+## 🐛 Bugs Corregidos
+- ✅ **GET /api/appointments retornaba 401**: `orderBy` con campo nullable causaba error
+  - **Solución**: Cambiar de `fechaSolicitada: "asc"` a `createdAt: "desc"`
+  - **Afectados**: Médicos y pacientes al listar citas
+
+- ✅ **Usuarios UTB (sin email) recibían 401 en todas las APIs**
+  - **Causa**: APIs buscaban usuario por `session.user.email` (vacío en usuarios UTB)
+  - **Solución**: Cambiar a `session.user.id` en todas las APIs de appointments
+  - **Archivos modificados**:
+    - `/api/appointments/route.ts` (GET, POST)
+    - `/api/appointments/[id]/route.ts` (GET, PATCH)
+    - `/api/appointments/[id]/approve/route.ts` (POST)
+    - `/api/appointments/[id]/reject/route.ts` (POST)
+    - `/api/appointments/[id]/complete/route.ts` (POST)
+
+- ✅ **Médicos recibían 403 al ver detalles de citas pendientes**
+  - **Causa**: Validación solo permitía acceso si `medicoId === user.id`, pero citas pendientes tienen `medicoId: null`
+  - **Solución**: Agregar condición para que cualquier médico pueda ver citas PENDIENTES sin médico asignado
+  - **Archivo**: `/api/appointments/[id]/route.ts` (GET)
+
+## 🚀 Resultado Final
+
+**Antes:** Chat → Link → `/appointments` → Form con fecha → Submit
+
+**Después:** Chat → Click botón → Modal inline → Solo motivo → Submit → Doctor asigna fecha
+
+## 📝 Resumen de Implementación
+
+### ✅ Completado
+1. **Nuevo componente**: `AppointmentModalFromChat.tsx`
+   - Modal simplificado con solo motivo de consulta
+   - Llama al API sin `fechaSolicitada`
+   - Toast de confirmación
+   - Tipado estricto sin `any`
+
+2. **Componentes modificados**:
+   - `MedicalAlert.tsx`: Botón con onClick en lugar de Link
+   - `ChatMessage.tsx`: Prop drilling del callback
+   - `ChatMessages.tsx`: Prop drilling del callback
+   - `ChatInterface.tsx`: Estado del modal y handlers
+   - `index.ts`: Export del nuevo componente
+
+3. **Backend**:
+   - **Schema**: `fechaSolicitada DateTime?` (nullable)
+   - **Migración**: `20251008165451_make_fecha_solicitada_optional`
+   - **API**: Validación solo requiere `motivoConsulta`
+   - **Sin `any`**: Uso de spread operator condicional
+
+4. **Tipos actualizados**:
+   - `Appointment.fechaSolicitada`: `Date | string | null`
+   - `CreateAppointmentInput.fechaSolicitada`: `Date` (opcional)
+
+### 🎯 Funcionamiento
+- Cuando el chatbot detecta `RECOMENDADO` o `URGENTE`
+- El usuario hace clic en "Agendar Cita" dentro del chat
+- Se abre modal que solo pide motivo de consulta
+- La cita se crea como `PENDIENTE` sin fecha específica
+- El doctor revisa y asigna fecha al aprobar
+
+## 🆕 Flujo de Aprobación con Fecha
+
+### ✅ Implementado
+1. **Nuevo componente**: `ApproveAppointmentModal.tsx`
+   - Modal con DateTimePicker para seleccionar fecha/hora
+   - Campo opcional de notas para el doctor
+   - Validación de fecha futura
+   - Integración completa con el API
+
+2. **DateTimePicker mejorado**:
+   - Convertido a componente controlado con props `date` y `setDate`
+   - Lógica corregida para formato 12 horas con AM/PM
+   - Mejor highlighting de hora seleccionada
+   - Preserva hora al cambiar fecha
+
+3. **API actualizado** (`/api/appointments/[id]/approve`):
+   - Ahora **requiere** `fechaSolicitada` en el body
+   - Valida que la fecha sea futura
+   - Actualiza cita con fecha, estado APROBADA y medicoId
+
+4. **Página de detalle modificada** (`/appointments/[id]/page.tsx`):
+   - Botón "Aprobar Cita" abre modal con selector de fecha
+   - Handler `handleApprove` recibe fecha y notas
+   - Envía datos completos al API
+   - Actualiza UI tras aprobación exitosa
+
+### 🎯 Flujo Completo
+1. **Paciente**: Chatbot recomienda cita → Modal inline → Crea cita sin fecha
+2. **Doctor**: Ve cita pendiente → Click "Aprobar" → Selecciona fecha/hora en modal → Confirma
+3. **Sistema**: Actualiza cita con fecha y estado APROBADA → Notifica paciente
+
+### ✅ Correcciones Finales
+- **Hook `useAppointments`**: Actualizado para requerir `fechaSolicitada` en `approveAppointment`
+- **Página de listado doctor**: Ahora usa `ApproveAppointmentModal` en lugar de aprobar directamente
+- **DateTimePicker**: Aumentado z-index a `9999` para que aparezca sobre el Dialog
+- **Modal de aprobación**: Ancho aumentado a `700px`, `modal={false}` para permitir interacción con Popover, overlay manual para mantener opacidad
+
+### 🎥 Sistema de Videollamadas con Horario
+
+#### ✅ Implementado
+1. **API de aprobación modificada** (`/api/appointments/[id]/approve`):
+   - Ya NO genera `roomName` al aprobar
+   - Solo asigna fecha, estado APROBADA y medicoId
+
+2. **Nuevo endpoint** (`/api/appointments/[id]/start-meeting`):
+   - Valida que sea el momento correcto (15 min antes hasta 1 hora después)
+   - Genera `roomName` solo cuando se inicia la videollamada
+   - Retorna error con mensaje si es muy temprano o muy tarde
+   - Si ya existe `roomName`, lo reutiliza
+
+3. **Utilidades de tiempo** (`/utils/appointments.ts`):
+   - `canJoinMeeting()`: Valida si es tiempo de unirse
+   - `getAppointmentTimeStatus()`: Retorna mensaje de estado ("En 30 minutos", "Puedes unirte ahora", etc.)
+
+4. **UI en página de detalle**:
+   - Botón "Unirse a Videollamada" solo aparece cuando es tiempo
+   - Botón deshabilitado muestra tiempo restante si es muy temprano
+   - Mensaje de error si el tiempo límite expiró (1 hora después)
+   - Funciona para médicos y pacientes
+
+#### 🕐 Reglas de Tiempo
+- **15 minutos antes**: Usuarios pueden unirse
+- **Durante la cita**: Usuarios pueden unirse
+- **Hasta 1 hora después**: Usuarios pueden unirse
+- **Después de 1 hora**: La videollamada ya no está disponible
+
+#### 🎯 Flujo Actualizado
+1. **Paciente**: Chatbot recomienda → Modal → Crea cita SIN fecha
+2. **Doctor**: Ve cita pendiente → Aprobar → Selecciona fecha/hora → Confirma (SIN roomName)
+3. **Ambos**: Esperan hasta el horario asignado
+4. **15 min antes o durante**: Aparece botón "Unirse a Videollamada"
+5. **Al hacer click**: Se genera `roomName` y se redirige a sala Jitsi
+6. **Después de 1 hora**: Ya no se puede unir
+
+### 🔧 Próximos Pasos Opcionales
+- [ ] Ocultar botón "Nueva Cita" en `/appointments/page.tsx`
+- [ ] Actualizar `APPOINTMENTS_SYSTEM.md` con nuevo flujo

+ 62 - 0
package-lock.json

@@ -18,6 +18,7 @@
         "@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-scroll-area": "^1.2.10",
         "@radix-ui/react-select": "^2.2.5",
         "@radix-ui/react-separator": "^1.1.7",
         "@radix-ui/react-slot": "^1.2.3",
@@ -3189,6 +3190,67 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-scroll-area": {
+      "version": "1.2.10",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
+      "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/number": "1.1.1",
+        "@radix-ui/primitive": "1.1.3",
+        "@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-presence": "1.1.5",
+        "@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"
+      },
+      "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-scroll-area/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-scroll-area/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-select": {
       "version": "2.2.5",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",

+ 1 - 0
package.json

@@ -33,6 +33,7 @@
     "@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-scroll-area": "^1.2.10",
     "@radix-ui/react-select": "^2.2.5",
     "@radix-ui/react-separator": "^1.1.7",
     "@radix-ui/react-slot": "^1.2.3",

+ 2 - 0
prisma/migrations/20251008165451_make_fecha_solicitada_optional/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Appointment" ALTER COLUMN "fechaSolicitada" DROP NOT NULL;

+ 4 - 0
prisma/migrations/20251008181125_add_consultation_notes/migration.sql

@@ -0,0 +1,4 @@
+-- AlterTable
+ALTER TABLE "Appointment" ADD COLUMN     "notasConsulta" TEXT,
+ADD COLUMN     "notasGuardadas" BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN     "notasGuardadasAt" TIMESTAMP(3);

+ 6 - 1
prisma/schema.prisma

@@ -75,12 +75,17 @@ model Appointment {
   record          Record?   @relation(fields: [recordId], references: [id], onDelete: SetNull)
   
   // Info de la cita
-  fechaSolicitada DateTime
+  fechaSolicitada DateTime?
   estado          AppointmentStatus @default(PENDIENTE)
   motivoConsulta  String
   motivoRechazo   String?
   notas           String?
   
+  // Notas de consulta (durante videollamada)
+  notasConsulta   String?
+  notasGuardadas  Boolean   @default(false)
+  notasGuardadasAt DateTime?
+  
   // Jitsi
   roomName        String?   @unique
   

+ 22 - 6
src/app/api/appointments/[id]/approve/route.ts

@@ -12,12 +12,12 @@ export async function POST(
     const { id } = await params;
     const session = await getServerSession(authOptions);
     
-    if (!session?.user?.email) {
+    if (!session?.user?.id) {
       return NextResponse.json({ error: "No autorizado" }, { status: 401 });
     }
 
     const user = await prisma.user.findUnique({
-      where: { email: session.user.email },
+      where: { id: session.user.id },
     });
 
     if (!user || user.role !== "DOCTOR") {
@@ -43,18 +43,34 @@ export async function POST(
     }
 
     const body = await request.json();
-    const { notas } = body;
+    const { notas, fechaSolicitada } = body;
 
-    // Generar roomName único para Jitsi
-    const roomName = `appointment-${id}-${Date.now()}`;
+    // Validar que se proporcione fecha
+    if (!fechaSolicitada) {
+      return NextResponse.json(
+        { error: "Debe proporcionar una fecha y hora para la cita" },
+        { status: 400 }
+      );
+    }
+
+    // Validar que la fecha sea futura
+    const fecha = new Date(fechaSolicitada);
+    if (fecha < new Date()) {
+      return NextResponse.json(
+        { error: "La fecha debe ser futura" },
+        { status: 400 }
+      );
+    }
 
+    // NO generar roomName aquí - se creará cuando inicie la videollamada
     const updated = await prisma.appointment.update({
       where: { id },
       data: {
         estado: "APROBADA",
         medicoId: user.id,
-        roomName,
+        fechaSolicitada: fecha,
         notas: notas || null,
+        // roomName se creará después cuando sea el momento de la cita
       },
       include: {
         paciente: {

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

@@ -12,12 +12,12 @@ export async function POST(
     const { id } = await params;
     const session = await getServerSession(authOptions);
     
-    if (!session?.user?.email) {
+    if (!session?.user?.id) {
       return NextResponse.json({ error: "No autorizado" }, { status: 401 });
     }
 
     const user = await prisma.user.findUnique({
-      where: { email: session.user.email },
+      where: { id: session.user.id },
     });
 
     if (!user) {

+ 135 - 0
src/app/api/appointments/[id]/consultation-notes/route.ts

@@ -0,0 +1,135 @@
+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]/consultation-notes - Obtener notas de consulta
+export async function GET(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const { id } = await params;
+    const session = await getServerSession(authOptions);
+    
+    if (!session?.user?.id) {
+      return NextResponse.json({ error: "No autorizado" }, { status: 401 });
+    }
+
+    const user = await prisma.user.findUnique({
+      where: { id: session.user.id },
+    });
+
+    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 });
+    }
+
+    // Validar que el usuario sea parte de la cita
+    const isPatient = appointment.pacienteId === user.id;
+    const isDoctor = appointment.medicoId === user.id;
+
+    if (!isPatient && !isDoctor) {
+      return NextResponse.json(
+        { error: "No tienes acceso a esta cita" },
+        { status: 403 }
+      );
+    }
+
+    // El paciente solo puede ver las notas si están guardadas
+    if (isPatient && !appointment.notasGuardadas) {
+      return NextResponse.json({
+        notasConsulta: null,
+        notasGuardadas: false,
+        notasGuardadasAt: null,
+      });
+    }
+
+    return NextResponse.json({
+      notasConsulta: appointment.notasConsulta,
+      notasGuardadas: appointment.notasGuardadas,
+      notasGuardadasAt: appointment.notasGuardadasAt,
+    });
+  } catch (error) {
+    console.error("Error al obtener notas de consulta:", error);
+    return NextResponse.json({ error: "Error al obtener notas" }, { status: 500 });
+  }
+}
+
+// POST /api/appointments/[id]/consultation-notes - Guardar notas de consulta (solo doctor)
+export async function POST(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const { id } = await params;
+    const session = await getServerSession(authOptions);
+    
+    if (!session?.user?.id) {
+      return NextResponse.json({ error: "No autorizado" }, { status: 401 });
+    }
+
+    const user = await prisma.user.findUnique({
+      where: { id: session.user.id },
+    });
+
+    if (!user || user.role !== "DOCTOR") {
+      return NextResponse.json(
+        { error: "Solo los médicos pueden guardar notas de consulta" },
+        { status: 403 }
+      );
+    }
+
+    const appointment = await prisma.appointment.findUnique({
+      where: { id },
+    });
+
+    if (!appointment) {
+      return NextResponse.json({ error: "Cita no encontrada" }, { status: 404 });
+    }
+
+    // Validar que el doctor sea el asignado a esta cita
+    if (appointment.medicoId !== user.id) {
+      return NextResponse.json(
+        { error: "Solo el médico asignado puede guardar notas" },
+        { status: 403 }
+      );
+    }
+
+    const body = await request.json();
+    const { notasConsulta, guardar } = body;
+
+    if (typeof notasConsulta !== "string") {
+      return NextResponse.json(
+        { error: "Las notas deben ser texto" },
+        { status: 400 }
+      );
+    }
+
+    // Actualizar las notas
+    const updated = await prisma.appointment.update({
+      where: { id },
+      data: {
+        notasConsulta,
+        notasGuardadas: guardar === true,
+        notasGuardadasAt: guardar === true ? new Date() : null,
+      },
+    });
+
+    return NextResponse.json({
+      notasConsulta: updated.notasConsulta,
+      notasGuardadas: updated.notasGuardadas,
+      notasGuardadasAt: updated.notasGuardadasAt,
+    });
+  } catch (error) {
+    console.error("Error al guardar notas de consulta:", error);
+    return NextResponse.json({ error: "Error al guardar notas" }, { status: 500 });
+  }
+}

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

@@ -12,12 +12,12 @@ export async function POST(
     const { id } = await params;
     const session = await getServerSession(authOptions);
     
-    if (!session?.user?.email) {
+    if (!session?.user?.id) {
       return NextResponse.json({ error: "No autorizado" }, { status: 401 });
     }
 
     const user = await prisma.user.findUnique({
-      where: { email: session.user.email },
+      where: { id: session.user.id },
     });
 
     if (!user || user.role !== "DOCTOR") {

+ 8 - 7
src/app/api/appointments/[id]/route.ts

@@ -12,12 +12,12 @@ export async function GET(
     const { id } = await params;
     const session = await getServerSession(authOptions);
     
-    if (!session?.user?.email) {
+    if (!session?.user?.id) {
       return NextResponse.json({ error: "No autorizado" }, { status: 401 });
     }
 
     const user = await prisma.user.findUnique({
-      where: { email: session.user.email },
+      where: { id: session.user.id },
     });
 
     if (!user) {
@@ -55,9 +55,10 @@ export async function GET(
 
     // Validar acceso
     const canAccess =
-      appointment.pacienteId === user.id ||
-      appointment.medicoId === user.id ||
-      user.role === "ADMIN";
+      appointment.pacienteId === user.id || // El paciente puede ver su cita
+      appointment.medicoId === user.id || // El médico asignado puede ver la cita
+      (user.role === "DOCTOR" && appointment.medicoId === null && appointment.estado === "PENDIENTE") || // Cualquier médico puede ver citas pendientes sin asignar
+      user.role === "ADMIN"; // Admin puede ver todas
 
     if (!canAccess) {
       return NextResponse.json({ error: "No autorizado" }, { status: 403 });
@@ -79,12 +80,12 @@ export async function PATCH(
     const { id } = await params;
     const session = await getServerSession(authOptions);
     
-    if (!session?.user?.email) {
+    if (!session?.user?.id) {
       return NextResponse.json({ error: "No autorizado" }, { status: 401 });
     }
 
     const user = await prisma.user.findUnique({
-      where: { email: session.user.email },
+      where: { id: session.user.id },
     });
 
     if (!user) {

+ 137 - 0
src/app/api/appointments/[id]/start-meeting/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";
+
+// POST /api/appointments/[id]/start-meeting - Iniciar videollamada
+export async function POST(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const { id } = await params;
+    const session = await getServerSession(authOptions);
+    
+    if (!session?.user?.id) {
+      return NextResponse.json({ error: "No autorizado" }, { status: 401 });
+    }
+
+    const user = await prisma.user.findUnique({
+      where: { id: session.user.id },
+    });
+
+    if (!user) {
+      return NextResponse.json({ error: "Usuario no encontrado" }, { status: 404 });
+    }
+
+    const appointment = await prisma.appointment.findUnique({
+      where: { id },
+      include: {
+        paciente: true,
+        medico: true,
+      },
+    });
+
+    if (!appointment) {
+      return NextResponse.json({ error: "Cita no encontrada" }, { status: 404 });
+    }
+
+    // Validar que el usuario sea parte de la cita
+    const isPatient = appointment.pacienteId === user.id;
+    const isDoctor = appointment.medicoId === user.id;
+
+    if (!isPatient && !isDoctor) {
+      return NextResponse.json(
+        { error: "No tienes acceso a esta cita" },
+        { status: 403 }
+      );
+    }
+
+    // Validar que la cita esté aprobada
+    if (appointment.estado !== "APROBADA") {
+      return NextResponse.json(
+        { error: "Solo se pueden iniciar citas aprobadas" },
+        { status: 400 }
+      );
+    }
+
+    // Validar que tenga fecha asignada
+    if (!appointment.fechaSolicitada) {
+      return NextResponse.json(
+        { error: "La cita no tiene fecha asignada" },
+        { status: 400 }
+      );
+    }
+
+    // Validar el tiempo: permitir unirse 15 minutos antes hasta 1 hora después
+    const now = new Date();
+    const appointmentTime = new Date(appointment.fechaSolicitada);
+    const fifteenMinutesBefore = new Date(appointmentTime.getTime() - 15 * 60 * 1000);
+    const oneHourAfter = new Date(appointmentTime.getTime() + 60 * 60 * 1000);
+
+    if (now < fifteenMinutesBefore) {
+      const minutesUntil = Math.floor((appointmentTime.getTime() - now.getTime()) / (60 * 1000));
+      return NextResponse.json(
+        { 
+          error: "Aún no es tiempo de la cita",
+          message: `La cita será en ${minutesUntil} minutos. Podrás unirte 15 minutos antes.`,
+          minutesUntil,
+        },
+        { status: 400 }
+      );
+    }
+
+    if (now > oneHourAfter) {
+      return NextResponse.json(
+        { error: "La cita ya finalizó. El tiempo límite para unirse ha expirado." },
+        { status: 400 }
+      );
+    }
+
+    // Si ya existe roomName, devolverlo
+    if (appointment.roomName) {
+      return NextResponse.json({ 
+        roomName: appointment.roomName,
+        appointment: appointment,
+      });
+    }
+
+    // Generar roomName único para Jitsi
+    const roomName = `appointment-${id}-${Date.now()}`;
+
+    const updated = await prisma.appointment.update({
+      where: { id },
+      data: {
+        roomName,
+      },
+      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({ 
+      roomName: updated.roomName,
+      appointment: updated,
+    });
+  } catch (error) {
+    console.error("Error al iniciar videollamada:", error);
+    return NextResponse.json({ error: "Error al iniciar videollamada" }, { status: 500 });
+  }
+}

+ 20 - 17
src/app/api/appointments/route.ts

@@ -8,7 +8,7 @@ export async function GET(request: NextRequest) {
   try {
     const session = await getServerSession(authOptions);
     
-    if (!session?.user?.email) {
+    if (!session?.user?.id) {
       return NextResponse.json(
         { error: "No autorizado" },
         { status: 401 }
@@ -16,7 +16,7 @@ export async function GET(request: NextRequest) {
     }
 
     const user = await prisma.user.findUnique({
-      where: { email: session.user.email },
+      where: { id: session.user.id },
     });
 
     if (!user) {
@@ -70,7 +70,7 @@ export async function GET(request: NextRequest) {
         },
         orderBy: [
           { estado: "asc" },
-          { fechaSolicitada: "asc" },
+          { createdAt: "desc" },
         ],
       });
     } else {
@@ -101,7 +101,7 @@ export async function GET(request: NextRequest) {
         },
         orderBy: [
           { estado: "asc" },
-          { fechaSolicitada: "asc" },
+          { createdAt: "desc" },
         ],
       });
     }
@@ -121,7 +121,7 @@ export async function POST(request: NextRequest) {
   try {
     const session = await getServerSession(authOptions);
     
-    if (!session?.user?.email) {
+    if (!session?.user?.id) {
       return NextResponse.json(
         { error: "No autorizado" },
         { status: 401 }
@@ -129,7 +129,7 @@ export async function POST(request: NextRequest) {
     }
 
     const user = await prisma.user.findUnique({
-      where: { email: session.user.email },
+      where: { id: session.user.id },
     });
 
     if (!user) {
@@ -150,29 +150,32 @@ export async function POST(request: NextRequest) {
     const body = await request.json();
     const { fechaSolicitada, motivoConsulta, recordId } = body;
 
-    if (!fechaSolicitada || !motivoConsulta) {
+    // Solo motivoConsulta es requerido ahora
+    if (!motivoConsulta) {
       return NextResponse.json(
-        { error: "Faltan campos requeridos" },
+        { error: "El motivo de consulta es requerido" },
         { 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 }
-      );
+    // Validar fecha si se proporciona
+    if (fechaSolicitada) {
+      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",
+        ...(recordId && { recordId }),
+        ...(fechaSolicitada && { fechaSolicitada: new Date(fechaSolicitada) }),
       },
       include: {
         paciente: {

+ 138 - 29
src/app/appointments/[id]/meet/page.tsx

@@ -1,11 +1,20 @@
 "use client";
 
-import { useEffect, useRef, useCallback } from "react";
+import { useEffect, useRef, useCallback, useState } from "react";
 import { useSession } from "next-auth/react";
-import { useParams, redirect } from "next/navigation";
+import { useParams, redirect, useRouter } from "next/navigation";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import { Button } from "@/components/ui/button";
-import { Loader2, Video } from "lucide-react";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Loader2, Video, AlertTriangle } from "lucide-react";
+import { ConsultationNotes } from "@/components/appointments/ConsultationNotes";
 
 interface JitsiMeetExternalAPI {
   dispose: () => void;
@@ -19,13 +28,19 @@ declare global {
 }
 
 export default function MeetPage() {
+  const router = useRouter();
   const { data: session, status } = useSession();
   const params = useParams();
   const jitsiContainer = useRef<HTMLDivElement>(null);
   const jitsiApi = useRef<JitsiMeetExternalAPI | null>(null);
+  const isInitialized = useRef(false);
+  const isLeavingIntentionally = useRef(false);
+  const [showExitDialog, setShowExitDialog] = useState(false);
 
   const initJitsi = useCallback(() => {
-    if (!jitsiContainer.current || !session) return;
+    if (!jitsiContainer.current || !session || isInitialized.current) return;
+
+    isInitialized.current = true;
 
     const domain = "meet.jit.si";
     const options = {
@@ -64,18 +79,74 @@ export default function MeetPage() {
 
     jitsiApi.current = new window.JitsiMeetExternalAPI(domain, options);
 
-    // Event listeners
+    // Event listeners - Solo redirigir si el usuario salió desde Jitsi directamente
     jitsiApi.current.addEventListener("videoConferenceLeft", () => {
-      window.location.href = "/appointments";
+      // Dar un pequeño delay para que el beforeunload se procese
+      setTimeout(() => {
+        if (isLeavingIntentionally.current) {
+          router.push("/appointments");
+        }
+      }, 100);
     });
 
     jitsiApi.current.addEventListener("readyToClose", () => {
-      window.location.href = "/appointments";
+      setTimeout(() => {
+        if (isLeavingIntentionally.current) {
+          router.push("/appointments");
+        }
+      }, 100);
     });
-  }, [session, params.id]);
+  }, [session, params.id, router]);
+
+  const handleExitClick = () => {
+    setShowExitDialog(true);
+  };
+
+  const handleConfirmExit = () => {
+    isLeavingIntentionally.current = true;
+    if (jitsiApi.current) {
+      jitsiApi.current.dispose();
+      jitsiApi.current = null;
+    }
+    isInitialized.current = false;
+    router.push("/appointments");
+  };
 
+  const handleCancelExit = () => {
+    setShowExitDialog(false);
+  };
+
+  // Interceptar cierre de pestaña o navegación
   useEffect(() => {
-    if (status === "loading" || !session || !jitsiContainer.current) return;
+    const handleBeforeUnload = (e: BeforeUnloadEvent) => {
+      // Solo mostrar advertencia si NO es una salida intencional
+      if (!isLeavingIntentionally.current) {
+        e.preventDefault();
+        e.returnValue = "¿Estás seguro de que quieres salir de la videollamada?";
+        return e.returnValue;
+      }
+    };
+
+    window.addEventListener("beforeunload", handleBeforeUnload);
+
+    return () => {
+      window.removeEventListener("beforeunload", handleBeforeUnload);
+    };
+  }, []);
+
+  useEffect(() => {
+    if (status === "loading" || !session || !jitsiContainer.current || isInitialized.current) return;
+
+    // Verificar si el script ya está cargado
+    const existingScript = document.querySelector('script[src="https://meet.jit.si/external_api.js"]');
+    
+    if (existingScript) {
+      // Si el script ya existe y window.JitsiMeetExternalAPI está disponible, inicializar directamente
+      if (window.JitsiMeetExternalAPI) {
+        initJitsi();
+      }
+      return;
+    }
 
     // Cargar Jitsi script
     const script = document.createElement("script");
@@ -87,10 +158,10 @@ export default function MeetPage() {
     return () => {
       if (jitsiApi.current) {
         jitsiApi.current.dispose();
+        jitsiApi.current = null;
       }
-      if (document.body.contains(script)) {
-        document.body.removeChild(script);
-      }
+      isInitialized.current = false;
+      // No eliminar el script aquí para evitar conflictos
     };
   }, [status, session, initJitsi]);
 
@@ -106,24 +177,62 @@ export default function MeetPage() {
     redirect("/auth/login");
   }
 
+  const isDoctor = session.user.role === "DOCTOR";
+  const appointmentId = params.id as string;
+
   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 className="container mx-auto px-4 py-8 max-w-7xl">
+        <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+          {/* Videollamada - 2 columnas en pantallas grandes */}
+          <div className="lg:col-span-2">
+            <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={handleExitClick}>
+                    Salir
+                  </Button>
+              </div>
+            </CardHeader>
+            <CardContent>
+              <div ref={jitsiContainer} className="w-full rounded-lg overflow-hidden bg-muted" />
+            </CardContent>
+          </Card>
+        </div>
+
+        {/* Notas de consulta - 1 columna en pantallas grandes */}
+        <div className="lg:col-span-1">
+          <ConsultationNotes appointmentId={appointmentId} isDoctor={isDoctor} />
+        </div>
+      </div>
     </div>
+
+    {/* Modal de confirmación de salida */}
+    <Dialog open={showExitDialog} onOpenChange={setShowExitDialog}>
+      <DialogContent>
+        <DialogHeader>
+          <div className="flex items-center gap-2">
+            <AlertTriangle className="h-5 w-5 text-destructive" />
+            <DialogTitle>¿Salir de la videollamada?</DialogTitle>
+          </div>
+          <DialogDescription>
+            Si sales ahora, la videollamada se cerrará. {isDoctor && "Asegúrate de haber guardado las notas de consulta si las tienes."}
+          </DialogDescription>
+        </DialogHeader>
+        <DialogFooter>
+          <Button variant="outline" onClick={handleCancelExit}>
+            Cancelar
+          </Button>
+          <Button variant="destructive" onClick={handleConfirmExit}>
+            Salir de la llamada
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  </>
   );
 }

+ 207 - 33
src/app/appointments/[id]/page.tsx

@@ -20,6 +20,7 @@ import {
 } from "@/components/ui/dialog";
 import { Textarea } from "@/components/ui/textarea";
 import { Label } from "@/components/ui/label";
+import { ApproveAppointmentModal } from "@/components/appointments/ApproveAppointmentModal";
 import {
   Calendar,
   Clock,
@@ -36,6 +37,7 @@ import { format } from "date-fns";
 import { es } from "date-fns/locale";
 import { toast } from "sonner";
 import type { Appointment } from "@/types/appointments";
+import { canJoinMeeting, getAppointmentTimeStatus } from "@/utils/appointments";
 
 interface PageProps {
   params: Promise<{ id: string }>;
@@ -46,6 +48,7 @@ export default function AppointmentDetailPage({ params }: PageProps) {
   const { data: session, status } = useSession();
   const [appointment, setAppointment] = useState<Appointment | null>(null);
   const [loading, setLoading] = useState(true);
+  const [approveDialog, setApproveDialog] = useState(false);
   const [rejectDialog, setRejectDialog] = useState(false);
   const [motivoRechazo, setMotivoRechazo] = useState("");
   const [actionLoading, setActionLoading] = useState(false);
@@ -120,22 +123,34 @@ export default function AppointmentDetailPage({ params }: PageProps) {
   const isPatient = userRole === "PATIENT";
   const isDoctor = userRole === "DOCTOR";
   const otherUser = isPatient ? appointment.medico : appointment.paciente;
-  const fecha = new Date(appointment.fechaSolicitada);
+  const hasFecha = appointment.fechaSolicitada !== null;
+  const fecha = hasFecha ? new Date(appointment.fechaSolicitada!) : null;
 
-  const handleApprove = async () => {
+  const handleApprove = async (fechaSolicitada: Date, notas?: string) => {
     setActionLoading(true);
     try {
       const response = await fetch(`/api/appointments/${appointment.id}/approve`, {
         method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({
+          fechaSolicitada: fechaSolicitada.toISOString(),
+          notas,
+        }),
       });
 
-      if (!response.ok) throw new Error("Error al aprobar la cita");
+      if (!response.ok) {
+        const error = await response.json();
+        throw new Error(error.error || "Error al aprobar la cita");
+      }
 
       const updated: Appointment = await response.json();
       setAppointment(updated);
+      setApproveDialog(false);
       toast.success("Cita aprobada exitosamente");
     } catch (error) {
-      toast.error("Error al aprobar la cita");
+      toast.error(error instanceof Error ? error.message : "Error al aprobar la cita");
       console.error(error);
     } finally {
       setActionLoading(false);
@@ -186,6 +201,30 @@ export default function AppointmentDetailPage({ params }: PageProps) {
     }
   };
 
+  const handleStartMeeting = async () => {
+    setActionLoading(true);
+    try {
+      const response = await fetch(`/api/appointments/${appointment.id}/start-meeting`, {
+        method: "POST",
+      });
+
+      if (!response.ok) {
+        const error = await response.json();
+        throw new Error(error.message || error.error || "No se puede iniciar la videollamada");
+      }
+
+      const data = await response.json();
+      
+      // Redirigir a la sala de Jitsi
+      router.push(`/appointments/${appointment.id}/meet`);
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Error al iniciar videollamada");
+      console.error(error);
+    } finally {
+      setActionLoading(false);
+    }
+  };
+
   const handleComplete = async () => {
     setActionLoading(true);
     try {
@@ -256,26 +295,42 @@ export default function AppointmentDetailPage({ params }: PageProps) {
             <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>
+            {hasFecha && fecha ? (
+              <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>
-              <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 className="bg-muted/50 p-4 rounded-lg">
+                <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">Fecha y hora</p>
+                    <p className="text-sm text-muted-foreground italic">
+                      {appointment.estado === "PENDIENTE"
+                        ? "Pendiente de asignación por el médico"
+                        : "No asignada"}
+                    </p>
+                  </div>
                 </div>
               </div>
-            </div>
+            )}
 
             <Separator />
 
@@ -308,7 +363,8 @@ export default function AppointmentDetailPage({ params }: PageProps) {
               </>
             )}
 
-            {appointment.roomName && (
+            {/* Solo mostrar sala si NO está completada */}
+            {appointment.roomName && appointment.estado !== "COMPLETADA" && (
               <>
                 <Separator />
                 <div className="bg-primary/10 p-4 rounded-lg">
@@ -330,6 +386,30 @@ export default function AppointmentDetailPage({ params }: PageProps) {
                 </div>
               </>
             )}
+
+            {appointment.notasGuardadas && appointment.notasConsulta && (
+              <>
+                <Separator />
+                <div className="bg-green-50 dark:bg-green-950 p-4 rounded-lg">
+                  <div className="flex items-start gap-3">
+                    <FileText className="h-5 w-5 text-green-700 dark:text-green-400 mt-0.5" />
+                    <div className="flex-1">
+                      <p className="text-sm font-medium text-green-900 dark:text-green-100 mb-1">
+                        Notas de la Consulta
+                      </p>
+                      {appointment.notasGuardadasAt && (
+                        <p className="text-xs text-green-700 dark:text-green-300 mb-2">
+                          Guardadas el {format(new Date(appointment.notasGuardadasAt), "d 'de' MMMM 'a las' HH:mm", { locale: es })}
+                        </p>
+                      )}
+                      <div className="text-sm text-green-900 dark:text-green-100 whitespace-pre-wrap bg-white/50 dark:bg-black/20 p-3 rounded">
+                        {appointment.notasConsulta}
+                      </div>
+                    </div>
+                  </div>
+                </div>
+              </>
+            )}
           </CardContent>
         </Card>
 
@@ -343,7 +423,7 @@ export default function AppointmentDetailPage({ params }: PageProps) {
               {isDoctor && appointment.estado === "PENDIENTE" && (
                 <>
                   <Button
-                    onClick={handleApprove}
+                    onClick={() => setApproveDialog(true)}
                     disabled={actionLoading}
                     className="flex-1 min-w-[150px]"
                   >
@@ -367,18 +447,44 @@ export default function AppointmentDetailPage({ params }: PageProps) {
               )}
 
               {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" />
+                <>
+                  {canJoinMeeting(appointment.fechaSolicitada).canJoin ? (
+                    <Button
+                      onClick={handleStartMeeting}
+                      disabled={actionLoading}
+                      className="flex-1 min-w-[150px]"
+                    >
+                      {actionLoading ? (
+                        <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+                      ) : (
+                        <Video className="h-4 w-4 mr-2" />
+                      )}
+                      Unirse a Videollamada
+                    </Button>
                   ) : (
-                    <CheckCircle2 className="h-4 w-4 mr-2" />
+                    <Button
+                      disabled
+                      variant="outline"
+                      className="flex-1 min-w-[150px]"
+                    >
+                      <Clock className="h-4 w-4 mr-2" />
+                      {getAppointmentTimeStatus(appointment.fechaSolicitada)}
+                    </Button>
                   )}
-                  Marcar como Completada
-                </Button>
+                  <Button
+                    onClick={handleComplete}
+                    disabled={actionLoading}
+                    variant="outline"
+                    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" && (
@@ -397,7 +503,67 @@ export default function AppointmentDetailPage({ params }: PageProps) {
                 </Button>
               )}
 
-              {appointment.estado === "APROBADA" && (
+              {isPatient && appointment.estado === "APROBADA" && (
+                <>
+                  {canJoinMeeting(appointment.fechaSolicitada).canJoin ? (
+                    <Button
+                      onClick={handleStartMeeting}
+                      disabled={actionLoading}
+                      className="flex-1 min-w-[150px]"
+                    >
+                      {actionLoading ? (
+                        <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+                      ) : (
+                        <Video className="h-4 w-4 mr-2" />
+                      )}
+                      Unirse a Videollamada
+                    </Button>
+                  ) : (
+                    <Button
+                      disabled
+                      variant="outline"
+                      className="flex-1 min-w-[150px]"
+                    >
+                      <Clock className="h-4 w-4 mr-2" />
+                      {getAppointmentTimeStatus(appointment.fechaSolicitada)}
+                    </Button>
+                  )}
+                </>
+              )}
+
+              {/* Acciones para citas completadas */}
+              {appointment.estado === "COMPLETADA" && (
+                <>
+                  {appointment.notasGuardadas && appointment.notasConsulta ? (
+                    <div className="flex-1 min-w-[150px] bg-green-50 dark:bg-green-950 p-4 rounded-lg">
+                      <div className="flex items-center gap-2 mb-2">
+                        <CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
+                        <p className="text-sm font-medium text-green-900 dark:text-green-100">
+                          Consulta Finalizada
+                        </p>
+                      </div>
+                      <p className="text-xs text-green-700 dark:text-green-300 mb-3">
+                        Las notas de la consulta están disponibles arriba
+                      </p>
+                    </div>
+                  ) : (
+                    <div className="flex-1 min-w-[150px] bg-muted p-4 rounded-lg">
+                      <div className="flex items-center gap-2 mb-2">
+                        <CheckCircle2 className="h-5 w-5 text-muted-foreground" />
+                        <p className="text-sm font-medium">
+                          Consulta Finalizada
+                        </p>
+                      </div>
+                      <p className="text-xs text-muted-foreground">
+                        Esta cita ha sido completada
+                      </p>
+                    </div>
+                  )}
+                </>
+              )}
+
+              {/* Botón genérico de unirse (solo para APROBADA, no COMPLETADA) */}
+              {appointment.estado === "APROBADA" && canJoinMeeting(appointment.fechaSolicitada).canJoin && (
                 <Button asChild className="flex-1 min-w-[150px]">
                   <Link href={`/appointments/${appointment.id}/meet`}>
                     <Video className="h-4 w-4 mr-2" />
@@ -454,6 +620,14 @@ export default function AppointmentDetailPage({ params }: PageProps) {
             </DialogFooter>
           </DialogContent>
         </Dialog>
+
+        {/* Approve Dialog */}
+        <ApproveAppointmentModal
+          open={approveDialog}
+          onClose={() => setApproveDialog(false)}
+          onConfirm={handleApprove}
+          isLoading={actionLoading}
+        />
       </div>
     </AuthenticatedLayout>
   );

+ 31 - 3
src/app/appointments/doctor/page.tsx

@@ -21,14 +21,20 @@ import { Textarea } from "@/components/ui/textarea";
 import { Label } from "@/components/ui/label";
 import { Button } from "@/components/ui/button";
 import { Loader2 } from "lucide-react";
+import { ApproveAppointmentModal } from "@/components/appointments/ApproveAppointmentModal";
 
 export default function DoctorAppointmentsPage() {
   const { data: session, status } = useSession();
+  const [approveDialog, setApproveDialog] = useState<{ open: boolean; id: string | null }>({
+    open: false,
+    id: null,
+  });
   const [rejectDialog, setRejectDialog] = useState<{ open: boolean; id: string | null }>({
     open: false,
     id: null,
   });
   const [motivoRechazo, setMotivoRechazo] = useState("");
+  const [actionLoading, setActionLoading] = useState(false);
   const [currentFilter, setCurrentFilter] = useState<AppointmentFilter>("pending");
 
   const {
@@ -86,8 +92,22 @@ export default function DoctorAppointmentsPage() {
     redirect("/appointments");
   }
 
-  const handleApprove = async (id: string) => {
-    await approveAppointment(id);
+  const handleApproveClick = (id: string) => {
+    setApproveDialog({ open: true, id });
+  };
+
+  const handleApprove = async (fechaSolicitada: Date, notas?: string) => {
+    if (!approveDialog.id) return;
+    
+    setActionLoading(true);
+    try {
+      await approveAppointment(approveDialog.id, fechaSolicitada, notas);
+      setApproveDialog({ open: false, id: null });
+    } catch (error) {
+      console.error(error);
+    } finally {
+      setActionLoading(false);
+    }
   };
 
   const handleRejectClick = (id: string) => {
@@ -146,7 +166,7 @@ export default function DoctorAppointmentsPage() {
           <AppointmentsGrid
             appointments={filteredAppointments}
             userRole="DOCTOR"
-            onApprove={handleApprove}
+            onApprove={handleApproveClick}
             onReject={handleRejectClick}
             emptyMessage={filterMessages[currentFilter]}
           />
@@ -198,6 +218,14 @@ export default function DoctorAppointmentsPage() {
             </DialogFooter>
           </DialogContent>
         </Dialog>
+
+        {/* Approve Dialog */}
+        <ApproveAppointmentModal
+          open={approveDialog.open}
+          onClose={() => setApproveDialog({ open: false, id: null })}
+          onConfirm={handleApprove}
+          isLoading={actionLoading}
+        />
       </div>
     </AuthenticatedLayout>
   );

+ 347 - 211
src/app/dashboard/page.tsx

@@ -2,19 +2,24 @@
 
 import { useSession } from "next-auth/react"
 import AuthenticatedLayout from "@/components/AuthenticatedLayout"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
 import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
 import { 
   MessageSquare, 
   FileText, 
   Users, 
   Activity, 
   Calendar,
-  User,
   Shield,
-  TrendingUp
+  TrendingUp,
+  ArrowRight,
+  Clock,
+  ChevronRight
 } from "lucide-react"
 import Link from "next/link"
+import { cn } from "@/lib/utils"
 
 export default function DashboardPage() {
   const { data: session } = useSession()
@@ -27,255 +32,386 @@ export default function DashboardPage() {
   const isAdmin = session.user.role === "ADMIN"
   const isPatient = session.user.role === "PATIENT"
 
-  // Mensaje de bienvenida según el rol
-  const welcomeMessage = isAdmin 
-    ? "Panel de administración del sistema - Control total de la plataforma"
-    : isDoctor 
-    ? "Panel médico - Gestiona pacientes y reportes"
-    : "Tu asistente médico virtual personal - Consulta y obtén reportes médicos"
+  const roleConfig = {
+    ADMIN: {
+      badge: "Admin",
+      badgeVariant: "default" as const,
+      description: "Gestión completa del sistema"
+    },
+    DOCTOR: {
+      badge: "Doctor",
+      badgeVariant: "secondary" as const,
+      description: "Panel de gestión médica"
+    },
+    PATIENT: {
+      badge: "Paciente",
+      badgeVariant: "outline" as const,
+      description: "Tu asistente médico personal"
+    }
+  }
+
+  const config = roleConfig[session.user.role as keyof typeof roleConfig]
 
   return (
     <AuthenticatedLayout>
-      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
-        {/* Welcome Section */}
-        <div className="mb-8">
-          <h1 className="text-3xl font-bold text-gray-900 mb-2">
-            ¡Bienvenido, {session.user.name}!
-          </h1>
-          <p className="text-gray-600">
-            {welcomeMessage}
-          </p>
-        </div>
-
-        {/* Quick Stats */}
-        <div className="grid md:grid-cols-4 gap-6 mb-8">
-          <Card>
-            <CardContent className="p-6">
-              <div className="flex items-center">
-                <div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mr-4">
-                  <MessageSquare className="w-6 h-6 text-blue-600" />
+      <div className="flex-1 space-y-6 p-8 pt-6">
+        {/* Header Section */}
+        <Card>
+          <CardContent className="p-6">
+            <div className="flex items-center justify-between">
+              <div className="flex items-center gap-4">
+                <div className="w-12 h-12 bg-primary rounded-lg flex items-center justify-center shadow-sm">
+                  <Activity className="w-6 h-6 text-primary-foreground" />
                 </div>
                 <div>
-                  <p className="text-sm font-medium text-gray-600">Consultas</p>
-                  <p className="text-2xl font-bold text-gray-900">
-                    {isPatient ? "Disponibles" : "Total"}
+                  <div className="flex items-center gap-3">
+                    <h2 className="text-2xl font-bold tracking-tight">Dashboard</h2>
+                    <Badge variant={config.badgeVariant}>{config.badge}</Badge>
+                  </div>
+                  <p className="text-sm text-muted-foreground mt-1">
+                    {config.description}
                   </p>
                 </div>
               </div>
-            </CardContent>
-          </Card>
+            </div>
+          </CardContent>
+        </Card>
 
+        {/* Stats Grid */}
+        <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
           <Card>
-            <CardContent className="p-6">
-              <div className="flex items-center">
-                <div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mr-4">
-                  <FileText className="w-6 h-6 text-green-600" />
-                </div>
-                <div>
-                  <p className="text-sm font-medium text-gray-600">Reportes</p>
-                  <p className="text-2xl font-bold text-gray-900">
-                    {isPatient ? "Mis Reportes" : "Generados"}
-                  </p>
-                </div>
+            <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+              <CardTitle className="text-sm font-medium">
+                Consultas
+              </CardTitle>
+              <MessageSquare className="h-4 w-4 text-muted-foreground" />
+            </CardHeader>
+            <CardContent>
+              <div className="text-2xl font-bold">
+                {isPatient ? "Disponible" : "24"}
               </div>
+              <p className="text-xs text-muted-foreground">
+                {isPatient ? "3 consultas por sesión" : "+12% desde el mes pasado"}
+              </p>
             </CardContent>
           </Card>
 
           <Card>
-            <CardContent className="p-6">
-              <div className="flex items-center">
-                <div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mr-4">
-                  <Activity className="w-6 h-6 text-purple-600" />
-                </div>
-                <div>
-                  <p className="text-sm font-medium text-gray-600">Estado</p>
-                  <p className="text-2xl font-bold text-gray-900">
-                    {isDoctor ? "Activo" : "Saludable"}
-                  </p>
-                </div>
+            <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+              <CardTitle className="text-sm font-medium">
+                Reportes
+              </CardTitle>
+              <FileText className="h-4 w-4 text-muted-foreground" />
+            </CardHeader>
+            <CardContent>
+              <div className="text-2xl font-bold">
+                {isPatient ? "15" : "128"}
               </div>
+              <p className="text-xs text-muted-foreground">
+                {isPatient ? "Reportes generados" : "+8 esta semana"}
+              </p>
             </CardContent>
           </Card>
 
           <Card>
-            <CardContent className="p-6">
-              <div className="flex items-center">
-                <div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center mr-4">
-                  <Calendar className="w-6 h-6 text-orange-600" />
-                </div>
-                <div>
-                  <p className="text-sm font-medium text-gray-600">Última Actividad</p>
-                  <p className="text-2xl font-bold text-gray-900">Hoy</p>
-                </div>
+            <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+              <CardTitle className="text-sm font-medium">
+                {isDoctor || isAdmin ? "Pacientes" : "Citas"}
+              </CardTitle>
+              {isDoctor || isAdmin ? (
+                <Users className="h-4 w-4 text-muted-foreground" />
+              ) : (
+                <Calendar className="h-4 w-4 text-muted-foreground" />
+              )}
+            </CardHeader>
+            <CardContent>
+              <div className="text-2xl font-bold">
+                {isDoctor || isAdmin ? "45" : "2"}
               </div>
+              <p className="text-xs text-muted-foreground">
+                {isDoctor || isAdmin ? "Activos en el sistema" : "Próximas citas"}
+              </p>
             </CardContent>
           </Card>
         </div>
 
-        {/* Main Actions */}
-        <div className="grid md:grid-cols-2 gap-8">
-          {isAdmin ? (
-            // Admin Dashboard
-            <>
-              <Card className="hover:shadow-lg transition-shadow">
-                <CardHeader>
-                  <CardTitle className="flex items-center">
-                    <Shield className="w-5 h-5 mr-2 text-purple-600" />
-                    Panel de Administración
-                  </CardTitle>
-                </CardHeader>
-                <CardContent>
-                  <p className="text-gray-600 mb-4">
-                    Control total del sistema: gestiona usuarios, doctores, pacientes 
-                    y todas las configuraciones de la plataforma.
-                  </p>
-                  <Link href="/admin">
-                    <Button className="w-full">
-                      Ir a Administración
-                    </Button>
-                  </Link>
-                </CardContent>
-              </Card>
+        {/* Main Content Grid */}
+        <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
+          {/* Quick Actions */}
+          <Card className="col-span-4">
+            <CardHeader>
+              <CardTitle>Acciones Rápidas</CardTitle>
+              <CardDescription>
+                Accede a las funciones principales del sistema
+              </CardDescription>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              {isAdmin ? (
+                <>
+                  <QuickActionItem
+                    icon={Shield}
+                    title="Panel de Administración"
+                    description="Gestiona usuarios, doctores y configuración"
+                    href="/admin"
+                    iconBg="bg-violet-100 dark:bg-violet-900/20"
+                    iconColor="text-violet-600 dark:text-violet-400"
+                  />
+                  <Separator />
+                  <QuickActionItem
+                    icon={FileText}
+                    title="Todos los Reportes"
+                    description="Accede al historial completo del sistema"
+                    href="/records"
+                    iconBg="bg-emerald-100 dark:bg-emerald-900/20"
+                    iconColor="text-emerald-600 dark:text-emerald-400"
+                  />
+                  <Separator />
+                  <QuickActionItem
+                    icon={Users}
+                    title="Gestión de Usuarios"
+                    description="Administra pacientes y personal médico"
+                    href="/admin/patients"
+                    iconBg="bg-blue-100 dark:bg-blue-900/20"
+                    iconColor="text-blue-600 dark:text-blue-400"
+                  />
+                </>
+              ) : isDoctor ? (
+                <>
+                  <QuickActionItem
+                    icon={Users}
+                    title="Mis Pacientes"
+                    description="Revisa y gestiona tus pacientes asignados"
+                    href="/admin"
+                    iconBg="bg-blue-100 dark:bg-blue-900/20"
+                    iconColor="text-blue-600 dark:text-blue-400"
+                  />
+                  <Separator />
+                  <QuickActionItem
+                    icon={FileText}
+                    title="Reportes Médicos"
+                    description="Historial de consultas y diagnósticos"
+                    href="/records"
+                    iconBg="bg-emerald-100 dark:bg-emerald-900/20"
+                    iconColor="text-emerald-600 dark:text-emerald-400"
+                  />
+                  <Separator />
+                  <QuickActionItem
+                    icon={Calendar}
+                    title="Agenda de Citas"
+                    description="Visualiza tu calendario de consultas"
+                    href="/appointments/doctor"
+                    iconBg="bg-amber-100 dark:bg-amber-900/20"
+                    iconColor="text-amber-600 dark:text-amber-400"
+                  />
+                </>
+              ) : (
+                <>
+                  <QuickActionItem
+                    icon={MessageSquare}
+                    title="Chat Médico"
+                    description="Consulta con el asistente virtual"
+                    href="/chat"
+                    iconBg="bg-blue-100 dark:bg-blue-900/20"
+                    iconColor="text-blue-600 dark:text-blue-400"
+                    badge="3 disponibles"
+                  />
+                  <Separator />
+                  <QuickActionItem
+                    icon={FileText}
+                    title="Mis Reportes"
+                    description="Historial de reportes médicos"
+                    href="/records"
+                    iconBg="bg-emerald-100 dark:bg-emerald-900/20"
+                    iconColor="text-emerald-600 dark:text-emerald-400"
+                  />
+                  <Separator />
+                  <QuickActionItem
+                    icon={Calendar}
+                    title="Mis Citas"
+                    description="Próximas consultas programadas"
+                    href="/appointments"
+                    iconBg="bg-amber-100 dark:bg-amber-900/20"
+                    iconColor="text-amber-600 dark:text-amber-400"
+                  />
+                </>
+              )}
+            </CardContent>
+          </Card>
 
-              <Card className="hover:shadow-lg transition-shadow">
-                <CardHeader>
-                  <CardTitle className="flex items-center">
-                    <FileText className="w-5 h-5 mr-2 text-green-600" />
-                    Todos los Reportes
-                  </CardTitle>
-                </CardHeader>
-                <CardContent>
-                  <p className="text-gray-600 mb-4">
-                    Accede a todos los reportes médicos del sistema y 
-                    mantén un registro completo de todas las consultas.
-                  </p>
-                  <Link href="/records">
-                    <Button className="w-full">
-                      Ver Reportes
-                    </Button>
-                  </Link>
-                </CardContent>
-              </Card>
-            </>
-          ) : isDoctor ? (
-            // Doctor Dashboard
-            <>
-              <Card className="hover:shadow-lg transition-shadow">
-                <CardHeader>
-                  <CardTitle className="flex items-center">
-                    <Users className="w-5 h-5 mr-2 text-blue-600" />
-                    Mis Pacientes
-                  </CardTitle>
-                </CardHeader>
-                <CardContent>
-                  <p className="text-gray-600 mb-4">
-                    Visualiza la lista de pacientes asignados, 
-                    revisa sus reportes y mantén seguimiento de sus consultas.
-                  </p>
-                  <Link href="/admin">
-                    <Button className="w-full">
-                      Ver Pacientes
-                    </Button>
-                  </Link>
-                </CardContent>
-              </Card>
+          {/* Recent Activity / Info Panel */}
+          <Card className="col-span-3">
+            <CardHeader>
+              <CardTitle>Información del Sistema</CardTitle>
+              <CardDescription>
+                Seguridad y privacidad
+              </CardDescription>
+            </CardHeader>
+            <CardContent className="space-y-6">
+              <div className="space-y-2">
+                <div className="flex items-start gap-3">
+                  <div className="rounded-lg bg-green-100 dark:bg-green-900/20 p-2">
+                    <Shield className="h-4 w-4 text-green-600 dark:text-green-400" />
+                  </div>
+                  <div className="space-y-1">
+                    <p className="text-sm font-medium leading-none">
+                      Seguridad Garantizada
+                    </p>
+                    <p className="text-sm text-muted-foreground">
+                      Encriptación de nivel bancario protege tus datos médicos
+                    </p>
+                  </div>
+                </div>
+              </div>
 
-              <Card className="hover:shadow-lg transition-shadow">
-                <CardHeader>
-                  <CardTitle className="flex items-center">
-                    <FileText className="w-5 h-5 mr-2 text-green-600" />
-                    Reportes Médicos
-                  </CardTitle>
-                </CardHeader>
-                <CardContent>
-                  <p className="text-gray-600 mb-4">
-                    Accede a los reportes médicos de tus pacientes y 
-                    mantén un registro de las consultas realizadas.
-                  </p>
-                  <Link href="/records">
-                    <Button className="w-full">
-                      Ver Reportes
-                    </Button>
-                  </Link>
-                </CardContent>
-              </Card>
-            </>
-          ) : (
-            // Patient Dashboard
-            <>
-              <Card className="hover:shadow-lg transition-shadow">
-                <CardHeader>
-                  <CardTitle className="flex items-center">
-                    <MessageSquare className="w-5 h-5 mr-2 text-blue-600" />
-                    Chat Médico
-                  </CardTitle>
-                </CardHeader>
-                <CardContent>
-                  <p className="text-gray-600 mb-4">
-                    Consulta con nuestro asistente médico virtual. 
-                    Puedes hacer hasta 3 consultas por sesión.
-                  </p>
-                  <Link href="/chat">
-                    <Button className="w-full">
-                      Iniciar Consulta
-                    </Button>
-                  </Link>
-                </CardContent>
-              </Card>
+              <Separator />
 
-              <Card className="hover:shadow-lg transition-shadow">
-                <CardHeader>
-                  <CardTitle className="flex items-center">
-                    <FileText className="w-5 h-5 mr-2 text-green-600" />
-                    Mis Reportes
-                  </CardTitle>
-                </CardHeader>
-                <CardContent>
-                  <p className="text-gray-600 mb-4">
-                    Revisa tu historial de reportes médicos generados 
-                    automáticamente después de cada consulta.
-                  </p>
-                  <Link href="/records">
-                    <Button className="w-full">
-                      Ver Mis Reportes
+              <div className="space-y-2">
+                <div className="flex items-start gap-3">
+                  <div className="rounded-lg bg-blue-100 dark:bg-blue-900/20 p-2">
+                    <Shield className="h-4 w-4 text-blue-600 dark:text-blue-400" />
+                  </div>
+                  <div className="space-y-1">
+                    <p className="text-sm font-medium leading-none">
+                      Privacidad Médica
+                    </p>
+                    <p className="text-sm text-muted-foreground">
+                      Cumplimiento de estándares internacionales HIPAA
+                    </p>
+                  </div>
+                </div>
+              </div>
+
+              <Separator />
+
+              <div className="space-y-2">
+                <div className="flex items-start gap-3">
+                  <div className="rounded-lg bg-violet-100 dark:bg-violet-900/20 p-2">
+                    <Activity className="h-4 w-4 text-violet-600 dark:text-violet-400" />
+                  </div>
+                  <div className="space-y-1">
+                    <p className="text-sm font-medium leading-none">
+                      Disponibilidad 24/7
+                    </p>
+                    <p className="text-sm text-muted-foreground">
+                      Asistencia médica virtual disponible en todo momento
+                    </p>
+                  </div>
+                </div>
+              </div>
+
+              {isPatient && (
+                <>
+                  <Separator />
+                  <div className="pt-2">
+                    <Button className="w-full" asChild>
+                      <Link href="/chat">
+                        Iniciar Consulta
+                        <ArrowRight className="ml-2 h-4 w-4" />
+                      </Link>
                     </Button>
-                  </Link>
-                </CardContent>
-              </Card>
-            </>
-          )}
+                  </div>
+                </>
+              )}
+            </CardContent>
+          </Card>
         </div>
 
-        {/* Additional Info */}
-        <div className="mt-8">
+        {/* Additional Information */}
+        {(isDoctor || isAdmin) && (
           <Card>
             <CardHeader>
-              <CardTitle className="flex items-center">
-                <Shield className="w-5 h-5 mr-2 text-purple-600" />
-                Información Importante
-              </CardTitle>
+              <CardTitle>Resumen de Actividad</CardTitle>
+              <CardDescription>
+                Estadísticas del sistema en tiempo real
+              </CardDescription>
             </CardHeader>
             <CardContent>
-              <div className="grid md:grid-cols-2 gap-6">
-                <div>
-                  <h4 className="font-semibold text-gray-900 mb-2">Seguridad</h4>
-                  <p className="text-sm text-gray-600">
-                    Todos tus datos médicos están protegidos con encriptación de nivel bancario 
-                    y solo tú y tu médico autorizado pueden acceder a esta información.
-                  </p>
+              <div className="grid gap-4 md:grid-cols-3">
+                <div className="flex items-center gap-4">
+                  <div className="rounded-full bg-blue-100 dark:bg-blue-900/20 p-3">
+                    <TrendingUp className="h-5 w-5 text-blue-600 dark:text-blue-400" />
+                  </div>
+                  <div>
+                    <p className="text-sm font-medium text-muted-foreground">
+                      Consultas Hoy
+                    </p>
+                    <p className="text-2xl font-bold">12</p>
+                  </div>
                 </div>
-                <div>
-                  <h4 className="font-semibold text-gray-900 mb-2">Privacidad</h4>
-                  <p className="text-sm text-gray-600">
-                    Cumplimos con los más altos estándares de privacidad médica. 
-                    Tu información nunca será compartida sin tu consentimiento explícito.
-                  </p>
+                <div className="flex items-center gap-4">
+                  <div className="rounded-full bg-emerald-100 dark:bg-emerald-900/20 p-3">
+                    <FileText className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
+                  </div>
+                  <div>
+                    <p className="text-sm font-medium text-muted-foreground">
+                      Reportes Generados
+                    </p>
+                    <p className="text-2xl font-bold">8</p>
+                  </div>
+                </div>
+                <div className="flex items-center gap-4">
+                  <div className="rounded-full bg-amber-100 dark:bg-amber-900/20 p-3">
+                    <Users className="h-5 w-5 text-amber-600 dark:text-amber-400" />
+                  </div>
+                  <div>
+                    <p className="text-sm font-medium text-muted-foreground">
+                      Pacientes Activos
+                    </p>
+                    <p className="text-2xl font-bold">45</p>
+                  </div>
                 </div>
               </div>
             </CardContent>
           </Card>
-        </div>
+        )}
       </div>
     </AuthenticatedLayout>
   )
+}
+
+interface QuickActionItemProps {
+  icon: React.ElementType
+  title: string
+  description: string
+  href: string
+  iconBg: string
+  iconColor: string
+  badge?: string
+}
+
+function QuickActionItem({
+  icon: Icon,
+  title,
+  description,
+  href,
+  iconBg,
+  iconColor,
+  badge
+}: QuickActionItemProps) {
+  return (
+    <Link 
+      href={href} 
+      className="group flex items-center justify-between rounded-lg transition-colors hover:bg-accent"
+    >
+      <div className="flex items-center gap-4 flex-1 py-2">
+        <div className={cn("rounded-lg p-2.5", iconBg)}>
+          <Icon className={cn("h-5 w-5", iconColor)} />
+        </div>
+        <div className="flex-1 space-y-1">
+          <div className="flex items-center gap-2">
+            <p className="text-sm font-medium leading-none">{title}</p>
+            {badge && (
+              <Badge variant="secondary" className="text-xs">
+                {badge}
+              </Badge>
+            )}
+          </div>
+          <p className="text-sm text-muted-foreground line-clamp-1">
+            {description}
+          </p>
+        </div>
+      </div>
+      <ChevronRight className="h-5 w-5 text-muted-foreground transition-transform group-hover:translate-x-1" />
+    </Link>
+  )
 } 

+ 1 - 1
src/components/account/PasswordChangeSection.tsx

@@ -28,7 +28,7 @@ export default function PasswordChangeSection({}: PasswordChangeSectionProps) {
           <div className="text-sm text-yellow-800">
             <p className="font-medium">Contraseña gestionada por UTB</p>
             <p className="mt-1">
-              Tu contraseña es la misma que usas para acceder al sistema académico.{" "}
+              Tu contraseña es la misma que usas para acceder al sistema de la universidad.{" "}
               Para cambiarla, dirígete a{" "}
               <a 
                 href="https://sai.utb.edu.ec" 

+ 16 - 8
src/components/appointments/AppointmentCard.tsx

@@ -23,7 +23,8 @@ export const AppointmentCard = ({
   onReject,
   onCancel,
 }: AppointmentCardProps) => {
-  const fecha = new Date(appointment.fechaSolicitada);
+  const hasFecha = appointment.fechaSolicitada !== null;
+  const fecha = hasFecha ? new Date(appointment.fechaSolicitada!) : null;
   const otherUser = userRole === "PATIENT" ? appointment.medico : appointment.paciente;
   
   return (
@@ -39,7 +40,7 @@ export const AppointmentCard = ({
                 </AvatarFallback>
               </Avatar>
             )}
-            <div>
+            <div className="flex-1">
               <h3 className="font-semibold text-lg">
                 {otherUser
                   ? `${otherUser.name} ${otherUser.lastname}`
@@ -47,12 +48,19 @@ export const AppointmentCard = ({
                   ? "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>
+              {hasFecha && fecha ? (
+                <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 className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
+                  <Clock className="h-4 w-4" />
+                  <span className="italic">Fecha por asignar</span>
+                </div>
+              )}
             </div>
           </div>
           <AppointmentStatusBadge status={appointment.estado} />

+ 140 - 0
src/components/appointments/ApproveAppointmentModal.tsx

@@ -0,0 +1,140 @@
+"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 { Textarea } from "@/components/ui/textarea";
+import { DateTimePicker } from "@/components/ui/datetime-picker";
+import { Calendar, AlertCircle } from "lucide-react";
+
+interface ApproveAppointmentModalProps {
+  open: boolean;
+  onClose: () => void;
+  onConfirm: (fechaSolicitada: Date, notas?: string) => Promise<void>;
+  isLoading: boolean;
+}
+
+export const ApproveAppointmentModal = ({
+  open,
+  onClose,
+  onConfirm,
+  isLoading,
+}: ApproveAppointmentModalProps) => {
+  const [fechaSolicitada, setFechaSolicitada] = useState<Date>();
+  const [notas, setNotas] = useState("");
+
+  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+
+    if (!fechaSolicitada) {
+      return;
+    }
+
+    await onConfirm(fechaSolicitada, notas.trim() || undefined);
+    
+    // Limpiar formulario
+    setFechaSolicitada(undefined);
+    setNotas("");
+  };
+
+  const handleClose = () => {
+    if (!isLoading) {
+      setFechaSolicitada(undefined);
+      setNotas("");
+      onClose();
+    }
+  };
+
+  const isFutureDate = fechaSolicitada && fechaSolicitada > new Date();
+
+  return (
+    <>
+      {/* Overlay manual para modal={false} */}
+      {open && (
+        <div 
+          className="fixed inset-0 z-50 bg-black/50 pointer-events-none"
+          aria-hidden="true"
+        />
+      )}
+      
+      <Dialog open={open} onOpenChange={handleClose} modal={false}>
+        <DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto pointer-events-auto">
+          <DialogHeader>
+            <div className="flex items-center gap-2">
+              <Calendar className="h-5 w-5 text-primary" />
+              <DialogTitle>Aprobar y Asignar Fecha</DialogTitle>
+            </div>
+            <DialogDescription>
+              Selecciona la fecha y hora para la cita telemática. El paciente será
+              notificado de la fecha asignada.
+            </DialogDescription>
+          </DialogHeader>
+
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="fecha">
+              Fecha y hora de la cita <span className="text-destructive">*</span>
+            </Label>
+            <DateTimePicker date={fechaSolicitada} setDate={setFechaSolicitada} />
+            {fechaSolicitada && !isFutureDate && (
+              <div className="flex items-center gap-2 text-xs text-destructive">
+                <AlertCircle className="h-3 w-3" />
+                <span>La fecha debe ser futura</span>
+              </div>
+            )}
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="notas">Notas para el paciente (opcional)</Label>
+            <Textarea
+              id="notas"
+              placeholder="Ej: Recordar traer exámenes previos, ayuno de 8 horas..."
+              value={notas}
+              onChange={(e) => setNotas(e.target.value)}
+              rows={4}
+              disabled={isLoading}
+              className="resize-none"
+            />
+            <p className="text-xs text-muted-foreground">
+              {notas.length}/500 caracteres
+            </p>
+          </div>
+
+          <div className="flex items-start gap-2 p-3 bg-muted rounded-lg">
+            <AlertCircle className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
+            <p className="text-xs text-muted-foreground">
+              Al aprobar, se creará una sala de videollamada y se asignará esta cita
+              a tu agenda. El paciente podrá unirse a la consulta en la fecha indicada.
+            </p>
+          </div>
+
+          <DialogFooter>
+            <Button
+              type="button"
+              variant="outline"
+              onClick={handleClose}
+              disabled={isLoading}
+            >
+              Cancelar
+            </Button>
+            <Button
+              type="submit"
+              disabled={!fechaSolicitada || !isFutureDate || isLoading}
+            >
+              {isLoading ? "Aprobando..." : "Aprobar cita"}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+    </>
+  );
+};

+ 217 - 0
src/components/appointments/ConsultationNotes.tsx

@@ -0,0 +1,217 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { FileText, Save, Loader2, Check, Eye } from "lucide-react";
+import { toast } from "sonner";
+import { format } from "date-fns";
+import { es } from "date-fns/locale";
+
+interface ConsultationNotesProps {
+  appointmentId: string;
+  isDoctor: boolean;
+}
+
+export function ConsultationNotes({ appointmentId, isDoctor }: ConsultationNotesProps) {
+  const [notes, setNotes] = useState("");
+  const [isSaved, setIsSaved] = useState(false);
+  const [savedAt, setSavedAt] = useState<Date | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [saving, setSaving] = useState(false);
+
+  // Cargar notas existentes
+  useEffect(() => {
+    const fetchNotes = async () => {
+      try {
+        const response = await fetch(`/api/appointments/${appointmentId}/consultation-notes`);
+        if (response.ok) {
+          const data = await response.json();
+          setNotes(data.notasConsulta || "");
+          setIsSaved(data.notasGuardadas || false);
+          setSavedAt(data.notasGuardadasAt ? new Date(data.notasGuardadasAt) : null);
+        }
+      } catch (error) {
+        console.error("Error al cargar notas:", error);
+      } finally {
+        setLoading(false);
+      }
+    };
+
+    fetchNotes();
+  }, [appointmentId]);
+
+  const handleSave = async (guardar: boolean = false) => {
+    setSaving(true);
+    try {
+      const response = await fetch(`/api/appointments/${appointmentId}/consultation-notes`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          notasConsulta: notes,
+          guardar,
+        }),
+      });
+
+      if (!response.ok) {
+        const error = await response.json();
+        throw new Error(error.error || "Error al guardar notas");
+      }
+
+      const data = await response.json();
+      setIsSaved(data.notasGuardadas);
+      setSavedAt(data.notasGuardadasAt ? new Date(data.notasGuardadasAt) : null);
+
+      toast.success(
+        guardar
+          ? "Notas guardadas y compartidas con el paciente"
+          : "Notas guardadas como borrador"
+      );
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Error al guardar notas");
+      console.error(error);
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  if (loading) {
+    return (
+      <Card>
+        <CardContent className="flex items-center justify-center py-8">
+          <Loader2 className="h-6 w-6 animate-spin" />
+        </CardContent>
+      </Card>
+    );
+  }
+
+  // Vista para pacientes
+  if (!isDoctor) {
+    if (!isSaved || !notes) {
+      return (
+        <Card>
+          <CardHeader>
+            <div className="flex items-center gap-2">
+              <FileText className="h-5 w-5" />
+              <CardTitle>Notas de Consulta</CardTitle>
+            </div>
+            <CardDescription>
+              Las notas del médico estarán disponibles una vez finalizadas
+            </CardDescription>
+          </CardHeader>
+          <CardContent>
+            <div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
+              <Eye className="h-12 w-12 mb-2 opacity-50" />
+              <p className="text-sm">El médico aún no ha compartido las notas</p>
+            </div>
+          </CardContent>
+        </Card>
+      );
+    }
+
+    return (
+      <Card>
+        <CardHeader>
+          <div className="flex items-center gap-2">
+            <FileText className="h-5 w-5" />
+            <CardTitle>Notas de Consulta</CardTitle>
+          </div>
+          <CardDescription>
+            Guardadas el {savedAt && format(savedAt, "d 'de' MMMM 'a las' HH:mm", { locale: es })}
+          </CardDescription>
+        </CardHeader>
+        <CardContent>
+          <div className="whitespace-pre-wrap rounded-lg bg-muted p-4 text-sm">
+            {notes}
+          </div>
+        </CardContent>
+      </Card>
+    );
+  }
+
+  // Vista para doctores
+  return (
+    <Card>
+      <CardHeader>
+        <div className="flex items-center justify-between">
+          <div className="flex items-center gap-2">
+            <FileText className="h-5 w-5" />
+            <CardTitle>Notas de Consulta</CardTitle>
+          </div>
+          {isSaved && savedAt && (
+            <div className="flex items-center gap-2 text-xs text-muted-foreground">
+              <Check className="h-4 w-4 text-green-600" />
+              <span>
+                Compartidas el {format(savedAt, "d/MM 'a las' HH:mm", { locale: es })}
+              </span>
+            </div>
+          )}
+        </div>
+        <CardDescription>
+          Registra información importante de la consulta. Puedes guardar borradores o compartir
+          con el paciente.
+        </CardDescription>
+      </CardHeader>
+      <CardContent className="space-y-4">
+        <div className="space-y-2">
+          <Label htmlFor="notes">Notas médicas</Label>
+          <Textarea
+            id="notes"
+            value={notes}
+            onChange={(e) => setNotes(e.target.value)}
+            placeholder="Escribe aquí tus observaciones, diagnóstico, tratamiento recomendado..."
+            rows={12}
+            className="resize-none font-mono text-sm"
+          />
+          <p className="text-xs text-muted-foreground">
+            Los borradores se guardan automáticamente pero solo son visibles para ti
+          </p>
+        </div>
+
+        <div className="flex gap-2">
+          <Button
+            onClick={() => handleSave(false)}
+            disabled={saving || !notes.trim()}
+            variant="outline"
+            className="flex-1"
+          >
+            {saving ? (
+              <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+            ) : (
+              <Save className="h-4 w-4 mr-2" />
+            )}
+            Guardar Borrador
+          </Button>
+          <Button
+            onClick={() => handleSave(true)}
+            disabled={saving || !notes.trim()}
+            className="flex-1"
+          >
+            {saving ? (
+              <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+            ) : (
+              <Check className="h-4 w-4 mr-2" />
+            )}
+            Guardar y Compartir
+          </Button>
+        </div>
+
+        {isSaved && (
+          <div className="rounded-lg bg-green-50 dark:bg-green-950 p-3 text-sm text-green-800 dark:text-green-200">
+            <div className="flex items-center gap-2">
+              <Check className="h-4 w-4" />
+              <span className="font-medium">
+                Notas compartidas con el paciente
+              </span>
+            </div>
+            <p className="text-xs mt-1 opacity-80">
+              El paciente puede ver estas notas en el detalle de la cita
+            </p>
+          </div>
+        )}
+      </CardContent>
+    </Card>
+  );
+}

+ 151 - 0
src/components/chatbot/AppointmentModalFromChat.tsx

@@ -0,0 +1,151 @@
+"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 { Textarea } from "@/components/ui/textarea";
+import { Calendar, AlertCircle } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+
+interface AppointmentModalFromChatProps {
+  open: boolean;
+  onClose: () => void;
+  onSuccess?: () => void;
+}
+
+export const AppointmentModalFromChat = ({
+  open,
+  onClose,
+  onSuccess,
+}: AppointmentModalFromChatProps) => {
+  const [motivoConsulta, setMotivoConsulta] = useState("");
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const { toast } = useToast();
+
+  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+
+    if (!motivoConsulta.trim()) {
+      toast({
+        title: "Campo requerido",
+        description: "Por favor describe el motivo de tu consulta",
+        variant: "destructive",
+      });
+      return;
+    }
+
+    setIsSubmitting(true);
+    try {
+      const response = await fetch("/api/appointments", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          motivoConsulta: motivoConsulta.trim(),
+          // No enviamos fechaSolicitada - el doctor la asignará
+        }),
+      });
+
+      if (!response.ok) {
+        const error = await response.json();
+        throw new Error(error.error || "Error al crear cita");
+      }
+
+      toast({
+        title: "¡Cita solicitada!",
+        description: "Un médico revisará tu solicitud y te asignará una fecha pronto.",
+      });
+
+      setMotivoConsulta("");
+      onClose();
+      onSuccess?.();
+    } catch (error) {
+      const message =
+        error instanceof Error ? error.message : "No se pudo crear la cita";
+      toast({
+        title: "Error",
+        description: message,
+        variant: "destructive",
+      });
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  const handleClose = () => {
+    if (!isSubmitting) {
+      setMotivoConsulta("");
+      onClose();
+    }
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={handleClose}>
+      <DialogContent className="sm:max-w-[500px]">
+        <DialogHeader>
+          <div className="flex items-center gap-2">
+            <Calendar className="h-5 w-5 text-primary" />
+            <DialogTitle>Solicitar Cita Médica</DialogTitle>
+          </div>
+          <DialogDescription className="pt-2">
+            Describe el motivo de tu consulta. Un médico revisará tu solicitud y
+            te asignará una fecha y hora para la cita telemática.
+          </DialogDescription>
+        </DialogHeader>
+
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="motivo-consulta">
+              Motivo de la consulta <span className="text-destructive">*</span>
+            </Label>
+            <Textarea
+              id="motivo-consulta"
+              placeholder="Ejemplo: Necesito consultar sobre los síntomas que mencioné en el chat..."
+              value={motivoConsulta}
+              onChange={(e) => setMotivoConsulta(e.target.value)}
+              rows={5}
+              required
+              disabled={isSubmitting}
+              className="resize-none"
+            />
+            <p className="text-xs text-muted-foreground">
+              {motivoConsulta.length}/500 caracteres
+            </p>
+          </div>
+
+          <div className="flex items-start gap-2 p-3 bg-muted rounded-lg">
+            <AlertCircle className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
+            <p className="text-xs text-muted-foreground">
+              Tu solicitud quedará pendiente hasta que un médico la revise y asigne
+              una fecha. Recibirás una notificación cuando sea aprobada.
+            </p>
+          </div>
+
+          <DialogFooter>
+            <Button
+              type="button"
+              variant="outline"
+              onClick={handleClose}
+              disabled={isSubmitting}
+            >
+              Cancelar
+            </Button>
+            <Button
+              type="submit"
+              disabled={!motivoConsulta.trim() || isSubmitting}
+            >
+              {isSubmitting ? "Enviando..." : "Solicitar cita"}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+};

+ 17 - 0
src/components/chatbot/ChatInterface.tsx

@@ -14,6 +14,7 @@ import { CompletedBanner } from "./CompletedBanner";
 import { ResetButton } from "./ResetButton";
 import { ReportModal } from "./ReportModal";
 import { ResetConfirmationModal } from "./ResetConfirmationModal";
+import { AppointmentModalFromChat } from "./AppointmentModalFromChat";
 
 const MAX_MESSAGES = 3;
 
@@ -23,6 +24,7 @@ export const ChatInterface = () => {
   const [showResetModal, setShowResetModal] = useState(false);
   const [showLastMessageToast, setShowLastMessageToast] = useState(false);
   const [isResetting, setIsResetting] = useState(false);
+  const [showAppointmentModal, setShowAppointmentModal] = useState(false);
 
   const {
     messages,
@@ -100,6 +102,14 @@ export const ChatInterface = () => {
     }
   };
 
+  const handleOpenAppointmentModal = () => {
+    setShowAppointmentModal(true);
+  };
+
+  const handleCloseAppointmentModal = () => {
+    setShowAppointmentModal(false);
+  };
+
   if (!session) {
     return (
       <div className="flex items-center justify-center h-64">
@@ -153,6 +163,7 @@ export const ChatInterface = () => {
                   messages={messages} 
                   isLoading={isLoading} 
                   showDynamicSuggestions={showDynamicSuggestions}
+                  onAppointmentClick={handleOpenAppointmentModal}
                 />
                 
                 {/* Dynamic Suggestions */}
@@ -199,6 +210,12 @@ export const ChatInterface = () => {
         onConfirm={handleResetWithReportAndModal}
         isResetting={isResetting}
       />
+
+      {/* Appointment Modal */}
+      <AppointmentModalFromChat
+        open={showAppointmentModal}
+        onClose={handleCloseAppointmentModal}
+      />
     </div>
   );
 };

+ 6 - 2
src/components/chatbot/ChatMessage.tsx

@@ -5,9 +5,10 @@ import { MedicalAlert } from "./MedicalAlert";
 
 interface ChatMessageProps {
   message: Message;
+  onAppointmentClick?: () => void;
 }
 
-export const ChatMessage = ({ message }: ChatMessageProps) => {
+export const ChatMessage = ({ message, onAppointmentClick }: ChatMessageProps) => {
   const formatTime = (date: Date | string) => {
     const dateObj = typeof date === 'string' ? new Date(date) : date;
     if (isNaN(dateObj.getTime())) {
@@ -74,7 +75,10 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
         {/* Mostrar alerta médica solo para mensajes del asistente que no están en streaming */}
         {message.role === "assistant" && message.medicalAlert && !message.isStreaming && (
           <div className="mt-3">
-            <MedicalAlert alert={message.medicalAlert} />
+            <MedicalAlert 
+              alert={message.medicalAlert}
+              onAppointmentClick={onAppointmentClick}
+            />
           </div>
         )}
       </div>

+ 12 - 2
src/components/chatbot/ChatMessages.tsx

@@ -7,9 +7,15 @@ interface ChatMessagesProps {
   messages: Message[];
   isLoading: boolean;
   showDynamicSuggestions?: boolean;
+  onAppointmentClick?: () => void;
 }
 
-export const ChatMessages = ({ messages, isLoading, showDynamicSuggestions }: ChatMessagesProps) => {
+export const ChatMessages = ({ 
+  messages, 
+  isLoading, 
+  showDynamicSuggestions,
+  onAppointmentClick 
+}: ChatMessagesProps) => {
   const messagesEndRef = useRef<HTMLDivElement>(null);
 
   // Scroll suave cuando se agregan nuevos mensajes
@@ -43,7 +49,11 @@ export const ChatMessages = ({ messages, isLoading, showDynamicSuggestions }: Ch
   return (
     <div className="space-y-4">
       {messages.map((message, index) => (
-        <ChatMessage key={index} message={message} />
+        <ChatMessage 
+          key={index} 
+          message={message}
+          onAppointmentClick={onAppointmentClick}
+        />
       ))}
       {isLoading && <DynamicLoader />}
       <div ref={messagesEndRef} />

+ 6 - 9
src/components/chatbot/MedicalAlert.tsx

@@ -4,12 +4,11 @@ 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;
+  onAppointmentClick?: () => void;
 }
 
 const alertConfig = {
@@ -36,10 +35,10 @@ const alertConfig = {
   }
 };
 
-export const MedicalAlert = ({ alert, className, showAppointmentButton = true }: MedicalAlertProps) => {
+export const MedicalAlert = ({ alert, className, onAppointmentClick }: MedicalAlertProps) => {
   const config = alertConfig[alert];
   const Icon = config.icon;
-  const shouldShowButton = showAppointmentButton && (alert === "RECOMENDADO" || alert === "URGENTE");
+  const shouldShowButton = onAppointmentClick && (alert === "RECOMENDADO" || alert === "URGENTE");
 
   return (
     <div className={cn(
@@ -56,15 +55,13 @@ export const MedicalAlert = ({ alert, className, showAppointmentButton = true }:
       </div>
       {shouldShowButton && (
         <Button
-          asChild
+          onClick={onAppointmentClick}
           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>
+          <Calendar className="h-4 w-4 mr-2" />
+          Agendar Cita
         </Button>
       )}
     </div>

+ 8 - 8
src/components/chatbot/SuggestedPrompts.tsx

@@ -51,28 +51,28 @@ export const SuggestedPrompts = ({
     <div className="bg-primary/5 rounded-lg p-4 border border-border">
       <div className="flex items-center space-x-2 mb-3">
         <Sparkles className="w-4 h-4 text-primary" />
-        <h3 className="font-medium text-sm text-foreground">
+        <h3 className="font-bold text-l text-foreground">
           Sugerencias rápidas:
         </h3>
       </div>
-      <div className="grid grid-cols-2 md:grid-cols-3 gap-2">
+      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
         {SUGGESTED_PROMPTS.map((suggestion, index) => (
           <Button
             key={index}
             variant="outline"
             size="sm"
-            className="justify-start text-left h-auto p-2 border-border hover:border-primary/30 hover:bg-primary/5 text-xs"
+            className="justify-start text-left h-auto min-h-[80px] p-3 border-border hover:border-primary/30 hover:bg-primary/5 transition-all whitespace-normal"
             onClick={() => onSuggestionClick(suggestion.prompt)}
             disabled={isLoading}
           >
-            <div className="w-full">
-              <div className="flex items-center space-x-1 mb-0.5">
-                <span className="text-sm">{suggestion.emoji}</span>
-                <span className="font-medium text-xs text-foreground leading-tight">
+            <div className="w-full space-y-2">
+              <div className="flex items-center space-x-2">
+                <span className="text-lg flex-shrink-0">{suggestion.emoji}</span>
+                <span className="font-semibold text-sm text-foreground">
                   {suggestion.title}
                 </span>
               </div>
-              <div className="text-xs text-muted-foreground leading-tight line-clamp-2">
+              <div className="text-xs text-muted-foreground leading-relaxed break-words">
                 {suggestion.prompt}
               </div>
             </div>

+ 1 - 0
src/components/chatbot/index.ts

@@ -9,4 +9,5 @@ export { CompletedState } from "./CompletedState";
 export { ResetButton } from "./ResetButton";
 export { ReportModal } from "./ReportModal";
 export { ResetConfirmationModal } from "./ResetConfirmationModal";
+export { AppointmentModalFromChat } from "./AppointmentModalFromChat";
 export * from "./types"; 

+ 2 - 2
src/components/sidebar/SidebarUserInfo.tsx

@@ -8,7 +8,7 @@ import { COLOR_PALETTE } from "@/utils/palette"
 
 export default function SidebarUserInfo({ isCollapsed }: { isCollapsed: boolean }) {
   const { data: session } = useSession()
-  const { profileImage } = useProfileImageContext()
+  const { profileImage, isLoading } = useProfileImageContext()
 
   if (!session) return null
 
@@ -22,7 +22,7 @@ export default function SidebarUserInfo({ isCollapsed }: { isCollapsed: boolean
     >
       <div className={`flex items-center ${isCollapsed ? 'flex-col space-y-2' : 'space-x-3'}`}>
         <ProfileImage
-          src={profileImage}
+          src={isLoading ? null : profileImage}
           alt={`${session.user.name} ${session.user.lastname}`}
           fallback={session.user.name}
           size={isCollapsed ? "sm" : "md"}

+ 161 - 0
src/components/ui/datetime-picker.tsx

@@ -0,0 +1,161 @@
+"use client";
+
+import * as React from "react";
+import { Calendar as CalendarIcon } from "lucide-react";
+import { format } from "date-fns";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Calendar } from "@/components/ui/calendar";
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from "@/components/ui/popover";
+import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
+
+interface DateTimePickerProps {
+  date?: Date;
+  setDate: (date: Date | undefined) => void;
+}
+
+export function DateTimePicker({ date, setDate }: DateTimePickerProps) {
+  const [isOpen, setIsOpen] = React.useState(false);
+
+  const hours = Array.from({ length: 12 }, (_, i) => i + 1);
+  
+  const handleDateSelect = (selectedDate: Date | undefined) => {
+    if (selectedDate) {
+      // Si ya hay una fecha, mantener la hora
+      if (date) {
+        selectedDate.setHours(date.getHours());
+        selectedDate.setMinutes(date.getMinutes());
+      } else {
+        // Si no hay fecha previa, inicializar a las 9:00 AM
+        selectedDate.setHours(9);
+        selectedDate.setMinutes(0);
+      }
+      setDate(selectedDate);
+    }
+  };
+
+  const handleTimeChange = (
+    type: "hour" | "minute" | "ampm",
+    value: string
+  ) => {
+    if (date) {
+      const newDate = new Date(date);
+      if (type === "hour") {
+        const hour = parseInt(value);
+        const currentHours = newDate.getHours();
+        const isPM = currentHours >= 12;
+        newDate.setHours(isPM ? (hour === 12 ? 12 : hour + 12) : (hour === 12 ? 0 : hour));
+      } else if (type === "minute") {
+        newDate.setMinutes(parseInt(value));
+      } else if (type === "ampm") {
+        const currentHours = newDate.getHours();
+        if (value === "PM" && currentHours < 12) {
+          newDate.setHours(currentHours + 12);
+        } else if (value === "AM" && currentHours >= 12) {
+          newDate.setHours(currentHours - 12);
+        }
+      }
+      setDate(newDate);
+    }
+  };
+
+  return (
+    <Popover open={isOpen} onOpenChange={setIsOpen}>
+      <PopoverTrigger asChild>
+        <Button
+          variant="outline"
+          className={cn(
+            "w-full justify-start text-left font-normal",
+            !date && "text-muted-foreground"
+          )}
+        >
+          <CalendarIcon className="mr-2 h-4 w-4" />
+          {date ? (
+            format(date, "MM/dd/yyyy hh:mm aa")
+          ) : (
+            <span>MM/DD/YYYY hh:mm aa</span>
+          )}
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent className="w-auto p-0" style={{ zIndex: 9999 }}>
+        <div className="sm:flex">
+          <Calendar
+            mode="single"
+            selected={date}
+            onSelect={handleDateSelect}
+            initialFocus
+          />
+          <div className="flex flex-col sm:flex-row sm:h-[300px] divide-y sm:divide-y-0 sm:divide-x">
+            <ScrollArea className="w-64 sm:w-auto">
+              <div className="flex sm:flex-col p-2">
+                {hours.reverse().map((hour) => {
+                  const isSelected = date && ((date.getHours() % 12) === (hour % 12) || (hour === 12 && date.getHours() === 12));
+                  return (
+                    <Button
+                      key={hour}
+                      size="icon"
+                      variant={isSelected ? "default" : "ghost"}
+                      className="sm:w-full shrink-0 aspect-square"
+                      onClick={() => handleTimeChange("hour", hour.toString())}
+                    >
+                      {hour}
+                    </Button>
+                  );
+                })}
+              </div>
+              <ScrollBar orientation="horizontal" className="sm:hidden" />
+            </ScrollArea>
+            <ScrollArea className="w-64 sm:w-auto">
+              <div className="flex sm:flex-col p-2">
+                {Array.from({ length: 12 }, (_, i) => i * 5).map((minute) => (
+                  <Button
+                    key={minute}
+                    size="icon"
+                    variant={
+                      date && date.getMinutes() === minute
+                        ? "default"
+                        : "ghost"
+                    }
+                    className="sm:w-full shrink-0 aspect-square"
+                    onClick={() =>
+                      handleTimeChange("minute", minute.toString())
+                    }
+                  >
+                    {minute}
+                  </Button>
+                ))}
+              </div>
+              <ScrollBar orientation="horizontal" className="sm:hidden" />
+            </ScrollArea>
+            <ScrollArea className="">
+              <div className="flex sm:flex-col p-2">
+                {["AM", "PM"].map((ampm) => (
+                  <Button
+                    key={ampm}
+                    size="icon"
+                    variant={
+                      date &&
+                      ((ampm === "AM" && date.getHours() < 12) ||
+                        (ampm === "PM" && date.getHours() >= 12))
+                        ? "default"
+                        : "ghost"
+                    }
+                    className="sm:w-full shrink-0 aspect-square"
+                    onClick={() => handleTimeChange("ampm", ampm)}
+                  >
+                    {ampm}
+                  </Button>
+                ))}
+              </div>
+            </ScrollArea>
+          </div>
+        </div>
+      </PopoverContent>
+    </Popover>
+  );
+}

+ 7 - 8
src/components/ui/profile-image.tsx

@@ -31,7 +31,7 @@ export function ProfileImage({
 
   return (
     <div className={cn(
-      "rounded-full overflow-hidden flex-shrink-0",
+      "rounded-full overflow-hidden flex-shrink-0 relative",
       sizeClasses[size],
       className
     )}>
@@ -47,13 +47,12 @@ export function ProfileImage({
             onLoad={() => setImageLoaded(true)}
             onError={() => setImageError(true)}
           />
-          {/* Fallback que se muestra mientras carga o si falla */}
-          <div className={cn(
-            "absolute inset-0 bg-primary rounded-full flex items-center justify-center text-primary-foreground font-semibold",
-            imageLoaded && !imageError ? "hidden" : ""
-          )}>
-            {fallback.charAt(0).toUpperCase()}
-          </div>
+          {/* Fallback que se muestra mientras carga */}
+          {!imageLoaded && (
+            <div className="absolute inset-0 bg-primary rounded-full flex items-center justify-center text-primary-foreground font-semibold">
+              {fallback.charAt(0).toUpperCase()}
+            </div>
+          )}
         </>
       ) : (
         <div className="w-full h-full bg-primary rounded-full flex items-center justify-center text-primary-foreground font-semibold">

+ 58 - 0
src/components/ui/scroll-area.tsx

@@ -0,0 +1,58 @@
+"use client"
+
+import * as React from "react"
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
+  return (
+    <ScrollAreaPrimitive.Root
+      data-slot="scroll-area"
+      className={cn("relative", className)}
+      {...props}
+    >
+      <ScrollAreaPrimitive.Viewport
+        data-slot="scroll-area-viewport"
+        className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
+      >
+        {children}
+      </ScrollAreaPrimitive.Viewport>
+      <ScrollBar />
+      <ScrollAreaPrimitive.Corner />
+    </ScrollAreaPrimitive.Root>
+  )
+}
+
+function ScrollBar({
+  className,
+  orientation = "vertical",
+  ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
+  return (
+    <ScrollAreaPrimitive.ScrollAreaScrollbar
+      data-slot="scroll-area-scrollbar"
+      orientation={orientation}
+      className={cn(
+        "flex touch-none p-px transition-colors select-none",
+        orientation === "vertical" &&
+          "h-full w-2.5 border-l border-l-transparent",
+        orientation === "horizontal" &&
+          "h-2.5 flex-col border-t border-t-transparent",
+        className
+      )}
+      {...props}
+    >
+      <ScrollAreaPrimitive.ScrollAreaThumb
+        data-slot="scroll-area-thumb"
+        className="bg-border relative flex-1 rounded-full"
+      />
+    </ScrollAreaPrimitive.ScrollAreaScrollbar>
+  )
+}
+
+export { ScrollArea, ScrollBar }

+ 13 - 2
src/contexts/ProfileImageContext.tsx

@@ -7,6 +7,7 @@ interface ProfileImageContextType {
   profileImage: string | null
   updateProfileImage: (imageUrl: string | null) => void
   refreshProfileImage: () => Promise<void>
+  isLoading: boolean
 }
 
 const ProfileImageContext = createContext<ProfileImageContextType | undefined>(undefined)
@@ -14,10 +15,15 @@ const ProfileImageContext = createContext<ProfileImageContextType | undefined>(u
 export function ProfileImageProvider({ children }: { children: ReactNode }) {
   const { data: session } = useSession()
   const [profileImage, setProfileImage] = useState<string | null>(null)
+  const [isLoading, setIsLoading] = useState(true)
 
   const loadProfileImage = async () => {
-    if (!session?.user?.id) return
+    if (!session?.user?.id) {
+      setIsLoading(false)
+      return
+    }
     
+    setIsLoading(true)
     try {
       const response = await fetch("/api/account/profile-image", {
         method: 'GET',
@@ -32,6 +38,8 @@ export function ProfileImageProvider({ children }: { children: ReactNode }) {
       }
     } catch (error) {
       console.error("Error cargando imagen de perfil:", error)
+    } finally {
+      setIsLoading(false)
     }
   }
 
@@ -46,6 +54,8 @@ export function ProfileImageProvider({ children }: { children: ReactNode }) {
   useEffect(() => {
     if (session?.user?.id) {
       loadProfileImage()
+    } else {
+      setIsLoading(false)
     }
   }, [session?.user?.id])
 
@@ -53,7 +63,8 @@ export function ProfileImageProvider({ children }: { children: ReactNode }) {
     <ProfileImageContext.Provider value={{
       profileImage: profileImage || session?.user?.profileImage || null,
       updateProfileImage,
-      refreshProfileImage
+      refreshProfileImage,
+      isLoading
     }}>
       {children}
     </ProfileImageContext.Provider>

+ 12 - 4
src/hooks/useAppointments.ts

@@ -66,15 +66,21 @@ export const useAppointments = () => {
     }
   };
 
-  const approveAppointment = async (id: string, notas?: string) => {
+  const approveAppointment = async (id: string, fechaSolicitada: Date, notas?: string) => {
     try {
       const response = await fetch(`/api/appointments/${id}/approve`, {
         method: "POST",
         headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ notas }),
+        body: JSON.stringify({ 
+          fechaSolicitada: fechaSolicitada.toISOString(),
+          notas 
+        }),
       });
 
-      if (!response.ok) throw new Error("Error al aprobar cita");
+      if (!response.ok) {
+        const error = await response.json();
+        throw new Error(error.error || "Error al aprobar cita");
+      }
 
       toast({
         title: "Cita aprobada",
@@ -83,11 +89,13 @@ export const useAppointments = () => {
 
       await fetchAppointments();
     } catch (error) {
+      const message = error instanceof Error ? error.message : "No se pudo aprobar la cita";
       toast({
         title: "Error",
-        description: "No se pudo aprobar la cita",
+        description: message,
         variant: "destructive",
       });
+      throw error;
     }
   };
 

+ 5 - 2
src/types/appointments.ts

@@ -7,11 +7,14 @@ export interface Appointment {
   pacienteId: string;
   medicoId: string | null;
   recordId: string | null;
-  fechaSolicitada: Date | string;
+  fechaSolicitada: Date | string | null;
   estado: "PENDIENTE" | "APROBADA" | "RECHAZADA" | "COMPLETADA" | "CANCELADA";
   motivoConsulta: string;
   motivoRechazo: string | null;
   notas: string | null;
+  notasConsulta: string | null;
+  notasGuardadas: boolean;
+  notasGuardadasAt: Date | string | null;
   roomName: string | null;
   paciente?: {
     id: string;
@@ -30,7 +33,7 @@ export interface Appointment {
 }
 
 export interface CreateAppointmentInput {
-  fechaSolicitada: Date;
+  fechaSolicitada?: Date;
   motivoConsulta: string;
   recordId?: string;
 }

+ 66 - 0
src/utils/appointments.ts

@@ -0,0 +1,66 @@
+/**
+ * Utilidades para el sistema de citas
+ */
+
+/**
+ * Verifica si es tiempo de unirse a una videollamada
+ * Permite unirse 15 minutos antes hasta 1 hora después de la cita
+ */
+export function canJoinMeeting(appointmentDate: Date | string | null): {
+  canJoin: boolean;
+  reason?: string;
+  minutesUntil?: number;
+} {
+  if (!appointmentDate) {
+    return {
+      canJoin: false,
+      reason: "La cita no tiene fecha asignada",
+    };
+  }
+
+  const now = new Date();
+  const appointmentTime = new Date(appointmentDate);
+  const fifteenMinutesBefore = new Date(appointmentTime.getTime() - 15 * 60 * 1000);
+  const oneHourAfter = new Date(appointmentTime.getTime() + 60 * 60 * 1000);
+
+  if (now < fifteenMinutesBefore) {
+    const minutesUntil = Math.floor((appointmentTime.getTime() - now.getTime()) / (60 * 1000));
+    return {
+      canJoin: false,
+      reason: `Podrás unirte 15 minutos antes de la cita`,
+      minutesUntil,
+    };
+  }
+
+  if (now > oneHourAfter) {
+    return {
+      canJoin: false,
+      reason: "La cita ya finalizó. El tiempo límite para unirse ha expirado.",
+    };
+  }
+
+  return { canJoin: true };
+}
+
+/**
+ * Obtiene el mensaje de estado de tiempo para una cita
+ */
+export function getAppointmentTimeStatus(appointmentDate: Date | string | null): string {
+  const result = canJoinMeeting(appointmentDate);
+  
+  if (result.canJoin) {
+    return "Puedes unirte ahora";
+  }
+
+  if (result.minutesUntil !== undefined) {
+    const hours = Math.floor(result.minutesUntil / 60);
+    const minutes = result.minutesUntil % 60;
+    
+    if (hours > 0) {
+      return `En ${hours}h ${minutes}m`;
+    }
+    return `En ${minutes} minutos`;
+  }
+
+  return result.reason || "No disponible";
+}