Compare commits

72 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 4.6 339ca8a40d debug: habilitar display_errors temporalmente para diagnosticar 500
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 08:47:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3dbf40e4fa feat(formulario): firma profesional directa en modal embebido (formularios solo-pro)
- ver_formulario_enviado.php: pre-scan $_soloFirmaPro; muestra canvas pro cuando modoTurnero+embebido+sesión activa+sin firma paciente
- fpw-save JS: recopila campos del form y envía solo_profesional=true; al guardar muestra éxito y postMessage turneroFirmado
- firmar_profesional_consentimiento.php: si solo_profesional=true guarda datos_respuestas y marca estado=firmado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 12:49:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1e2cfbaa0b refactor(lugar): embebido como botón en fila de consentimiento, no sección separada
- Eliminar sec-form-embebido como sección independiente
- En modo embebido: fila de consentimiento muestra botón "Firmar" que abre modal directamente por token
- En modo link: botones "Firmar presencial" + "WA" como antes
- abrirModalConsentimientoPorToken(token) recibe token directo sin cache

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 12:37:50 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b399c3710e fix(formulario): no mostrar firma paciente si schema tiene firma_profesional
Usar _esquemaTieneFirmaAlguna (cualquier tipo firma) en lugar de
_esquemaTieneFirmaPaciente para suprimir el widget fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 12:32:01 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a0189a61eb feat(lugar): formulario embebido como modal en lugar de iframe inline
- Botón "Abrir formulario de firma" en ficha abre modal overlay
- Modal con iframe cargando ver_formulario_enviado.php?embed=1
- postMessage 'turneroFirmado' cierra modal, refresca consentimientos y oculta botón
- Token pre-cargado al abrir ficha para apertura rápida del modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 12:16:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 27135e4d2f fix(formulario): múltiples correcciones modo embebido/turnero
- ver_formulario_enviado.php: usar opciones??options??items como fallback de claves del schema
- ver_formulario_enviado.php: marcar _esquemaTieneFirmaPaciente en cualquier campo firma (no solo sin SVG), evita mostrar firma paciente cuando schema solo tiene firma_profesional
- ver_formulario_enviado.php: modo embebido (?embed=1) con layout compacto para iframe
- lugar.php: pasar &embed=1 al iframe src

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 12:15:34 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7b734195e0 fix(lugar): incluir formulario_modo en query de lugares
La columna no estaba en el SELECT, siempre quedaba 'link'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 12:01:59 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5dc2b0efa8 fix(turnero): corregir envío WA desde recepcion
- recepcion.php: quitar guard !solicitudActiva en enviarConsentimientosTodos (bloqueaba silenciosamente antes de guardar solicitud)
- send_consentimiento.php: eliminar requisito de solicitud y exámenes para poder reenviar; usar WhatsAppService('turnero') con rawComponents correctos (1 body param=codigo, button param=token); JOIN lab_pacientes también via t.paciente_id como fallback de teléfono

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 11:59:40 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2dec300f3a Fix professional signature row only showing when form schema requires it
Only display the 'Falta firma enfermero' row and button when the assigned
consent form actually contains a firma_profesional field in its esquema.
get_consentimientos.php now computes requiere_firma_profesional from the
JSON schema and recepcion.php gates the row on that flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 11:55:20 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 05c7230eda fix(formularios): pre-fill fecha_hoy and edad in ver_formulario_enviado
- Modo editar: fecha_hoy → type=date prefilled with today; edad → type=number prefilled from paciente.fecha_nacimiento
- Modo vista: if no saved value, fecha_hoy shows today's date; edad calculates from DOB instead of skipping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 11:47:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f09fcd5b4e fix(turno): no enviar WA si el lugar de recepción está en modo embebido
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 11:44:59 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e1f0fe3450 refactor(formularios): split 'calculado' into two direct field types
Instead of one field with a sub-option, there are now two independent palette
items under a 'Calculados' section:
- fecha_hoy: renders type=date pre-filled with today's date (YYYY-MM-DD)
- edad: renders type=number pre-filled with age calculated from paciente.fecha_nacimiento

Both are editable by the patient and have a yellow tint.
Preview in the builder also shows the live date value for fecha_hoy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 11:44:11 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f8245b4d6f feat: modo embebido de formulario por lugar (link vs iframe)
- Nueva columna turnero_lugares.formulario_modo (link|embebido)
- Config: radio en modal de edición para elegir el modo
- lugar.php: muestra iframe con ver_formulario_enviado al activar turno
- API get_consent_token.php: crea/obtiene token para turno+lugar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 11:35:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 daa9827d93 feat(builder): tablet-friendly drag — pointer events + tap-to-add palette
- Palette items: onclick adds field directly (tap on tablet, no drag needed)
- Canvas reorder: PointerEvents drag (touchstart/move/end) with visual ghost
  and blue line indicator; HTML5 drag kept for mouse/desktop
- CSS: drop-above/drop-below indicators, ghost element, larger touch targets
  for handle and action buttons on coarse pointer devices
- Hide preview panel on ≤680px to give more canvas space on small tablets
- calculado added to TIPO_ICON map

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 11:28:28 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1385c25b30 feat(formularios): add 'calculado' field type with fecha_hoy and edad sub-types
Builder:
- New palette item 'Campo calculado' (fa-calculator)
- Editor modal lets user pick: Fecha de hoy / Edad del paciente
- Preview shows yellow-tinted input with calculator badge

form_cliente.php:
- fecha_hoy: pre-fills today's date (YYYY-MM-DD), editable by patient
- edad: calculates age from patient's fecha_nacimiento, editable
- Collected on submit like any normal input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 11:23:36 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0c68dff307 fix: mostrar canvas para todos los campos firma en modo turnero
Antes solo el primer campo (consentimiento) tenía canvas; el segundo
(desistimiento) se omitía. Ahora cada campo firma tiene su propio widget
usando clases CSS en vez de IDs hardcodeados.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 11:22:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 597ec87964 fix: redirigir UUID tokens de form_cliente.php a ver_formulario_enviado.php
El botón WA del template apunta a form_cliente.php?t={{1}}; si el token
es UUID (turnero) se redirige al flujo de consentimiento correcto.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 11:16:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 538af86407 fix(wa): enviar solo el token como parámetro del botón URL, no la URL completa
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 11:11:54 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b47f6019cd fix(turno): no prepender +57 si el tel ya tiene código de país
573168950803 quedaba como +57573168950803 — WhatsAppService lo normaliza solo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 10:47:51 -05:00
Lizandro Guarnizo 9d34c6b984 debug: mostrar teléfono de paciente por documento 2026-07-03 10:42:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 735d70dc50 fix(wa): enviar botón URL como componente button/url, no como header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 10:34:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7a56ec25a0 fix(wa): usar language_code real de la plantilla (es, no es_CO)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 10:32:15 -05:00
Lizandro Guarnizo 61c8c0b9ca fix debug: quitar creado_at del test INSERT 2026-07-03 10:30:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f18104dd5d fix: quitar creado_at (columna no existe en turnero_consentimientos)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 10:28:43 -05:00
Lizandro Guarnizo ad0eefe31f debug: marker [usite] para verificar deploy 2026-07-03 10:26:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1bb520ef3f debug: agregar diagnóstico tabla consentimientos y turnos del día
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 10:24:46 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 434df7b741 fix(turno): crear consentimiento siempre, enviar WA solo si hay teléfono
Estado queda 'pendiente' si no hay cel; se actualiza a 'enviado' al enviar WA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 10:17:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1a92707e3e create_turno: corregir params plantilla (solo {{1}}=código) + fix debug test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 10:09:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 707ef24572 configuracion: sección perfil WhatsApp Business (foto, about, dirección, email, webs, categoría)
- wa_profile.php: GET carga perfil desde Graph API, POST guardar campos, POST foto sube imagen
- configuracion.php tab sesión: UI con foto de perfil editable, campos del negocio y botón guardar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 10:08:41 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 eddfe4d16c debug_turno_wa: corregir tabla system_config para token/phone WA
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 10:02:34 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f09b7d42fd debug: agregar debug_turno_wa.php para diagnosticar envío WA desde kiosko
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 09:59:53 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d232cf69f5 create_turno: usar canal 'turnero' para envío de WA desde kiosko
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 09:57:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6fec24f850 create_turno: enviar turno + consentimiento en un solo WA desde el kiosko
- Al crear turno busca el formulario configurado en el desk de recepción
- Crea token de consentimiento y lo marca como 'enviado'
- Envía plantilla consentimiento_turno con nombre real, nombre formulario y link
- Fallback texto plano incluye el link si está disponible
- Tipo F (solo muestra) sigue usando plantilla sin consentimiento

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 09:41:44 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9d57bbac5c recepcion: historial paciente muestra 5 visitas por página (antes 15)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 09:16:44 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 03f02d2788 fix(webhook): skip SSE notification and DB notification for canal=turnero messages
Turnero messages were triggering the SSE event that conversations.php listens to,
causing the contact to flash briefly at the top of the list before disappearing
on the next poll (since get_conversations.php filters them out by canal).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:43:20 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3f964b424a fix(conversations): filter canal='turnero' out of individual chat message views
get_user_messages.php and get_recent_messages.php now exclude canal='turnero'
so the main bot chat only shows bot messages even for users who also have
turnero conversations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:39:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7eea2ff7ba fix(conversations): exclude canal='turnero' messages from main conversations list
All queries in get_conversations.php now filter (canal IS NULL OR canal='bot')
so the main chat only shows bot conversations and the turnero chat stays isolated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:35:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4d04fea1de fix(turnero): save outgoing messages with canal='turnero' so they appear in chat
saveOutgoingMessage() now reads __app_meta.canal and stores it in conversations.
sendMediaById() accepts a $meta param and forwards it to saveOutgoingMessage.
chat_upload_media.php passes $extra (canal=turnero) to sendMediaById.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:32:23 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5acd46df5a fix(turnero): route incoming messages by phone_number_id and fix WhatsAppService canal
- WhatsAppService($canal): when 'turnero', swaps phoneNumberId to whatsapp_phone_number_id_turnero
  so outgoing messages actually go from the turnero number (not the main bot)
- webhook.php: reads metadata.phone_number_id from the payload and compares it to
  whatsapp_phone_number_id_turnero; if it matches, saves canal='turnero' and skips bot processing
- Previously all incoming messages had canal=NULL/bot so Chat Turnero never saw them,
  and all outgoing messages from Chat Turnero were sent via the wrong (main bot) number

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:26:11 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 00aee615c5 lugar: cambiar label 'TOMA DE MUESTRAS' → 'ENTREGA DE MUESTRAS'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:25:30 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 20146535c7 formularios: agregar botón Clonar para duplicar plantillas
- save_formulario.php: manejo de {clonar:true} → crea copia con "Copia de [nombre]"
- lab_formularios.php: botón icono copy en cada tarjeta + función clonarFormulario()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:21:50 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 17703f0157 fix(chat-turnero): prevent messages mixing between contacts on fast switching
Capture activeUserId at the start of each async function (fetchMessages,
loadMoreMessages, pollMessages) and discard the response if the user changed
contacts while the request was in-flight.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:19:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a0979e31e7 recepcion: quitar botón duplicar del historial (va en sección formatos)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:19:27 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 78e71d5926 recepcion: mostrar kiosko + paciente vinculado en dos líneas separadas
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:15:03 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c511da18bd recepcion: fix solo_muestras, reorder fields, duplicar visita, kiosko label
- create_solicitud.php: evitar SQL `IN ()` vacío cuando solo_muestras=1 sin exámenes
- recepcion.php: mover embarazada+médico después del historial del paciente
- recepcion.php: actualizar label kiosko con nombre real al vincular paciente
- recepcion.php: botón "Duplicar" en historial para copiar exámenes y pago de última visita
- recepcion.php: omitir validación de consentimientos pendientes cuando es solo_muestras
- recepcion.php: agregar alias esc() = escHtml() usado en buscarMedico

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:13:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d3826972ad feat(turnero): selector de médico tratante en recepción, visible en toma de muestras
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:55:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ec614c2a89 feat(chat-turnero): backend APIs for media upload, reactions, and template messages
- chat_upload_media.php: multipart upload to WhatsApp Media API, sends image/video/audio/document
- chat_react.php: sends emoji reaction to a message via WhatsApp, updates reaction_emoji in DB
- chat_send_message.php: extended to support type=template with lang+params

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:53:23 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a7ab91615f feat(chat-turnero): rich messaging UI — images, video, audio, reactions, emoji, templates
- Attach button with file preview (image thumb for images, name+size for others)
- Audio recording via MediaRecorder API with live timer; sends as voice note
- Emoji panel with 35 common emojis (medical/comms focused)
- Reaction picker on message hover — sends via chat_react.php API
- Template modal (name, lang, JSON params) — sends via chat_send_message.php type=template
- buildBubble() now renders: inline images, <video> player, <audio> player, doc links with type icon
- Shows reaction_emoji below bubble and status tick on outgoing messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:53:23 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3b378c0d75 feat(medicos): seed 444 médicos + columnas telefonos/email en tabla y modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:51:57 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 48f9e32c22 chore(migrations): script para correr migraciones pendientes de una vez
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:48:03 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 bb6ee12e0a fix: restaurar migraciones del turnero perdidas por force push
- 20260626_activar_numero_turnero.php
- 20260626_turnero_canal_conversations.sql

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:43:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1574f12275 feat(medicos): módulo CRUD de médicos con tabla, búsqueda y modal edición
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:42:36 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 665fee99a9 fix(turnero): restaurar APIs del chat perdidas por force push del servidor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:41:40 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d93dccf006 feat(turnero): agregar Verificar Paciente al menú admin/supervisor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:39:00 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 cb77425ca2 feat(turnero): vista verificar_paciente dentro del ERP con Layout
- Crea modules/turnero/views/verificar_paciente.php con Layout::open/close
- Flujo completo: cédula → OTP → éxito, mismo JS que verificar.php público
- Link del módulo ahora apunta a erp.php?m=turnero&v=verificar_paciente

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:35:00 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ff0fd3284e fix(turnero): usar plantilla correcta según tipo de turno y ajustar sidebar
- create_turno.php: tipo F usa turnero_wa_template_muestra, resto usa turnero_wa_template
- styles.css: has-active ya incluye font-size 0.82rem (verificado)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:30:06 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4f686bd9ca fix(sidebar): restaurar diseño con colores por módulo
- nav-link.active: color-mix tintado, font-size 0.93rem, border 4px
- sidebar-section: 0.72rem base, 0.82rem cuando tiene subitem activo
- sb-group: fondo y borde tintados con --cat-color via color-mix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:28:46 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fd95e7b38a fix: restaurar todas las optimizaciones móvil/tablet perdidas por force push
- chat.php: recuperado y re-aplicado panel-switch móvil, viewport iOS,
  safe-area, sticky topbar, link ← Menú, botón volver
- lugar.php: panel-switch tablet (≤860px), botón ← Cola, JS mobile
- recepcion.php: panel-switch tablet (≤900px), botón ← Cola, JS mobile
- sidebar.php: soporte atributo target en links de módulo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:25:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 be4cb88420 fix(turnero): restaurar Chat Turnero en menú admin/supervisor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:19:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 98507ad1a5 fix(turnero): restaurar dos plantillas WhatsApp en tab Sesión
- Plantilla con consentimiento (turnero_wa_template)
- Plantilla muestra pendiente (turnero_wa_template_muestra)
- sesion_turno.php guarda ambas en lab_config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:17:17 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a592ddb30d fix: restaurar cambios revertidos por rebase del servidor
- verificar.php: BASE_URL absoluta en fetch, fix paste multi-dígito
- send_otp.php: mensaje SMS correcto sin dos puntos ni expiración
- module.php: Chat Turnero y Verificar Paciente para recepcionistas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:13:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e1ba78edb8 fix(otp): pegar código completo distribuye dígitos en todos los campos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:12:28 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 34004b4d98 feat(turnero): opción solo entrega de muestras con prioridad y badge naranja en cola
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:07:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 76dda4722a feat(turnero): pago combinado con desglose por efectivo, transferencia y tarjeta
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:02:48 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4626ca3f28 feat(display): nombre paciente más grande y visible en overlay de llamada
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:58:09 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 38bd211ca6 feat(kiosko): mostrar nombre/cédula en ticket y quitar posición en cola
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:53:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c9e5744de4 feat(turnero): campo embarazada en recepción y badge en toma de muestras
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:48:03 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d40da57a60 fix(sidebar): mantener posición de scroll al navegar entre páginas
Guarda scrollTop en localStorage al hacer clic en un nav-link y lo
restaura en DOMContentLoaded, evitando que el menú lateral salte al
inicio en cada navegación.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:40:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7475f3c58e fix(turnero): mostrar nombre del paciente en cola al vincularlo en recepción
Al vincular un paciente al turno, actualiza paciente_nombre con el
nombre_completo real de lab_pacientes. El siguiente poll de la cola
muestra el nombre en lugar de la cédula del kiosko.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:35:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8074f33b32 fix(turnero): eliminar duplicados en pantalla TV al llamar turno
callKey usaba id+llamado_at: si llamado_at llegaba null en el primer
poll y con valor en el segundo, se generaban dos claves distintas para
el mismo evento y el turno se anunciaba dos veces.

Cambia callKey a String(id) — cada turno se anuncia una sola vez
por sesión de pantalla, sin importar cambios en el timestamp.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:32:31 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c4154b5b61 fix(turnero): deduplicar historial de llamados por turno en pantalla TV
El array 'llamados' acumulaba una entrada por cada lugar al que pasaba
un turno (recepción + toma de muestras). Al insertar un nuevo llamado
se elimina la entrada previa del mismo turno, dejando solo el último lugar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:28:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 910edeb876 feat(turnero): botón de llamado por registro en lugar/toma de muestras
- Agrega botón campana por cada card en cola (solo turnos en_espera_lugar)
- Click en card abre ficha sin llamar; campana llama ese turno específico
- Llamar siguiente ahora también llama de verdad (antes solo abría ficha)
- Cards en_servicio mantienen indicador de estetoscopio sin botón

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:25:34 -05:00
55 changed files with 4502 additions and 397 deletions
+12 -9
View File
@@ -64,24 +64,27 @@ try {
$sql .= "
FROM users u
LEFT JOIN conversations c ON c.user_id = u.id
LEFT JOIN conversations c ON c.user_id = u.id AND (c.canal IS NULL OR c.canal = 'bot')
LEFT JOIN (
SELECT t1.* FROM conversations t1
JOIN (
SELECT user_id, MAX(created_at) AS last_time FROM conversations GROUP BY user_id
SELECT user_id, MAX(created_at) AS last_time FROM conversations
WHERE (canal IS NULL OR canal = 'bot') GROUP BY user_id
) t2 ON t1.user_id = t2.user_id AND t1.created_at = t2.last_time
WHERE (t1.canal IS NULL OR t1.canal = 'bot')
) lm ON lm.user_id = u.id";
// Agregar condición de búsqueda si existe
if (!empty($search)) {
$sql .= "
WHERE (
u.name LIKE ?
OR u.phone_number LIKE ?
u.name LIKE ?
OR u.phone_number LIKE ?
OR EXISTS (
SELECT 1 FROM conversations c2
WHERE c2.user_id = u.id
SELECT 1 FROM conversations c2
WHERE c2.user_id = u.id
AND c2.content LIKE ?
AND (c2.canal IS NULL OR c2.canal = 'bot')
)
)";
}
@@ -147,9 +150,9 @@ try {
} else {
// Conteo normal sin búsqueda
if ($filter === 'unread') {
$totalRow = $db->fetch("SELECT COUNT(DISTINCT user_id) AS count FROM conversations c WHERE c.direction = 'incoming' AND (c.is_read IS NULL OR c.is_read = 0)");
$totalRow = $db->fetch("SELECT COUNT(DISTINCT user_id) AS count FROM conversations c WHERE c.direction = 'incoming' AND (c.is_read IS NULL OR c.is_read = 0) AND (c.canal IS NULL OR c.canal = 'bot')");
} else {
$totalRow = $db->fetch("SELECT COUNT(DISTINCT user_id) AS count FROM conversations");
$totalRow = $db->fetch("SELECT COUNT(DISTINCT user_id) AS count FROM conversations WHERE (canal IS NULL OR canal = 'bot')");
}
}
$total = isset($totalRow['count']) ? intval($totalRow['count']) : 0;
+1 -1
View File
@@ -27,7 +27,7 @@ try {
u.name
FROM conversations c
JOIN users u ON c.user_id = u.id
WHERE c.content IS NOT NULL AND c.content != ''
WHERE c.content IS NOT NULL AND c.content != '' AND (c.canal IS NULL OR c.canal = 'bot')
ORDER BY c.created_at DESC
LIMIT 10"
);
+4 -4
View File
@@ -74,9 +74,9 @@ try {
c.reply_to_message_id as reply_to_message_id,
c.reaction_emoji as reaction_emoji,
c.reaction_to_message_id as reaction_to_message_id
FROM conversations c
FROM conversations c
LEFT JOIN users u ON c.user_id = u.id
WHERE c.user_id = :user_id";
WHERE c.user_id = :user_id AND (c.canal IS NULL OR c.canal = 'bot')";
// Soporte para paginación estable: before (timestamp) y before_id (id del mensaje más antiguo del bloque)
$beforeId = isset($_GET['before_id']) ? intval($_GET['before_id']) : null;
@@ -157,8 +157,8 @@ try {
mime_type,
status,
created_at
FROM conversations
WHERE user_id = :user_id
FROM conversations
WHERE user_id = :user_id AND (canal IS NULL OR canal = 'bot')
ORDER BY created_at ASC",
['user_id' => $userId]
);
+15
View File
@@ -22,6 +22,21 @@ try {
jsonOk(['id' => (int)$datos['id']], 'Formulario eliminado');
}
// Clonar
if (!empty($datos['id']) && !empty($datos['clonar'])) {
$original = $form->obtener((int)$datos['id']);
if (!$original) jsonError('Formulario no encontrado', 404);
$nuevoId = $form->crear([
'nombre' => 'Copia de ' . $original['nombre'],
'descripcion' => $original['descripcion'] ?? null,
'categoria' => $original['categoria'] ?? 'otro',
'esquema' => $original['esquema'] ?? '[]',
'permite_firma' => $original['permite_firma'] ?? 0,
'requiere_firma' => $original['requiere_firma'] ?? 0,
], $admin);
jsonOk(['id' => $nuevoId], 'Formulario clonado');
}
// Editar
if (!empty($datos['id'])) {
$id = (int)$datos['id'];
+1 -1
View File
@@ -71,7 +71,7 @@ try {
// Enviar SMS
$numero = preg_replace('/\D/', '', $pac['telefono']);
$mensaje = "Tu código de verificación de Laboratorio es: {$codigo}. Válido por 5 minutos.";
$mensaje = "{$codigo} es tu codigo de verificacion, Laboratorio Ximena Caicedo";
$payload = json_encode(['numero' => $numero, 'mensaje' => $mensaje], JSON_UNESCAPED_UNICODE);
$ch = curl_init($smsUrl . '/api/sms/send');
+34 -25
View File
@@ -98,6 +98,11 @@ class WhatsAppWebhook {
}
private function processconversations($value) {
// Detectar si este mensaje llegó al número turnero
$receivingPhoneId = $value['metadata']['phone_number_id'] ?? null;
$turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
$isTurnero = $receivingPhoneId && $turneroPhoneId && ($receivingPhoneId === $turneroPhoneId);
// Aceptar tanto payloads con 'conversations' como con 'messages' (WhatsApp varía según la integración)
$items = [];
if (isset($value['conversations']) && is_array($value['conversations'])) {
@@ -299,7 +304,8 @@ class WhatsAppWebhook {
'content' => $messageText,
'media_url' => $mediaUrl,
'status' => 'received',
'is_read' => 0
'is_read' => 0,
'canal' => $isTurnero ? 'turnero' : 'bot',
];
// Guardar whatsapp_media_id si el mediaUrl es un ID numérico de WhatsApp
@@ -389,35 +395,38 @@ class WhatsAppWebhook {
}
}
// Crear notificación para UI (nuevo mensaje entrante)
try {
$this->db->insert('notifications', [
// Notificaciones y SSE solo para el canal principal (no turnero)
if (!$isTurnero) {
try {
$this->db->insert('notifications', [
'user_id' => $user['id'],
'type' => 'incoming_message',
'message' => substr($messageText, 0, 250),
'data' => json_encode(['message_id' => $messageId]),
'is_read' => 0,
'created_at' => date('Y-m-d H:i:s')
]);
} catch (Exception $e) {
error_log('Failed to create notification: ' . $e->getMessage());
}
$this->pushSSEEvent('new_message', [
'user_id' => $user['id'],
'type' => 'incoming_message',
'phone_number' => $user['phone_number'],
'name' => $user['name'] ?? ($phoneNumber ?? ''),
'message' => substr($messageText, 0, 250),
'data' => json_encode(['message_id' => $messageId]),
'is_read' => 0,
'created_at' => date('Y-m-d H:i:s')
'message_type' => $messageType,
'timestamp' => date('Y-m-d H:i:s')
]);
} catch (Exception $e) {
error_log('Failed to create notification: ' . $e->getMessage());
}
// Empujar evento SSE en tiempo real
$this->pushSSEEvent('new_message', [
'user_id' => $user['id'],
'phone_number' => $user['phone_number'],
'name' => $user['name'] ?? $from,
'message' => substr($messageText, 0, 250),
'message_type' => $messageType,
'timestamp' => date('Y-m-d H:i:s')
]);
// Procesar con bot (protección contra excepciones externas)
try {
$this->botService->processMessage($user, $messageText, $messageType);
} catch (Exception $e) {
error_log("Bot processing failed: " . $e->getMessage());
// Procesar con bot solo si el mensaje llegó al número principal
if (!$isTurnero) {
try {
$this->botService->processMessage($user, $messageText, $messageType);
} catch (Exception $e) {
error_log("Bot processing failed: " . $e->getMessage());
}
}
}
+9 -6
View File
@@ -133,11 +133,12 @@ body {
.sidebar-menu .nav-link.active {
color: var(--cat-color, var(--brand-dark, #0d47a1));
background: rgba(255, 255, 255, 0.95);
font-weight: 600;
background: color-mix(in srgb, var(--cat-color, #fff) 10%, #fff);
font-weight: 700;
font-size: 0.93rem;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
transform: translateX(3px);
border-left: 3px solid var(--cat-color, var(--brand, #1565c0));
transform: translateX(4px);
border-left: 4px solid var(--cat-color, var(--brand, #1565c0));
}
.sidebar-menu .nav-link i {
@@ -152,11 +153,11 @@ body {
.sidebar-menu li.sidebar-section {
border-bottom: none;
padding: 10px 30px 6px;
font-size: 0.65rem;
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgba(255, 255, 255, 0.55);
color: rgba(255, 255, 255, 0.65);
pointer-events: auto;
cursor: pointer;
display: block;
@@ -173,6 +174,8 @@ body {
border-left-color: var(--cat-color, rgba(255,255,255,.7));
background: rgba(255,255,255,.1);
color: #fff;
font-size: 0.82rem;
letter-spacing: 0.06em;
}
.sidebar-menu li.sidebar-section .sb-cat-label { display:inline-flex; align-items:center; gap:4px; }
.sidebar-menu li.sidebar-section::after { display: none; }
+236
View File
@@ -0,0 +1,236 @@
<?php
/**
* Debug: flujo WA kiosko → consentimiento_turno
* Acceso: /debug_turno_wa.php (solo admins)
*/
require_once __DIR__ . '/config/config.php';
if (!isUserLoggedIn()) { header('Location: login.php'); exit; }
header('Content-Type: text/plain; charset=utf-8');
$pdo = Database::getInstance()->getConnection();
echo "════════════════════════════════════════\n";
echo " DEBUG: Turno WA → consentimiento_turno [usite]\n";
echo "════════════════════════════════════════\n\n";
// ── 1. Config WA turnero ──────────────────────────────────────
echo "1. CONFIGURACIÓN WHATSAPP\n";
echo "─────────────────────────\n";
// Token y phone IDs viven en system_config; resto en lab_config
$sysCfg = $pdo->query(
"SELECT config_key AS k, config_value AS v FROM system_config
WHERE config_key IN ('whatsapp_token','whatsapp_phone_number_id','whatsapp_phone_number_id_turnero')"
)->fetchAll(PDO::FETCH_KEY_PAIR);
$labCfg = $pdo->query(
"SELECT clave AS k, valor AS v FROM lab_config
WHERE clave IN ('turnero_wa_template','turnero_wa_lang','turnero_wa_kiosko_enabled')"
)->fetchAll(PDO::FETCH_KEY_PAIR);
$cfg = array_merge($sysCfg, $labCfg);
$tokenLen = strlen($cfg['whatsapp_token'] ?? '');
echo "Token WA: " . ($tokenLen > 0 ? "✅ presente ({$tokenLen} chars)" : "❌ VACÍO") . "\n";
echo "Phone ID principal: " . ($cfg['whatsapp_phone_number_id'] ?? '❌ no configurado') . "\n";
echo "Phone ID turnero: " . ($cfg['whatsapp_phone_number_id_turnero'] ?? '⚠️ no configurado (usará principal)') . "\n";
echo "Plantilla turno: " . ($cfg['turnero_wa_template'] ?? 'consentimiento_turno (default)') . "\n";
echo "Idioma: " . ($cfg['turnero_wa_lang'] ?? 'es_CO (default)') . "\n";
echo "Kiosko WA habilitado: " . (($cfg['turnero_wa_kiosko_enabled'] ?? '0') === '1' ? '✅ SÍ' : '❌ NO') . "\n\n";
// ── 2. Plantilla en BD ────────────────────────────────────────
echo "2. PLANTILLA EN BASE DE DATOS\n";
echo "──────────────────────────────\n";
$tplNombre = $cfg['turnero_wa_template'] ?? 'consentimiento_turno';
$tpl = $pdo->prepare("SELECT template_name, language_code, status, body_text FROM message_templates WHERE template_name = ? LIMIT 1");
$tpl->execute([$tplNombre]);
$tplRow = $tpl->fetch(PDO::FETCH_ASSOC);
if ($tplRow) {
echo "✅ Encontrada: {$tplRow['template_name']} [{$tplRow['language_code']}] estado={$tplRow['status']}\n";
echo "Body: " . substr($tplRow['body_text'] ?? '', 0, 200) . "\n";
} else {
echo "❌ Plantilla '{$tplNombre}' NO encontrada en message_templates\n";
}
echo "\n";
// ── 3. Formularios de recepción configurados ──────────────────
echo "3. FORMULARIOS DE CONSENTIMIENTO EN RECEPCIÓN\n";
echo "───────────────────────────────────────────────\n";
$forms = $pdo->query(
"SELECT l.nombre AS desk, f.id, f.nombre AS formulario, f.is_active
FROM turnero_lugar_consentimientos tlc
JOIN turnero_lugares l ON l.id = tlc.lugar_id AND l.tipo = 'recepcion'
JOIN lab_formularios f ON f.id = tlc.formulario_id
ORDER BY l.nombre, f.nombre"
)->fetchAll(PDO::FETCH_ASSOC);
if ($forms) {
foreach ($forms as $r) {
$act = $r['is_active'] ? '✅' : '⚠️ inactivo';
echo " {$act} Desk: {$r['desk']} → [{$r['id']}] {$r['formulario']}\n";
}
} else {
echo "❌ Ningún formulario configurado en desks de recepción\n";
echo " Ve a Configuración → Sesión y vincula un formulario al desk de recepción\n";
}
echo "\n";
// ── 4. Últimos consentimientos creados desde kiosko ───────────
echo "4. ÚLTIMOS CONSENTIMIENTOS CREADOS (recientes)\n";
echo "─────────────────────────────────────────────────\n";
try {
$rows = $pdo->query(
"SELECT tc.id, tc.turno_id, tc.estado, tc.enviado_at,
tt.codigo, tt.paciente_nombre, tt.paciente_cel,
f.nombre AS formulario
FROM turnero_consentimientos tc
JOIN turnero_turnos tt ON tt.id = tc.turno_id
JOIN lab_formularios f ON f.id = tc.formulario_id
ORDER BY tc.id DESC
LIMIT 10"
)->fetchAll(PDO::FETCH_ASSOC);
if ($rows) {
foreach ($rows as $r) {
echo " Turno {$r['codigo']} | {$r['paciente_nombre']} | cel={$r['paciente_cel']}\n";
echo " Formulario: {$r['formulario']}\n";
echo " Estado: {$r['estado']} | Enviado: " . ($r['enviado_at'] ?? 'NO') . "\n\n";
}
} else {
echo " Sin registros\n";
}
} catch (\Throwable $e) {
echo " ❌ Error SQL: " . $e->getMessage() . "\n";
}
echo "\n";
// ── 4b. Diagnóstico tabla turnero_consentimientos ─────────────
echo "4b. DIAGNÓSTICO TABLA Y TURNOS RECIENTES\n";
echo "──────────────────────────────────────────\n";
// Verificar que la tabla existe y tiene las columnas esperadas
try {
$cols = $pdo->query("DESCRIBE turnero_consentimientos")->fetchAll(PDO::FETCH_COLUMN);
echo "Columnas: " . implode(', ', $cols) . "\n";
} catch (\Throwable $e) {
echo "❌ Error al leer tabla: " . $e->getMessage() . "\n";
}
// Últimos turnos de hoy (con o sin consentimiento)
$hoyTurnos = $pdo->query(
"SELECT tt.id, tt.codigo, tt.paciente_nombre, tt.paciente_cel, tt.creado_at,
(SELECT COUNT(*) FROM turnero_consentimientos tc WHERE tc.turno_id = tt.id) AS tiene_consent
FROM turnero_turnos tt
WHERE DATE(tt.creado_at) = CURDATE()
ORDER BY tt.id DESC
LIMIT 10"
)->fetchAll(PDO::FETCH_ASSOC);
echo "\nÚltimos 10 turnos de hoy:\n";
if ($hoyTurnos) {
foreach ($hoyTurnos as $t) {
$cel = $t['paciente_cel'] ?: '(sin cel)';
$con = $t['tiene_consent'] ? '✅ consent' : '❌ sin consent';
echo " [{$t['id']}] {$t['codigo']} | {$t['paciente_nombre']} | cel={$cel} | {$con} | {$t['creado_at']}\n";
}
} else {
echo " Sin turnos hoy\n";
}
// Test de inserción directa
echo "\nTest inserción en turnero_consentimientos:\n";
if ($hoyTurnos) {
$turnoTest = $hoyTurnos[0];
$formTest = $forms[0] ?? null;
if ($formTest) {
try {
$testToken = 'DEBUG-TEST-' . time();
$ins = $pdo->prepare(
"INSERT IGNORE INTO turnero_consentimientos
(turno_id, formulario_id, token, estado)
VALUES (?, ?, ?, 'pendiente')"
);
$ins->execute([$turnoTest['id'], $formTest['id'], $testToken]);
if ($ins->rowCount()) {
$pdo->prepare("DELETE FROM turnero_consentimientos WHERE token = ?")->execute([$testToken]);
echo " ✅ Inserción OK (se insertó y eliminó el registro de prueba)\n";
} else {
echo " ⚠️ INSERT IGNORE no insertó (¿registro duplicado para turno {$turnoTest['id']}?)\n";
}
} catch (\Throwable $e) {
echo " ❌ Error al insertar: " . $e->getMessage() . "\n";
}
} else {
echo " ⚠️ No hay formularios de recepción configurados (sección 3)\n";
}
} else {
echo " ⚠️ Sin turnos hoy para probar\n";
}
// Mostrar teléfono del paciente por documento
$docBuscar = $_GET['doc'] ?? '1095819405';
echo "\nPaciente doc={$docBuscar} en lab_pacientes:\n";
try {
$stmtPac = $pdo->prepare(
"SELECT id, nombre_completo, telefono, numero_documento FROM lab_pacientes WHERE numero_documento = ? AND is_active = 1 LIMIT 1"
);
$stmtPac->execute([$docBuscar]);
$pacInfo = $stmtPac->fetch(PDO::FETCH_ASSOC);
if ($pacInfo) {
echo " ID: {$pacInfo['id']} | Nombre: {$pacInfo['nombre_completo']}\n";
echo " Teléfono registrado: " . ($pacInfo['telefono'] ?: '(vacío — por eso no llega WA)') . "\n";
if (!$pacInfo['telefono']) {
echo " → Para que llegue WA, actualiza el teléfono de este paciente a 3168950803\n";
}
} else {
echo " ❌ Paciente con documento '{$docBuscar}' no encontrado en lab_pacientes\n";
}
} catch (\Throwable $e) {
echo "" . $e->getMessage() . "\n";
}
// Versión del archivo create_turno.php
echo "\nVersión create_turno.php: " . date('Y-m-d H:i:s', filemtime(__DIR__ . '/modules/turnero/api/create_turno.php')) . "\n";
echo "\n";
// ── 5. Test de envío ──────────────────────────────────────────
if (isset($_GET['test_cel'])) {
echo "5. TEST DE ENVÍO\n";
echo "─────────────────\n";
$testCel = trim($_GET['test_cel']);
$testTpl = $cfg['turnero_wa_template'] ?? 'consentimiento_turno';
// Usar el idioma real de la plantilla aprobada en Meta
$testLang = $tplRow['language_code'] ?? ($cfg['turnero_wa_lang'] ?? 'es');
require_once __DIR__ . '/services/WhatsAppService.php';
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'];
$testLink = $baseUrl . '/ver_formulario_enviado.php?token=TEST123';
echo "Plantilla: {$testTpl} [{$testLang}]\n";
echo "Body: " . ($tplRow['body_text'] ?? '—') . "\n";
echo "Params: ['A001']\n";
echo "URL botón: {$testLink}\n\n";
require_once __DIR__ . '/services/WhatsAppService.php';
try {
$wa = new WhatsAppService('turnero');
$rawComps = [
['type' => 'body', 'parameters' => [['type' => 'text', 'text' => 'A001']]]
];
$rawComps[] = [
'type' => 'button',
'sub_type' => 'url',
'index' => '0',
'parameters' => [['type' => 'text', 'text' => 'TEST123']],
];
$wa->sendTemplateMessage($testCel, $testTpl, $testLang, [], [], $rawComps);
echo "✅ Mensaje enviado a {$testCel}\n";
} catch (\Throwable $e) {
echo "❌ Error: " . $e->getMessage() . "\n";
}
} else {
echo "5. TEST DE ENVÍO\n";
echo "─────────────────\n";
echo "Agrega ?test_cel=573001234567 a la URL para enviar un mensaje de prueba\n";
}
echo "\n════ FIN ════\n";
+34
View File
@@ -4,6 +4,11 @@
* NO REQUIERE AUTENTICACIÓN. Acceso via token: ?t=TOKEN
*/
$token = trim($_GET['t'] ?? '');
// Si el token es un UUID (viene del turnero vía plantilla WA), redirigir al flujo de consentimiento
if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $token)) {
header('Location: ver_formulario_enviado.php?token=' . urlencode($token));
exit;
}
?>
<!DOCTYPE html>
<html lang="es">
@@ -550,6 +555,35 @@ function renderCampos(esquema, prefilled) {
<label class="form-check-label">${esc(o)}</label>
</div>`).join('');
html += wrapOpen + `<div class="mb-3">${lbl}${opts}${linkedNote}</div>` + wrapClose;
} else if (c.tipo === 'fecha_hoy') {
let calcVal = pad[c.id] || '';
if (!calcVal) {
const now = new Date();
calcVal = now.getFullYear() + '-'
+ String(now.getMonth()+1).padStart(2,'0') + '-'
+ String(now.getDate()).padStart(2,'0');
}
html += wrapOpen + `<div class="mb-3">${lbl}
<input type="date" class="form-control" style="background:#fff9e6;border-color:#ffc107"
name="${c.id}" value="${esc(calcVal)}" ${req}>
</div>` + wrapClose;
} else if (c.tipo === 'edad') {
let calcVal = pad[c.id] || '';
if (!calcVal) {
const fnac = pad['__paciente']?.fecha_nacimiento || pad.fecha_nacimiento || '';
if (fnac) {
const hoy = new Date();
const bday = new Date(fnac);
let edad = hoy.getFullYear() - bday.getFullYear();
const m = hoy.getMonth() - bday.getMonth();
if (m < 0 || (m === 0 && hoy.getDate() < bday.getDate())) edad--;
calcVal = edad >= 0 ? String(edad) : '';
}
}
html += wrapOpen + `<div class="mb-3">${lbl}
<input type="number" min="0" max="120" class="form-control" style="background:#fff9e6;border-color:#ffc107"
name="${c.id}" value="${esc(calcVal)}" ${req} placeholder="Edad en años">
</div>` + wrapClose;
} else {
const t = c.tipo === 'numero' ? 'number'
: (c.tipo === 'fecha' || c.linked_key === 'fecha_nacimiento') ? 'date'
+204 -18
View File
@@ -215,10 +215,47 @@ $formId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
::-webkit-scrollbar-thumb { background: #c1c9d4; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #8fa0b5; }
/* ── Drop indicators (touch drag) ─────────────────────── */
.field-item.touch-dragging { opacity: .3; }
.field-item.drop-above::before {
content: ''; position: absolute; top: -4px; left: 0; right: 0;
height: 3px; background: #1565c0; border-radius: 2px; pointer-events: none;
}
.field-item.drop-below::after {
content: ''; position: absolute; bottom: -4px; left: 0; right: 0;
height: 3px; background: #1565c0; border-radius: 2px; pointer-events: none;
}
#drag-ghost {
position: fixed; pointer-events: none; z-index: 9999;
background: #fff; border: 1.5px solid #1565c0; border-radius: 8px;
box-shadow: 0 8px 28px rgba(21,101,192,.25);
padding: 9px 12px; display: flex; align-items: center; gap: 8px;
font-size: 12.5px; font-weight: 600; color: #1565c0;
opacity: .92; transition: none;
}
/* ── Palette tap hint ──────────────────────────────────── */
.palette-item { touch-action: none; }
@media (pointer: coarse) {
.palette-item::after {
content: '+'; margin-left: auto; font-size: 15px;
font-weight: 700; color: #1565c0; opacity: .5;
}
.field-item .fi-handle {
font-size: 18px; padding: 4px 6px;
color: #90a4ae;
}
.field-item .fi-btn { padding: 6px 8px; font-size: 14px; }
}
@media (max-width: 900px) {
#panel-palette { width: 170px; min-width: 170px; }
#panel-preview { width: 260px; min-width: 260px; }
}
@media (max-width: 680px) {
#panel-preview { display: none; }
#panel-palette { width: 140px; min-width: 140px; }
}
/* ── Pantalla de éxito ─────────────────────────────── */
#success-screen {
@@ -295,6 +332,8 @@ $formId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
<div id="palette"></div>
<div class="palette-group-title" style="margin-top:12px">Vinculados al paciente</div>
<div id="palette-linked"></div>
<div class="palette-group-title" style="margin-top:12px">Calculados</div>
<div id="palette-calc"></div>
</div>
</div>
@@ -479,6 +518,10 @@ const TIPOS_CAMPO = [
{ tipo:'parrafo_inline', label:'Párrafo con campos', icon:'fa-align-left' },
{ tipo:'lista_marcable', label:'Lista marcable', icon:'fa-list-ol' },
];
const TIPOS_CALC = [
{ tipo:'fecha_hoy', label:'Fecha actual', icon:'fa-calendar-day' },
{ tipo:'edad', label:'Edad del paciente', icon:'fa-user-clock' },
];
const TIPOS_LINKED = [
{ key:'nombre_completo', label:'Nombre completo', icon:'fa-user' },
{ key:'numero_documento', label:'N.º documento', icon:'fa-id-card' },
@@ -529,25 +572,27 @@ function renderPalette() {
$('palette').innerHTML = TIPOS_CAMPO.map(t => `
<div class="palette-item" draggable="true"
data-tipo="${t.tipo}"
ondragstart="palDragStart(event,'${t.tipo}')">
ondragstart="palDragStart(event,'${t.tipo}')"
onclick="agregarCampo({tipo:'${t.tipo}',linked:null})">
<i class="fas ${t.icon}"></i>${t.label}
</div>`).join('');
$('palette-linked').innerHTML = TIPOS_LINKED.map(t => `
<div class="palette-item linked" draggable="true"
data-linked="${t.key}"
ondragstart="palDragStart(event,'linked','${t.key}')">
data-tipo="linked" data-linked="${t.key}"
ondragstart="palDragStart(event,'linked','${t.key}')"
onclick="agregarCampo({tipo:'linked',linked:'${t.key}'})">
<i class="fas ${t.icon}"></i>${t.label}
</div>`).join('');
// Click también agrega campo (además del drag)
document.querySelectorAll('.palette-item').forEach(el => {
el.addEventListener('click', () => {
const tipo = el.dataset.tipo || 'linked';
const linked = el.dataset.linked || null;
agregarCampo({ tipo, linked });
});
});
$('palette-calc').innerHTML = TIPOS_CALC.map(t => `
<div class="palette-item" draggable="true"
data-tipo="${t.tipo}"
ondragstart="palDragStart(event,'${t.tipo}')"
onclick="agregarCampo({tipo:'${t.tipo}',linked:null})"
style="color:#856404">
<i class="fas ${t.icon}"></i>${t.label}
</div>`).join('');
}
// ════════════════════════════════════════════════════════════════════
@@ -595,6 +640,10 @@ function agregarCampo({ tipo, linked }) {
campo = { id, tipo:'parrafo_inline', contenido:'Yo, {nombre_completo}, con N.º de identificación {numero_documento}, declaro que…' };
} else if (tipo === 'lista_marcable') {
campo = { id, tipo:'lista_marcable', label:'Marque con X la prueba solicitada', items:['Opción 1','Opción 2'], required:false };
} else if (tipo === 'fecha_hoy') {
campo = { id, tipo:'fecha_hoy', label:'Fecha', required:false };
} else if (tipo === 'edad') {
campo = { id, tipo:'edad', label:'Edad', required:false };
} else {
campo = { id, tipo, label:'Campo sin título', placeholder:'', required:false };
}
@@ -610,7 +659,8 @@ const TIPO_ICON = {
texto:'fa-font', textarea:'fa-align-left', numero:'fa-hashtag',
fecha:'fa-calendar', hora:'fa-clock', select:'fa-list', radio:'fa-dot-circle',
checkbox:'fa-check-square', firma:'fa-signature', firma_profesional:'fa-user-md', separador:'fa-minus', linked:'fa-link',
parrafo:'fa-paragraph', parrafo_inline:'fa-align-left', lista_marcable:'fa-list-ol'
parrafo:'fa-paragraph', parrafo_inline:'fa-align-left', lista_marcable:'fa-list-ol',
fecha_hoy:'fa-calendar-day', edad:'fa-user-clock'
};
function renderCanvas() {
@@ -647,29 +697,139 @@ function renderCanvas() {
</button>
</div>`;
// Reordenar drag
// Reordenar — HTML5 drag (mouse desktop)
div.setAttribute('draggable', 'true');
div.addEventListener('dragstart', e => {
if (e.target.closest('.fi-btn')) { e.preventDefault(); return; }
_dragSrc = idx; _dragTipo = null;
e.dataTransfer.effectAllowed = 'move';
div.classList.add('dragging');
});
div.addEventListener('dragend', () => div.classList.remove('dragging'));
div.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect='move'; });
div.addEventListener('dragend', () => {
div.classList.remove('dragging');
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
fi.classList.remove('drop-above','drop-below'));
});
div.addEventListener('dragover', e => {
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
fi.classList.remove('drop-above','drop-below'));
const r = div.getBoundingClientRect();
div.classList.add(e.clientY < r.top + r.height / 2 ? 'drop-above' : 'drop-below');
});
div.addEventListener('drop', e => {
e.stopPropagation();
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
fi.classList.remove('drop-above','drop-below'));
if (_dragSrc !== null && _dragSrc !== idx) {
const r = div.getBoundingClientRect();
const before = e.clientY < r.top + r.height / 2;
const [moved] = _campos.splice(_dragSrc, 1);
_campos.splice(idx, 0, moved);
const dest = _dragSrc < idx ? (before ? idx - 1 : idx) : (before ? idx : idx + 1);
_campos.splice(dest, 0, moved);
_dragSrc = null;
renderCanvas();
renderPreview();
renderCanvas(); renderPreview();
}
});
// Reordenar — Pointer Events (touch / tablet)
initPointerDrag(div, idx);
zone.appendChild(div);
});
}
// ════════════════════════════════════════════════════════════════════
// POINTER DRAG (touch + tablet)
// ════════════════════════════════════════════════════════════════════
let _pDrag = null;
function initPointerDrag(div, idx) {
const handle = div.querySelector('.fi-handle');
if (!handle) return;
handle.addEventListener('pointerdown', e => {
// Solo si es touch o el botón primario del mouse (pero el mouse usa HTML5)
if (e.pointerType === 'mouse') return;
e.preventDefault();
e.stopPropagation();
_dragSrc = idx; _dragTipo = null;
const rect = div.getBoundingClientRect();
// Crear ghost
const ghost = document.createElement('div');
ghost.id = 'drag-ghost';
ghost.innerHTML = div.querySelector('.fi-icon').outerHTML +
'<span>' + (div.querySelector('.fi-label')?.textContent || '') + '</span>';
ghost.style.top = rect.top + 'px';
ghost.style.left = rect.left + 'px';
ghost.style.width = rect.width + 'px';
document.body.appendChild(ghost);
div.classList.add('touch-dragging');
handle.setPointerCapture(e.pointerId);
_pDrag = { idx, div, ghost, offsetY: e.clientY - rect.top };
});
handle.addEventListener('pointermove', e => {
if (!_pDrag || _pDrag.idx !== idx) return;
e.preventDefault();
const { ghost } = _pDrag;
ghost.style.top = (e.clientY - _pDrag.offsetY) + 'px';
// Indicador de posición
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
fi.classList.remove('drop-above','drop-below'));
ghost.style.display = 'none';
const el = document.elementFromPoint(e.clientX, e.clientY);
ghost.style.display = '';
const target = el?.closest('.field-item');
if (target && target !== _pDrag.div) {
const r = target.getBoundingClientRect();
target.classList.add(e.clientY < r.top + r.height / 2 ? 'drop-above' : 'drop-below');
}
});
const endDrag = e => {
if (!_pDrag || _pDrag.idx !== idx) return;
const { div: origDiv, ghost } = _pDrag;
ghost.style.display = 'none';
const el = document.elementFromPoint(e.clientX, e.clientY);
ghost.remove();
origDiv.classList.remove('touch-dragging');
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
fi.classList.remove('drop-above','drop-below'));
const target = el?.closest('.field-item');
if (target && target !== origDiv) {
const targetIdx = parseInt(target.dataset.idx);
const r = target.getBoundingClientRect();
const before = e.clientY < r.top + r.height / 2;
if (!isNaN(targetIdx) && targetIdx !== idx) {
const [moved] = _campos.splice(idx, 1);
const dest = idx < targetIdx
? (before ? targetIdx - 1 : targetIdx)
: (before ? targetIdx : targetIdx + 1);
_campos.splice(dest, 0, moved);
renderCanvas(); renderPreview();
}
}
_pDrag = null; _dragSrc = null;
};
handle.addEventListener('pointerup', endDrag);
handle.addEventListener('pointercancel', e => {
if (!_pDrag || _pDrag.idx !== idx) return;
_pDrag.ghost.remove();
_pDrag.div.classList.remove('touch-dragging');
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
fi.classList.remove('drop-above','drop-below'));
_pDrag = null; _dragSrc = null;
});
}
function eliminarCampo(idx) {
_campos.splice(idx, 1);
renderCanvas();
@@ -765,6 +925,20 @@ function renderCampoPreview(c) {
).join('');
return `<div class="preview-field">${lbl}${items}</div>`;
}
if (c.tipo === 'fecha_hoy') {
const hoy = new Date();
const val = hoy.getFullYear()+'-'+String(hoy.getMonth()+1).padStart(2,'0')+'-'+String(hoy.getDate()).padStart(2,'0');
return `<div class="preview-field">${lbl}
<input type="date" value="${val}" disabled style="background:#fff9e6;border-color:#ffc107">
<small style="color:#856404;font-size:9px"><i class="fas fa-calendar-day"></i> Fecha actual — editable</small>
</div>`;
}
if (c.tipo === 'edad') {
return `<div class="preview-field">${lbl}
<input type="number" placeholder="Ej: 34" disabled style="background:#fff9e6;border-color:#ffc107">
<small style="color:#856404;font-size:9px"><i class="fas fa-user-clock"></i> Edad calculada del paciente — editable</small>
</div>`;
}
const t = c.tipo==='numero'?'number':c.tipo==='fecha'?'date':c.tipo==='hora'?'time':'text';
return `<div class="preview-field">${lbl}
<input type="${t}" disabled placeholder="${esc(c.placeholder||'')}"></div>`;
@@ -878,6 +1052,18 @@ function abrirEditorCampo(idx) {
<input class="form-check-input" type="checkbox" id="ce-required" ${c.required?'checked':''}>
<label class="form-check-label small" for="ce-required">Requiere al menos una selección</label>
</div>`;
} else if (c.tipo === 'fecha_hoy' || c.tipo === 'edad') {
html += `<div class="alert alert-warning py-2 small mb-2">
<i class="fas fa-calculator me-1"></i>
${c.tipo === 'fecha_hoy'
? 'Se pre-llenará con la fecha actual al abrir el formulario.'
: 'Se pre-llenará con la edad calculada a partir de la fecha de nacimiento del paciente.'}
El paciente puede editarlo.
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="ce-required" ${c.required?'checked':''}>
<label class="form-check-label small" for="ce-required">Campo obligatorio</label>
</div>`;
} else if (c.tipo === 'firma' || c.tipo === 'firma_profesional') {
if (c.tipo === 'firma_profesional') {
const cm = c.modos || ['canvas'];
+17
View File
@@ -445,6 +445,8 @@ function renderFormularios() {
<i class="fas fa-paper-plane me-1"></i>Enviar
</button>
${PUEDE_ESCRIBIR ? `
<button class="btn btn-sm btn-outline-secondary" onclick="clonarFormulario(${f.id},'${esc(f.nombre)}')"
title="Clonar formulario"><i class="fas fa-copy"></i></button>
<button class="btn btn-sm btn-outline-primary" onclick="builder.editar(${f.id})"
title="Editar diseño"><i class="fas fa-edit"></i></button>
<button class="btn btn-sm btn-outline-danger" onclick="confirmarBorrar(${f.id},'${esc(f.nombre)}')"
@@ -456,6 +458,21 @@ function renderFormularios() {
`).join('');
}
async function clonarFormulario(id, nombre) {
if (!confirm(`¿Clonar el formulario "${nombre}"?\nSe creará una copia con el nombre "Copia de ${nombre}".`)) return;
const r = await fetch('api/lab/save_formulario.php', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ id, clonar: true }),
});
const d = await r.json();
if (d.success || d.ok) {
showToast('✅ Formulario clonado');
cargarFormularios();
} else {
alert(d.error || 'Error al clonar');
}
}
async function confirmarBorrar(id, nombre) {
if (!confirm(`¿Eliminar el formulario "${nombre}"?\nLos envíos existentes se conservarán.`)) return;
const r = await fetch('api/lab/save_formulario.php', {
@@ -0,0 +1,16 @@
<?php
/**
* Migración: Activar número WhatsApp del Turnero
* Phone Number ID: 1154129601114656
* Ya registrado en Meta — solo queda guardarlo en system_config.
*/
require_once __DIR__ . '/../config/config.php';
try {
saveConfigToDB('whatsapp_phone_number_id_turnero', '1154129601114656');
echo "OK: whatsapp_phone_number_id_turnero = 1154129601114656\n";
} catch (Exception $e) {
echo "ERROR: " . $e->getMessage() . "\n";
exit(1);
}
@@ -0,0 +1,9 @@
-- Migración: agregar columna `canal` a la tabla conversations
-- Permite distinguir mensajes del bot (canal='bot') del turnero (canal='turnero')
-- Todos los registros existentes quedan como 'bot' (sin pérdida de datos)
ALTER TABLE `conversations`
ADD COLUMN IF NOT EXISTS `canal` VARCHAR(20) NOT NULL DEFAULT 'bot' AFTER `status`;
ALTER TABLE `conversations`
ADD INDEX IF NOT EXISTS `idx_conversations_canal` (`canal`);
@@ -0,0 +1,4 @@
ALTER TABLE turnero_solicitudes
ADD COLUMN embarazada TINYINT(1) NOT NULL DEFAULT 0
COMMENT 'Indica si la paciente está embarazada al momento de la solicitud'
AFTER observaciones;
@@ -0,0 +1,2 @@
ALTER TABLE turnero_lugares
ADD COLUMN IF NOT EXISTS formulario_modo ENUM('link','embebido') NOT NULL DEFAULT 'link';
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS medicos (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
codigo VARCHAR(20) NOT NULL UNIQUE,
nombres VARCHAR(100) NOT NULL,
apellidos VARCHAR(100) NOT NULL,
direccion VARCHAR(200) DEFAULT NULL,
telefonos VARCHAR(50) DEFAULT NULL,
email VARCHAR(100) DEFAULT NULL,
beeper VARCHAR(50) DEFAULT NULL,
cod_especialidad VARCHAR(10) DEFAULT NULL,
cod_ciudad VARCHAR(10) DEFAULT NULL,
docidmedico VARCHAR(30) DEFAULT NULL,
activo TINYINT(1) NOT NULL DEFAULT 1,
creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
actualizado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+447
View File
@@ -0,0 +1,447 @@
-- Seed: 444 médicos importados desde DBLAB_XIMENA_FB25.FDB
INSERT INTO medicos (codigo, nombres, apellidos, direccion, telefonos, email, beeper, cod_especialidad, cod_ciudad, docidmedico) VALUES
('REHA', 'Rehabilitar', '.', '', '', '', '', '.', '54001', ''),
('ULP', 'Urb. Las Palmas', '.', '', '', '', '', '.', '54001', ''),
('YEAR', 'Yezmin E', 'Abrahim R', 'Calle 18 1ae-31', '', '', '5717607-3005658', 'PSQ', '54001', ''),
('JAG', 'Johanna Carolina', 'Acosta Guio', 'Cl 14a 1e-41 Caobos', '5943124-593244', '', '', 'NL', '54001', '52693593'),
('AF', 'Andres', 'Afanador', '', '', '', '', '.', '54001', ''),
('OA', 'Omar Javier', 'Albarracin Acosta', '', '', '', '', 'NLF', '54001', ''),
('JEAG', 'Johan E', 'Alfaro Garzon', '', '', '', '', 'GEN', '54001', ''),
('HOAM', 'Hugo Oswaldo', 'Alvarado Montañez', 'Cm San Jose Consultorio 213b', '', '', '', 'END', '54001', ''),
('JAAA', 'Javier Alexis', 'Alvarez Arciniegas', 'Sanaty Ips', '5955421', '', '', 'GEN', '54001', '88190024'),
('AAD', 'Arevalo Duran', 'Alvaro', '', '', '', '', 'DE', '54001', ''),
('HAS', 'Hector', 'Amaya Santiago', '', '', '', '', 'GEN', '54001', ''),
('EAC', 'Emiro', 'Andrade Chaparro', 'Calle 13 No 2e', '', '', '', 'PLA', '54001', ''),
('VAA', 'Victor', 'Antolinez Ayala', '', '', '', '', 'QX', '54001', '19486001'),
('VEAA', 'Victor Enrique', 'Antolinez Ayala', '', '', '', '', 'QX', '54001', '19486001'),
('MAAQ', 'Maria Alejandra', 'Aranda Quintero', '', '', '', '', 'GEN', '54001', '1090529115'),
('CJA', 'Carlos Jose', 'Arciniegas Pinto', 'Cm Jerico', '', '', '', 'CA', '54001', ''),
('IAR', 'Isnardo', 'Ardila Rueda', '', '', '', '', '.', '54001', ''),
('NJA', 'Nestor Julian', 'Arenas', 'Uronorte', '', '', '', 'URO', '54001', ''),
('AA', 'Alvaro', 'Arevalo Duran', '4346/91', '', '', '', 'DE', '54001', '88135768'),
('SA', 'Susana', 'Arias', 'Calle 13 No,1e-44 Caobos Cons', '3144392817', 'info@centranut.com', '3144392817', 'NUT', '54001', ''),
('AFAS', 'Andres Felipe', 'Arias Sanchez', 'Calle 19 No 15e-41', '5771188', 'infectopedces@gmail.com', '3502165991', 'PE', '54001', ''),
('JA', 'Jairo', 'Ascencio', 'Cm Samanes', '', '', '', 'PE', '54001', ''),
('999', 'No', 'Asignado', '.', NULL, NULL, NULL, '.', '23001', NULL),
('JASE', 'Jose Antonio', 'Assaf Elcure', '', '', '', '', '.', '54001', ''),
('LAP', 'Liliana', 'Atuesca Palacio', 'Centro Med Norte Cons 100', '', 'lituescapa@hotmail.com', '', 'PE', '54001', ''),
('HOAZ', 'Hollman Omar', 'Avendaño Zambrano', '', '', '', '', 'GI', '54001', '19494294'),
('MYAC', 'Mabel Yaneth', 'Avila C', 'Bucaramanga', '', '', '', 'DE', '54001', ''),
('AJAS', 'Ambar Johana', 'Avila Salazar', '', '', '', '', 'GAS', '54001', '469330'),
('EFAS', 'Edgar Fernando', 'Ayala Sierra', '', '', '', '', 'MI', '54001', '1054091243'),
('OBC', 'Orlando', 'Ballen Caceres', 'Cooneuro', '5943165-3135859398', '', '', 'NL', '54001', ''),
('ABR', 'Angelica Maria', 'Barba Rueda', 'Calle 10 15e-41 Urb La Esperan', '', '', '', 'PE', '54001', ''),
('LAB', 'Liliana', 'Barbosa', '', '', '', '', '.', '54001', ''),
('MEB', 'Maria Eugenia', 'Barbosa R', 'Cm Ume', '3005731327', '', '', 'PE', '54001', '9410/86'),
('FCBH', 'Franklin Camilo', 'Baron Hernandez', 'Ecopetrol', '', '', '', 'GEN', '54001', '1019131702'),
('CRVJ', 'Carlos Roberto', 'Baron Jaimes', 'Calle 9 Av 11e', '', '', '', 'HE', '54001', ''),
('AB', 'Alvaro', 'Barrera Prada', 'Cl 7 9e-15 Colsag', '', 'abarreramd@hotmail.com', '', 'EN', '54001', '79533170'),
('ATBC', 'Angel Tercero', 'Barreto Castilla', 'Vitta', '', '', '', 'ORT', '54001', '1022384187'),
('JBM', 'Johandry', 'Bastidas', '', '', '', '', 'GAS', '54001', ''),
('AJBR', 'Alain Jasaf', 'Bautista', 'Cm Jerico Cons 603', '', 'alainjasaf@yahoo.es', '', 'RE', '54001', ''),
('JEBG', 'Jaime Ernesto', 'Bautista Gomez', 'Av 0 No 12-05 Con 207', '', '', '', 'OTO', '54001', ''),
('SB', 'Samuel Enrique', 'Bautista V', 'Csj Torre B Cons 311b', '3015884166', 'samuelbautistamd@hotmail.com', '3124247628', 'GI', '54001', ''),
('JB', 'Josue', 'Becerra', 'Uronorte', '', '', '', 'URO', '54001', ''),
('LCBA', 'Luis Carlos', 'Becerra A', 'Jerico', '', '', '', 'ORT', '54001', ''),
('PBO', 'Paulo', 'Becerra O.', '', '', '', '', 'FI', '54001', ''),
('OBCH', 'Otmaro', 'Belalcázar Chaves', '', '', '', '', 'MI', '54001', '98364362'),
('GB', 'Giovanny', 'Beltran', 'Cll 14 A 1e-41 Caobos', '', '', '', '.', '54001', ''),
('SMBD', 'Sergio Mauricio', 'Beltran Diaz', '', '', '', '', '.', '54001', ''),
('FB', 'Federico', 'Bencardino Carpio', 'Cm Riviera', '', '', '', 'URO', '54001', '13373080'),
('FBA', 'Fabio', 'Berbesi Alvarez', 'Cm Norte Con 309', '', '', '', 'NEU', '54001', '13443663'),
('JAB', 'Jose Alexander', 'Bermudez', 'Cooneuro', '', '', '', 'NL', '54001', ''),
('FMB', 'Felix Martin', 'Bermudez Santaella', 'Cm Norte Cons 306', '', '', '', 'PE', '54001', ''),
('DAB', 'Diego Andres', 'Blanco Fuentes', 'Cm Jerico Cons 406', '', 'drblancoped@gmail.com', '', 'PE', '54001', '88254168'),
('MTBF', 'Miguel Tonno', 'Botta Fernandez', 'Uronorte', '', '', '', 'URO', '54001', ''),
('GBF', 'Giancarlo', 'Botta Fernandez', 'Cm Samanes Cons 404', '', '', '', 'ORT', '54001', ''),
('JCGM', 'Juan Carlos', 'Brahim Muñoz', '', '3005561734', '', '', 'CGR', '54001', '15310'),
('JIBT', 'Jose Ignacio', 'Bravo Torres', '', '', '', '', 'CGR', '54001', '19333825'),
('LMBA', 'Luz Marina', 'Buenaer Arevalo', '', '5753685', '', '', 'BAC', '54001', ''),
('RAB', 'Ricardo Andres', 'Bustamente', '', '', '', '', 'MG', '54001', '1234093720'),
('SCO', 'Sergio', 'Caceres Orozco', 'Calle 17 No. 1e-138', '', '', '', 'DE', '54001', '19488514'),
('MECO', 'Maria Eugenia', 'Caceres Orozco', '', '', '', '', 'BAC', '54001', ''),
('CMFNT', 'Comfanorte', 'Caja De Compensacion', '000', '000', '', '', '.', '54001', '000'),
('RCG', 'Rossana', 'Calderon Garcia', '', '', '', '', 'PE', '54001', '1051442516'),
('OCH', 'Oswaldo', 'Calvache H', 'Edi Ingrid Cons 205', '', '', '', '.', '54001', ''),
('LHC', 'Luis', 'Campos', 'Cucuta', '', '', '', 'MG', '54001', '88196186'),
('PSCG', 'Pilar Sofia', 'Cardenas Garcia', '', '', '', '', '.', '54001', ''),
('ECR', 'Edgar', 'Cardona Reyes', 'Calle 15a No 1e-31', '', 'edgarcardonareyes@hotmail.com', '', '.', '54001', ''),
('AFCZ', 'Andres Felipe', 'Cardona Zorrilla', '', '', '', '', 'ON', '54001', '79940178'),
('LECJ', 'Leonardo Enrique', 'Carrascal Jacome', '', '', '', '', 'NUT', '54001', ''),
('FJCB', 'Fernando Jose', 'Carrasco Blanco', 'Ctro Espe Santa Ana', '3202745025', '', '', 'CA', '54001', '13487485'),
('ROSSYCG', 'Rossy', 'Carrillo Gutierrez', '', '', '', '', 'CAR', '54001', '1127348421'),
('JACM', 'Jose Armando', 'Carrillo M', 'Jerico Cons 708', '', '', '', '.', '54001', ''),
('LMCQ', 'Lucia M', 'Carrillo Quintero', '', '', '', '', 'GEN', '54001', '55250333'),
('GECF', 'Gustavo E', 'Carvajal Franklin', 'Av12 4-38 Cons 102', '3202745025', '', '', 'GAS', '54001', ''),
('LACA', 'Luis Alfonso', 'Casanova Arambula', 'Fresenius', '', '', '', 'URO', '54001', ''),
('TCG', 'Tatyiana', 'Casilimas Garzon', '', '', '', '', 'MG', '54001', '39703462'),
('HCM', 'Hector', 'Castaño Moreno', '', '', '', '', 'MC', '54001', '10255796'),
('IECG', 'Ivan Enrique', 'Castiblanco Gomez', '', '', '', '', 'OTO', '54001', '764'),
('NC', 'Noe', 'Castro Gomez', 'Cm San Jose', '5821111', '', '', 'MI', '54001', '13255814'),
('CCL', 'Carlos', 'Castro Lobo', 'Colegioio Medico Of 304', '', '', '', 'GI', '54001', ''),
('NGCP', 'Nelson G', 'Castro Perez', 'Cm San Jose Cons 305a', '', '', '', '.', '54001', ''),
('DLCR', 'Derly Liseth', 'Castro Rojas', '', '', '', '', '.', '54001', ''),
('AGZ', 'Agustin', 'Castro Zapata', '', '5714417', '', '', 'CA', '54001', '13451600'),
('IVCG', 'Ithala Valentina', 'Celis Gutierrez', 'Cucuta', '', '', '', 'MG', '54001', '1004921250'),
('LAC', 'Luis Antonio', 'Cely', 'Fresenius', '', '', '', 'NF', '54001', '13504412'),
('XMCM', 'Ximena Marcela', 'Cervantes M', 'Av 1e No. 11-52', '5951155-3144587047', 'dra.ximenacervantes@gmail.com', '3505125885', 'PE', '54001', '52262577'),
('CEC', 'Carlos Enrique', 'Chacon', 'Clinica Norte Cardiologia', '', '', '', 'CAR', '54001', ''),
('ICF', 'Ivan', 'Chacon Florez', 'Santa Ana', '', '', '', '.', '54001', ''),
('MCR', 'Miguel Alfonso', 'Chahin Rueda', 'Cm Calle 15 Cons 5', '5831598', 'mchahinr@yahoo.es', '3002412558', 'MI', '54001', '79351230'),
('AC', 'Adriana', 'Chaves', 'Uronorte', '', '', '', 'URO', '54001', ''),
('JAC', 'Johanna Andrea', 'Chavez', '', '', '', '', '.', '54001', ''),
('ANCHM', 'Angie Natalia', 'Chona Marquez', '', '', '', '', 'GEN', '54001', '1094285502'),
('FAC', 'Fernando A', 'Cianci', '', '', '', '', 'CGR', '54001', ''),
('RDCC', 'Richard D', 'Claro C', '', '', '', '', 'PE', '54001', '1127953864'),
('RJCJ', 'Roberto Jose', 'Claro Jure', 'Avenida 1 No 11-58', '', '', '', 'GEN', '54001', ''),
('PC', 'Perfect', 'Clinic', '', '', '', '', '.', '54001', ''),
('PECP', 'Pablo Enrique', 'Colmenares Porras', '', '', '', '', 'DE', '54001', '13243877'),
('CMC', 'Centro Medico', 'Colsanitas', 'Av 0 No 15-56', '', '', '', '.', '54001', '9010416914'),
('CCC', 'Cecilia', 'Concha Cortes', 'Cm Norte Cons 201', '5718716-5712367', 'concha_cecilia@yahoo.com', '', 'MG', '54001', '39689931'),
('LFCB', 'Luis Fernando', 'Conde Buitrago', 'Cm Norte Cons 203', '', '', '', 'CGR', '54001', ''),
('DACD', 'Daniel Alexander', 'Contreras Duarte', '', '', '', '', 'MG', '54001', ''),
('AMCF', 'Armando Moises', 'Contreras Fernandez', 'Cucuta', '', '', '', 'NL', '54001', '37339724'),
('GACG', 'Gustavo Adolfo', 'Contreras Garcia', 'Cll 7 9e-15 Colsag', '', 'gustacon@yahoo.es', '', 'GAS', '54001', ''),
('SYCL', 'Sindy Yulieth', 'Contreras Leal', '', '', '', '', 'MG', '54001', '1090391219'),
('KCO', 'Krisell', 'Contreras Omaña', '', '', '', '', 'MI', '54001', ''),
('TC', 'Tulia', 'Copete', 'Calle 8 5e-68 Urb Sayago Rivie', '5921372-3155501207', '', '', 'ES', '54001', ''),
('JGCB', 'Javier Gonzalo', 'Corona Bueno', 'Jerico Con 602', '5721047-3209809045', '', '', '.', '54001', ''),
('JC', 'Julio Ernesto', 'Coronel Becerra', 'Avenida O No 15-29', '5716818-5718301', '', '', 'CA', '54001', '34320'),
('NSCF', 'Nicol Stefanny', 'Corredor Figueredo', 'Av 1 17-93 Con 09', '3166176214-5833746', '', '', 'GI', '54001', '00150'),
('IPSFC', 'Ips Figuras', 'Cucuta', '', '', '', '', 'ES', '54001', ''),
('AYC', 'Andres Yesid', 'Cuellar', '', '', '', '', 'GEN', '54001', '1090501619'),
('MJCR', 'María Juliana', 'Cáceres Rueda', 'Clinica De La Piel Caobos', '3168350578', 'caceresderma@gmail.com', '3168350578', 'DE', '54001', '1090455804'),
('DFD', 'Diego Fernando', 'Dallos', '', '', '', '', 'URO', '54001', ''),
('CDLR', 'Carolina', 'De La Rosa', 'Calle 14a No 2e-22', '3507282887', '', '', 'GAS', '54001', '37392625'),
('JADR', 'Javier Alfonso', 'De La Rosa Pareja', 'Calle 11 Av 0, Cons 706', '', '', '', 'GAS', '54001', ''),
('CENS', 'Centrales Electricas', 'Del Norte De Santander', '', '', '', '', 'MG', '54001', ''),
('GADS', 'Gustavo Adolfo', 'Delgado Sierra', 'Avenida 11a No 8a-39 Cosn 103', '', 'gadelgados@bt.unal.edu.co', '', '.', '54001', ''),
('ADC', 'Armando', 'Diaz Cardenas', 'Cmn', '5717286-3173790402', '', '', 'OTO', '54001', '13448326'),
('MDC', 'Manuel', 'Diaz Caro', 'Uronorte', '', '', '', 'URO', '54001', ''),
('ADD', 'Alberto', 'Diaz Diaz', '', '', '', '', 'OF', '54001', ''),
('ADV', 'Armando', 'Diaz Vergel', '', '', '', '', '.', '54001', ''),
('CLAUDIA', 'Claudia', 'Dominguez', '', '', '', '', 'MG', '54001', ''),
('MLDI', 'Maria Liliana', 'Dorado Illera', '', '', '', '', 'MG', '54001', ''),
('CIDB', 'Clara Ines', 'Duarte Barreto', 'Colegio Medico', '', '', '', 'BAC', '54001', ''),
('AMDL', 'Angelica Maria', 'Duque Leal', '', '', '', '', 'GIN', '54001', ''),
('FDO', 'Frankiln', 'Duran Omeara', '', '', '', '', '.', '54001', ''),
('JFDP', 'Javier F', 'Duran Pachon', '', '', '', '', 'GI', '54001', '13509603'),
('EEL', 'Emilio', 'Escalante Leiva', '', '', '', '', 'GI', '54001', ''),
('LEEL', 'Luis Emilio', 'Escalante Luzardo', 'Cm San Jose', '', 'luisemilioescalante@hotmail.com', '', 'GI', '54001', ''),
('GEP', 'Gilary Andrea', 'Eslava Prieto', '', '', '', '', 'MG', '54001', '1094281517'),
('ZMEO', 'Zully M', 'Espinel O', 'Cm San Jose Cons 301a', '', '', '', 'NUT', '54001', ''),
('CFET', 'Claudia Fernanda', 'Estupiñan Trujillo', '', '', '', '', 'MI', '54001', ''),
('EJFJ', 'Eduardo Jose', 'Fajardo Jaimes', '', '', '', '', 'MG', '54001', ''),
('RAFP', 'Rafael Alberto', 'Fandiño Prada', 'Coneuro', '', '', '', 'QX', '54001', ''),
('JCFR', 'Juan Carlos', 'Fernandez Romero', '', '', '', '', 'PLA', '54001', ''),
('LFU', 'Leonardo', 'Fernandez Ustariz', 'Unidad Hematologica', '', '', '', '.', '54001', ''),
('COFD', 'Carlos Omar', 'Figueredo Diettes', 'Av 0 20-38 B Blanco', '5730619-5719143-', 'drcofigueredod@hotmail.com', '3052433956', 'ON', '54001', '91277299'),
('JFM', 'Joaquin', 'Figueredo Molina', 'Avenida 1 No 15-92', '', 'ceginob@hotmail.com', '', 'GI', '54001', ''),
('CAFM', 'Carlos Arturo', 'Figueredo Molina', '', '', '', '', 'GI', '54001', ''),
('MSF', 'Maria Sofia', 'Figueroa', '', '', '', '', 'GEN', '54001', ''),
('IPS', 'Ips', 'Figuras', '', '', '', '', '.', '54001', ''),
('TF', 'Tatiana', 'Florez', '', '', '', '', 'OF', '54001', ''),
('GFE', 'Gabriel', 'Florez Echeverria', 'Cll 16 1e-110 Caobos', '', '', '', 'OF', '54001', ''),
('SCF', 'Silvia Carolina', 'Florez Faillace', 'Clinica San Diego', '', '', '', 'OF', '54001', ''),
('MLF', 'Martha Lucy', 'Florez Jaimes', '', '', '', '', 'GEN', '54001', ''),
('SLFM', 'Sandra Liliana', 'Florez Muñoz', '', '5740015', '', '3156687785', 'PE', '54001', ''),
('MF', 'Martha Lucia', 'Florez Nuncira', 'Cm Norte Con 308', '', '', '3154740868', 'GI', '54001', '27604104'),
('GAFC', 'German A', 'Foliaco Colmenares', 'Calle 15a # 1e-55', '', '', '', 'PSQ', '54001', ''),
('RF', 'Rafael Dario', 'Forero', '', '', '', '', 'GI', '54001', ''),
('FOTO', 'Fotomilenio', 'Fotomilenio', '', '3208557309', '', '', 'GEN', '54001', '27102020'),
('DLFR', 'Dabeiba Luz', 'Freile Rada', 'Ecopetrol', '', '', '3103410325', 'CGR', '54001', '32822325'),
('PFT', 'Pedro Alonso', 'Fuentes Torrado', 'Cm Jerico Cons 401', '', '', '', 'ORT', '54001', ''),
('MAFT', 'Maria Alexandra', 'Fuentes Troya', '', '', '', '', 'CGR', '54001', '59817259'),
('XGR', 'Martha Ximena', 'Galeano Ramirez', 'Cm San Jose Cosn 511b', '', '', '', 'GI', '54001', ''),
('MLG', 'Martha Lucia', 'Gallardo', '', '', '', '', 'BAC', '54001', ''),
('PGC', 'Pablo', 'Galvis Centurion', 'Cm San Jose Con 511', '', '', '', 'GI', '54001', '88211939'),
('MGM', 'Mario A', 'Galvis Mantilla', 'Avenid 9e No 6-56 Cons 2', '5727258-5407300', '', '3002137949', 'GI', '54001', '91230980'),
('EMG', 'Edgar Mauricio', 'Garcia Acosta', 'Clinica San Diego', '', '', '', 'PLA', '54001', ''),
('JGM', 'Jorge', 'Garcia M', '', '', '', '', '.', '54001', ''),
('JLGM', 'Jorge Luis', 'Garcia Menco', '', '', '', '', 'MI', '54001', '8565776'),
('KBGP', 'Karen Bibiana', 'Garcia Peña', '', '', '', '', 'URO', '54001', '1020786225'),
('007', 'Yohanna', 'Garcia Quintero', 'Calle 10bn #4a-75 El Bosque', '3114685804', 'yohannagarciaq.salud@gmail.com', '3114685804', 'NUT', '54001', '37291582'),
('TGR', 'Tatiana', 'Garcia Rey', 'Bucaramanga', '', '', '', 'OTO', '54001', '1018418737'),
('CJGS', 'Cesar Julio', 'Garcia Sandoval', '', '', '', '', 'MI', '54001', ''),
('HGT', 'Harold H', 'Garcia Touchie', 'Cm Norte Cons 201', '5718716-5712367', 'hhgarciat@gmail.com', '', 'EN', '54001', '13447683'),
('EMGV', 'Eliana', 'Garcia Villamizar', '', '', '', '', 'GIN', '54001', '53007073'),
('MAG', 'Margarita', 'Gelvez', '', '', '', '', 'ES', '54001', ''),
('79792615', 'Julian Arturo', 'Gil Forero', '', '', '', '', 'EN', '54001', '79792615'),
('SGA', 'Sandra', 'Giraldo Aristizabal', 'Av 11e 5an-71', '', 'sandragiraldo97@hotmail.com', '', '.', '54001', ''),
('JGP', 'Jessika', 'Gomez', '', '', '', '', 'EN', '54001', '1127611155'),
('JMGR', 'José Manuel', 'Gomez', '', '', '', '', 'MC', '54001', '13479231'),
('MTGB', 'Marco Tulio', 'Gomez Botello', 'Coneuro-jerico', '', '', '', 'NL', '54001', ''),
('MIGC', 'Maria Ines', 'Gomez C', 'Cm San Jose Cons 401b-freseniu', '', '', '5730137', '.', '54001', ''),
('RGF', 'Ramiro Hernando', 'Gomez Franco', 'Cm Negomon', '', '', '', 'GAS', '54001', ''),
('CEGF', 'Carlos Eduardo', 'Gomez Franco', 'Cm Negomon', '', '', '', 'GI', '54001', '13447569'),
('MCGM', 'Maria Camila', 'Gomez Morales', '', '', '', '', 'FI', '54001', ''),
('SRGP', 'Samuel Ricardo', 'Gomez Pinzon', 'Cm Samanes Cons 107a', '', '', '', 'MI', '54001', ''),
('RLGR', 'Rosa Leonor', 'Gomez Rodriguez', '', '', '', '', 'MG', '54001', '60447600'),
('NG', 'Nataly', 'Gonzalez', '', '', '', '', '.', '54001', ''),
('GGC', 'Guillermo', 'Gonzalez Castro', '', '', '', '', '.', '54001', ''),
('ATG', 'Ana Teresa', 'Govin', '', '', '', '', 'HEM', '54001', ''),
('JRG', 'Jose Ricardo', 'Granados', '', '', '', '', 'NEU', '54001', ''),
('JRGQ', 'Jorge Ricardo', 'Granados Quiñones', '', '', '', '', 'NEU', '54001', '88222482'),
('AGS', 'Alvaro', 'Granados Santafe', 'Csj Torre B Cons 314b', '5717768', '', '', 'RE', '54001', '19295956'),
('AMGG', 'Alex Mauricio', 'Grandados Gomez', '', '', '', '', 'GEN', '54001', ''),
('AGB', 'Amparo', 'Grosso Bonilla', '', '3204947894', '', '', '.', '54001', '46364237'),
('GG', 'Gerson', 'Guarin', '', '', '', '', 'MI', '54001', ''),
('SG', 'Sonia', 'Gutierrez', '', '', '', '', 'BAC', '54001', ''),
('AG', 'Alvaro', 'Gutierrez', '', '', '', '', 'OF', '54001', '5521/82'),
('JPG', 'Judith Patricia', 'Gutierrez', '', '', '', '', 'QX', '54001', '60322196'),
('AGA', 'Andres', 'Gutierrez Aparicio', 'Bogota', '', '', '', '.', '54001', ''),
('WG', 'Wilson', 'Gutierrez Ramirez', 'Cm Norte Cons 310', '', 'wilsongutierrez@gmail.com', '', 'CL', '54001', ''),
('DGGS', 'Daniel Giovanny', 'Gutierrez Silva', '', '3187857873', 'danielgutierrez68@hotmail.com', '', 'CGR', '54001', '6373'),
('JAHT', 'Jose Antonio', 'Hakim Tawil', 'Hospital Santafé De Bogotá', '2152308', 'teleconsultajaht@gmail.com', '', 'CCC', '11001', '10992'),
('NHS', 'Nabil', 'Hamdam S', '', '', '', '', '.', '54001', ''),
('WH', 'William', 'Heredia', '', '', '', '', 'MG', '54001', ''),
('EHF', 'Esteban', 'Hernandez Florez', '', '', '', '', 'HE', '54001', ''),
('JGHR', 'José Gregorio', 'Hernandez Ruiz', '', '', '', '', 'MI', '54001', '79721183'),
('AHT', 'Arturo', 'Hernandez T', 'Cc San Jose Cons 309b', '', '', '', '.', '54001', ''),
('AYHV', 'Andrea Yiseth', 'Hernandez Vasquez', '', '', '', '', '.', '54001', ''),
('CHG', 'Claudia', 'Hurtado', '', '', '', '', 'BAC', '54001', ''),
('NAIY', 'Nestor Alfredo', 'Ibarra Yañez', '54203-2012', '', '', '3232060912', 'AN', '54001', '80719996'),
('100', 'Firma', 'Ilegible', 'Cl', '0', NULL, NULL, '.', '23001', NULL),
('PRO', 'Proesmed', 'Ips', 'Calle 16a No 2e-25 Caobos', '3184414653', 'proesmedips@gmail.com', '5726402', 'MG', '54001', '9010023367'),
('MIS', 'Mario', 'Izquierdo Sandoval', 'Cm San Jose Cons 101a', '', '', '', 'FI', '54001', '17115809'),
('JXJ', 'Jennifer Ximena', 'Jaimes', '', '', '', '', '.', '54001', ''),
('CAJO', 'Carlos Arturo', 'Jaimes O', '', '', '', '', 'MG', '54001', ''),
('JJD', 'Javier', 'Jimenez Duarte', 'Clinica Sandiego', '5960150', '', '', 'OTO', '54001', '79982271'),
('DEJH', 'Diego Enrique', 'Jimenez Hernandez', '', '', '', '', 'GEN', '54001', '1127056461'),
('GJP', 'German', 'Jimenez Pallares', '', '', '', '', 'ALE', '54001', ''),
('CLD', 'Calidad', 'Lab Ximena C', '', '', '', '', 'GEN', '54001', ''),
('AUTO', 'Autoinmunity', 'Laboratorio', '', '', '', '', 'GEN', '54001', ''),
('NLA', 'Nell', 'Lara Arroyo', '', '', '', '', 'FI', '54001', ''),
('CALG', 'Carlos Alberto', 'Larios Garcia', '', '', '', '', 'URO', '54001', '79557883'),
('GENERAL', 'Rodolfo Ramon', 'Leiva Barrera', '', '3165329192', '', '', 'GEN', '54001', ''),
('JALN', 'Jairo Francisco', 'Lizarazo Niño', 'Cooneuro', '', '', '', 'NL', '54001', ''),
('FDLP', 'Freddy Duvan', 'Lizcano Pabon', '', '', '', '', 'GEN', '54001', '1005053470'),
('GL', 'Gabriel', 'Lobo', 'Avenida 9e No 6-58 La Riviera', '5779990-3112786695', '', '', 'CL', '54001', ''),
('MAL', 'Manuel Alejandro', 'Lobo', '', '', '', '', 'URO', '54001', ''),
('LAL', 'Luis Alberto', 'Lobo Jacome', 'Calle 16 # 0e-15', '', '', '', 'URO', '54001', ''),
('RLR', 'Roberto', 'Lobo Rodriguez', 'Av 0 15-25 Cons 201', '', 'robertolobo05@hotmail.com', '', 'ORT', '54001', ''),
('CGL', 'Carlos German', 'Lopez', '', '', '', '', 'GIN', '54001', '79866975'),
('EL', 'Eduard', 'Lopez', '', '', '', '', 'OF', '54001', ''),
('SBLG', 'Sandra Belen', 'Lopez Gomez', 'Cm San Jose Cons 310b', '', '', '', '.', '54001', ''),
('ALG', 'Astrid', 'Lopez Gomez', '', '', '', '', 'GI', '54001', ''),
('DJLM', 'Deivis Jesus', 'Lopez Melo', 'Gastroquirurgica', '5955775', '', '', 'CGR', '54001', '88258550'),
('LFPG', 'Parra Gonzalez', 'Luis Fernando', '', '', '', '', 'CL', '54001', ''),
('JM', 'Jaime', 'Machicado Herrera', 'Cm Norte', '5712622', 'clinicamachicado@gmail.com', '', 'GIN', '54001', ''),
('SMV', 'Santiago', 'Machicado Villamizar', 'Cm Norte', '', '', '', 'GI', '54001', '1125638019'),
('AM', 'Alexandra', 'Madariaga La Roche', 'Cm Jerico', '', '', '', 'GI', '54001', ''),
('MAMR', 'María Alexandra', 'Madariaga Laroche', '', '', '', '', 'ON', '54001', '60449946'),
('HM', 'Harvey', 'Manosalva', 'Calle 16 1e-42 Caobos', '5711047-3005641394', '', '', 'RA', '54001', ''),
('AMMN', 'Adolfo Mario', 'Manotas Navarro', '', '', '', '', 'GEN', '54001', ''),
('MFMD', 'Miguel Fabian', 'Mantilla Duran', 'Av 1 No 17-73 Cosn 504', '5891315-3153255040', 'dr.migue@gmail.com', '', 'ORT', '54001', ''),
('MM', 'Magreth', 'Mariejo', '', '', '', '', '.', '54001', ''),
('LEMM', 'Leonor Eugenia', 'Mariño Murillo', '', '', '', '', 'CAR', '54001', '63556483'),
('JHM', 'Johandry', 'Marquez', '', '', '', '', 'GAS', '54001', ''),
('SPMN', 'Sandra Patricia', 'Martin Niño', 'Calle 7 No 10e-72', '5777676', '', '3156785070', 'GAS', '54001', '7039'),
('MAM', 'Maria Amparo', 'Martinez', '', '', '', '', 'GIN', '54001', ''),
('VLM', 'Victor Leonardo', 'Martinez B', 'Cc Bodytech Local 103a', '', 'victorl_martinez@hotmail.com', '', 'MG', '54001', ''),
('JKMR', 'Jessica Katherine', 'Martinez Rojas', '', '', '', '', 'MG', '54001', '1090413776'),
('GM', 'Gabriel F', 'Matamoros Barreto', 'Calle 21b No 08-106', '', 'pielmedicoslspo@hotmail.com', '', 'DE', '54001', ''),
('LFM', 'Luis Felipe', 'Matamoros Barreto', 'Cm San Jose Cons 511', '', '', '', 'MC', '54001', '13488285'),
('AMV', 'Alexy', 'Maza Villadiego', '', '', '', '', 'ODO', '54001', '73143772'),
('NMA', 'Nelson', 'Mejia Alvarez', '', '', '', '', '.', '54001', ''),
('MCMF', 'Maria Claudia', 'Mejia Fajardo', '', '', '', '', 'MG', '54001', '1037588460'),
('JPM', 'Julieth Patricia', 'Meneses', '', '', '', '', 'GEN', '54001', ''),
('BLK', 'Belkis', 'Meneses', '', '', '', '', 'MI', '54001', '37339724'),
('JMN', 'Juleith Patricia', 'Meneses Navarro', '', '', '', '', 'MC', '54001', ''),
('JMP', 'Juliana', 'Meneses Perez', 'Clinica San Diego', '', '', '', 'OF', '54001', ''),
('JJMC', 'Jorge Jose', 'Mirep Corona', '', '', '', '', 'OTO', '54001', ''),
('WRMR', 'Wiliam R', 'Mogollon Rodriguez', '', '', '', '', 'CL', '54001', ''),
('MMMM', 'Maria Monica', 'Molano Melo', '', '', '', '', 'GEN', '54001', ''),
('WAPV', 'Miguel Angel', 'Molina Lazaro', 'Tibu', '', '', '', 'MG', '54001', '1090516493'),
('JAM', 'Juan Andres', 'Monsalve', '', '', '', '', 'NL', '54001', '91537583'),
('NXM', 'Nohora Ximena', 'Monsalve', 'Cm Norte', '5713690-5712367', '', '', 'PSQ', '54001', '63549320'),
('CM', 'Laboratorio Carlos', 'Montoya Y Suarez Sas', '', '', '', '', 'BAC', '54001', ''),
('YMG', 'Yadira', 'Mora G', '', '', '', '', 'GEN', '54001', ''),
('JAMM', 'Jose Alfonso', 'Mora Morante', '', '', '', '', '.', '54001', ''),
('PM', 'Maria Del Pilar', 'Mora Urbina', 'Clinica San Diego', '', '', '', 'OF', '54001', ''),
('CHMU', 'Carlos Humberto', 'Mora Urbina', 'Coneuro', '', '', '', 'QX', '54001', ''),
('JOMV', 'Javier O', 'Mora V.', 'Av 1e 3-237 Ceiba', '5945523', '', '', 'GI', '54001', ''),
('MGMS', 'Marcos Gabriel', 'Morales Soto', '', '', '', '', 'HE', '54001', ''),
('AMT', 'Alexander', 'Moreno', '', '', '', '', 'DE', '54001', '1245'),
('FAM', 'Francisco A', 'Moreno', '', '', '', '', 'MG', '54001', ''),
('AMF', 'Alexander', 'Moreno Figueredo', '', '', '', '', '.', '54001', ''),
('JMO', 'Juliana', 'Moreno Olaya', 'Rts Caobos', '', '', '', 'NF', '54001', '1098647415'),
('LAMV', 'Luis Alberto', 'Moreno Vera', '', '', '', '', 'CV', '54001', ''),
('MEM', 'Manuel Eduardo', 'Moros Vera', 'Calle 7 No 10e-72', '5777676-3156785070', 'gastroquirurgicaltda@yahoo.com', '', 'GAS', '54001', '1051'),
('LFMA', 'Luis Fernando', 'Muñoz Acosta', '', '', '', '', '.', '54001', ''),
('GAMD', 'German Alberto', 'Muñoz Duran', '', '', '', '', 'NF', '54001', ''),
('LFMG', 'Luis Fernando', 'Muñoz Gomez', '', '', '', '', '.', '54001', ''),
('003', 'Sandra Fabiola', 'Muñoz Peñaloza', '', '58 424-1215638', 'dra.sandramunoz.p@gmail.com', '', 'CGR', '54001', '11017693'),
('ESNR', 'Edwin Sebastian', 'Navarro Rodriguez', '', '', '', '', 'GEN', '54001', '1005384395'),
('FNM', 'Fabian', 'Niño Monsalve', '', '', '', '', '.', '54001', ''),
('FN', 'Freddy', 'Niño Prato', 'Cm San Jose Cons 302', '', '', '', 'EN', '54001', '88220061-3'),
('JNV', 'Jenifer', 'Numa Valdes', '', '', '', '', '.', '54001', ''),
('AOC', 'Alvaro', 'Ochoa Cuberos', '', '', '', '', 'GIN', '54001', ''),
('CO', 'Carlos', 'Olivares', '', '', '', '', '.', '54001', ''),
('FO', 'Felipe', 'Ordoñez', '', '', '', '', '.', '54001', ''),
('JCO', 'Juan Carlos', 'Ortega', 'Cm Jerico', '', '', '', 'CA', '54001', ''),
('LAOG', 'Laura Alejandra', 'Ortega Gonzalez', '', '', '', '', '.', '54001', ''),
('NPC', 'Nancy', 'Pacheco Caceres', '', '', '', '', 'PE', '54001', ''),
('RP1', 'Rafael', 'Padilla', 'Jerico', '', '', '', 'MG', '54001', ''),
('OP', 'Omar', 'Paez', '', '', '', '', 'RE', '54001', '13351535'),
('VP', 'Victor', 'Paez', '', '', '', '', 'MI', '54001', ''),
('ZP', 'Zamir', 'Paez', 'Avenida 1 No 17-73 Cosn 702', '', '', '3504797908', 'CL', '54001', ''),
('JP', 'Juan', 'Paez', 'Av 2e No 17a-02 Caobos', '3024449734', 'dr.juanpaez@medicinafuncional.com', '', 'GEN', '54001', ''),
('LFPC', 'Luis Fernando', 'Paez Carrascal', '', '3138106405', '', '', 'GEN', '54001', '3545'),
('FPC', 'Fernando', 'Paez Carrascal', '', '3124861765', 'ferpaezcarrascal@gmail.com', '3124861765', 'ON', '54001', ''),
('GSPM', 'Gamal Salin', 'Paez Mojica', '', '', '', '', 'CGR', '54001', '1019012573'),
('AMPP', 'Adriana', 'Paez Paez', '', '', '', '', 'GI', '54001', '1026259061'),
('EPS', 'Efrain', 'Paez Suz', 'Av 12e-4-388 Con 104', '5745532-3002099889', '', '', 'MI', '54001', '13446107'),
('DP', 'Deyanira', 'Paipilla', '', '', '', '', 'FIS', '54001', ''),
('CEPT', 'Camilo Ernesto', 'Palencia Tejedor', 'Calle 13a No 1e-112', '5955421', '', '', 'MI', '54001', '7125'),
('CFPA', 'Carlos Fernando', 'Panqueva Arias', '', '3106066503', '', '3106066503', 'PE', '54001', '1090389125'),
('OAPP', 'Oscar Antonio', 'Parada Parada', 'Av 1 17-28', '', '', '', 'RA', '54001', '591'),
('OFPR', 'Oscar Fernando', 'Parada Rojas', '', '', '', '', 'NEU', '54001', '79133328'),
('WPV', 'Wolfgang', 'Parada V.', '', '3144422314', '', '', 'PLA', '54001', ''),
('WLL', 'William', 'Parada Vecino', '', '', '', '', 'GEN', '54001', ''),
('SP', 'Siliana', 'Parejo', '', '', '', '', 'GEN', '54001', ''),
('MAP', 'Miguel Antonio', 'Parra', '', '', '', '3166207878', 'GEN', '54001', ''),
('WAP', 'William Alfonso', 'Parra', '', '', '', '', 'GEN', '54001', ''),
('LP', 'Leonardo', 'Perez', '', '', '', '', '.', '54001', ''),
('WPB', 'William', 'Perez B', '', '', '', '', 'GEN', '54001', ''),
('HPL', 'Hugo', 'Perez Lizcano', '', '', '', '', '.', '54001', ''),
('JCP', 'Juan Carlos', 'Pertuz', '', '', '', '', 'NL', '54001', ''),
('CIP', 'Carlos Ivan', 'Peñaranda', '', '', '', '', '.', '54001', ''),
('JAP', 'Jesus Andres', 'Peñaranda', '', '', '', '', 'GEN', '54001', ''),
('JAPG', 'Jesus Andres', 'Peñaranda Granados', 'Tiub', '', '', '', 'MG', '54810', '1091808592'),
('CFPH', 'Carlos Felipe', 'Peñaranda Henao', 'Calle 17 No 8-88', '5831193', 'retinodrperanda@gmail.com', '3013417494', 'OF', '54001', ''),
('WFP', 'Wilson Fernando', 'Picon', '', '', '', '', '.', '54001', ''),
('OAP', 'Omar Alejandro', 'Pinzon', '', '', '', '', 'MG', '54001', ''),
('DMPR', 'Diana Marcela', 'Pinzon Rincon', '', '', '', '', 'FI', '54001', ''),
('JMPS', 'Jose Manuel', 'Pinzon Sarria', '', '', '', '', 'ORT', '54001', ''),
('RP', 'Ricardo', 'Plazas', 'Jerico', '5717495-5725256', '', '', 'ON', '54001', ''),
('CP', 'Cesar', 'Pompeyo', '', '', '', '', 'GEN', '54001', ''),
('MAUAP', 'Maury Alejandra', 'Porras', '', '', '', '', 'PE', '54001', '1127057554'),
('LPPS', 'Lina Patricia', 'Pradilla Suarez', 'Ecopetrol', '', '', '', 'MI', '54001', ''),
('MNQ', 'Mario', 'Quintero Ocariz', 'Clinica Cancerología', '5835932-5835933', 'mariofqo@yahoo.es', '', 'HE', '54001', '13479403'),
('MFQO', 'Mario Fernando', 'Quintero Ocariz', '', '', '', '', 'HE', '54001', '13479403'),
('DR', 'Daniel', 'Ramirez', '', '', '', '', 'MI', '54001', ''),
('JRF', 'Javier', 'Ramirez Figueroa', 'Calle 13a No 2e-87', '5943165-3135859398', 'javierr@hotmail.com', '3135859398', 'RE', '54001', '13446690'),
('IRG', 'Igor', 'Ramirez Gomez', 'Cm Santa Ana Cons 104', '5743785-', 'igorramirezgomez@hotmail.com', '3005714812', 'GIN', '54001', '8745'),
('ERG', 'Emilio', 'Ramirez Gomez', '', '', '', '', 'GAS', '54001', ''),
('ARM', 'Alvaro', 'Ramirez Morelli', 'Cl 7 9e-15 Colsag', '5773167', '', '', 'PE', '54001', '13253656'),
('GRM', 'Gerardo', 'Ramirez Morelli', '', '', '', '', 'ORT', '54001', ''),
('LARO', 'Luis Antonio', 'Ramirez Ortega', '', '', '', '', 'MI', '54001', '1232407096'),
('JURR', 'Jairo Uriel', 'Ramirez Ramirez', '', '', '', '', 'MG', '54001', '1098712304'),
('CR', 'María Cristina', 'Ramos', 'Jerico Cons 404', '3182285662', 'contyacto@dracristinaramos.com.co', '', 'ORT', '54001', ''),
('CRP', 'Candelaria', 'Ramos Padilla', '', '', '', '', 'BAC', '54001', ''),
('OR', 'Omar', 'Rangel', '', '', '', '', '.', '54001', ''),
('ERO', 'Emma', 'Reyes Oviedo', 'Cm San Jose Cons 407', '', 'reyesemma@hotmail.com', '', 'DE', '54001', ''),
('JARR', 'Julio A', 'Reyes Ramon', 'Mz 2 Lote 12a 1-3 Claret', '', '', '', 'URO', '54001', ''),
('MAR', 'Maira Alejandra', 'Rincon', '', '', 'mairita_a@hotmail.com', '', 'URO', '54001', ''),
('FRU', 'Fredy Alberto', 'Rivero', '', '', '', '', 'CV', '54001', '7570687'),
('FARU', 'Fabio Alberto', 'Rivero U', '', '', '', '', 'CV', '54001', '7570687'),
('MRU', 'Monica', 'Rivero Ustariz', '', '', '', '', 'AN', '54001', ''),
('YJY', 'Yeisson Yinary', 'Rodriguez', '', '', '', '', 'GEN', '54001', ''),
('JR', 'Julian', 'Rodriguez', 'Tibu', '', '', '', 'GEN', '54001', ''),
('JARB', 'Jaime A', 'Rodriguez B.', 'Av 0 21-38 Local 105', '', '', '', '.', '54001', ''),
('SRG', 'Santiago', 'Rodriguez Garcia', 'Calle 18 1e', '', '', '', 'CP', '54001', '736'),
('MZR', 'Michel Zarick', 'Rodriguez Ortega', '', '', '', '', 'MG', '54001', '1193388012'),
('MRQ', 'Martha Janice', 'Rodriguez Quintero', '', '', '', '', 'OF', '54001', '44202'),
('GR', 'Gabriel', 'Rodriguez R', 'Anenida 2 No 17-94', '5835932-5835933', '', '', 'ON', '54001', '8702987'),
('HRR', 'Hernando', 'Rodriguez Ramirez', 'Calle 9 # 6e''21', '', '', '', 'ON', '54001', ''),
('ARV', 'Asid', 'Rodriguez Villanueva', '', '', '', '', 'CGR', '54001', ''),
('BR', 'Beatriz', 'Rojas', '', '', '', '', 'GEN', '54001', ''),
('DMRA', 'Diana Maria', 'Rojas Aguilar', '', '', '', '', 'G', '54001', '1032382184'),
('CARD', 'Carlos Augusto', 'Rojas Diaz', '', '', '', '', 'ON', '54001', ''),
('RDR', 'Rafael Dario', 'Rolon', '', '', '', '', 'GIN', '54001', ''),
('HMPA', 'Heydi Milena', 'Rolon Ascanio', 'Av 0 No 13-99', '', '', '', 'MG', '54001', ''),
('TR', 'Tatiana', 'Romero', '', '', '', '', 'OF', '54001', '52049553'),
('GRL', 'Laboratorio Gabriela', 'Romero Luna', 'Av 9 11-27', '5731865-', 'gaby_romero_luna@hotmail.com', '', 'BAC', '54001', '111'),
('ACR', 'Ana Cecilia', 'Roso', '', '', '', '', 'MG', '54001', '0024335'),
('AR', 'Anderson', 'Rozo', '', '', '', '', 'FI', '54001', ''),
('DGRC', 'Diana Guadalupe', 'Rueda Caceres', 'Av 2e No 17a-02 Local 2', '', 'dianiseu02@hotmail.com', '', 'PE', '54001', '1090379335'),
('ECO', 'Econorte', 'S.a.s', 'Calle 10 1-19 Cc Ortegon L 104', '3160447199', '', '3160447199', 'RA', '54001', 'Rm 1228'),
('AMSB', 'Ana Maria', 'Saad Brahim', 'Cm Jerico', '', '', '', 'MI', '54001', '60351958'),
('XS', 'Ximena', 'Saieh', '', '', '', '', 'FI', '54001', ''),
('CFSS', 'Carlos F', 'Saieh S', 'C M Santa Ana Cons 106', '', '', '', 'GI', '54001', ''),
('ES', 'Edgar', 'Salas', 'Calle 13 No 1e-81', '3174270921', '', '', 'ODO', '54001', '88197687'),
('MLS', 'Martha Liliana', 'Salgar', 'Calle 13 1e-74 Cons 205', '5722457', '', '', 'BAC', '54001', ''),
('ESV', 'Edgar Jose', 'Salgar Villamizar', 'Cm San Jose Cons 103', '', '', '', 'GAS', '54001', '13221189'),
('CESL', 'Carlos E', 'Sanabria L', '', '', '', '', 'HE', '54001', ''),
('ASL', 'Antonio', 'Sanabria Labrador', '', '', '', '', 'GEN', '54001', ''),
('SANA', 'Sanaty', 'Sanaty', 'Calle 13a No 1e-112', '3183722711', '', '3185762377', 'MG', '54001', ''),
('ESA', 'Eugenio', 'Sanchez Arias', 'Calle 17 No 2e-06 Caobos', '5726711', 'eugeniosanchez13@hotmail.com', '3158037484', 'ES', '54001', ''),
('RASC', 'Ramon Alberto', 'Sanchez Carballo', 'Ave 11e No 8a-155', '3158112330', '', '', 'PE', '54001', '79502155'),
('333', 'Robinson', 'Sanchez Garcia', '', '', '', '', 'CA', '54001', '91492347'),
('MJSP', 'Milton Javier', 'Sanchez Perez', 'Calle 13 A No 1e-112 Caobos Sa', '5955421', '', '', 'GEN', '54001', ''),
('JS', 'Jaime', 'Sanchez R', '', '', '', '', 'ORT', '54001', ''),
('MPSC', 'María Paula', 'Santos Contreras', '', '', '', '', 'MG', '54001', '1000943027'),
('PS', 'Paulo A', 'Santos Rivera', 'Uronorte', '5722722', '', '', 'URO', '54001', '88225847'),
('CASR', 'Carlos Augusto', 'Sarmiento Riveros', '', '', '', '', 'CGR', '54001', ''),
('MS', 'Mauricio', 'Sarrazola', 'Cm San Jose Cons 510b', '', '', '', 'ALE', '54001', '72125164'),
('JKSR', 'Johann', 'Schloeter', '', '', '', '', 'GAS', '54001', '655171'),
('PSR', 'Pablo', 'Segura R', '', '', '', '', '.', '54001', ''),
('AS', 'Angel', 'Sepulveda', '', '', '', '', 'FI', '54001', ''),
('SS', 'Sandra', 'Sepulveda', '', '', '', '', 'NF', '54001', ''),
('DSS', 'Diego', 'Serna Suarez', '', '', '', '', 'GEN', '54001', '1082924044'),
('JCSC', 'Juan Carlos', 'Serrano Casas', '', '', '', '', 'FI', '54001', ''),
('GSN', 'Gabriel', 'Sierra', '', '', '', '', 'NL', '54001', ''),
('KLSCL', 'Karen Liliana', 'Sierra Claro', '', '', '', '', 'MG', '54001', '1094283456'),
('JIS', 'Jesus Ivan', 'Sierra Laguado', '', '', '', '', 'GIN', '54001', ''),
('GS', 'German', 'Silva', '', '', '', '', 'ODO', '54001', ''),
('MSB', 'Melissa', 'Silva', '', '', '', '', 'MI', '54001', '60447247'),
('ASMB', 'Aura Melisa', 'Silva Barbosa', '', '', '', '', 'EN', '54001', '60447247'),
('JFSP', 'Juan Fernando', 'Silva Perez', '', '', '', '', 'URO', '54001', ''),
('CS', 'Claudia', 'Sinisterra', '', '', '', '', '.', '54001', ''),
('VS', 'Virgelma', 'Solano', '', '', '', '', 'BAC', '54001', ''),
('JHSE', 'Jesus Hernando', 'Solano Espinosa', '', '', '', '', 'GIN', '54001', '88154570'),
('AMSM', 'Angela Maria', 'Solano Moreno', 'Pamplona', '', '', '', 'PE', '54001', '52816457'),
('YSV', 'Yuanet', 'Solias Villalta', '', '6075770337', '', '3054803890', 'DE', '54001', ''),
('LCS', 'Luis Carlos', 'Soto', '', '', '', '', 'CV', '54001', ''),
('CAS', 'Carlos Andres', 'Suarez', 'Csj Cons 401b', '5836283-3202400048', '', '', 'ALT', '54001', '88208825'),
('FOSC', 'Fabio O', 'Suarez Castrillon', '', '', '', '', 'URO', '54001', ''),
('ESM', 'Elizabeth', 'Suarez Marin', 'Jerico Cons 504', '', '', '', 'PE', '54001', ''),
('JSS', 'Juan', 'Sus Slim', 'Vias Digestivas Riviera', '', '', '', 'CGR', '54001', ''),
('MATV', 'Manuel Alberto', 'Tellez Vargas', 'Foscal', '', '', '', 'GEN', '68001', '13352271'),
('TIBU', 'Tibu', 'Tibu', '', '', '', '', 'GEN', '54001', ''),
('YPT', 'Yeimy Paola', 'Torrado', '', '', '', '', 'MI', '54001', ''),
('LYTE', 'Leidy Yajaira', 'Torres', '', '', '', '', 'FI', '54001', ''),
('KTA', 'Katerin', 'Torres Auce', '', '', '', '', 'GIN', '54001', ''),
('METR', 'Milton E', 'Torres Ramirez', '', '3015898818', '', '', 'MC', '54001', '80173645'),
('GT', 'Gustavo Eduardo', 'Tovar Ovallo', 'Sanaty', '', 'gtovarovallos@gmail.com', '', 'MC', '54001', ''),
('MLTV', 'Magda Lucero', 'Trujillo Vargas', '', '', '', '', 'PE', '54001', ''),
('SU', 'Sergio Enrique', 'Urbina Echeverry', 'Cm Jerico', '', 'serurbina@hotmail.com', '', 'GI', '54001', ''),
('AEUF', 'Alvaro Eduardo', 'Uribe Figueroa', '', '', '', '', 'URO', '54001', '1090396165'),
('MAUG', 'Mauricio Alfonso', 'Uribe Gil', '', '', '', '', 'GEN', '54001', '13496272'),
('SUG', 'Sandra Milena', 'Uribe Granados', 'Av. 1 #17-73', '315 4255404', '', '', 'PE', '54001', ''),
('HUM', 'Humberto', 'Uribe Morelly', '', '', '', '', 'CL', '54001', ''),
('LUM', 'Leidy', 'Uscategui Mendez', '', '', '', '', 'MG', '54001', '1026570678'),
('SUM', 'Sandra', 'Uscategui Moreno', '', '', '', '', 'FI', '54001', ''),
('RHUO', 'Rosmery Hysleyne', 'Uzcategui Ortega', '', '', '', '', 'GEN', '54001', '1093797829'),
('ANV', 'Angela', 'Valbuena', '', '', '', '', 'GI', '54001', ''),
('ZV', 'Zenair', 'Valero', '', '', '', '', '.', '54001', '487892'),
('JJV', 'Juan Jose', 'Vanegas Acevedo', 'Avenida 1 No 15-04', '', 'clinicasandiegocucuta@gmail.com', '', 'OF', '54001', ''),
('JJVG', 'Juan Jose', 'Vargas', 'Coneuro', '', '', '', 'NL', '54001', 'Gelvis'),
('JCV', 'Juan Carlos', 'Vargas', '', '', '', '', 'NF', '54001', ''),
('DCVA', 'Diana Carolina', 'Vargas Angel', '', '', '', '', 'NF', '54001', '1032438553'),
('DDVJ', 'David Dario', 'Varon Jaimes', '', '', '', '', 'NF', '54001', ''),
('BVH', 'Bernardo', 'Vega Henao', '', '', '', '', 'GIN', '54001', ''),
('GJVS', 'Gerardo Jose', 'Vega Sosa', 'Vitta Consul 803', '', '', '', 'GI', '54001', '1127660821'),
('JFVA', 'Joaquin Fernando', 'Velez Ascanio', '', '', '', '', 'NL', '54001', '1065564961'),
('JCVM', 'Juan Carlos', 'Vergel Martinez', 'Calle 16 No 0e-15', '5715082-5715092', '', '3103042108', 'GIN', '54001', '1090390783'),
('LFVT', 'Luis Freddy', 'Vergel Torrents', '', '', '', '', 'GIN', '54001', ''),
('AV', 'Analizar', 'Veterinario', '', '', '', '', '.', '54001', ''),
('JV', 'Jeyson', 'Vidal', '', '', '', '', 'MI', '54001', ''),
('JIVP', 'Jorge Isaac', 'Villabona Perez', 'Cl 14 12-77', '3157520392', '', '', '.', '54001', ''),
('ENVT', 'Ever Nai', 'Villada Toro', '', '', '', '', 'NL', '54001', '10113193'),
('10113193', 'Ever Nai', 'Villada Toro', '', '', '', '', 'NL', '54001', '10113193'),
('SV', 'Sergio', 'Villamizar', '', '', '', '', 'MI', '54001', ''),
('DVB', 'Diana', 'Villamizar Bacca', 'Calle 1 No 1e-91 Faroles', '', 'dradianavillamizar@gmail.com', '', 'PE', '54001', ''),
('OAVG', 'Orlando Afranio', 'Villamizar Galvis', '', '', '', '', 'GIN', '54001', '13459776'),
('HV', 'Hernando Antonio', 'Villamizar Gomez', 'Calle 15 Av 0', '', '', '', 'PE', '54001', ''),
('OVJ', 'Orlando', 'Villamizar Jaimes', 'Av 1 17-14', '5834206-3152589452', '', '', 'GIN', '54001', '13459776'),
('JHVP', 'Jairo Hunmberto', 'Villamizar Peñaranda', '', '', '', '', 'DE', '54001', ''),
('GMV', 'Giovanna Maria', 'Villamizar Real', 'Clinica San Diego', '5960150', 'clinicasandiegocucuta@gtmail.com', '3175018617', 'OF', '54001', '32764821'),
('EV', 'Enrique', 'Villamizar Zuñiga', '', '', '', '', 'PE', '54001', ''),
('JY', 'Julian', 'Yañez Hartman', 'Cm Jerico', '', '', '', 'GI', '54001', '80096866'),
('JCZS', 'Juan Carlos', 'Zabala Sierra', '', '', '', '', '.', '54001', ''),
('AEZH', 'Aleyda E', 'Zabaleta Hernández', 'Avenida 1 No 6-49', '', '', '3123699223', 'MC', '54001', '5431'),
('JDFZ', 'Juan De Francisco', 'Zambrano', 'Cra 16 No,84a-09 Cons 712', '', '', '3118134483', 'GAS', '54001', '79140821'),
('JGZJ', 'Juan Guillermo', 'Zapata Jaramillo', '', '', '', '', '.', '54001', '')
ON DUPLICATE KEY UPDATE nombres=VALUES(nombres);
+7
View File
@@ -0,0 +1,7 @@
ALTER TABLE turnero_solicitudes
MODIFY COLUMN metodo_pago ENUM(
'efectivo','transferencia','tarjeta','eps','cortesia','combinado'
) DEFAULT NULL,
ADD COLUMN pagos_detalle JSON DEFAULT NULL
COMMENT 'Desglose de pago combinado {efectivo, transferencia, tarjeta}'
AFTER metodo_pago;
+4
View File
@@ -0,0 +1,4 @@
ALTER TABLE turnero_solicitudes
ADD COLUMN medico_id INT UNSIGNED DEFAULT NULL
COMMENT 'FK medicos.id — médico tratante seleccionado en recepción'
AFTER solo_muestras;
+4
View File
@@ -0,0 +1,4 @@
ALTER TABLE turnero_solicitudes
ADD COLUMN solo_muestras TINYINT(1) NOT NULL DEFAULT 0
COMMENT 'Paciente solo entrega muestras, sin exámenes a registrar'
AFTER embarazada;
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* Ejecuta las migraciones SQL pendientes.
* Uso: php migrations/run_pendientes.php
*/
require_once __DIR__ . '/../config/config.php';
$pendientes = [
'20260702_turnero_solicitudes_embarazada.sql',
'20260703_pago_combinado.sql',
'20260703_solo_muestras.sql',
'20260703_medicos.sql',
'20260703_medicos_seed.sql',
'20260703_solicitud_medico.sql',
];
$pdo = Database::getInstance()->getConnection();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "\n── Migraciones pendientes ──────────────────\n\n";
foreach ($pendientes as $file) {
$path = __DIR__ . '/' . $file;
if (!file_exists($path)) {
echo " ⚠ No encontrado: $file\n";
continue;
}
try {
$pdo->exec(file_get_contents($path));
echo "$file\n";
} catch (PDOException $e) {
// Si ya existe la columna/tabla, no es error crítico
$msg = $e->getMessage();
if (str_contains($msg, 'Duplicate column') || str_contains($msg, 'already exists')) {
echo "$file (ya aplicada)\n";
} else {
echo "$file$msg\n";
}
}
}
echo "\n── Listo ───────────────────────────────────\n\n";
+24
View File
@@ -0,0 +1,24 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok'=>false,'error'=>'Método no permitido']); exit;
}
$datos = json_decode(file_get_contents('php://input'), true) ?? [];
$id = !empty($datos['id']) ? (int)$datos['id'] : 0;
if (!$id) { echo json_encode(['ok'=>false,'error'=>'ID inválido.']); exit; }
$pdo = Database::getInstance()->getConnection();
$stmt = $pdo->prepare("DELETE FROM medicos WHERE id = ?");
$stmt->execute([$id]);
if ($stmt->rowCount()) {
echo json_encode(['ok'=>true,'mensaje'=>'Médico eliminado.']);
} else {
echo json_encode(['ok'=>false,'error'=>'Médico no encontrado.']);
}
+26
View File
@@ -0,0 +1,26 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
header('Content-Type: application/json');
$pdo = Database::getInstance()->getConnection();
$buscar = trim($_GET['q'] ?? '');
if ($buscar !== '') {
$like = '%' . $buscar . '%';
$stmt = $pdo->prepare(
"SELECT id, codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico, activo
FROM medicos
WHERE nombres LIKE ? OR apellidos LIKE ? OR codigo LIKE ? OR docidmedico LIKE ?
ORDER BY apellidos, nombres"
);
$stmt->execute([$like, $like, $like, $like]);
} else {
$stmt = $pdo->query(
"SELECT id, codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico, activo
FROM medicos ORDER BY apellidos, nombres"
);
}
echo json_encode(['ok' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
+48
View File
@@ -0,0 +1,48 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok'=>false,'error'=>'Método no permitido']); exit;
}
$datos = json_decode(file_get_contents('php://input'), true) ?? [];
$id = !empty($datos['id']) ? (int) $datos['id'] : null;
$codigo = strtoupper(trim($datos['codigo'] ?? ''));
$nombres = trim($datos['nombres'] ?? '');
$apellidos = trim($datos['apellidos'] ?? '');
$especialidad = trim($datos['cod_especialidad'] ?? '');
$docid = trim($datos['docidmedico'] ?? '');
$telefonos = trim($datos['telefonos'] ?? '');
$email = trim($datos['email'] ?? '');
if (!$codigo) { echo json_encode(['ok'=>false,'error'=>'El código es obligatorio.']); exit; }
if (!$nombres) { echo json_encode(['ok'=>false,'error'=>'Los nombres son obligatorios.']); exit; }
if (!$apellidos) { echo json_encode(['ok'=>false,'error'=>'Los apellidos son obligatorios.']); exit; }
$pdo = Database::getInstance()->getConnection();
if ($id) {
// Editar
$stmt = $pdo->prepare(
"UPDATE medicos SET codigo=?, nombres=?, apellidos=?, telefonos=?, email=?, cod_especialidad=?, docidmedico=?
WHERE id=?"
);
$stmt->execute([$codigo, $nombres, $apellidos, $telefonos ?: null, $email ?: null, $especialidad ?: null, $docid ?: null, $id]);
echo json_encode(['ok'=>true,'mensaje'=>'Médico actualizado correctamente.']);
} else {
// Crear — verificar código único
$existe = $pdo->prepare("SELECT id FROM medicos WHERE codigo = ?");
$existe->execute([$codigo]);
if ($existe->fetch()) {
echo json_encode(['ok'=>false,'error'=>"El código '$codigo' ya existe."]); exit;
}
$stmt = $pdo->prepare(
"INSERT INTO medicos (codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico)
VALUES (?, ?, ?, ?, ?, ?, ?)"
);
$stmt->execute([$codigo, $nombres, $apellidos, $telefonos ?: null, $email ?: null, $especialidad ?: null, $docid ?: null]);
echo json_encode(['ok'=>true,'id'=>(int)$pdo->lastInsertId(),'mensaje'=>'Médico creado correctamente.']);
}
+14
View File
@@ -0,0 +1,14 @@
<?php return [
'slug' => 'medicos',
'name' => 'Médicos',
'icon' => 'fas fa-user-md',
'category' => 'clinico',
'route' => '/erp.php?m=medicos&v=index',
'is_active' => true,
'sort_order' => 51,
'oleada' => 0,
'description' => 'Gestión del catálogo de médicos',
'links' => [
['name' => 'Médicos', 'icon' => 'fas fa-user-md', 'route' => '/erp.php?m=medicos&v=index'],
],
];
+287
View File
@@ -0,0 +1,287 @@
<?php
require_once APP_ROOT . '/config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
Layout::open('Médicos', 'fas fa-user-md');
$API = BASE_URL . 'modules/medicos/api/';
?>
<style>
body { background: #f1f5f9; }
.page-header {
background: #fff; border-bottom: 1px solid #e2e8f0;
padding: 14px 24px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
}
.page-header h1 { font-size: 1.18rem; font-weight: 700; color: #1e293b; margin: 0; flex: 1; }
.content-wrap { max-width: 1100px; margin: 24px auto; padding: 0 16px 48px; }
.card-box { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; }
.card-box .toolbar {
padding: 14px 16px; display: flex; gap: 10px; align-items: center;
border-bottom: 1px solid #e2e8f0; flex-wrap: wrap;
}
.card-box .toolbar input { max-width: 260px; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
thead th { background: #f8fafc; padding: 10px 14px; font-size: .72rem; text-transform: uppercase;
letter-spacing: .07em; color: #64748b; border-bottom: 1px solid #e2e8f0; text-align: left; }
tbody td { padding: 10px 14px; border-bottom: 1px solid #f1f5f9; color: #1e293b; vertical-align: middle; }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: #f8fafc; }
.badge-esp { background: #eff6ff; color: #1d4ed8; border-radius: 20px; padding: 2px 10px;
font-size: .75rem; font-weight: 600; }
.acciones { display: flex; gap: 6px; }
#tbl-empty { text-align: center; padding: 3rem; color: #94a3b8; }
</style>
<div class="page-header">
<i class="fas fa-user-md" style="font-size:1.3rem;color:#6366f1"></i>
<h1>Médicos</h1>
<button class="btn btn-primary btn-sm ms-auto" onclick="abrirModal()">
<i class="fas fa-plus me-1"></i>Nuevo médico
</button>
</div>
<div class="content-wrap">
<div class="card-box">
<div class="toolbar">
<input type="search" id="inp-buscar" class="form-control form-control-sm"
placeholder="Buscar por nombre, código o documento…" oninput="buscar()">
<span class="text-muted small ms-auto" id="lbl-total"></span>
</div>
<div style="overflow-x:auto">
<table id="tbl-medicos">
<thead>
<tr>
<th>Código</th>
<th>Nombres</th>
<th>Apellidos</th>
<th>Especialidad</th>
<th>Teléfonos</th>
<th>Doc. ID</th>
<th></th>
</tr>
</thead>
<tbody id="tbl-body">
<tr id="tbl-empty"><td colspan="6">Cargando…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ══ Modal agregar / editar ══════════════════════════════════════ -->
<div class="modal fade" id="modalMedico" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modal-titulo">Nuevo médico</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="modal-alert" class="alert alert-danger d-none py-2"></div>
<input type="hidden" id="med-id">
<div class="row g-3">
<div class="col-4">
<label class="form-label small fw-semibold">Código <span class="text-danger">*</span></label>
<input type="text" id="med-codigo" class="form-control form-control-sm"
placeholder="Ej: MED001" maxlength="20">
</div>
<div class="col-8">
<label class="form-label small fw-semibold">Doc. ID Médico</label>
<input type="text" id="med-docid" class="form-control form-control-sm"
placeholder="Número de documento" maxlength="30">
</div>
<div class="col-6">
<label class="form-label small fw-semibold">Nombres <span class="text-danger">*</span></label>
<input type="text" id="med-nombres" class="form-control form-control-sm"
placeholder="Nombres" maxlength="100">
</div>
<div class="col-6">
<label class="form-label small fw-semibold">Apellidos <span class="text-danger">*</span></label>
<input type="text" id="med-apellidos" class="form-control form-control-sm"
placeholder="Apellidos" maxlength="100">
</div>
<div class="col-6">
<label class="form-label small fw-semibold">Teléfonos</label>
<input type="text" id="med-telefonos" class="form-control form-control-sm"
placeholder="Teléfonos" maxlength="50">
</div>
<div class="col-6">
<label class="form-label small fw-semibold">Email</label>
<input type="email" id="med-email" class="form-control form-control-sm"
placeholder="correo@ejemplo.com" maxlength="100">
</div>
<div class="col-12">
<label class="form-label small fw-semibold">Código especialidad</label>
<input type="text" id="med-esp" class="form-control form-control-sm"
placeholder="Ej: GEN, PED, GIN…" maxlength="10">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-primary btn-sm" id="btn-guardar" onclick="guardar()">
<i class="fas fa-save me-1"></i>Guardar
</button>
</div>
</div>
</div>
</div>
<!-- ══ Modal confirmar eliminar ══════════════════════════════════════ -->
<div class="modal fade" id="modalEliminar" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title text-danger"><i class="fas fa-trash me-1"></i>Eliminar médico</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
¿Eliminar a <strong id="del-nombre"></strong>? Esta acción no se puede deshacer.
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-danger btn-sm" id="btn-confirmar-del" onclick="confirmarEliminar()">
Eliminar
</button>
</div>
</div>
</div>
</div>
<script>
const API = '<?= $API ?>';
let _modal, _modalDel, _pendingDelId, _buscarTimer;
document.addEventListener('DOMContentLoaded', () => {
_modal = new bootstrap.Modal(document.getElementById('modalMedico'));
_modalDel = new bootstrap.Modal(document.getElementById('modalEliminar'));
cargar();
});
async function cargar(q = '') {
const url = API + 'list.php' + (q ? '?q=' + encodeURIComponent(q) : '');
const res = await fetch(url);
const json = await res.json();
if (!json.ok) return;
renderTabla(json.data);
}
function buscar() {
clearTimeout(_buscarTimer);
_buscarTimer = setTimeout(() => cargar(document.getElementById('inp-buscar').value.trim()), 280);
}
function renderTabla(rows) {
const body = document.getElementById('tbl-body');
document.getElementById('lbl-total').textContent = rows.length + ' médico(s)';
if (!rows.length) {
body.innerHTML = '<tr id="tbl-empty"><td colspan="6">Sin resultados.</td></tr>';
return;
}
body.innerHTML = rows.map(m => `
<tr>
<td><code>${esc(m.codigo)}</code></td>
<td>${esc(m.nombres)}</td>
<td>${esc(m.apellidos)}</td>
<td>${m.cod_especialidad ? `<span class="badge-esp">${esc(m.cod_especialidad)}</span>` : '<span class="text-muted">—</span>'}</td>
<td style="font-size:.82rem;color:#475569">${esc(m.telefonos || '—')}</td>
<td>${esc(m.docidmedico || '—')}</td>
<td>
<div class="acciones">
<button class="btn btn-outline-primary btn-sm py-0 px-2" onclick='abrirEditar(${JSON.stringify(m)})'>
<i class="fas fa-pencil-alt"></i>
</button>
<button class="btn btn-outline-danger btn-sm py-0 px-2" onclick="pedirEliminar(${m.id}, '${esc(m.nombres)} ${esc(m.apellidos)}')">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>`).join('');
}
function abrirModal(m = null) {
document.getElementById('modal-titulo').textContent = m ? 'Editar médico' : 'Nuevo médico';
document.getElementById('modal-alert').classList.add('d-none');
document.getElementById('med-id').value = m?.id ?? '';
document.getElementById('med-codigo').value = m?.codigo ?? '';
document.getElementById('med-nombres').value = m?.nombres ?? '';
document.getElementById('med-apellidos').value = m?.apellidos ?? '';
document.getElementById('med-telefonos').value = m?.telefonos ?? '';
document.getElementById('med-email').value = m?.email ?? '';
document.getElementById('med-esp').value = m?.cod_especialidad ?? '';
document.getElementById('med-docid').value = m?.docidmedico ?? '';
_modal.show();
setTimeout(() => document.getElementById('med-codigo').focus(), 300);
}
function abrirEditar(m) { abrirModal(m); }
async function guardar() {
const alerta = document.getElementById('modal-alert');
alerta.classList.add('d-none');
const payload = {
id: document.getElementById('med-id').value || null,
codigo: document.getElementById('med-codigo').value.trim(),
nombres: document.getElementById('med-nombres').value.trim(),
apellidos: document.getElementById('med-apellidos').value.trim(),
telefonos: document.getElementById('med-telefonos').value.trim(),
email: document.getElementById('med-email').value.trim(),
cod_especialidad: document.getElementById('med-esp').value.trim(),
docidmedico: document.getElementById('med-docid').value.trim(),
};
const btn = document.getElementById('btn-guardar');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando…';
try {
const res = await fetch(API + 'save.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const json = await res.json();
if (!json.ok) {
alerta.textContent = json.error;
alerta.classList.remove('d-none');
return;
}
_modal.hide();
cargar(document.getElementById('inp-buscar').value.trim());
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar';
}
}
function pedirEliminar(id, nombre) {
_pendingDelId = id;
document.getElementById('del-nombre').textContent = nombre;
_modalDel.show();
}
async function confirmarEliminar() {
const btn = document.getElementById('btn-confirmar-del');
btn.disabled = true;
try {
const res = await fetch(API + 'delete.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: _pendingDelId }),
});
const json = await res.json();
if (json.ok) {
_modalDel.hide();
cargar(document.getElementById('inp-buscar').value.trim());
}
} finally {
btn.disabled = false;
}
}
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
<?php Layout::close(); ?>
+91
View File
@@ -0,0 +1,91 @@
<?php
/**
* API - Lista de contactos del chat turnero
* Devuelve usuarios que tienen conversaciones con canal='turnero',
* con el último mensaje y conteo de no leídos.
*/
require_once __DIR__ . '/../../../config/config.php';
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
try {
$db = Database::getInstance();
$limit = max(1, min(200, intval($_GET['limit'] ?? 50)));
$page = max(1, intval($_GET['page'] ?? 1));
$offset = ($page - 1) * $limit;
$search = trim($_GET['search'] ?? '');
$whereClauses = ["c.canal = 'turnero'"];
$params = [];
if ($search !== '') {
$whereClauses[] = "(u.name LIKE :s1 OR u.phone_number LIKE :s2)";
$params[':s1'] = '%' . $search . '%';
$params[':s2'] = '%' . $search . '%';
}
$where = implode(' AND ', $whereClauses);
$sql = "SELECT
u.id AS user_id,
COALESCE(u.name, u.phone_number) AS name,
u.phone_number,
u.avatar_url,
lm.content AS last_message,
lm.direction AS last_direction,
lm.message_type AS last_message_type,
lm.created_at AS last_time,
IFNULL(SUM(CASE WHEN c.direction = 'incoming' AND (c.is_read IS NULL OR c.is_read = 0) THEN 1 ELSE 0 END), 0) AS unread_count
FROM users u
JOIN conversations c ON c.user_id = u.id AND c.canal = 'turnero'
LEFT JOIN (
SELECT t1.*
FROM conversations t1
JOIN (
SELECT user_id, MAX(created_at) AS last_time
FROM conversations
WHERE canal = 'turnero'
GROUP BY user_id
) t2 ON t1.user_id = t2.user_id AND t1.created_at = t2.last_time AND t1.canal = 'turnero'
) lm ON lm.user_id = u.id
WHERE {$where}
GROUP BY u.id
ORDER BY lm.created_at DESC
LIMIT {$limit} OFFSET {$offset}";
$rows = $db->fetchAll($sql, $params);
$totalRow = $db->fetch(
"SELECT COUNT(DISTINCT c.user_id) AS cnt FROM conversations c
JOIN users u ON u.id = c.user_id
WHERE c.canal = 'turnero'" . ($search !== '' ? " AND (u.name LIKE :s1 OR u.phone_number LIKE :s2)" : ''),
$search !== '' ? [':s1' => '%'.$search.'%', ':s2' => '%'.$search.'%'] : []
);
$data = array_map(function($r) {
return [
'user_id' => intval($r['user_id']),
'name' => $r['name'],
'phone_number' => $r['phone_number'],
'avatar_url' => $r['avatar_url'] ?? null,
'last_message' => $r['last_message'] ?? '',
'last_direction' => $r['last_direction'] ?? 'incoming',
'last_message_type' => $r['last_message_type'] ?? 'text',
'last_time' => $r['last_time'] ?? null,
'unread_count' => intval($r['unread_count']),
];
}, $rows ?: []);
$total = intval($totalRow['cnt'] ?? 0);
$hasMore = ($page * $limit) < $total;
echo json_encode(['success' => true, 'data' => $data, 'total' => $total, 'has_more' => $hasMore, 'page' => $page]);
} catch (Exception $e) {
error_log('chat_get_list.php: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Error interno']);
}
+121
View File
@@ -0,0 +1,121 @@
<?php
/**
* API - Mensajes de un contacto en el canal turnero
* Soporta paginación hacia atrás (before / before_id) y polling (since).
*/
require_once __DIR__ . '/../../../config/config.php';
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
try {
$userId = intval($_GET['user_id'] ?? 0);
if (!$userId) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'user_id requerido']);
exit;
}
$limit = max(1, min(200, intval($_GET['limit'] ?? 50)));
$before = !empty($_GET['before']) ? $_GET['before'] : null;
$beforeId = !empty($_GET['before_id']) ? intval($_GET['before_id']) : null;
$since = !empty($_GET['since']) ? $_GET['since'] : null;
$tsRegex = '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/';
if ($before && !preg_match($tsRegex, $before)) {
http_response_code(400); echo json_encode(['success' => false, 'error' => 'before inválido']); exit;
}
if ($since && !preg_match($tsRegex, $since)) {
http_response_code(400); echo json_encode(['success' => false, 'error' => 'since inválido']); exit;
}
$db = Database::getInstance();
$params = ['user_id' => $userId];
$sql = "SELECT
c.id, c.user_id, c.content, c.message_id,
c.media_url, c.local_file, c.local_thumb,
c.filename, c.mime_type,
c.direction, c.message_type, c.status,
c.created_at, COALESCE(c.is_read, 0) AS is_read,
c.reply_to_message_id, c.reaction_emoji, c.reaction_to_message_id,
u.phone_number AS user_phone
FROM conversations c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.user_id = :user_id AND c.canal = 'turnero'";
if ($since) {
$sql .= " AND c.created_at > :since";
$params['since'] = $since;
$sql .= " ORDER BY c.created_at ASC, c.id ASC LIMIT {$limit}";
} else {
if ($before) {
$sql .= " AND (c.created_at < :before_lt";
$params['before_lt'] = $before;
if ($beforeId) {
$sql .= " OR (c.created_at = :before_eq AND c.id < :before_id)";
$params['before_eq'] = $before;
$params['before_id'] = $beforeId;
}
$sql .= ")";
}
$sql .= " ORDER BY c.created_at DESC, c.id DESC LIMIT {$limit}";
}
$rows = $db->fetchAll($sql, $params);
if (!$since) {
$rows = array_reverse($rows ?: []);
}
$hasMore = count($rows) === $limit;
$earliest = $rows[0]['created_at'] ?? null;
$earliestId = $rows[0]['id'] ?? null;
$data = array_map(function($m) {
$mediaUrl = $m['media_url'] ?? null;
$external = null;
if ($mediaUrl) {
if (preg_match('#^https?://#i', $mediaUrl)) {
$external = $mediaUrl;
} else {
$external = '../../../api/get_media.php?id=' . urlencode($mediaUrl);
}
}
return [
'id' => intval($m['id']),
'user_id' => intval($m['user_id']),
'content' => $m['content'] ?? '',
'message_id' => $m['message_id'] ?? null,
'local_file' => $m['local_file'] ?? null,
'local_thumb' => $m['local_thumb'] ?? null,
'media_url_external' => $external,
'filename' => $m['filename'] ?? null,
'mime_type' => $m['mime_type'] ?? null,
'user_phone' => $m['user_phone'] ?? null,
'direction' => $m['direction'] ?? 'incoming',
'message_type' => $m['message_type'] ?? 'text',
'status' => $m['status'] ?? 'received',
'created_at' => $m['created_at'],
'is_read' => intval($m['is_read']),
'reply_to_message_id' => $m['reply_to_message_id'] ?? null,
'reaction_emoji' => $m['reaction_emoji'] ?? null,
'reaction_to_message_id' => $m['reaction_to_message_id'] ?? null,
];
}, $rows);
echo json_encode([
'success' => true,
'data' => $data,
'has_more' => $hasMore,
'earliest' => $earliest,
'earliest_id' => $earliestId,
], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
error_log('chat_get_messages.php: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Error interno']);
}
+40
View File
@@ -0,0 +1,40 @@
<?php
/**
* API - Marcar como leídos los mensajes entrantes del canal turnero de un usuario
*/
require_once __DIR__ . '/../../../config/config.php';
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Método no permitido']);
exit;
}
try {
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$userId = intval($input['user_id'] ?? 0);
if (!$userId) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'user_id requerido']);
exit;
}
$db = Database::getInstance();
$db->query(
"UPDATE conversations SET is_read = 1
WHERE user_id = :uid AND canal = 'turnero' AND direction = 'incoming' AND (is_read IS NULL OR is_read = 0)",
['uid' => $userId]
);
echo json_encode(['success' => true]);
} catch (Exception $e) {
error_log('chat_mark_read.php: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Error interno']);
}
+47
View File
@@ -0,0 +1,47 @@
<?php
/**
* POST — envía una reacción emoji a un mensaje desde el número turnero.
* Body JSON: { message_id, user_id, emoji }
*/
require_once __DIR__ . '/../../../config/config.php';
require_once __DIR__ . '/../../../services/WhatsAppService.php';
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['success'=>false,'error'=>'Método no permitido']); exit; }
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$messageId = trim($input['message_id'] ?? '');
$userId = intval($input['user_id'] ?? 0);
$emoji = trim($input['emoji'] ?? '');
if (!$messageId || !$userId || !$emoji) {
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'message_id, user_id y emoji son requeridos']);
exit;
}
try {
$db = Database::getInstance();
$user = $db->fetch("SELECT phone_number FROM users WHERE id = ?", [$userId]);
if (!$user) { http_response_code(404); echo json_encode(['success'=>false,'error'=>'Usuario no encontrado']); exit; }
$phoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
if (!$phoneId) { http_response_code(503); echo json_encode(['success'=>false,'error'=>'Número turnero no configurado']); exit; }
$wa = new WhatsAppService('turnero');
$resp = $wa->sendReaction($user['phone_number'], $messageId, $emoji, false);
// Guardar reacción en el mensaje
$db->query(
"UPDATE messages SET reaction_emoji = ?, reaction_to_message_id = ? WHERE whatsapp_message_id = ?",
[$emoji, $messageId, $messageId]
);
echo json_encode(['success' => true, 'response' => $resp]);
} catch (Exception $e) {
error_log('chat_react: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
+51
View File
@@ -0,0 +1,51 @@
<?php
/**
* API - Enviar mensaje desde el número turnero
* Soporta: text, template
*/
require_once __DIR__ . '/../../../config/config.php';
require_once __DIR__ . '/../../../services/WhatsAppService.php';
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['success'=>false,'error'=>'Método no permitido']); exit; }
try {
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$userId = intval($input['user_id'] ?? 0);
$type = $input['type'] ?? 'text';
$message = trim($input['message'] ?? '');
$template = trim($input['template'] ?? '');
$lang = trim($input['lang'] ?? 'es_CO');
$params = $input['params'] ?? [];
if (!$userId || ($type === 'text' && $message === '') || ($type === 'template' && $template === '')) {
http_response_code(400); echo json_encode(['success'=>false,'error'=>'Parámetros insuficientes']); exit;
}
$db = Database::getInstance();
$user = $db->fetch("SELECT phone_number, name FROM users WHERE id = :id", ['id' => $userId]);
if (!$user) { http_response_code(404); echo json_encode(['success'=>false,'error'=>'Usuario no encontrado']); exit; }
$turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
if (empty($turneroPhoneId)) { http_response_code(503); echo json_encode(['success'=>false,'error'=>'Número turnero no configurado']); exit; }
$operatorId = $_SESSION['admin_id'] ?? $_SESSION['user_id'] ?? null;
$wa = new WhatsAppService('turnero');
if ($type === 'template') {
$response = $wa->sendTemplateMessage($user['phone_number'], $template, $lang, $params);
} else {
$extra = ['canal' => 'turnero'];
if ($operatorId) $extra['operator_id'] = $operatorId;
$response = $wa->sendTextMessage($user['phone_number'], $message, $extra);
}
echo json_encode(['success' => true, 'whatsapp_response' => $response]);
} catch (Exception $e) {
error_log('chat_send_message.php: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
+69
View File
@@ -0,0 +1,69 @@
<?php
/**
* POST multipart — sube un archivo y lo envía por WhatsApp desde el número turnero.
* Campos: user_id, file (multipart), caption (opcional)
*/
require_once __DIR__ . '/../../../config/config.php';
require_once __DIR__ . '/../../../services/WhatsAppService.php';
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['success'=>false,'error'=>'Método no permitido']); exit; }
function tmErr($msg, $code=400) { http_response_code($code); echo json_encode(['success'=>false,'error'=>$msg]); exit; }
$userId = intval($_POST['user_id'] ?? 0);
if (!$userId) tmErr('user_id requerido');
if (empty($_FILES['file'])) tmErr('Archivo requerido');
$file = $_FILES['file'];
if ($file['error'] !== UPLOAD_ERR_OK) tmErr('Error al subir archivo: código ' . $file['error']);
$maxBytes = 64 * 1024 * 1024; // 64 MB
if ($file['size'] > $maxBytes) tmErr('El archivo supera el límite de 64 MB');
$mime = mime_content_type($file['tmp_name']) ?: $file['type'];
$caption = trim($_POST['caption'] ?? '');
$isVoice = !empty($_POST['is_voice']);
// Determinar tipo de media
if (str_starts_with($mime, 'image/')) $mediaType = 'image';
elseif (str_starts_with($mime, 'video/')) $mediaType = 'video';
elseif (str_starts_with($mime, 'audio/')) $mediaType = 'audio';
else $mediaType = 'document';
try {
$db = Database::getInstance();
$user = $db->fetch("SELECT phone_number FROM users WHERE id = ?", [$userId]);
if (!$user) tmErr('Usuario no encontrado', 404);
$phoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
if (!$phoneId) tmErr('Número turnero no configurado', 503);
$wa = new WhatsAppService('turnero');
// Subir a WhatsApp Media API
$mediaId = $wa->uploadMedia($file['tmp_name'], $mime);
if (!$mediaId) tmErr('No se pudo subir el archivo a WhatsApp', 502);
$opId = $_SESSION['admin_id'] ?? $_SESSION['user_id'] ?? null;
$extra = ['canal' => 'turnero'];
if ($opId) $extra['operator_id'] = $opId;
$resp = $wa->sendMediaById(
$user['phone_number'],
$mediaId,
$mediaType,
$caption ?: null,
$file['name'],
false,
$isVoice,
$extra
);
echo json_encode(['success' => true, 'media_type' => $mediaType, 'response' => $resp]);
} catch (Exception $e) {
error_log('chat_upload_media: ' . $e->getMessage());
tmErr($e->getMessage(), 500);
}
+29 -15
View File
@@ -27,18 +27,30 @@ $examIds = isset($datos['exam_tipo_ids']) && is_array($datos['exam_tipo_ids'
? array_filter(array_map('intval', $datos['exam_tipo_ids'])) : [];
$total = isset($datos['total_cobrado']) && $datos['total_cobrado'] !== null
? (float) $datos['total_cobrado'] : null;
$metodoPago = isset($datos['metodo_pago']) ? trim((string) $datos['metodo_pago']) : null;
$obs = isset($datos['observaciones']) ? trim((string) $datos['observaciones']) : null;
$numOrden = isset($datos['numero_orden']) ? trim((string) $datos['numero_orden']) : null;
$metodoPago = isset($datos['metodo_pago']) ? trim((string) $datos['metodo_pago']) : null;
$obs = isset($datos['observaciones']) ? trim((string) $datos['observaciones']) : null;
$numOrden = isset($datos['numero_orden']) ? trim((string) $datos['numero_orden']) : null;
$embarazada = !empty($datos['embarazada']) ? 1 : 0;
$soloMuestras = !empty($datos['solo_muestras']) ? 1 : 0;
$medicoId = !empty($datos['medico_id']) ? (int)$datos['medico_id'] : null;
$pagosDetalle = null;
if ($metodoPago === 'combinado' && isset($datos['pagos_detalle']) && is_array($datos['pagos_detalle'])) {
$pd = $datos['pagos_detalle'];
$pagosDetalle = json_encode([
'efectivo' => max(0, (float)($pd['efectivo'] ?? 0)),
'transferencia' => max(0, (float)($pd['transferencia'] ?? 0)),
'tarjeta' => max(0, (float)($pd['tarjeta'] ?? 0)),
]);
}
if ($numOrden === '') $numOrden = null;
// ── Validaciones ──────────────────────────────────────────────
if ($turnoId <= 0) jsonError('turno_id inválido.');
if ($pacienteId <= 0) jsonError('paciente_id inválido.');
if ($lugarId <= 0) jsonError('lugar_id inválido.');
if (empty($examIds)) jsonError('Debe seleccionar al menos un examen.');
if (empty($examIds) && !$soloMuestras) jsonError('Debe seleccionar al menos un examen.');
$metodosValidos = ['efectivo', 'transferencia', 'tarjeta', 'eps', 'cortesia', ''];
$metodosValidos = ['efectivo', 'transferencia', 'tarjeta', 'eps', 'cortesia', 'combinado', ''];
if ($metodoPago && !in_array($metodoPago, $metodosValidos, true)) {
jsonError('metodo_pago inválido.');
}
@@ -82,13 +94,15 @@ try {
jsonError('Lugar destino no encontrado o inactivo.', 404);
}
// Verificar que todos los exam_tipo_ids existen
$in = implode(',', array_fill(0, count($examIds), '?'));
$stmt = $pdo->prepare("SELECT COUNT(*) FROM exam_tipos WHERE id IN ({$in}) AND activo = 1");
$stmt->execute($examIds);
if ((int)$stmt->fetchColumn() !== count($examIds)) {
$pdo->rollBack();
jsonError('Uno o más exámenes no existen o están inactivos.', 422);
// Verificar que todos los exam_tipo_ids existen (se omite si solo_muestras y sin exámenes)
if (!empty($examIds)) {
$in = implode(',', array_fill(0, count($examIds), '?'));
$stmt = $pdo->prepare("SELECT COUNT(*) FROM exam_tipos WHERE id IN ({$in}) AND activo = 1");
$stmt->execute($examIds);
if ((int)$stmt->fetchColumn() !== count($examIds)) {
$pdo->rollBack();
jsonError('Uno o más exámenes no existen o están inactivos.', 422);
}
}
// ── Insertar o reemplazar solicitud (DELETE + INSERT para poder re-guardar) ──
@@ -103,12 +117,12 @@ try {
$stmt = $pdo->prepare(
"INSERT INTO turnero_solicitudes
(turno_id, paciente_id, lugar_id, numero_orden, total_cobrado, metodo_pago, observaciones, creado_por)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
(turno_id, paciente_id, lugar_id, numero_orden, total_cobrado, metodo_pago, pagos_detalle, observaciones, embarazada, solo_muestras, medico_id, creado_por)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
$stmt->execute([
$turnoId, $pacienteId, $lugarId, $numOrden,
$total, $metodoPago ?: null, $obs ?: null, adminId(),
$total, $metodoPago ?: null, $pagosDetalle, $obs ?: null, $embarazada, $soloMuestras, $medicoId, adminId(),
]);
$solicitudId = (int) $pdo->lastInsertId();
+84 -33
View File
@@ -102,58 +102,108 @@ try {
notificarSSE($sesionId);
// ── Enviar notificación WhatsApp con la plantilla configurada ──
// ── Crear registro de consentimiento (siempre, con o sin teléfono) ──
$enlaceConsentimiento = null;
$esSoloMuestra = ($prioCodigo === 'F');
if (!$esSoloMuestra) {
try {
$stmtForms = $pdo->prepare(
"SELECT tlc.formulario_id, l.formulario_modo
FROM turnero_lugar_consentimientos tlc
JOIN lab_formularios f ON f.id = tlc.formulario_id AND f.is_active = 1
JOIN turnero_lugares l ON l.id = tlc.lugar_id AND l.tipo = 'recepcion' AND l.activo = 1
ORDER BY tlc.formulario_id ASC
LIMIT 1"
);
$stmtForms->execute();
$formRow = $stmtForms->fetch(\PDO::FETCH_ASSOC);
if ($formRow) {
$conToken = sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0,0xffff), mt_rand(0,0xffff),
mt_rand(0,0xffff),
mt_rand(0,0x0fff)|0x4000,
mt_rand(0,0x3fff)|0x8000,
mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff)
);
$pdo->prepare(
"INSERT IGNORE INTO turnero_consentimientos
(turno_id, formulario_id, token, estado)
VALUES (?, ?, ?, 'pendiente')"
)->execute([$turnoId, (int)$formRow['formulario_id'], $conToken]);
// Solo generar enlace si el modo es 'link' (embebido no usa WA)
if (($formRow['formulario_modo'] ?? 'link') === 'link') {
$baseUrl = defined('BASE_URL') ? rtrim(BASE_URL, '/') : '';
$enlaceConsentimiento = $baseUrl . '/ver_formulario_enviado.php?token=' . urlencode($conToken);
}
}
} catch (\Throwable $e) {
// Sin consentimiento disponible, continuar sin link
}
}
// ── Enviar notificación WhatsApp ──
if ($pacienteTel) {
try {
$stmtCfg = $pdo->prepare(
"SELECT clave, valor FROM lab_config WHERE clave IN ('turnero_wa_template','turnero_wa_lang','turnero_wa_kiosko_enabled')"
"SELECT clave, valor FROM lab_config WHERE clave IN ('turnero_wa_template','turnero_wa_template_muestra','turnero_wa_lang','turnero_wa_kiosko_enabled')"
);
$stmtCfg->execute();
$cfgWA = $stmtCfg->fetchAll(PDO::FETCH_KEY_PAIR);
$waKioskoEnabled = ($cfgWA['turnero_wa_kiosko_enabled'] ?? '0') === '1';
$waTemplate = $esSoloMuestra
? ($cfgWA['turnero_wa_template_muestra'] ?? 'turno_muestra_pendiente')
: ($cfgWA['turnero_wa_template'] ?? 'consentimiento_turno');
// Usar idioma real de la plantilla aprobada (puede diferir del config)
$stmtLang = $pdo->prepare("SELECT language_code FROM message_templates WHERE template_name = ? LIMIT 1");
$stmtLang->execute([$waTemplate]);
$waLang = $stmtLang->fetchColumn() ?: ($cfgWA['turnero_wa_lang'] ?? 'es');
} catch (\Throwable $e) {
$waKioskoEnabled = true;
$waTemplate = 'consentimiento_turno';
$waLang = 'es';
}
}
if ($pacienteTel && $waKioskoEnabled) {
try {
$cfgWA = [];
$stmtCfg = $pdo->prepare(
"SELECT clave, valor FROM lab_config WHERE clave IN ('turnero_wa_template','turnero_wa_lang')"
);
$stmtCfg->execute();
$cfgWA = $stmtCfg->fetchAll(PDO::FETCH_KEY_PAIR);
$waTemplate = $cfgWA['turnero_wa_template'] ?? 'consentimiento_turno';
$waLang = $cfgWA['turnero_wa_lang'] ?? 'es_CO';
} catch (\Throwable $e) {
$waTemplate = 'consentimiento_turno';
$waLang = 'es_CO';
// WhatsAppService::formatPhoneNumber maneja el formato colombiano correctamente
$cel = preg_replace('/[^0-9]/', '', $pacienteTel);
// Marcar como enviado si vamos a mandar WA
if ($enlaceConsentimiento) {
try {
$pdo->prepare(
"UPDATE turnero_consentimientos SET estado='enviado', enviado_at=NOW()
WHERE turno_id=? AND estado='pendiente'"
)->execute([$turnoId]);
} catch (\Throwable $_) {}
}
$cel = preg_replace('/[\s\-\.]/', '', $pacienteTel);
if (!str_starts_with($cel, '+')) {
$cel = '+57' . ltrim($cel, '0');
}
require_once __DIR__ . '/../../../services/WhatsAppService.php';
$nombreWA = $pacienteNombre ?: 'Paciente';
try {
$wa = new WhatsAppService();
// Enviar solo el código, sin nombre/apellido
$wa->sendTemplateMessage(
$cel,
$waTemplate,
$waLang,
[
'Paciente',
$codigo,
'',
],
[]
);
$wa = new WhatsAppService('turnero');
$rawComps = [
['type' => 'body', 'parameters' => [['type' => 'text', 'text' => $codigo]]]
];
if ($conToken) {
$rawComps[] = [
'type' => 'button',
'sub_type' => 'url',
'index' => '0',
'parameters' => [['type' => 'text', 'text' => $conToken]],
];
}
$wa->sendTemplateMessage($cel, $waTemplate, $waLang, [], [], $rawComps);
} catch (\Throwable $eTmpl) {
try {
$mensajeTexto = "Su turno *{$codigo}* ha sido registrado. Preséntese al laboratorio.";
$wa->sendTextMessage($cel, $mensajeTexto);
$msg = "Hola {$nombreWA}, su turno *{$codigo}* ha sido registrado.";
if ($enlaceConsentimiento) {
$msg .= "\n\nPor favor firme su consentimiento informado antes de ser atendido:\n{$enlaceConsentimiento}";
}
$wa->sendTextMessage($cel, $msg);
} catch (\Throwable $eTxt) {
// Silenciar
}
@@ -188,6 +238,7 @@ try {
'prioridad_codigo' => $prioCodigo,
'posicion_cola' => $posicion,
'sesion_id' => $sesionId,
'paciente_nombre' => $pacienteNombre ?: $pacienteDoc,
],
], "Turno $codigo asignado correctamente");
@@ -16,6 +16,9 @@ $body = inputJson();
$turnoId = (int)($body['turno_id'] ?? 0);
$formularioId = (int)($body['formulario_id'] ?? 0);
$svg = $body['svg'] ?? '';
$soloPro = !empty($body['solo_profesional']);
$datosResp = (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']))
? json_encode($body['datos_respuestas'], JSON_UNESCAPED_UNICODE) : null;
if (!$turnoId) jsonError('turno_id requerido.');
if (!$formularioId) jsonError('formulario_id requerido.');
@@ -37,12 +40,22 @@ $tc = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$tc) jsonError('Consentimiento no encontrado.', 404);
$stmt = $pdo->prepare(
"UPDATE turnero_consentimientos
SET firma_profesional_svg = ?, firmado_profesional_at = NOW()
WHERE turno_id = ? AND formulario_id = ?"
);
$stmt->execute([$svg, $turnoId, $formularioId]);
if ($soloPro) {
// El profesional es el firmante final: guardar datos + marcar como firmado
$pdo->prepare(
"UPDATE turnero_consentimientos
SET firma_profesional_svg = ?, firmado_profesional_at = NOW(),
estado = 'firmado', firmado_at = NOW(),
datos_respuestas = COALESCE(?, datos_respuestas)
WHERE turno_id = ? AND formulario_id = ?"
)->execute([$svg, $datosResp, $turnoId, $formularioId]);
} else {
$pdo->prepare(
"UPDATE turnero_consentimientos
SET firma_profesional_svg = ?, firmado_profesional_at = NOW()
WHERE turno_id = ? AND formulario_id = ?"
)->execute([$svg, $turnoId, $formularioId]);
}
notificarSSE((int)$tc['sesion_id']);
+10 -8
View File
@@ -50,21 +50,20 @@ if ($sesionCerrada) {
// ── Cola según área ───────────────────────────────────────────
if ($area === 'recepcion') {
// Recepción: todos los turnos en espera/en_recepcion
$inEstados = "'espera', 'en_recepcion'";
$filtroLugar = '';
$bindsCola = [$sesionId];
$inEstados = "'espera', 'en_recepcion'";
$stmt = $pdo->prepare(
"SELECT t.id, t.codigo, t.numero, t.estado, t.paciente_nombre,
t.recepcion_desk_id, t.creado_at, t.llamado_recepcion_at, t.llamado_lugar_at,
p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre,
p.color AS prioridad_color, p.orden_peso
p.color AS prioridad_color, p.orden_peso,
COALESCE(s.solo_muestras, 0) AS solo_muestras
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
WHERE t.sesion_id = ? AND t.estado IN ($inEstados)
ORDER BY p.orden_peso ASC, t.creado_at ASC"
);
$stmt->execute($bindsCola);
$stmt->execute([$sesionId]);
} else {
// Lugar: cola = en_espera_lugar del grupo + en_servicio en ESTA estación
$grupoIds = lugarIdsDeGrupo($lugarId);
@@ -73,13 +72,15 @@ if ($area === 'recepcion') {
$cols = "t.id, t.codigo, t.numero, t.estado, t.paciente_nombre,
t.recepcion_desk_id, t.creado_at, t.llamado_recepcion_at, t.llamado_lugar_at,
p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre,
p.color AS prioridad_color, p.orden_peso";
p.color AS prioridad_color, p.orden_peso,
COALESCE(s.solo_muestras, 0) AS solo_muestras";
$stmt = $pdo->prepare(
"SELECT * FROM (
(SELECT $cols
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
WHERE t.sesion_id = ?
AND t.estado = 'en_espera_lugar'
AND t.lugar_destino_id IN ($inLugares))
@@ -87,11 +88,12 @@ if ($area === 'recepcion') {
(SELECT $cols
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
WHERE t.sesion_id = ?
AND t.estado = 'en_servicio'
AND t.lugar_destino_id = ?)
) AS cola_union
ORDER BY orden_peso ASC, creado_at ASC"
ORDER BY solo_muestras DESC, orden_peso ASC, creado_at ASC"
);
$stmt->execute([$sesionId, $sesionId, $lugarId]);
}
+52
View File
@@ -0,0 +1,52 @@
<?php
/**
* GET ?turno_id=X&lugar_id=Y
* Devuelve (o crea) el token de consentimiento para un turno+lugar en modo embebido.
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
$turnoId = (int)($_GET['turno_id'] ?? 0);
$lugarId = (int)($_GET['lugar_id'] ?? 0);
if (!$turnoId || !$lugarId) jsonError('turno_id y lugar_id requeridos');
$pdo = db();
// Obtener formulario configurado para el lugar
$formRow = $pdo->prepare(
"SELECT tlc.formulario_id FROM turnero_lugar_consentimientos tlc
JOIN lab_formularios f ON f.id = tlc.formulario_id AND f.is_active = 1
WHERE tlc.lugar_id = ?
ORDER BY tlc.formulario_id ASC LIMIT 1"
);
$formRow->execute([$lugarId]);
$form = $formRow->fetch(PDO::FETCH_ASSOC);
if (!$form) jsonError('No hay formulario configurado para este lugar', 404);
$formularioId = (int)$form['formulario_id'];
// Buscar token existente
$existing = $pdo->prepare(
"SELECT token FROM turnero_consentimientos WHERE turno_id = ? AND formulario_id = ? LIMIT 1"
);
$existing->execute([$turnoId, $formularioId]);
$row = $existing->fetch(PDO::FETCH_ASSOC);
if ($row) {
jsonOk(['token' => $row['token'], 'nuevo' => false]);
}
// Crear nuevo token
$token = sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0,0xffff), mt_rand(0,0xffff),
mt_rand(0,0xffff),
mt_rand(0,0x0fff)|0x4000,
mt_rand(0,0x3fff)|0x8000,
mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff)
);
$pdo->prepare(
"INSERT IGNORE INTO turnero_consentimientos (turno_id, formulario_id, token, estado) VALUES (?, ?, ?, 'pendiente')"
)->execute([$turnoId, $formularioId, $token]);
jsonOk(['token' => $token, 'nuevo' => true]);
+40 -4
View File
@@ -37,7 +37,8 @@ $stmt = $pdo->prepare(
tc.firmado_at,
(tc.firma_profesional_svg IS NOT NULL) AS tiene_firma_profesional,
tc.firmado_profesional_at,
f.nombre AS formulario_nombre
f.nombre AS formulario_nombre,
f.esquema AS formulario_esquema
FROM turnero_consentimientos tc
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
WHERE tc.turno_id = ?
@@ -46,6 +47,22 @@ $stmt = $pdo->prepare(
$stmt->execute([$turnoId]);
$consentimientos = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Compute requiere_firma_profesional from schema; strip raw esquema from response
foreach ($consentimientos as &$c) {
$esquema = $c['formulario_esquema'] ?? null;
$campos = [];
if ($esquema) {
$decoded = json_decode($esquema, true);
$campos = is_array($decoded) ? $decoded : ($decoded['campos'] ?? []);
}
$c['requiere_firma_profesional'] = !empty(array_filter(
$campos,
fn($f) => ($f['tipo'] ?? '') === 'firma_profesional'
));
unset($c['formulario_esquema']);
}
unset($c);
$respuesta = [
'turno_id' => $turnoId,
'turno_estado' => $turno['estado'],
@@ -56,10 +73,14 @@ $respuesta = [
if ($incluirSolicitud) {
$stmt = $pdo->prepare(
"SELECT s.id, s.turno_id, s.paciente_id, s.lugar_id,
s.total_cobrado, s.metodo_pago, s.observaciones, s.creado_at,
l.nombre AS lugar_nombre
s.total_cobrado, s.metodo_pago, s.observaciones, s.embarazada, s.solo_muestras, s.medico_id, s.creado_at,
l.nombre AS lugar_nombre,
CONCAT(m.nombres, ' ', m.apellidos) AS medico_nombre,
m.cod_especialidad AS medico_especialidad,
m.codigo AS medico_codigo
FROM turnero_solicitudes s
LEFT JOIN turnero_lugares l ON l.id = s.lugar_id
LEFT JOIN medicos m ON m.id = s.medico_id
WHERE s.turno_id = ?
LIMIT 1"
);
@@ -167,7 +188,8 @@ if ($incluirSolicitud) {
tc.estado, tc.enviado_at, tc.firmado_at,
(tc.firma_profesional_svg IS NOT NULL) AS tiene_firma_profesional,
tc.firmado_profesional_at,
f.nombre AS formulario_nombre
f.nombre AS formulario_nombre,
f.esquema AS formulario_esquema
FROM turnero_consentimientos tc
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
WHERE tc.turno_id = ?
@@ -175,6 +197,20 @@ if ($incluirSolicitud) {
);
$stmtC->execute([$turnoId]);
$consentimientos = $stmtC->fetchAll(PDO::FETCH_ASSOC);
foreach ($consentimientos as &$c) {
$esquema = $c['formulario_esquema'] ?? null;
$campos = [];
if ($esquema) {
$decoded = json_decode($esquema, true);
$campos = is_array($decoded) ? $decoded : ($decoded['campos'] ?? []);
}
$c['requiere_firma_profesional'] = !empty(array_filter(
$campos,
fn($f) => ($f['tipo'] ?? '') === 'firma_profesional'
));
unset($c['formulario_esquema']);
}
unset($c);
$respuesta['consentimientos'] = $consentimientos;
}
}
+6 -4
View File
@@ -52,6 +52,8 @@ $sortOrder = (int)($input['sort_order'] ?? 99);
$activo = (int)($input['activo'] ?? 1);
$tipo = in_array($input['tipo'] ?? '', ['recepcion', 'muestras'], true)
? $input['tipo'] : 'muestras';
$formModo = in_array($input['formulario_modo'] ?? '', ['link', 'embebido'], true)
? $input['formulario_modo'] : 'link';
$formularioIds = isset($input['formulario_ids']) && is_array($input['formulario_ids'])
? array_filter(array_map('intval', $input['formulario_ids']), fn($v) => $v > 0)
: [];
@@ -64,15 +66,15 @@ $pdo->beginTransaction();
try {
if ($id) {
$stmt = $pdo->prepare(
'UPDATE turnero_lugares SET nombre=?, tipo=?, descripcion=?, sort_order=?, activo=? WHERE id=?'
'UPDATE turnero_lugares SET nombre=?, tipo=?, descripcion=?, sort_order=?, activo=?, formulario_modo=? WHERE id=?'
);
$stmt->execute([$nombre, $tipo, $desc ?: null, $sortOrder, $activo, $id]);
$stmt->execute([$nombre, $tipo, $desc ?: null, $sortOrder, $activo, $formModo, $id]);
$lugarId = $id;
} else {
$stmt = $pdo->prepare(
'INSERT INTO turnero_lugares (nombre, tipo, descripcion, sort_order, activo) VALUES (?, ?, ?, ?, ?)'
'INSERT INTO turnero_lugares (nombre, tipo, descripcion, sort_order, activo, formulario_modo) VALUES (?, ?, ?, ?, ?, ?)'
);
$stmt->execute([$nombre, $tipo, $desc ?: null, $sortOrder, $activo]);
$stmt->execute([$nombre, $tipo, $desc ?: null, $sortOrder, $activo, $formModo]);
$lugarId = (int) $pdo->lastInsertId();
}
+31 -66
View File
@@ -32,52 +32,31 @@ if ($turnoId <= 0) jsonError('turno_id inválido.');
$pdo = db();
// ── 1. Cargar turno y su solicitud ────────────────────────────
// ── 1. Cargar turno (con fallback de paciente desde kiosko) ──
$stmt = $pdo->prepare(
"SELECT t.id, t.codigo, t.paciente_nombre, t.paciente_cel,
"SELECT t.id, t.codigo, t.paciente_nombre, t.paciente_cel, t.paciente_id,
t.estado,
s.id AS solicitud_id,
s.paciente_id,
s.lugar_id,
p.telefono AS pac_telefono,
p.telefono AS pac_celular,
p.nombre_completo AS pac_nombre
COALESCE(p_sol.telefono, p_tur.telefono) AS pac_celular,
COALESCE(p_sol.nombre_completo, p_tur.nombre_completo) AS pac_nombre
FROM turnero_turnos t
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
LEFT JOIN lab_pacientes p ON p.id = s.paciente_id
LEFT JOIN lab_pacientes p_sol ON p_sol.id = s.paciente_id
LEFT JOIN lab_pacientes p_tur ON p_tur.id = t.paciente_id
WHERE t.id = ?"
);
$stmt->execute([$turnoId]);
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$turno) jsonError('Turno no encontrado.', 404);
if (!$turno['solicitud_id']) jsonError('El turno no tiene solicitud registrada. Guarde la solicitud primero.', 422);
// Determinar celular de contacto (preferencia: paciente → kiosko)
$celular = $turno['pac_celular'] ?? $turno['pac_telefono'] ?? $turno['paciente_cel'] ?? null;
// Celular: preferencia paciente BD → kiosko
$celular = $turno['pac_celular'] ?: $turno['paciente_cel'] ?: null;
if (!$celular) {
jsonError('El paciente no tiene número de celular registrado. Actualice el paciente antes de enviar.', 422);
jsonError('El paciente no tiene número de celular registrado.', 422);
}
$celular = preg_replace('/[^0-9]/', '', $celular);
// Normalizar celular (quitar espacios / guiones, agregar +57 si no tiene código)
$celular = preg_replace('/[\s\-\.]/', '', $celular);
if (!str_starts_with($celular, '+')) {
// Asumir Colombia si no tiene prefijo internacional
$celular = '+57' . ltrim($celular, '0');
}
// ── 2. Obtener exámenes de la solicitud ───────────────────────
$stmt = $pdo->prepare(
"SELECT exam_tipo_id FROM turnero_examen_items WHERE solicitud_id = ?"
);
$stmt->execute([$turno['solicitud_id']]);
$examIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (empty($examIds)) {
jsonError('La solicitud no tiene exámenes registrados.', 422);
}
// ── 3. Obtener formularios configurados para este turno ───────
// ── 2. Obtener formularios configurados para este turno ───────
$stmt = $pdo->prepare(
"SELECT tc.formulario_id, f.nombre AS formulario_nombre
FROM turnero_consentimientos tc
@@ -124,14 +103,16 @@ try {
WHERE id = ?"
);
$wa = new WhatsAppService();
// URL base del sistema (para generar el enlace de firma)
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http')
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
// Si BASE_URL está definida, úsala
if (defined('BASE_URL')) {
$baseUrl = rtrim(BASE_URL, '/');
}
$wa = new WhatsAppService('turnero');
$waTemplate = 'consentimiento_turno';
$stmtLang = $pdo->prepare("SELECT language_code FROM message_templates WHERE template_name = ? LIMIT 1");
$stmtLang->execute([$waTemplate]);
$waLang = $stmtLang->fetchColumn() ?: 'es';
$baseUrl = defined('BASE_URL') ? rtrim(BASE_URL, '/') : (
(isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http')
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost')
);
foreach ($formulariosRequeridos as $form) {
$formularioId = (int) $form['formulario_id'];
@@ -161,39 +142,23 @@ try {
$consentId = (int) $pdo->lastInsertId();
}
// ── Construir enlace de firma ──────────────────────────
$enlaceFirma = $baseUrl . '/ver_formulario_enviado.php?token=' . urlencode($token);
$nombrePac = $turno['pac_nombre'] ?: ($turno['paciente_nombre'] ?? 'Paciente');
// ── Nombre del paciente para el mensaje ───────────────
$nombrePac = $turno['pac_nombre'] ?? $turno['paciente_nombre'] ?? 'Paciente';
// ── Enviar mensaje WhatsApp ────────────────────────────
// Intentamos enviar con template "consentimiento_turno".
// Si el template no existe, enviamos texto plano como fallback.
// ── Enviar con template (mismo formato que create_turno.php) ──
$enviado = false;
try {
$wa->sendTemplateMessage(
$celular,
'consentimiento_turno',
'es',
// Parámetros del body: {{1}} = nombre, {{2}} = nombre formulario, {{3}} = código turno
[
htmlspecialchars($nombrePac, ENT_QUOTES),
htmlspecialchars($formularioNom, ENT_QUOTES),
$turno['codigo'],
],
// Parámetro del header o botón URL (URL del enlace)
[$enlaceFirma]
);
$rawComps = [
['type' => 'body', 'parameters' => [['type' => 'text', 'text' => $turno['codigo']]]],
['type' => 'button', 'sub_type' => 'url', 'index' => '0',
'parameters' => [['type' => 'text', 'text' => $token]]],
];
$wa->sendTemplateMessage($celular, $waTemplate, $waLang, [], [], $rawComps);
$enviado = true;
} catch (\Throwable $eTemplate) {
// Fallback: enviar mensaje de texto plano
// Fallback texto plano
try {
$mensajeTexto = "Hola {$nombrePac}, le informamos que para su turno *{$turno['codigo']}* "
. "debe firmar el siguiente consentimiento informado:\n\n"
. "*{$formularioNom}*\n\n"
. "Puede firmarlo en el siguiente enlace:\n{$enlaceFirma}\n\n"
. "Si ya firmó este documento presencial, ignore este mensaje.";
$mensajeTexto = "Hola {$nombrePac}, su turno *{$turno['codigo']}* requiere firma de consentimiento:\n{$enlaceFirma}";
$wa->sendTextMessage($celular, $mensajeTexto);
$enviado = true;
} catch (\Throwable $eTexto) {
+9 -4
View File
@@ -80,15 +80,19 @@ if ($accion === 'reabrir') {
// ── CONFIG WHATSAPP ──────────────────────────────────────────
if ($accion === 'config_wa') {
$template = trim($input['template'] ?? '');
$lang = trim($input['lang'] ?? 'es_CO');
$waKiosko = (int)($input['wa_kiosko'] ?? 0);
$waConsent = (int)($input['wa_consent'] ?? 0);
$template = trim($input['template'] ?? '');
$templateMuestra = trim($input['template_muestra'] ?? '');
$lang = trim($input['lang'] ?? 'es_CO');
$waKiosko = (int)($input['wa_kiosko'] ?? 0);
$waConsent = (int)($input['wa_consent'] ?? 0);
if ($template === '') jsonError('El nombre de plantilla es requerido');
if (!preg_match('/^[a-z0-9_]{1,64}$/i', $template)) {
jsonError('Nombre de plantilla inválido — solo letras, números y guiones bajos');
}
if ($templateMuestra !== '' && !preg_match('/^[a-z0-9_]{1,64}$/i', $templateMuestra)) {
jsonError('Nombre de plantilla muestra inválido — solo letras, números y guiones bajos');
}
if (!preg_match('/^[a-z]{2}_[A-Z]{2}$/i', $lang)) {
jsonError('Código de idioma inválido (formato: es_CO)');
}
@@ -99,6 +103,7 @@ if ($accion === 'config_wa') {
ON DUPLICATE KEY UPDATE valor = VALUES(valor)'
);
$upsert->execute(['turnero_wa_template', $template]);
$upsert->execute(['turnero_wa_template_muestra', $templateMuestra]);
$upsert->execute(['turnero_wa_lang', $lang]);
$upsert->execute(['turnero_wa_kiosko_enabled', (string)$waKiosko]);
$upsert->execute(['turnero_wa_consent_enabled', (string)$waConsent]);
+11 -3
View File
@@ -13,9 +13,17 @@ $pacienteId = (int)($d['paciente_id'] ?? 0);
if (!$turnoId || !$pacienteId) jsonError('turno_id y paciente_id requeridos.');
$pdo = db();
$stmt = $pdo->prepare('UPDATE turnero_turnos SET paciente_id = ? WHERE id = ?');
$stmt->execute([$pacienteId, $turnoId]);
$pdo = db();
// Obtener nombre real del paciente
$nombre = $pdo->prepare('SELECT nombre_completo FROM lab_pacientes WHERE id = ?');
$nombre->execute([$pacienteId]);
$nombreCompleto = $nombre->fetchColumn() ?: null;
$stmt = $pdo->prepare(
'UPDATE turnero_turnos SET paciente_id = ?, paciente_nombre = COALESCE(?, paciente_nombre) WHERE id = ?'
);
$stmt->execute([$pacienteId, $nombreCompleto, $turnoId]);
if (!$stmt->rowCount()) jsonError('Turno no encontrado.', 404);
+152
View File
@@ -0,0 +1,152 @@
<?php
/**
* GET → Lee el perfil de negocio del número WA turnero desde la Graph API
* POST accion=guardar → Actualiza campos del perfil (about, description, address, email, websites, vertical)
* POST accion=foto → Sube imagen y la aplica como foto de perfil
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
require_once __DIR__ . '/../../../services/WhatsAppService.php';
$token = getConfigFromDB('whatsapp_token', '');
$phoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '')
?: getConfigFromDB('whatsapp_phone_number_id', '');
$apiBase = 'https://graph.facebook.com/v22.0/';
if (!$token || !$phoneId) jsonError('Token o Phone ID del turnero no configurados.', 422);
// ── GET: cargar perfil actual ────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$fields = 'about,address,description,email,profile_picture_url,websites,vertical,messaging_product';
$url = $apiBase . $phoneId . '/whatsapp_business_profile?fields=' . $fields
. '&access_token=' . urlencode($token);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$resp = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($resp, true);
if ($http !== 200 || isset($data['error'])) {
jsonError($data['error']['message'] ?? 'Error al leer perfil de WhatsApp', $http);
}
$perfil = $data['data'][0] ?? $data;
jsonOk(['perfil' => $perfil]);
}
// ── POST ────────────────────────────────────────────────────
requireMethod('POST');
// Detectar si viene como multipart (foto) o JSON
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
$esMultipart = str_contains($contentType, 'multipart/form-data');
if ($esMultipart) {
$accion = $_POST['accion'] ?? '';
} else {
$datos = inputJson();
$accion = $datos['accion'] ?? '';
}
// ── POST accion=foto ─────────────────────────────────────────
if ($accion === 'foto') {
if (empty($_FILES['foto']) || $_FILES['foto']['error'] !== UPLOAD_ERR_OK) {
jsonError('No se recibió imagen válida.');
}
$file = $_FILES['foto'];
$mime = mime_content_type($file['tmp_name']);
$allowed = ['image/jpeg', 'image/png'];
if (!in_array($mime, $allowed, true)) jsonError('Solo se aceptan JPG o PNG.');
if ($file['size'] > 5 * 1024 * 1024) jsonError('Imagen máxima: 5 MB.');
// 1. Subir media a WhatsApp
$uploadUrl = $apiBase . $phoneId . '/media';
$ch = curl_init($uploadUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
CURLOPT_POSTFIELDS => [
'messaging_product' => 'whatsapp',
'type' => $mime,
'file' => new CURLFile($file['tmp_name'], $mime, $file['name']),
],
CURLOPT_TIMEOUT => 30,
]);
$resp = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$uploadData = json_decode($resp, true);
if ($http !== 200 || empty($uploadData['id'])) {
jsonError('Error al subir imagen: ' . ($uploadData['error']['message'] ?? $resp));
}
$mediaHandle = $uploadData['id'];
// 2. Aplicar como foto de perfil
$profileUrl = $apiBase . $phoneId . '/whatsapp_business_profile';
$payload = json_encode([
'messaging_product' => 'whatsapp',
'profile_picture_handle' => $mediaHandle,
]);
$ch = curl_init($profileUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 15,
]);
$resp2 = curl_exec($ch);
$http2 = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$r2 = json_decode($resp2, true);
if (isset($r2['error'])) jsonError('Error al aplicar foto: ' . $r2['error']['message']);
jsonOk([], 'Foto de perfil actualizada');
}
// ── POST accion=guardar ──────────────────────────────────────
if ($accion === 'guardar') {
$campos = ['about', 'description', 'address', 'email', 'vertical'];
$body = ['messaging_product' => 'whatsapp'];
foreach ($campos as $c) {
$val = trim($datos[$c] ?? '');
if ($val !== '') $body[$c] = $val;
}
// Websites: array de hasta 2
$web = array_filter(array_map('trim', (array)($datos['websites'] ?? [])));
if ($web) $body['websites'] = array_values($web);
$url = $apiBase . $phoneId . '/whatsapp_business_profile';
$payload = json_encode($body);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 15,
]);
$resp = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$r = json_decode($resp, true);
if (isset($r['error'])) jsonError('Error de WhatsApp: ' . $r['error']['message']);
jsonOk([], 'Perfil de WhatsApp actualizado');
}
jsonError('Acción no reconocida.');
+5 -1
View File
@@ -29,7 +29,9 @@ $_trLinks = [];
// ── Recepcionista: solo sus escritorios + pantalla TV ─────────
if ($_trIsRecep) {
$_trLinks[] = ['name' => 'Pantalla TV', 'icon' => 'fas fa-tv', 'route' => '/erp.php?m=turnero&v=display_global'];
$_trLinks[] = ['name' => 'Chat Turnero', 'icon' => 'fab fa-whatsapp', 'route' => '/erp.php?m=turnero&v=chat'];
$_trLinks[] = ['name' => 'Verificar Paciente', 'icon' => 'fas fa-id-card', 'route' => '/erp.php?m=turnero&v=verificar_paciente'];
$_trLinks[] = ['name' => 'Pantalla TV', 'icon' => 'fas fa-tv', 'route' => '/erp.php?m=turnero&v=display_global'];
if (!empty($_trDesks)) {
foreach ($_trDesks as $_d) {
$_trLinks[] = [
@@ -59,6 +61,8 @@ if ($_trIsRecep) {
} else {
$_trLinks[] = ['name' => 'Dashboard', 'icon' => 'fas fa-tachometer-alt', 'route' => '/erp.php?m=turnero&v=dashboard'];
$_trLinks[] = ['name' => 'Historial', 'icon' => 'fas fa-history', 'route' => '/erp.php?m=turnero&v=historial'];
$_trLinks[] = ['name' => 'Chat Turnero', 'icon' => 'fab fa-whatsapp', 'route' => '/erp.php?m=turnero&v=chat'];
$_trLinks[] = ['name' => 'Verificar Paciente', 'icon' => 'fas fa-id-card', 'route' => '/erp.php?m=turnero&v=verificar_paciente'];
$_trLinks[] = ['name' => 'Configuración', 'icon' => 'fas fa-sliders-h', 'route' => '/erp.php?m=turnero&v=configuracion'];
$_trLinks[] = ['name' => 'Kiosko', 'icon' => 'fas fa-desktop', 'route' => '/erp.php?m=turnero&v=kiosko'];
$_trLinks[] = ['name' => 'Pantalla TV Global', 'icon' => 'fas fa-th-large', 'route' => '/erp.php?m=turnero&v=display_global'];
+967
View File
@@ -0,0 +1,967 @@
<?php
/**
* modules/turnero/views/chat.php
* Inbox del número WhatsApp del Turnero.
* Permite ver y responder mensajes de pacientes que escriben al número turnero.
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
$turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
<title>Chat Turnero — WhatsApp</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; }
.chat-shell { display: flex; height: 100%; }
/* ── Sidebar ── */
.sidebar {
width: 340px; min-width: 280px; max-width: 380px;
background: #fff; border-right: 1px solid #e9ecef;
display: flex; flex-direction: column;
}
.sidebar-header {
padding: 16px; background: #25d366; color: #fff;
display: flex; align-items: center; gap: 10px;
}
.sidebar-header h2 { font-size: 1rem; flex: 1; }
.sidebar-header .badge-config {
font-size: .7rem; background: rgba(0,0,0,.2); padding: 3px 8px; border-radius: 12px;
}
.search-box { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; }
.search-box input {
width: 100%; padding: 8px 12px; border: 1px solid #e0e0e0;
border-radius: 20px; font-size: .85rem; outline: none;
background: #f5f5f5;
}
.search-box input:focus { border-color: #25d366; background: #fff; }
.contact-list { flex: 1; overflow-y: auto; }
.contact-item {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; cursor: pointer; border-bottom: 1px solid #f7f7f7;
transition: background .15s;
}
.contact-item:hover { background: #f5f5f5; }
.contact-item.active { background: #e7fce8; }
.contact-avatar {
width: 44px; height: 44px; border-radius: 50%;
background: #25d366; color: #fff; display: flex; align-items: center;
justify-content: center; font-weight: 700; font-size: 1.1rem; flex-shrink: 0;
}
.contact-info { flex: 1; min-width: 0; }
.contact-name { font-weight: 600; font-size: .9rem; color: #111; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contact-last { font-size: .78rem; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.contact-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.contact-time { font-size: .7rem; color: #aaa; }
.badge-unread {
background: #25d366; color: #fff; font-size: .65rem; font-weight: 700;
padding: 2px 6px; border-radius: 10px; min-width: 18px; text-align: center;
}
.contact-empty { text-align: center; padding: 40px 16px; color: #aaa; font-size: .85rem; }
/* ── Chat window ── */
.chat-window {
flex: 1; display: flex; flex-direction: column;
background: #efeae2;
}
.chat-empty-state {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; color: #aaa; gap: 12px;
}
.chat-empty-state i { font-size: 3rem; color: #25d366; }
.chat-empty-state p { font-size: .95rem; }
.chat-topbar {
background: #fff; padding: 12px 16px;
display: flex; align-items: center; gap: 12px;
border-bottom: 1px solid #e9ecef; min-height: 60px;
position: sticky; top: 0; z-index: 10; flex-shrink: 0;
}
.chat-topbar-avatar {
width: 38px; height: 38px; border-radius: 50%;
background: #25d366; color: #fff; display: flex;
align-items: center; justify-content: center; font-weight: 700;
}
.chat-topbar-info h3 { font-size: .95rem; font-weight: 600; }
.chat-topbar-info span { font-size: .78rem; color: #888; }
.messages-area {
flex: 1; overflow-y: auto; padding: 12px 16px;
display: flex; flex-direction: column; gap: 6px;
min-height: 0; -webkit-overflow-scrolling: touch;
}
.msg-bubble {
max-width: 65%; padding: 8px 12px; border-radius: 8px;
font-size: .88rem; line-height: 1.4; position: relative;
word-break: break-word;
}
.msg-bubble.incoming {
background: #fff; align-self: flex-start;
border-bottom-left-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,.08);
}
.msg-bubble.outgoing {
background: #d9fdd3; align-self: flex-end;
border-bottom-right-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,.08);
}
.msg-time {
font-size: .68rem; color: #aaa; text-align: right; margin-top: 3px;
}
.msg-type-badge {
font-size: .7rem; color: #888; font-style: italic;
}
.msg-media-thumb {
max-width: 220px; max-height: 180px; border-radius: 6px;
display: block; margin-bottom: 4px; cursor: pointer;
}
.msg-media-link {
display: flex; align-items: center; gap: 6px; font-size: .82rem; color: #1a73e8;
text-decoration: none; padding: 4px 0;
}
.msg-media-link:hover { text-decoration: underline; }
.load-more-btn {
align-self: center; padding: 6px 18px; font-size: .8rem;
background: #fff; border: 1px solid #ddd; border-radius: 16px;
cursor: pointer; color: #555;
}
.load-more-btn:hover { background: #f5f5f5; }
/* ── Input area ── */
.chat-input-area {
background: #f0f2f5; padding: 10px 16px;
padding-bottom: max(10px, env(safe-area-inset-bottom));
display: flex; align-items: flex-end; gap: 10px;
border-top: 1px solid #e0e0e0; flex-shrink: 0;
}
.chat-input-area textarea {
flex: 1; padding: 10px 14px; border: none; border-radius: 22px;
background: #fff; font-size: .9rem; resize: none; outline: none;
max-height: 120px; line-height: 1.4;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.btn-send {
width: 42px; height: 42px; border-radius: 50%; border: none;
background: #25d366; color: #fff; font-size: 1.1rem;
cursor: pointer; display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: background .2s;
}
.btn-send:hover { background: #1ebe5c; }
.btn-send:disabled { background: #ccc; cursor: default; }
/* ── Alertas ── */
.alert-no-config {
background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px;
padding: 12px 16px; margin: 16px; font-size: .85rem; color: #856404;
display: flex; align-items: center; gap: 8px;
}
.typing-indicator { font-size: .75rem; color: #888; padding: 2px 8px; }
/* ── Botones extra en input ── */
.input-extra-btn {
background: none; border: none; color: #888; font-size: 1.15rem;
cursor: pointer; padding: 6px; border-radius: 50%; transition: background .15s, color .15s;
flex-shrink: 0;
}
.input-extra-btn:hover { background: rgba(0,0,0,.06); color: #555; }
/* ── Panel emoji ── */
.emoji-panel {
position: absolute; bottom: 64px; left: 0;
background: #fff; border: 1px solid #e0e0e0; border-radius: 12px;
box-shadow: 0 6px 24px rgba(0,0,0,.15); padding: 10px;
display: none; flex-wrap: wrap; gap: 2px; width: 280px; z-index: 50;
}
.emoji-panel.show { display: flex; }
.emoji-panel span { font-size: 1.3rem; cursor: pointer; padding: 4px; border-radius: 6px; }
.emoji-panel span:hover { background: #f0f2f5; }
/* ── Reaction picker ── */
#reaction-picker {
position: fixed; display: none; z-index: 9000;
background: #fff; border-radius: 30px; padding: 6px 10px;
box-shadow: 0 4px 20px rgba(0,0,0,.18);
gap: 4px; align-items: center;
}
#reaction-picker.show { display: flex; }
#reaction-picker span { font-size: 1.4rem; cursor: pointer; padding: 4px 6px; border-radius: 50%; transition: transform .1s; }
#reaction-picker span:hover { transform: scale(1.3); }
.msg-bubble { position: relative; }
.msg-bubble:hover .react-btn { opacity: 1; }
.react-btn {
position: absolute; top: 4px; opacity: 0; transition: opacity .15s;
background: #fff; border: 1px solid #e0e0e0; border-radius: 50%;
width: 24px; height: 24px; font-size: .75rem; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 1px 4px rgba(0,0,0,.12);
}
.msg-bubble.incoming .react-btn { right: -28px; }
.msg-bubble.outgoing .react-btn { left: -28px; }
.msg-reaction { font-size: .95rem; margin-top: 2px; }
/* ── Preview adjunto ── */
#attach-preview {
display: none; align-items: center; gap: 8px;
padding: 8px 12px; background: #e9f5fe;
border-top: 1px solid #bee3f8; font-size: .85rem;
}
#attach-preview img { width: 48px; height: 48px; object-fit: cover; border-radius: 6px; }
#attach-preview .attach-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#attach-preview .attach-remove { background: none; border: none; color: #e53e3e; cursor: pointer; font-size: 1rem; }
/* ── Recording indicator ── */
#recording-bar {
display: none; align-items: center; gap: 10px;
padding: 8px 14px; background: #fff0f0; border-top: 1px solid #fed7d7;
font-size: .85rem; color: #e53e3e;
}
#recording-bar .rec-dot { width: 10px; height: 10px; border-radius: 50%; background: #e53e3e; animation: blink 1s infinite; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.2} }
/* ── Modal plantilla ── */
#template-modal {
display: none; position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,.4); align-items: center; justify-content: center;
}
#template-modal.show { display: flex; }
.tpl-card { background: #fff; border-radius: 14px; padding: 24px; width: 340px; box-shadow: 0 8px 32px rgba(0,0,0,.2); }
.tpl-card h3 { font-size: 1rem; font-weight: 700; margin-bottom: 16px; }
.tpl-field { margin-bottom: 12px; }
.tpl-field label { font-size: .8rem; color: #555; font-weight: 600; display: block; margin-bottom: 4px; }
.tpl-field input { width: 100%; padding: 8px 10px; border: 1.5px solid #e0e0e0; border-radius: 8px; font-size: .9rem; outline: none; }
.tpl-field input:focus { border-color: #25d366; }
.tpl-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
.btn-tpl-cancel { padding: 8px 16px; background: #f5f5f5; border: none; border-radius: 8px; cursor: pointer; font-size: .88rem; }
.btn-tpl-send { padding: 8px 16px; background: #25d366; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: .88rem; font-weight: 600; }
/* ── Media en burbuja ── */
.msg-img { max-width: 220px; max-height: 200px; border-radius: 8px; cursor: pointer; display: block; margin-bottom: 3px; }
.msg-video { max-width: 220px; border-radius: 8px; display: block; margin-bottom: 3px; }
.msg-audio { width: 200px; margin-bottom: 3px; }
.msg-doc-link { display: flex; align-items: center; gap: 7px; font-size: .83rem; color: #1a73e8; text-decoration: none; padding: 4px 0; }
.msg-doc-link:hover { text-decoration: underline; }
.back-btn { display: none; background: none; border: none; color: #fff; font-size: 1.1rem; cursor: pointer; padding: 4px 8px; }
@media (max-width: 680px) {
.chat-shell { position: relative; overflow: hidden; }
.sidebar { position: absolute; inset: 0; z-index: 10; transition: transform .2s ease; }
.sidebar.mobile-hidden { transform: translateX(-100%); pointer-events: none; }
.chat-window { position: absolute; inset: 0; z-index: 5; transform: translateX(100%); height: 100%; transition: transform .2s ease; }
.chat-window.mobile-active { transform: translateX(0); }
.back-btn { display: flex; }
}
</style>
</head>
<body>
<div class="chat-shell">
<!-- ── Sidebar: lista de contactos ── -->
<div class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="<?= BASE_URL ?>erp.php?m=turnero&v=dashboard"
style="color:rgba(255,255,255,.7);font-size:.85rem;text-decoration:none;display:flex;align-items:center;gap:5px;margin-bottom:8px">
<i class="fas fa-arrow-left"></i> Menú
</a>
<i class="fab fa-whatsapp" style="font-size:1.4rem"></i>
<h2>Chat Turnero</h2>
<?php if ($turneroPhoneId): ?>
<span class="badge-config" title="Número turnero configurado">
<i class="fas fa-check-circle"></i> Activo
</span>
<?php else: ?>
<span class="badge-config" style="background:rgba(220,53,69,.6)" title="Sin configurar">
<i class="fas fa-exclamation-circle"></i> Sin config
</span>
<?php endif; ?>
</div>
<div class="search-box">
<input type="text" id="searchInput" placeholder="Buscar contacto…" autocomplete="off">
</div>
<div class="contact-list" id="contactList">
<div class="contact-empty"><i class="fas fa-spinner fa-spin"></i><br>Cargando…</div>
</div>
</div>
<!-- ── Ventana de chat ── -->
<div class="chat-window" id="chatWindow">
<?php if (!$turneroPhoneId): ?>
<div class="alert-no-config">
<i class="fas fa-exclamation-triangle"></i>
El número de WhatsApp del Turnero no está configurado.
<a href="/erp.php?m=turnero&v=configuracion" style="margin-left:8px;color:#856404;font-weight:600;">Configurar ahora</a>
</div>
<?php endif; ?>
<div class="chat-empty-state" id="emptyState">
<i class="fab fa-whatsapp"></i>
<p>Selecciona un contacto para ver la conversación</p>
</div>
<div id="activeChat" style="display:none; flex:1; flex-direction:column; overflow:hidden;">
<div class="chat-topbar" id="chatTopbar">
<button class="back-btn" onclick="closeChatMobile()"><i class="fas fa-arrow-left"></i></button>
<div class="chat-topbar-avatar" id="topbarAvatar">?</div>
<div class="chat-topbar-info">
<h3 id="topbarName">—</h3>
<span id="topbarPhone">—</span>
</div>
</div>
<div class="messages-area" id="messagesArea">
<button class="load-more-btn" id="loadMoreBtn" style="display:none" onclick="loadMoreMessages()">
<i class="fas fa-chevron-up"></i> Ver mensajes anteriores
</button>
</div>
<div id="typingIndicator" class="typing-indicator" style="display:none">Escribiendo…</div>
<!-- Preview adjunto -->
<div id="attach-preview">
<img id="attachThumb" src="" alt="" style="display:none">
<span class="attach-name" id="attachName"></span>
<button class="attach-remove" onclick="clearAttach()">✕</button>
</div>
<!-- Barra de grabación -->
<div id="recording-bar">
<div class="rec-dot"></div>
<span>Grabando…</span>
<span id="recTimer" style="font-weight:700;min-width:32px">0:00</span>
<button onclick="stopRecording()" style="margin-left:auto;background:#e53e3e;color:#fff;border:none;border-radius:6px;padding:4px 12px;cursor:pointer;font-size:.82rem">Enviar</button>
<button onclick="cancelRecording()" style="background:#f5f5f5;border:none;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:.82rem">Cancelar</button>
</div>
<input type="file" id="fileInput" style="display:none"
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.csv"
onchange="onFileSelected(event)">
<div class="chat-input-area" style="position:relative">
<div class="emoji-panel" id="emojiPanel"></div>
<button class="input-extra-btn" onclick="toggleEmojiPanel()" title="Emoji"><i class="fas fa-smile"></i></button>
<button class="input-extra-btn" onclick="document.getElementById('fileInput').click()" title="Adjuntar"><i class="fas fa-paperclip"></i></button>
<button class="input-extra-btn" id="micBtn" onclick="toggleRecording()" title="Grabar audio"><i class="fas fa-microphone"></i></button>
<textarea id="msgInput" rows="1" placeholder="Escribe un mensaje…"
onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<button class="input-extra-btn" onclick="openTemplatePicker()" title="Plantilla"><i class="fas fa-file-alt"></i></button>
<button class="btn-send" id="btnSend" onclick="sendMessage()" <?= $turneroPhoneId ? '' : 'disabled' ?>>
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
</div><!-- /chat-window -->
</div><!-- /chat-shell -->
<!-- ── Reaction picker ── -->
<div id="reaction-picker">
<?php foreach (['👍','❤️','😂','😮','😢','🙏','🔥','👏'] as $em): ?>
<span onclick="sendReactionEmoji('<?= $em ?>')"><?= $em ?></span>
<?php endforeach; ?>
</div>
<!-- ── Modal plantilla ── -->
<div id="template-modal">
<div class="tpl-card">
<h3><i class="fas fa-file-alt" style="color:#25d366;margin-right:6px"></i>Enviar Plantilla</h3>
<div class="tpl-field">
<label>Nombre de plantilla</label>
<input type="text" id="tplName" placeholder="ej: consentimiento_turno">
</div>
<div class="tpl-field">
<label>Idioma</label>
<input type="text" id="tplLang" value="es_CO" placeholder="es_CO">
</div>
<div class="tpl-field">
<label>Parámetros (JSON, opcional)</label>
<input type="text" id="tplParams" placeholder='[{"type":"text","text":"valor"}]'>
</div>
<div class="tpl-actions">
<button class="btn-tpl-cancel" onclick="closeTemplatePicker()">Cancelar</button>
<button class="btn-tpl-send" onclick="sendTemplate()">Enviar</button>
</div>
</div>
</div>
<script>
const API_LIST = 'modules/turnero/api/chat_get_list.php';
const API_MESSAGES = 'modules/turnero/api/chat_get_messages.php';
const API_SEND = 'modules/turnero/api/chat_send_message.php';
const API_READ = 'modules/turnero/api/chat_mark_read.php';
const API_MEDIA = 'modules/turnero/api/chat_upload_media.php';
const API_REACT = 'modules/turnero/api/chat_react.php';
const BASE = (function() {
const s = window.location.pathname;
return s.replace(/\/erp\.php.*$/, '/') || '/';
})();
let state = {
contacts: [],
activeUserId: null,
activeUser: null,
messages: [],
earliest: null,
earliestId: null,
hasMore: false,
polling: null,
lastPollTime: null,
};
// ── Init ─────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadContacts();
document.getElementById('searchInput').addEventListener('input', debounce(() => {
loadContacts(document.getElementById('searchInput').value.trim());
}, 300));
setInterval(pollMessages, 4000);
setInterval(() => loadContacts(document.getElementById('searchInput').value.trim(), true), 12000);
});
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
// ── Contactos ─────────────────────────────────────────────────────────────────
async function loadContacts(search = '', silent = false) {
const url = BASE + API_LIST + '?limit=100' + (search ? '&search=' + encodeURIComponent(search) : '');
try {
const res = await fetch(url);
const json = await res.json();
if (!json.success) return;
state.contacts = json.data;
renderContacts(json.data);
} catch(e) {
if (!silent) console.error('loadContacts:', e);
}
}
function renderContacts(contacts) {
const el = document.getElementById('contactList');
if (!contacts.length) {
el.innerHTML = '<div class="contact-empty"><i class="fas fa-comment-slash"></i><br>Sin conversaciones aún</div>';
return;
}
el.innerHTML = contacts.map(c => {
const initials = (c.name || c.phone_number).charAt(0).toUpperCase();
const preview = escHtml(c.last_message || '—');
const time = c.last_time ? formatTime(c.last_time) : '';
const unread = c.unread_count > 0 ? `<span class="badge-unread">${c.unread_count}</span>` : '';
const active = state.activeUserId === c.user_id ? ' active' : '';
return `<div class="contact-item${active}" onclick="openChat(${c.user_id})">
<div class="contact-avatar">${initials}</div>
<div class="contact-info">
<div class="contact-name">${escHtml(c.name || c.phone_number)}</div>
<div class="contact-last">${preview}</div>
</div>
<div class="contact-meta">
<span class="contact-time">${time}</span>
${unread}
</div>
</div>`;
}).join('');
}
// ── Abrir chat ────────────────────────────────────────────────────────────────
async function openChat(userId) {
state.activeUserId = userId;
state.messages = [];
state.earliest = null;
state.earliestId = null;
state.hasMore = false;
state.lastPollTime = null;
const contact = state.contacts.find(c => c.user_id === userId);
state.activeUser = contact;
document.getElementById('emptyState').style.display = 'none';
const activeEl = document.getElementById('activeChat');
activeEl.style.display = 'flex';
activeEl.style.flexDirection = 'column';
activeEl.style.overflow = 'hidden';
activeEl.style.flex = '1';
const initials = contact ? (contact.name || contact.phone_number).charAt(0).toUpperCase() : '?';
document.getElementById('topbarAvatar').textContent = initials;
document.getElementById('topbarName').textContent = contact ? (contact.name || contact.phone_number) : '…';
document.getElementById('topbarPhone').textContent = contact ? contact.phone_number : '';
document.querySelectorAll('.contact-item').forEach(el => el.classList.remove('active'));
const items = document.querySelectorAll('.contact-item');
items.forEach(el => { if (el.onclick.toString().includes(`openChat(${userId})`)) el.classList.add('active'); });
const area = document.getElementById('messagesArea');
area.innerHTML = '<div style="text-align:center;padding:20px;color:#aaa"><i class="fas fa-spinner fa-spin"></i></div>';
await fetchMessages();
markRead(userId);
loadContacts(document.getElementById('searchInput').value.trim(), true);
openChatMobile();
}
function isMobile() { return window.innerWidth <= 680; }
function openChatMobile() {
if (!isMobile()) return;
document.getElementById('sidebar').classList.add('mobile-hidden');
document.getElementById('chatWindow').classList.add('mobile-active');
}
function closeChatMobile() {
if (!isMobile()) return;
document.getElementById('sidebar').classList.remove('mobile-hidden');
document.getElementById('chatWindow').classList.remove('mobile-active');
}
// ── Mensajes ──────────────────────────────────────────────────────────────────
async function fetchMessages() {
if (!state.activeUserId) return;
const forUser = state.activeUserId; // captura antes del await
const url = BASE + API_MESSAGES + '?user_id=' + forUser + '&limit=50';
try {
const res = await fetch(url);
const json = await res.json();
if (forUser !== state.activeUserId) return; // usuario cambió mientras esperaba
if (!json.success) return;
state.messages = json.data;
state.hasMore = json.has_more;
state.earliest = json.earliest;
state.earliestId = json.earliest_id;
state.lastPollTime = json.data.length ? json.data[json.data.length - 1].created_at : null;
renderMessages(json.data, false);
} catch(e) { console.error('fetchMessages:', e); }
}
async function loadMoreMessages() {
if (!state.activeUserId || !state.earliest) return;
const forUser = state.activeUserId;
const url = BASE + API_MESSAGES
+ '?user_id=' + forUser
+ '&limit=50'
+ '&before=' + encodeURIComponent(state.earliest)
+ (state.earliestId ? '&before_id=' + state.earliestId : '');
try {
const res = await fetch(url);
const json = await res.json();
if (forUser !== state.activeUserId) return;
if (!json.success || !json.data.length) {
document.getElementById('loadMoreBtn').style.display = 'none';
return;
}
state.messages = [...json.data, ...state.messages];
state.hasMore = json.has_more;
state.earliest = json.earliest;
state.earliestId = json.earliest_id;
prependMessages(json.data);
} catch(e) { console.error('loadMoreMessages:', e); }
}
async function pollMessages() {
if (!state.activeUserId || !state.lastPollTime) return;
const forUser = state.activeUserId;
const sinceTime = state.lastPollTime;
const url = BASE + API_MESSAGES
+ '?user_id=' + forUser
+ '&limit=50'
+ '&since=' + encodeURIComponent(sinceTime);
try {
const res = await fetch(url);
const json = await res.json();
if (forUser !== state.activeUserId) return; // usuario cambió mientras esperaba
if (!json.success || !json.data.length) return;
json.data.forEach(m => {
if (!state.messages.find(x => x.id === m.id)) {
state.messages.push(m);
appendMessage(m);
}
});
state.lastPollTime = json.data[json.data.length - 1].created_at;
markRead(forUser);
loadContacts(document.getElementById('searchInput').value.trim(), true);
} catch(e) {}
}
// ── Render mensajes ───────────────────────────────────────────────────────────
function renderMessages(msgs, prepend) {
const area = document.getElementById('messagesArea');
area.innerHTML = '';
const loadBtn = document.createElement('button');
loadBtn.className = 'load-more-btn';
loadBtn.id = 'loadMoreBtn';
loadBtn.style.display = state.hasMore ? 'block' : 'none';
loadBtn.onclick = loadMoreMessages;
loadBtn.innerHTML = '<i class="fas fa-chevron-up"></i> Ver mensajes anteriores';
area.appendChild(loadBtn);
msgs.forEach(m => area.appendChild(buildBubble(m)));
area.scrollTop = area.scrollHeight;
}
function prependMessages(msgs) {
const area = document.getElementById('messagesArea');
const oldTop = area.scrollHeight - area.scrollTop;
const loadBtn = document.getElementById('loadMoreBtn');
msgs.forEach(m => area.insertBefore(buildBubble(m), loadBtn.nextSibling));
if (!state.hasMore) loadBtn.style.display = 'none';
area.scrollTop = area.scrollHeight - oldTop;
}
function appendMessage(msg) {
const area = document.getElementById('messagesArea');
const bubble = buildBubble(msg);
area.appendChild(bubble);
area.scrollTop = area.scrollHeight;
}
function buildBubble(msg) {
const div = document.createElement('div');
div.className = 'msg-bubble ' + (msg.direction === 'outgoing' ? 'outgoing' : 'incoming');
div.dataset.msgId = msg.id;
div.dataset.waId = msg.message_id || '';
const mediaSrc = msg.local_file ? ('../' + msg.local_file) : (msg.media_url_external || null);
let content = '';
if (msg.message_type === 'image' && mediaSrc) {
content = `<img class="msg-img" src="${escHtml(mediaSrc)}" loading="lazy"
onclick="window.open('${escHtml(mediaSrc)}','_blank')" alt="imagen">`;
if (msg.content) content += `<div style="font-size:.82rem;margin-top:2px">${escHtml(msg.content)}</div>`;
} else if (msg.message_type === 'video' && mediaSrc) {
content = `<video class="msg-video" src="${escHtml(mediaSrc)}" controls preload="metadata"></video>`;
if (msg.content) content += `<div style="font-size:.82rem;margin-top:2px">${escHtml(msg.content)}</div>`;
} else if (msg.message_type === 'audio' && mediaSrc) {
content = `<audio class="msg-audio" src="${escHtml(mediaSrc)}" controls preload="metadata"></audio>`;
} else if (msg.message_type === 'document' && mediaSrc) {
const icon = (msg.mime_type || '').includes('pdf') ? 'fa-file-pdf' :
(msg.mime_type || '').includes('spreadsheet') || (msg.mime_type || '').includes('excel') ? 'fa-file-excel' :
(msg.mime_type || '').includes('word') ? 'fa-file-word' : 'fa-file-alt';
content = `<a class="msg-doc-link" href="${escHtml(mediaSrc)}" target="_blank">
<i class="fas ${icon}" style="font-size:1.3rem"></i>
<span>${escHtml(msg.filename || 'Documento')}</span>
</a>`;
} else {
content = nl2br(escHtml(msg.content || ''));
}
const time = msg.created_at ? formatTimeFull(msg.created_at) : '';
const statusIcon = msg.direction === 'outgoing'
? (msg.status === 'read' ? ' ✓✓' : msg.status === 'delivered' ? ' ✓✓' : ' ✓')
: '';
const reactBtn = msg.message_id
? `<button class="react-btn" onclick="showReactionPicker(event, '${escHtml(msg.message_id)}')">😊</button>`
: '';
div.innerHTML = content
+ `<div class="msg-time">${time}<span style="opacity:.7;font-size:.7rem">${statusIcon}</span></div>`
+ (msg.reaction_emoji ? `<div class="msg-reaction">${escHtml(msg.reaction_emoji)}</div>` : '')
+ reactBtn;
return div;
}
// ── Enviar texto ─────────────────────────────────────────────────────────────
async function sendMessage() {
// Si hay archivo adjunto, enviar como media
if (attachFile) { await sendMedia(); return; }
const input = document.getElementById('msgInput');
const message = input.value.trim();
if (!message || !state.activeUserId) return;
const btn = document.getElementById('btnSend');
btn.disabled = true;
input.value = '';
autoResize(input);
try {
const res = await fetch(BASE + API_SEND, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: state.activeUserId, message })
});
const json = await res.json();
if (!json.success) {
alert('Error al enviar: ' + (json.error || 'desconocido'));
input.value = message;
} else {
await pollMessages();
}
} catch(e) {
alert('Error de red al enviar el mensaje');
input.value = message;
} finally {
btn.disabled = false;
input.focus();
}
}
// ── Adjuntar archivo ──────────────────────────────────────────────────────────
let attachFile = null;
function onFileSelected(e) {
const file = e.target.files[0];
if (!file) return;
attachFile = file;
const prev = document.getElementById('attach-preview');
const thumb = document.getElementById('attachThumb');
const name = document.getElementById('attachName');
name.textContent = file.name + ' (' + (file.size > 1048576 ? (file.size/1048576).toFixed(1) + ' MB' : Math.round(file.size/1024) + ' KB') + ')';
if (file.type.startsWith('image/')) {
thumb.src = URL.createObjectURL(file);
thumb.style.display = 'block';
} else {
thumb.style.display = 'none';
}
prev.style.display = 'flex';
e.target.value = '';
}
function clearAttach() {
attachFile = null;
document.getElementById('attach-preview').style.display = 'none';
document.getElementById('attachThumb').src = '';
}
async function sendMedia() {
if (!attachFile || !state.activeUserId) return;
const btn = document.getElementById('btnSend');
btn.disabled = true;
const caption = document.getElementById('msgInput').value.trim();
const fd = new FormData();
fd.append('user_id', state.activeUserId);
fd.append('file', attachFile);
if (caption) fd.append('caption', caption);
try {
const res = await fetch(BASE + API_MEDIA, { method: 'POST', body: fd });
const json = await res.json();
if (!json.success) {
alert('Error al enviar archivo: ' + (json.error || 'desconocido'));
} else {
clearAttach();
document.getElementById('msgInput').value = '';
autoResize(document.getElementById('msgInput'));
await pollMessages();
}
} catch(e) {
alert('Error de red al enviar el archivo');
} finally {
btn.disabled = false;
}
}
// ── Emoji picker ──────────────────────────────────────────────────────────────
const EMOJI_LIST = ['😀','😂','😍','😎','😢','😡','🤔','👍','👎','❤️','🔥','✅','⭐','🎉','🙏','💪','🤣','😅','😊','🥰','😘','🤗','😴','🤒','😷','💊','🏥','👨‍⚕️','👩‍⚕️','📋','📞','📅','⏰','💉','🩺'];
function toggleEmojiPanel() {
const panel = document.getElementById('emojiPanel');
if (!panel.classList.contains('show')) {
panel.innerHTML = EMOJI_LIST.map(e => `<span onclick="insertEmoji('${e}')">${e}</span>`).join('');
}
panel.classList.toggle('show');
}
function insertEmoji(em) {
const inp = document.getElementById('msgInput');
const pos = inp.selectionStart;
inp.value = inp.value.slice(0, pos) + em + inp.value.slice(pos);
inp.selectionStart = inp.selectionEnd = pos + em.length;
inp.focus();
autoResize(inp);
document.getElementById('emojiPanel').classList.remove('show');
}
document.addEventListener('click', e => {
const panel = document.getElementById('emojiPanel');
if (panel.classList.contains('show') && !e.target.closest('.chat-input-area')) {
panel.classList.remove('show');
}
const rp = document.getElementById('reaction-picker');
if (rp.classList.contains('show') && !e.target.closest('#reaction-picker') && !e.target.closest('.react-btn')) {
rp.classList.remove('show');
}
});
// ── Reacciones ────────────────────────────────────────────────────────────────
let _reactionTarget = null;
function showReactionPicker(e, waId) {
e.stopPropagation();
_reactionTarget = waId;
const rp = document.getElementById('reaction-picker');
rp.classList.add('show');
const rect = e.target.getBoundingClientRect();
rp.style.top = (rect.top - 60) + 'px';
rp.style.left = Math.max(4, rect.left - 80) + 'px';
}
async function sendReactionEmoji(emoji) {
document.getElementById('reaction-picker').classList.remove('show');
if (!_reactionTarget || !state.activeUserId) return;
try {
await fetch(BASE + API_REACT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: state.activeUserId, message_id: _reactionTarget, emoji })
});
await pollMessages();
} catch(e) { console.error('reaction:', e); }
}
// ── Grabación de audio ────────────────────────────────────────────────────────
let mediaRecorder = null, audioChunks = [], recInterval = null, recSeconds = 0;
async function toggleRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') {
stopRecording();
} else {
await startRecording();
}
}
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioChunks = [];
mediaRecorder = new MediaRecorder(stream, { mimeType: MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/ogg' });
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
mediaRecorder.start(100);
recSeconds = 0;
document.getElementById('recording-bar').style.display = 'flex';
document.getElementById('recTimer').textContent = '0:00';
document.getElementById('micBtn').style.color = '#e53e3e';
recInterval = setInterval(() => {
recSeconds++;
const m = Math.floor(recSeconds/60), s = recSeconds%60;
document.getElementById('recTimer').textContent = m + ':' + String(s).padStart(2,'0');
}, 1000);
} catch(e) {
alert('No se pudo acceder al micrófono: ' + e.message);
}
}
async function stopRecording() {
if (!mediaRecorder) return;
clearInterval(recInterval);
document.getElementById('recording-bar').style.display = 'none';
document.getElementById('micBtn').style.color = '';
return new Promise(resolve => {
mediaRecorder.onstop = async () => {
const mime = mediaRecorder.mimeType || 'audio/webm';
const blob = new Blob(audioChunks, { type: mime });
const ext = mime.includes('ogg') ? 'ogg' : 'webm';
const file = new File([blob], `audio_${Date.now()}.${ext}`, { type: mime });
mediaRecorder.stream.getTracks().forEach(t => t.stop());
mediaRecorder = null;
if (!state.activeUserId) { resolve(); return; }
const fd = new FormData();
fd.append('user_id', state.activeUserId);
fd.append('file', file);
fd.append('is_voice', '1');
try {
const res = await fetch(BASE + API_MEDIA, { method: 'POST', body: fd });
const json = await res.json();
if (json.success) await pollMessages();
else alert('Error al enviar audio: ' + (json.error || ''));
} catch(e) { alert('Error de red al enviar audio'); }
resolve();
};
mediaRecorder.stop();
});
}
function cancelRecording() {
if (!mediaRecorder) return;
clearInterval(recInterval);
mediaRecorder.stream.getTracks().forEach(t => t.stop());
mediaRecorder = null;
audioChunks = [];
document.getElementById('recording-bar').style.display = 'none';
document.getElementById('micBtn').style.color = '';
}
// ── Plantilla ─────────────────────────────────────────────────────────────────
function openTemplatePicker() {
if (!state.activeUserId) return;
document.getElementById('template-modal').classList.add('show');
document.getElementById('tplName').focus();
}
function closeTemplatePicker() {
document.getElementById('template-modal').classList.remove('show');
}
async function sendTemplate() {
const name = document.getElementById('tplName').value.trim();
const lang = document.getElementById('tplLang').value.trim() || 'es_CO';
const rawP = document.getElementById('tplParams').value.trim();
if (!name || !state.activeUserId) return alert('Ingresa el nombre de la plantilla');
let params = [];
if (rawP) {
try { params = JSON.parse(rawP); } catch(e) { return alert('Los parámetros no son JSON válido'); }
}
closeTemplatePicker();
try {
const res = await fetch(BASE + API_SEND, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: state.activeUserId, type: 'template', template: name, lang, params })
});
const json = await res.json();
if (!json.success) alert('Error al enviar plantilla: ' + (json.error || ''));
else await pollMessages();
} catch(e) { alert('Error de red al enviar plantilla'); }
}
async function markRead(userId) {
try {
await fetch(BASE + API_READ, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId })
});
} catch(e) {}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function handleKey(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
}
function autoResize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
}
function escHtml(str) {
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function nl2br(str) { return str.replace(/\n/g, '<br>'); }
function formatTime(ts) {
const d = new Date(ts.replace(' ', 'T'));
const now = new Date();
if (d.toDateString() === now.toDateString()) {
return d.toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString('es-CO', { day: '2-digit', month: '2-digit' });
}
function formatTimeFull(ts) {
const d = new Date(ts.replace(' ', 'T'));
return d.toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' });
}
</script>
</body>
</html>
+207 -20
View File
@@ -207,7 +207,7 @@ $tab = $_GET['tab'] ?? 'lugares';
<span class="badge <?= $lu['activo'] ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary' ?> ms-1">
<?= $lu['activo'] ? 'Activo' : 'Inactivo' ?>
</span>
<button class="btn-icon text-primary" onclick="editarLugar(<?= $lu['id'] ?>,'<?= addslashes($lu['nombre']) ?>','<?= addslashes($lu['descripcion'] ?? '') ?>',<?= $lu['activo'] ?>,<?= $lu['sort_order'] ?>,'recepcion')" title="Editar">
<button class="btn-icon text-primary" onclick="editarLugar(<?= $lu['id'] ?>,'<?= addslashes($lu['nombre']) ?>','<?= addslashes($lu['descripcion'] ?? '') ?>',<?= $lu['activo'] ?>,<?= $lu['sort_order'] ?>,'recepcion','<?= $lu['formulario_modo'] ?? 'link' ?>')" title="Editar">
<i class="fas fa-pencil-alt"></i>
</button>
<button class="btn-icon text-danger" onclick="eliminarLugar(<?= $lu['id'] ?>, '<?= addslashes($lu['nombre']) ?>')" title="Eliminar">
@@ -268,7 +268,7 @@ $tab = $_GET['tab'] ?? 'lugares';
<button class="btn-icon" onclick="copiarUrlDisplay(<?= $lu['id'] ?>)" title="Copiar URL pantalla TV de este lugar">
<i class="fas fa-tv"></i>
</button>
<button class="btn-icon text-primary" onclick="editarLugar(<?= $lu['id'] ?>,'<?= addslashes($lu['nombre']) ?>','<?= addslashes($lu['descripcion'] ?? '') ?>',<?= $lu['activo'] ?>,<?= $lu['sort_order'] ?>,'muestras')" title="Editar">
<button class="btn-icon text-primary" onclick="editarLugar(<?= $lu['id'] ?>,'<?= addslashes($lu['nombre']) ?>','<?= addslashes($lu['descripcion'] ?? '') ?>',<?= $lu['activo'] ?>,<?= $lu['sort_order'] ?>,'muestras','<?= $lu['formulario_modo'] ?? 'link' ?>')" title="Editar">
<i class="fas fa-pencil-alt"></i>
</button>
<button class="btn-icon text-danger" onclick="eliminarLugar(<?= $lu['id'] ?>, '<?= addslashes($lu['nombre']) ?>')" title="Eliminar">
@@ -334,7 +334,7 @@ $tab = $_GET['tab'] ?? 'lugares';
<div class="mb-2 mt-3">
<label class="form-label small fw-semibold">
<i class="fas fa-file-signature me-1 text-warning"></i>
Consentimientos requeridos para este lugar
Formularios de consentimiento
</label>
<div id="edit-lu-consents" class="border rounded p-2" style="max-height:160px;overflow-y:auto;background:#fffbeb">
<?php foreach ($formulariosCons as $fc): ?>
@@ -352,6 +352,23 @@ $tab = $_GET['tab'] ?? 'lugares';
<small class="text-muted">No hay formularios activos configurados.</small>
<?php endif; ?>
</div>
<div class="mt-2">
<label class="form-label small fw-semibold mb-1">Modo de presentación del formulario</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="edit-lu-form-modo" id="edit-lu-modo-link" value="link" checked>
<label class="form-check-label small" for="edit-lu-modo-link">
<i class="fas fa-link me-1 text-primary"></i>Enviar por link / WA
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="edit-lu-form-modo" id="edit-lu-modo-embebido" value="embebido">
<label class="form-check-label small" for="edit-lu-modo-embebido">
<i class="fas fa-window-maximize me-1 text-success"></i>Embebido en pantalla
</label>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer py-2 px-3">
@@ -760,12 +777,19 @@ $tab = $_GET['tab'] ?? 'lugares';
</div>
<div class="row g-2">
<div class="col-md-6">
<label class="form-label small fw-semibold">Nombre de plantilla Meta aprobada</label>
<div class="col-md-5">
<label class="form-label small fw-semibold">Plantilla con consentimiento</label>
<input type="text" id="wa-template" class="form-control form-control-sm"
value="<?= htmlspecialchars($cfg['turnero_wa_template'] ?? 'consentimiento_turno') ?>"
placeholder="consentimiento_turno">
<small class="text-muted">Nombre exacto en Meta Business Manager</small>
<small class="text-muted">Consulta, toma de muestra, etc. — incluye link de firma</small>
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Plantilla muestra pendiente</label>
<input type="text" id="wa-template-muestra" class="form-control form-control-sm"
value="<?= htmlspecialchars($cfg['turnero_wa_template_muestra'] ?? 'turno_muestra_pendiente') ?>"
placeholder="turno_muestra_pendiente">
<small class="text-muted">Solo notifica turno, sin consentimiento</small>
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold">Código de idioma</label>
@@ -773,20 +797,101 @@ $tab = $_GET['tab'] ?? 'lugares';
value="<?= htmlspecialchars($cfg['turnero_wa_lang'] ?? 'es_CO') ?>"
placeholder="es_CO">
</div>
<div class="col-md-3 d-flex align-items-end">
<button class="btn btn-primary btn-sm w-100" onclick="guardarWhatsApp()">
<i class="fas fa-save me-1"></i>Guardar
</button>
</div>
</div>
<div class="d-flex justify-content-end mt-2">
<button class="btn btn-primary btn-sm" onclick="guardarWhatsApp()">
<i class="fas fa-save me-1"></i>Guardar configuración
</button>
</div>
<div class="alert alert-info py-2 px-3 mt-3" style="font-size:.82rem">
<i class="fas fa-info-circle me-1"></i>
La plantilla debe estar aprobada en Meta y contener una variable de URL
(<code>{{1}}</code>) para el enlace de firma del consentimiento.
La plantilla con consentimiento debe contener una variable de URL
(<code>{{1}}</code>) para el enlace de firma.
El sistema usará texto plano como fallback si la plantilla falla.
</div>
</div>
<!-- Perfil de WhatsApp Business -->
<div class="mt-4">
<p class="section-title"><i class="fab fa-whatsapp me-1" style="color:#25d366"></i>Perfil de WhatsApp Business</p>
<div class="d-flex align-items-center gap-3 mb-3">
<!-- Foto de perfil -->
<div style="position:relative;flex-shrink:0">
<img id="wa-perfil-foto" src="" alt="Foto perfil"
style="width:80px;height:80px;border-radius:50%;object-fit:cover;
border:2px solid #dee2e6;background:#f1f5f9;display:none">
<div id="wa-perfil-foto-placeholder"
style="width:80px;height:80px;border-radius:50%;background:#e2e8f0;
display:flex;align-items:center;justify-content:center;font-size:2rem">
<i class="fab fa-whatsapp" style="color:#25d366"></i>
</div>
<label title="Cambiar foto" style="position:absolute;bottom:0;right:0;
background:#25d366;color:#fff;border-radius:50%;width:26px;height:26px;
display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:.8rem">
<i class="fas fa-camera"></i>
<input type="file" id="wa-foto-input" accept="image/jpeg,image/png"
style="display:none" onchange="subirFotoPerfil(this)">
</label>
</div>
<div class="flex-grow-1">
<div class="fw-semibold" id="wa-perfil-nombre" style="font-size:.95rem">—</div>
<div class="text-muted small" id="wa-perfil-about">—</div>
<button class="btn btn-outline-secondary btn-sm mt-1" onclick="cargarPerfilWA()">
<i class="fas fa-sync-alt me-1"></i>Cargar perfil actual
</button>
</div>
</div>
<div class="row g-2">
<div class="col-12">
<label class="form-label small fw-semibold">Descripción corta (About) <small class="text-muted fw-normal">máx. 139 chars</small></label>
<input type="text" id="wa-about" maxlength="139" class="form-control form-control-sm"
placeholder="Ej: Laboratorio clínico · Lunes a Sábado 7am-5pm">
</div>
<div class="col-12">
<label class="form-label small fw-semibold">Descripción del negocio</label>
<textarea id="wa-description" rows="2" class="form-control form-control-sm"
placeholder="Descripción completa del laboratorio…"></textarea>
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Dirección</label>
<input type="text" id="wa-address" class="form-control form-control-sm"
placeholder="Calle 5 #12-34, Cúcuta">
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Email</label>
<input type="email" id="wa-email" class="form-control form-control-sm"
placeholder="contacto@laboratorio.com">
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Sitio web 1</label>
<input type="url" id="wa-web1" class="form-control form-control-sm"
placeholder="https://www.laboratorio.com">
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Sitio web 2 <small class="text-muted fw-normal">(opcional)</small></label>
<input type="url" id="wa-web2" class="form-control form-control-sm"
placeholder="https://instagram.com/laboratorio">
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Categoría</label>
<select id="wa-vertical" class="form-select form-select-sm">
<option value="">— sin especificar —</option>
<option value="HEALTH">Salud</option>
<option value="MEDICAL_AND_HEALTH">Médico y Salud</option>
<option value="BEAUTY">Belleza</option>
<option value="EDUCATION">Educación</option>
<option value="OTHER">Otro</option>
</select>
</div>
</div>
<div class="d-flex justify-content-end mt-2">
<button class="btn btn-success btn-sm" onclick="guardarPerfilWA()">
<i class="fas fa-save me-1"></i>Guardar perfil
</button>
</div>
</div>
<!-- ════════════════════════════════════════
TAB 5 — PANTALLA TV
════════════════════════════════════════ -->
@@ -898,7 +1003,10 @@ async function guardarLugar(tipoOrId = 'muestras', id = null) {
});
}
const body = { nombre, tipo, descripcion: desc, sort_order: orden, activo, formulario_ids: formularioIds };
const formModo = esEdicion
? (document.querySelector('input[name="edit-lu-form-modo"]:checked')?.value || 'link')
: 'link';
const body = { nombre, tipo, descripcion: desc, sort_order: orden, activo, formulario_ids: formularioIds, formulario_modo: formModo };
if (id) body.id = id;
try {
@@ -913,7 +1021,7 @@ async function guardarLugar(tipoOrId = 'muestras', id = null) {
} catch (e) { toast('Error de conexión', 'error'); }
}
function editarLugar(id, nombre, desc, activo, orden, tipo = 'muestras') {
function editarLugar(id, nombre, desc, activo, orden, tipo = 'muestras', formModo = 'link') {
document.getElementById('edit-lu-id').value = id;
document.getElementById('edit-lu-tipo').value = tipo;
document.getElementById('edit-lu-nombre').value = nombre;
@@ -929,6 +1037,10 @@ function editarLugar(id, nombre, desc, activo, orden, tipo = 'muestras') {
chk.checked = formIds.includes(parseInt(chk.value));
});
// Modo de presentación
const modoEl = document.querySelector(`input[name="edit-lu-form-modo"][value="${formModo}"]`);
if (modoEl) modoEl.checked = true;
new bootstrap.Modal(document.getElementById('modalEditarLugar')).show();
}
@@ -1172,17 +1284,92 @@ async function borrarTvVideo() {
} catch(e) { toast('Error de conexión', 'error'); }
}
async function cargarPerfilWA() {
try {
const res = await fetch(API + 'wa_profile.php');
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error al cargar perfil', 'error'); return; }
const p = json.perfil || {};
if (p.profile_picture_url) {
document.getElementById('wa-perfil-foto').src = p.profile_picture_url;
document.getElementById('wa-perfil-foto').style.display = '';
document.getElementById('wa-perfil-foto-placeholder').style.display = 'none';
}
document.getElementById('wa-perfil-about').textContent = p.about || '—';
document.getElementById('wa-about').value = p.about || '';
document.getElementById('wa-description').value = p.description || '';
document.getElementById('wa-address').value = p.address || '';
document.getElementById('wa-email').value = p.email || '';
const webs = p.websites || [];
document.getElementById('wa-web1').value = webs[0] || '';
document.getElementById('wa-web2').value = webs[1] || '';
if (p.vertical) document.getElementById('wa-vertical').value = p.vertical;
toast('Perfil cargado');
} catch (e) { toast('Error de conexión', 'error'); }
}
async function guardarPerfilWA() {
const body = {
accion: 'guardar',
about: document.getElementById('wa-about').value.trim(),
description: document.getElementById('wa-description').value.trim(),
address: document.getElementById('wa-address').value.trim(),
email: document.getElementById('wa-email').value.trim(),
vertical: document.getElementById('wa-vertical').value,
websites: [document.getElementById('wa-web1').value.trim(),
document.getElementById('wa-web2').value.trim()].filter(Boolean),
};
try {
const res = await fetch(API + 'wa_profile.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast('Perfil de WhatsApp actualizado');
document.getElementById('wa-perfil-about').textContent = body.about || '—';
} catch (e) { toast('Error de conexión', 'error'); }
}
async function subirFotoPerfil(input) {
if (!input.files[0]) return;
const form = new FormData();
form.append('accion', 'foto');
form.append('foto', input.files[0]);
const btn = input.closest('label');
const orig = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const res = await fetch(API + 'wa_profile.php', { method: 'POST', body: form });
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error al subir foto', 'error'); return; }
// Preview local
const reader = new FileReader();
reader.onload = e => {
document.getElementById('wa-perfil-foto').src = e.target.result;
document.getElementById('wa-perfil-foto').style.display = '';
document.getElementById('wa-perfil-foto-placeholder').style.display = 'none';
};
reader.readAsDataURL(input.files[0]);
toast('Foto de perfil actualizada');
} catch (e) { toast('Error al subir foto', 'error'); } finally {
btn.innerHTML = orig;
input.value = '';
}
}
async function guardarWhatsApp() {
const template = document.getElementById('wa-template')?.value.trim();
const lang = document.getElementById('wa-lang')?.value.trim();
const waKiosko = document.getElementById('chk-wa-kiosko')?.checked ? 1 : 0;
const waConsent = document.getElementById('chk-wa-consent')?.checked ? 1 : 0;
const template = document.getElementById('wa-template')?.value.trim();
const templateMuestra = document.getElementById('wa-template-muestra')?.value.trim();
const lang = document.getElementById('wa-lang')?.value.trim();
const waKiosko = document.getElementById('chk-wa-kiosko')?.checked ? 1 : 0;
const waConsent = document.getElementById('chk-wa-consent')?.checked ? 1 : 0;
if (!template) { toast('El nombre de plantilla es requerido', 'error'); return; }
try {
const res = await fetch(API + 'sesion_turno.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accion: 'config_wa', template, lang, wa_kiosko: waKiosko, wa_consent: waConsent })
body: JSON.stringify({ accion: 'config_wa', template, template_muestra: templateMuestra, lang, wa_kiosko: waKiosko, wa_consent: waConsent })
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
+6 -6
View File
@@ -322,8 +322,8 @@ $_hasVideo = (bool)$_tvVideo;
color: #fff; margin-top: .2rem;
}
.ann-pac {
font-size: clamp(1.05rem, 1.8vw, 1.35rem);
color: rgba(255,255,255,.45); margin-top: .3rem;
font-size: clamp(2rem, 4vw, 3rem);
color: rgba(255,255,255,.85); margin-top: .3rem;
}
.ann-progress {
width: 200px; height: 3px;
@@ -616,8 +616,7 @@ function renderSnapshot(snap) {
let anuncioNuevo = false;
allTurnos.forEach(t => {
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
// Clave única por evento de llamada (id+llamado_at), independiente del escritorio
const callKey = (t.id || t.codigo) + '|' + (t.llamado_at || '');
const callKey = String(t.id || t.codigo);
currentKeys.add(key);
if (!lastSeenKeys.has(key)) {
const c = t.prioridad_color || '<?= $_labColor ?>';
@@ -627,7 +626,8 @@ function renderSnapshot(snap) {
if (!_firstLoad) {
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: nomVoz, color: c });
}
llamados.unshift({ codigo: t.codigo, destino: t.destino, paciente: t.paciente_nombre, color: c });
llamados = llamados.filter(l => l.id !== t.id);
llamados.unshift({ id: t.id, codigo: t.codigo, destino: t.destino, paciente: t.paciente_nombre, color: c });
if (llamados.length > 30) llamados.pop();
anuncioNuevo = true;
}
@@ -642,7 +642,7 @@ function renderSnapshot(snap) {
const t = sorted[0];
const c = t.prioridad_color || '<?= $_labColor ?>';
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
const callKey = (t.id || t.codigo) + '|' + (t.llamado_at || '');
const callKey = String(t.id || t.codigo);
const nomVoz = t.paciente_id ? t.paciente_nombre : null;
announcedCalls.add(callKey);
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: nomVoz, color: c });
+2 -5
View File
@@ -464,10 +464,7 @@ unset($p);
<div class="ticket-label">Su número de turno es</div>
<div id="tick-codigo" class="ticket-codigo">—</div>
<div id="tick-prio-badge"></div>
<div class="ticket-pos">
<div class="lbl">Posición en cola</div>
<div class="num" id="tick-posicion">—</div>
</div>
<div id="tick-nombre" style="font-size:0.95rem;margin:4pt 0 2pt;font-weight:600;"></div>
<div class="ticket-instruc">
Por favor espere a ser llamado.<br>
Recuerde traer su documento de identidad y la orden médica.
@@ -606,7 +603,7 @@ unset($p);
setSpinner(false);
document.getElementById('tick-codigo').textContent = t.codigo;
document.getElementById('tick-codigo').style.color = prioColor;
document.getElementById('tick-posicion').textContent = '#' + t.posicion_cola;
document.getElementById('tick-nombre').textContent = t.paciente_nombre || '';
document.getElementById('tick-prio-badge').innerHTML =
`<span class="ticket-prio-badge" style="background:${prioColor}">${prioCodigo} — ${prioNombre}</span>`;
mostrar('screen-ticket');
+209 -41
View File
@@ -17,19 +17,21 @@ if (!isUserLoggedIn()) {
try {
$pdo = Database::getInstance()->getConnection();
$lugares = $pdo->query(
"SELECT id, nombre, descripcion FROM turnero_lugares WHERE activo = 1 ORDER BY sort_order ASC"
"SELECT id, nombre, descripcion, formulario_modo FROM turnero_lugares WHERE activo = 1 ORDER BY sort_order ASC"
)->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable) {
$lugares = [];
}
$lugarIdParam = isset($_GET['lugar_id']) ? (int)$_GET['lugar_id'] : 0;
$lugarIdParam = isset($_GET['lugar_id']) ? (int)$_GET['lugar_id'] : 0;
// Resolver nombre del lugar para el título
$lugarNombre = 'Estación de Servicio';
// Resolver nombre y modo del lugar para el título
$lugarNombre = 'Estación de Servicio';
$lugarFormModo = 'link'; // 'link' | 'embebido'
foreach ($lugares as $l) {
if ((int)$l['id'] === $lugarIdParam) {
$lugarNombre = $l['nombre'];
$lugarNombre = $l['nombre'];
$lugarFormModo = $l['formulario_modo'] ?? 'link';
break;
}
}
@@ -54,8 +56,12 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
overflow: hidden;
}
@media (max-width: 860px) {
.lugar-layout { grid-template-columns: 1fr; }
.lugar-cola { max-height: 240px; }
.lugar-layout { grid-template-columns: 1fr; position: relative; overflow: hidden; }
.lugar-cola { transition: transform .2s ease; }
.lugar-cola.mobile-oculta { transform: translateX(-100%); position: absolute; inset: 0; pointer-events: none; }
.lugar-ficha { position: absolute; inset: 0; background: #fff; z-index: 10; transform: translateX(100%); transition: transform .2s ease; overflow-y: auto; }
.lugar-ficha.mobile-visible { transform: translateX(0); }
.btn-volver-cola { display: inline-flex !important; align-items: center; gap: 6px; }
}
/* ── Columna cola ── */
@@ -256,6 +262,11 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
<!-- ══ Ficha del turno ════════════════════════════════ -->
<div class="lugar-ficha" id="lugar-ficha">
<button class="btn btn-sm btn-outline-secondary btn-volver-cola mb-3"
style="display:none" onclick="volverACola()">
<i class="fas fa-arrow-left"></i> Cola
</button>
<div class="ficha-placeholder" id="ficha-placeholder">
<i class="fas fa-stethoscope fa-3x mb-3" style="color:#cbd5e1"></i>
<p class="fw-semibold mb-1">Sin turno activo</p>
@@ -285,6 +296,18 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
<div class="pac-dato"><span class="lbl">Fecha nac.</span><span class="val" id="pac-fec">—</span></div>
<div class="pac-dato"><span class="lbl">Celular</span><span class="val" id="pac-cel">—</span></div>
</div>
<div id="pac-embarazada" class="d-none mt-2">
<span class="badge text-bg-danger">
<i class="fas fa-baby me-1"></i>Paciente embarazada
</span>
</div>
<div id="pac-medico" class="d-none mt-2">
<div class="pac-dato">
<span class="lbl">Médico</span>
<span class="val" id="pac-medico-nombre" style="color:#4f46e5"></span>
</div>
</div>
<div id="bloque-pac-sin" class="text-muted small">
<i class="fas fa-info-circle me-1"></i>Sin paciente vinculado
</div>
@@ -298,7 +321,8 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
</div>
</div>
<!-- ── Sección: Consentimientos ── -->
<div class="ficha-sec" id="sec-consent">
<h6><i class="fas fa-file-signature me-1"></i>Consentimientos informados</h6>
@@ -358,6 +382,38 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
</div><!-- /lugar-layout -->
</main>
<!-- ── Modal de consentimiento embebido ──────────────────────── -->
<div id="modal-consentimiento" style="
display:none;position:fixed;inset:0;z-index:9000;
background:rgba(0,0,0,.55);align-items:center;justify-content:center;padding:16px">
<div style="
background:#fff;border-radius:14px;box-shadow:0 8px 40px rgba(0,0,0,.25);
width:min(96vw,680px);max-height:92vh;
display:flex;flex-direction:column;overflow:hidden">
<!-- Header modal -->
<div style="
display:flex;align-items:center;justify-content:space-between;
padding:12px 18px;border-bottom:1px solid #e2e8f0;flex-shrink:0">
<span style="font-weight:700;font-size:.95rem;color:#1e293b">
<i class="fas fa-file-signature me-2 text-primary"></i>Formulario de consentimiento
</span>
<button onclick="cerrarModalConsentimiento()"
style="background:none;border:none;font-size:1.2rem;color:#94a3b8;cursor:pointer;padding:2px 6px">
<i class="fas fa-times"></i>
</button>
</div>
<!-- Loading -->
<div id="modal-consent-loading" style="padding:32px;text-align:center;color:#64748b;flex-shrink:0">
<i class="fas fa-spinner fa-spin fa-2x mb-2 d-block"></i>Cargando formulario…
</div>
<!-- Iframe -->
<iframe id="modal-consent-iframe" src="" frameborder="0"
style="flex:1;border:none;display:none;min-height:60vh"
onload="document.getElementById('modal-consent-loading').style.display='none';this.style.display='block'">
</iframe>
</div>
</div>
<script>
// ── Estado global ─────────────────────────────────────────────
@@ -368,9 +424,10 @@ let hayPendientes = false;
let pollingColaId = null;
let pollingConsentId = null;
const API = '<?= BASE_URL ?>modules/turnero/api/';
const BASE_WA = '<?= BASE_URL ?>';
const LUGAR_NOMBRE = document.getElementById('lbl-lugar-titulo')?.textContent || '<?= addslashes($lugarNombre) ?>';
const API = '<?= BASE_URL ?>modules/turnero/api/';
const BASE_WA = '<?= BASE_URL ?>';
const LUGAR_NOMBRE = document.getElementById('lbl-lugar-titulo')?.textContent || '<?= addslashes($lugarNombre) ?>';
const LUGAR_FORM_MODO = '<?= $lugarFormModo ?>';
// ── Arranque ──────────────────────────────────────────────────
@@ -448,23 +505,35 @@ function renderCola(snap) {
lista.innerHTML = items.map(t => {
const enServicio = t.estado === 'en_servicio';
const esActivo = turnoActivo?.id == t.id;
const onclick = enServicio
? `seleccionarSinLlamar(${t.id})`
: `seleccionarSinLlamar(${t.id})`;
const indicador = enServicio
? `<div class="ms-auto d-flex flex-column align-items-center" style="gap:1px">
? `<div class="ms-auto d-flex flex-column align-items-center" style="gap:1px;flex-shrink:0">
<i class="fas fa-stethoscope text-success" style="font-size:.85rem"></i>
<span style="font-size:.58rem;color:#16a34a;font-weight:700">EN SERVICIO</span>
</div>`
: `<i class="fas fa-chevron-right text-muted ms-auto" style="font-size:.7rem"></i>`;
: `<button onclick="event.stopPropagation();llamarTurnoEspecifico(${t.id})"
title="Llamar turno ${escHtml(t.codigo)}"
style="flex-shrink:0;background:#2563eb;border:none;color:#fff;
border-radius:7px;padding:5px 9px;font-size:.78rem;cursor:pointer">
<i class="fas fa-bell"></i>
</button>`;
const esMuestra = t.solo_muestras == 1;
const muestraBadge = esMuestra
? `<div style="font-size:.6rem;font-weight:700;color:#ea580c;letter-spacing:.04em;margin-top:1px">
<i class="fas fa-plus-square me-1"></i>ENTREGA DE MUESTRAS
</div>`
: '';
const cardStyle = esMuestra
? 'cursor:pointer;border:2px solid #ea580c!important;background:#fff7ed!important'
: 'cursor:pointer';
return `
<div class="cola-card ${enServicio ? 'en-servicio-card' : ''} ${esActivo ? 'activo' : ''}"
style="cursor:pointer" onclick="${onclick}"
style="${cardStyle}" onclick="seleccionarSinLlamar(${t.id})"
title="${enServicio ? 'Ver turno en servicio' : `Ver turno ${escHtml(t.codigo)}`}">
<div class="prio-dot" style="background:${t.prioridad_color}">${t.prioridad_codigo}</div>
<div class="prio-dot" style="background:${esMuestra ? '#ea580c' : t.prioridad_color}">${esMuestra ? '<i class="fas fa-plus"></i>' : escHtml(t.prioridad_codigo)}</div>
<div class="turno-info">
<div class="cod">${escHtml(t.codigo)}</div>
<div class="pac">${escHtml(t.paciente_nombre || 'Paciente')}</div>
${muestraBadge}
</div>
${indicador}
</div>`;
@@ -490,6 +559,10 @@ async function seleccionarSinLlamar(turnoId) {
await cargarFichaSolicitud(t.id);
clearInterval(pollingConsentId);
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
if (LUGAR_FORM_MODO === 'embebido' && lugarId) {
cargarFormEmbebido(t.id);
}
mostrarFichaMobile();
}
async function rellamarDesdeCard(turnoId) {
@@ -503,15 +576,13 @@ async function rellamarDesdeCard(turnoId) {
await abrirFicha(json.turno);
}
// ── Llamar siguiente: selecciona el primero en espera sin llamarlo
// ── Llamar siguiente: llama al primero en espera ─────────────
async function llamarSiguiente() {
const primero = [..._colaMap.values()].find(t => t.estado === 'en_espera_lugar');
if (!primero) { mostrarToast('Cola vacía — no hay turnos en espera.', 'warn', 3000); return; }
await seleccionarSinLlamar(primero.id);
await _llamar({});
}
async function llamarTurnoEspecifico(turnoId) {
await seleccionarSinLlamar(turnoId);
await _llamar({ turno_id: turnoId });
}
async function _llamar(extra) {
@@ -564,6 +635,13 @@ async function abrirFicha(turno) {
// Iniciar polling de consentimientos
clearInterval(pollingConsentId);
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
// Formulario embebido
if (LUGAR_FORM_MODO === 'embebido' && lugarId) {
cargarFormEmbebido(turno.id);
}
mostrarFichaMobile();
}
// ── Cargar datos de la solicitud del turno ────────────────────
@@ -579,6 +657,20 @@ async function cargarFichaSolicitud(turnoId) {
let consts = json.consentimientos || [];
// Datos del paciente
// Badge embarazada
const badgeEmb = document.getElementById('pac-embarazada');
if (badgeEmb) badgeEmb.classList.toggle('d-none', !(sol && sol.embarazada == 1));
const medDiv = document.getElementById('pac-medico');
if (medDiv) {
const tieneMedico = sol && sol.medico_nombre;
medDiv.classList.toggle('d-none', !tieneMedico);
if (tieneMedico) {
const esp = sol.medico_especialidad ? ' · ' + sol.medico_especialidad : '';
document.getElementById('pac-medico-nombre').textContent = sol.medico_nombre + esp;
}
}
if (pac) {
document.getElementById('bloque-pac-info').style.display = '';
document.getElementById('bloque-pac-sin').classList.add('d-none');
@@ -654,29 +746,40 @@ function renderConsentimientos(lista) {
const idJs = parseInt(c.id) || 0;
const tId = parseInt(c.turno_id) || (turnoActivo?.id) || 0;
// Firmar aquí: solo si no completado y hay token
const btnFirmar = (!ya && c.token)
? `<button class="btn btn-outline-primary" title="Firmar aquí"
onclick="abrirFirmaPresencial('${token}', ${idJs}, ${nomJs.replace(/"/g,'&quot;')})">
<i class="fas fa-signature"></i> Firmar
</button>` : '';
// Botón ver (firmado)
const btnVer = (ya && c.token)
? `<a href="${BASE_WA}ver_formulario_enviado.php?token=${token}" target="_blank"
class="btn btn-outline-secondary" title="Ver firmado">
<i class="fas fa-eye"></i> Ver
</a>` : '';
// WA / Ver: según estado
const btnWa = ya
? (c.token ? `<a href="${BASE_WA}ver_formulario_enviado.php?token=${token}" target="_blank"
class="btn btn-outline-secondary" title="Ver firmado">
<i class="fas fa-eye"></i> Ver
</a>` : '')
: `<button class="btn btn-outline-success" title="Reenviar por WhatsApp"
onclick="reenviarConsentimiento(${tId})">
<i class="fab fa-whatsapp"></i> WA
</button>`;
// Acción principal según modo y estado
let btnAccion = '';
if (!ya && c.token) {
if (LUGAR_FORM_MODO === 'embebido') {
// Modo embebido: abrir modal directamente
btnAccion = `<button class="btn btn-primary" title="Abrir formulario"
onclick="abrirModalConsentimientoPorToken('${token}')">
<i class="fas fa-pen me-1"></i>Firmar
</button>`;
} else {
// Modo link: firmar presencial o reenviar WA
btnAccion = `<button class="btn btn-outline-primary" title="Firmar aquí"
onclick="abrirFirmaPresencial('${token}', ${idJs}, ${nomJs.replace(/"/g,'&quot;')})">
<i class="fas fa-signature"></i> Firmar
</button>
<button class="btn btn-outline-success" title="Reenviar por WhatsApp"
onclick="reenviarConsentimiento(${tId})">
<i class="fab fa-whatsapp"></i> WA
</button>`;
}
}
return `<div class="consent-row ${c.estado}" data-consent-id="${c.id}">
<i class="fas ${ico}"></i>
<span class="nom-form">${escHtml(c.formulario_nombre || 'Consentimiento')}</span>
<span class="c-badge">${label}</span>
<div class="acciones-consent">${btnFirmar}${btnWa}</div>
<div class="acciones-consent">${btnAccion}${btnVer}</div>
</div>`;
}).join('');
}
@@ -787,12 +890,64 @@ async function cambiarEstadoTurno(nuevoEstado) {
}
}
// ── Reset ─────────────────────────────────────────────────────
// ── Embebido como modal ───────────────────────────────────────
let _consentTokenCache = null; // { turnoId, token }
// cargarFormEmbebido ya no necesita hacer nada (el token viene de la fila de consentimiento)
async function cargarFormEmbebido(turnoId) { /* token llega vía renderConsentimientos */ }
function _abrirModalConToken(token) {
const modal = document.getElementById('modal-consentimiento');
const iframe = document.getElementById('modal-consent-iframe');
const load = document.getElementById('modal-consent-loading');
iframe.style.display = 'none';
iframe.src = '';
load.style.display = '';
modal.style.display = 'flex';
setTimeout(() => {
iframe.src = `${BASE_WA}ver_formulario_enviado.php?token=${encodeURIComponent(token)}&embed=1`;
}, 80);
}
function abrirModalConsentimiento() {
if (!_consentTokenCache?.token) { mostrarError('Token de consentimiento no disponible.'); return; }
_abrirModalConToken(_consentTokenCache.token);
}
function abrirModalConsentimientoPorToken(token) {
_abrirModalConToken(token);
}
function cerrarModalConsentimiento() {
const modal = document.getElementById('modal-consentimiento');
const iframe = document.getElementById('modal-consent-iframe');
modal.style.display = 'none';
iframe.src = '';
iframe.style.display = 'none';
document.getElementById('modal-consent-loading').style.display = '';
// Refrescar estado del consentimiento
if (turnoActivo) actualizarConsentimientos(turnoActivo.id);
}
// Cerrar modal si se firma desde el iframe
window.addEventListener('message', function(e) {
if (e.data && e.data.type === 'turneroFirmado') {
cerrarModalConsentimiento();
mostrarToast('Consentimiento firmado ✓', 'success', 3000);
if (turnoActivo) actualizarConsentimientos(turnoActivo.id);
// Ocultar botón de firma
const sec = document.getElementById('sec-form-embebido');
if (sec) sec.classList.add('d-none');
}
});
function resetFicha() {
clearInterval(pollingConsentId);
turnoActivo = null;
tieneConsent = false;
hayPendientes = false;
_consentTokenCache = null;
cerrarModalConsentimiento();
document.getElementById('ficha-turno').classList.add('d-none');
document.getElementById('ficha-placeholder').classList.remove('d-none');
@@ -803,6 +958,19 @@ function resetFicha() {
document.getElementById('btn-iniciar').disabled = false;
document.getElementById('btn-finalizar').classList.add('d-none');
document.getElementById('btn-regresar').classList.add('d-none');
volverACola();
}
function esMobile() { return window.innerWidth <= 860; }
function mostrarFichaMobile() {
if (!esMobile()) return;
document.getElementById('lugar-ficha').classList.add('mobile-visible');
document.querySelector('.lugar-cola').classList.add('mobile-oculta');
}
function volverACola() {
document.getElementById('lugar-ficha').classList.remove('mobile-visible');
document.querySelector('.lugar-cola').classList.remove('mobile-oculta');
}
// ── Helpers ───────────────────────────────────────────────────
+204 -22
View File
@@ -88,8 +88,12 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
overflow: hidden;
}
@media (max-width: 900px) {
.rec-layout { grid-template-columns: 1fr; }
.rec-cola { max-height: 260px; }
.rec-layout { grid-template-columns: 1fr; position: relative; overflow: hidden; }
.rec-cola { transition: transform .2s ease; }
.rec-cola.mobile-oculta { transform: translateX(-100%); position: absolute; inset: 0; pointer-events: none; }
.rec-ficha { position: absolute; inset: 0; background: #fff; z-index: 10; transform: translateX(100%); transition: transform .2s ease; overflow-y: auto; }
.rec-ficha.mobile-visible { transform: translateX(0); }
.btn-volver-cola { display: inline-flex !important; align-items: center; gap: 6px; }
}
/* ── Columna cola ── */
@@ -347,6 +351,11 @@ document.addEventListener('DOMContentLoaded', function() {
<!-- ══ Columna derecha: ficha del turno ═══════════════ -->
<div class="rec-ficha" id="rec-ficha">
<button class="btn btn-sm btn-outline-secondary btn-volver-cola mb-3"
style="display:none" onclick="volverACola()">
<i class="fas fa-arrow-left"></i> Cola
</button>
<div class="ficha-placeholder" id="ficha-placeholder">
<i class="fas fa-ticket-alt fa-3x mb-3" style="color:#cbd5e1"></i>
<p class="mb-0 fw-semibold">Sin turno activo</p>
@@ -395,8 +404,12 @@ document.addEventListener('DOMContentLoaded', function() {
<!-- Si el kiosko capturó un nombre -->
<div id="bloque-nombre-kiosko" class="d-none mb-2">
<small class="text-muted">Nombre del kiosko:</small>
<small class="text-muted">Kiosko:</small>
<div class="fw-semibold" id="lbl-nombre-kiosko"></div>
<div id="bloque-pac-kiosko" class="d-none mt-1">
<small class="text-muted">Paciente:</small>
<div class="fw-semibold text-primary" id="lbl-pac-kiosko-nombre"></div>
</div>
</div>
<div id="bloque-pac-no-vinculado">
@@ -444,6 +457,37 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
</div>
<!-- Embarazo -->
<div class="mt-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="chk-embarazada">
<label class="form-check-label small fw-semibold text-danger" for="chk-embarazada">
<i class="fas fa-baby me-1"></i>Paciente embarazada
</label>
</div>
</div>
<!-- Médico tratante -->
<div class="mt-2" id="bloque-medico">
<div class="small fw-semibold text-secondary mb-1">
<i class="fas fa-user-md me-1"></i>Médico tratante <span class="text-muted fw-normal">(opcional)</span>
</div>
<div id="medico-seleccionado" class="d-none mb-1">
<span class="badge text-bg-light border" style="font-size:.8rem;padding:5px 9px" id="medico-badge-txt"></span>
<button type="button" class="btn btn-link btn-sm p-0 ms-1 text-danger" onclick="quitarMedico()" title="Quitar médico"><i class="fas fa-times"></i></button>
</div>
<div id="medico-buscador">
<input type="text" id="inp-buscar-medico" class="form-control form-control-sm"
placeholder="Buscar por nombre, código o doc…"
autocomplete="off" oninput="buscarMedico()">
<div id="medico-dropdown" class="position-relative">
<ul id="medico-resultados" class="list-unstyled mb-0 border rounded bg-white shadow-sm position-absolute w-100 d-none"
style="z-index:200;max-height:180px;overflow-y:auto;top:2px"></ul>
</div>
</div>
<input type="hidden" id="inp-medico-id">
</div>
</div>
</div>
@@ -461,6 +505,12 @@ document.addEventListener('DOMContentLoaded', function() {
<!-- ── Sección 3: Exámenes ── -->
<div class="ficha-section">
<h6><i class="fas fa-vial me-1"></i>Exámenes solicitados</h6>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="chk-solo-muestras" onchange="toggleSoloMuestras()">
<label class="form-check-label small fw-semibold text-warning" for="chk-solo-muestras">
<i class="fas fa-vial me-1"></i>Solo entrega de muestras
</label>
</div>
<div id="lista-examenes">
<?php foreach ($examenesAgrupados as $cat => $items): ?>
<div class="exam-group-title"><?= htmlspecialchars($cat) ?></div>
@@ -491,16 +541,32 @@ document.addEventListener('DOMContentLoaded', function() {
placeholder="Total $" step="100" min="0">
</div>
<div class="col-6">
<select id="sel-pago" class="form-select form-select-sm">
<select id="sel-pago" class="form-select form-select-sm" onchange="togglePagoCombinado()">
<option value="">— Forma de pago —</option>
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="tarjeta">Tarjeta</option>
<option value="eps">EPS / Convenio</option>
<option value="cortesia">Cortesía</option>
<option value="combinado">Pago combinado</option>
</select>
</div>
</div>
<!-- Panel pago combinado -->
<div id="panel-combinado" class="d-none mt-2 p-2 border rounded" style="background:#f8fafc">
<div class="row g-2 align-items-center mb-1">
<div class="col-5"><label class="form-label mb-0 small fw-semibold"><i class="fas fa-money-bill-wave me-1 text-success"></i>Efectivo</label></div>
<div class="col-7"><input type="number" id="comb-efectivo" class="form-control form-control-sm" placeholder="$0" min="0" step="100" oninput="sumarCombinado()"></div>
</div>
<div class="row g-2 align-items-center mb-1">
<div class="col-5"><label class="form-label mb-0 small fw-semibold"><i class="fas fa-university me-1 text-primary"></i>Transferencia</label></div>
<div class="col-7"><input type="number" id="comb-transferencia" class="form-control form-control-sm" placeholder="$0" min="0" step="100" oninput="sumarCombinado()"></div>
</div>
<div class="row g-2 align-items-center">
<div class="col-5"><label class="form-label mb-0 small fw-semibold"><i class="fas fa-credit-card me-1 text-warning"></i>Tarjeta</label></div>
<div class="col-7"><input type="number" id="comb-tarjeta" class="form-control form-control-sm" placeholder="$0" min="0" step="100" oninput="sumarCombinado()"></div>
</div>
</div>
<textarea id="inp-obs" class="form-control form-control-sm mt-2"
rows="2" placeholder="Observaciones…" maxlength="500"></textarea>
</div>
@@ -976,10 +1042,11 @@ function abrirFicha(turno) {
document.getElementById('badge-turno-activo').classList.remove('d-none');
document.getElementById('badge-codigo').textContent = turno.codigo;
// Nombre del kiosko
// Nombre del kiosko (valor capturado en kiosko)
if (turno.paciente_nombre) {
document.getElementById('bloque-nombre-kiosko').classList.remove('d-none');
document.getElementById('lbl-nombre-kiosko').textContent = turno.paciente_nombre;
document.getElementById('bloque-pac-kiosko').classList.add('d-none');
document.getElementById('inp-buscar-pac').value = turno.paciente_nombre;
}
@@ -1006,6 +1073,7 @@ function abrirFicha(turno) {
// Cargar consentimientos del desk actual para este turno
if (DESK_ID) cargarConsentimientosDesk(turno.id);
mostrarFichaMobile();
}
// ── Cargar consentimientos del desk para el turno ─────────────
@@ -1091,13 +1159,18 @@ function seleccionarPaciente(pac) {
}
document.getElementById('bloque-pac-no-vinculado').classList.add('d-none');
document.getElementById('bloque-pac-seleccionado').classList.remove('d-none');
document.getElementById('lbl-pac-nombre').textContent =
(pac.full_name || (pac.nombre||'') + ' ' + (pac.apellido||'')).trim();
const pacNombre = (pac.full_name || pac.nombre_completo || (pac.nombre||'') + ' ' + (pac.apellido||'')).trim();
document.getElementById('lbl-pac-nombre').textContent = pacNombre;
document.getElementById('lbl-pac-doc').textContent =
(pac.tipo_documento||'') + ' ' + (pac.documento||pac.numero_documento||'');
document.getElementById('lbl-pac-cel').textContent =
pac.telefono || pac.celular || '';
document.getElementById('lista-pacientes-res').innerHTML = '';
// Mostrar nombre del paciente vinculado debajo del valor del kiosko
if (!document.getElementById('bloque-nombre-kiosko').classList.contains('d-none')) {
document.getElementById('lbl-pac-kiosko-nombre').textContent = pacNombre;
document.getElementById('bloque-pac-kiosko').classList.remove('d-none');
}
// Mostrar historial del paciente
const secHist = document.getElementById('sec-historial-pac');
@@ -1110,17 +1183,40 @@ function seleccionarPaciente(pac) {
_historialPacienteId = pac.id;
}
function togglePagoCombinado() {
const combinado = document.getElementById('sel-pago').value === 'combinado';
document.getElementById('panel-combinado').classList.toggle('d-none', !combinado);
const inp = document.getElementById('inp-total');
inp.readOnly = combinado;
inp.style.background = combinado ? '#e9ecef' : '';
if (combinado) sumarCombinado();
}
function sumarCombinado() {
const v = id => parseFloat(document.getElementById(id).value) || 0;
const total = v('comb-efectivo') + v('comb-transferencia') + v('comb-tarjeta');
document.getElementById('inp-total').value = total || '';
}
function resetPagoCombinado() {
['comb-efectivo','comb-transferencia','comb-tarjeta'].forEach(id => document.getElementById(id).value = '');
document.getElementById('panel-combinado').classList.add('d-none');
const inp = document.getElementById('inp-total');
inp.readOnly = false;
inp.style.background = '';
}
function desvincularPaciente() {
pacienteActivo = null;
_historialPacienteId = null;
_historialPacienteCargado = false;
document.getElementById('bloque-pac-no-vinculado').classList.remove('d-none');
document.getElementById('bloque-pac-seleccionado').classList.add('d-none');
document.getElementById('bloque-pac-kiosko').classList.add('d-none');
document.getElementById('sec-historial-pac').classList.add('d-none');
document.getElementById('hist-pac-body').classList.add('d-none');
document.getElementById('btn-hist-toggle').classList.remove('open');
document.getElementById('lista-pacientes-res').innerHTML = '';
document.getElementById('inp-buscar-pac').value = '';
document.getElementById('chk-embarazada').checked = false;
}
function cambiarPaciente() {
@@ -1148,7 +1244,7 @@ async function cargarHistorialPaciente(pacienteId) {
const content = document.getElementById('hist-pac-content');
content.innerHTML = '<div class="text-center text-muted py-2 small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando historial…</div>';
try {
const res = await fetch(`${API}get_historial.php?paciente_id=${pacienteId}&per_page=15&page=1`);
const res = await fetch(`${API}get_historial.php?paciente_id=${pacienteId}&per_page=5&page=1`);
const json = await res.json();
if (!json.ok || !json.turnos || !json.turnos.length) {
content.innerHTML = '<div class="text-center text-muted py-3 small"><i class="fas fa-inbox me-1"></i>Sin visitas anteriores registradas</div>';
@@ -1229,15 +1325,18 @@ async function guardarSolicitud() {
const lugarId = parseInt(document.getElementById('sel-lugar').value);
if (!lugarId) { mostrarError('Seleccione el lugar destino.'); return; }
// ✅ VALIDACIÓN: verificar que los consentimientos del lugar estén firmados
const consentPendientes = Array.from(document.querySelectorAll('.consent-item.pendiente, .consent-item.enviado, .consent-item.visto')).length;
if (consentPendientes > 0) {
mostrarError('⚠️ El paciente debe firmar los consentimientos del lugar antes de guardar la solicitud.');
return;
}
const soloMuestras = document.getElementById('chk-solo-muestras').checked;
// ✅ VALIDACIÓN: verificar que los consentimientos del lugar estén firmados (omitir en solo muestras)
if (!soloMuestras) {
const consentPendientes = Array.from(document.querySelectorAll('.consent-item.pendiente, .consent-item.enviado, .consent-item.visto')).length;
if (consentPendientes > 0) {
mostrarError('⚠️ El paciente debe firmar los consentimientos del lugar antes de guardar la solicitud.');
return;
}
}
const examIds = Array.from(document.querySelectorAll('.exam-chk:checked')).map(c => parseInt(c.value));
if (!examIds.length) { mostrarError('Seleccione al menos un examen.'); return; }
if (!soloMuestras && !examIds.length) { mostrarError('Seleccione al menos un examen.'); return; }
const btn = document.getElementById('btn-guardar');
btn.disabled = true;
@@ -1253,9 +1352,17 @@ async function guardarSolicitud() {
lugar_id: lugarId,
exam_tipo_ids: examIds,
numero_orden: document.getElementById('inp-consecutivo').value.trim() || null,
total_cobrado: parseFloat(document.getElementById('inp-total').value) || null,
metodo_pago: document.getElementById('sel-pago').value || null,
observaciones: document.getElementById('inp-obs').value.trim() || null,
total_cobrado: parseFloat(document.getElementById('inp-total').value) || null,
metodo_pago: document.getElementById('sel-pago').value || null,
pagos_detalle: document.getElementById('sel-pago').value === 'combinado' ? {
efectivo: parseFloat(document.getElementById('comb-efectivo').value) || 0,
transferencia: parseFloat(document.getElementById('comb-transferencia').value) || 0,
tarjeta: parseFloat(document.getElementById('comb-tarjeta').value) || 0,
} : null,
observaciones: document.getElementById('inp-obs').value.trim() || null,
embarazada: document.getElementById('chk-embarazada').checked ? 1 : 0,
solo_muestras: soloMuestras ? 1 : 0,
medico_id: parseInt(document.getElementById('inp-medico-id').value) || null,
}),
});
const json = await res.json();
@@ -1334,9 +1441,9 @@ function renderConsentimientos(lista) {
<i class="fab fa-whatsapp"></i> WhatsApp
</button>`;
// Fila de firma del profesional (solo visible si el paciente ya firmó)
// Fila de firma del profesional (solo si el formulario la requiere y el paciente ya firmó)
let profRow = '';
if (c.estado === 'firmado') {
if (c.estado === 'firmado' && c.requiere_firma_profesional) {
const tienePro = c.tiene_firma_profesional == 1 || c.tiene_firma_profesional === true;
if (tienePro) {
profRow = `<div class="consent-prof-row">
@@ -1474,7 +1581,7 @@ async function refrescarConsentimientos(turnoId) {
// ── Enviar consentimientos (todos) ───────────────────────────
async function enviarConsentimientosTodos() {
if (!turnoActivo || !solicitudActiva) return;
if (!turnoActivo) return;
const btn = document.getElementById('btn-reenviar-consent');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Enviando…';
@@ -1575,6 +1682,7 @@ async function marcarAusente() {
document.getElementById('ficha-placeholder').classList.remove('d-none');
document.getElementById('badge-turno-activo').classList.add('d-none');
cargarCola();
volverACola();
}
// ── Soltar turno ──────────────────────────────────────────────
@@ -1595,11 +1703,72 @@ async function soltarTurno() {
document.getElementById('badge-turno-activo').classList.add('d-none');
mostrarToast('Turno liberado', 'success');
cargarCola();
volverACola();
}
// ── Helpers ───────────────────────────────────────────────────
// ── Médico tratante ─────────────────────────────────────────────
let _medicoTimer = null;
const API_MEDICOS = '<?= BASE_URL ?>modules/medicos/api/list.php';
function buscarMedico() {
clearTimeout(_medicoTimer);
const q = document.getElementById('inp-buscar-medico').value.trim();
const ul = document.getElementById('medico-resultados');
if (q.length < 2) { ul.classList.add('d-none'); ul.innerHTML = ''; return; }
_medicoTimer = setTimeout(async () => {
const res = await fetch(API_MEDICOS + '?q=' + encodeURIComponent(q));
const json = await res.json();
const rows = (json.data || []).slice(0, 8);
if (!rows.length) { ul.innerHTML = '<li class="px-3 py-2 text-muted small">Sin resultados</li>'; ul.classList.remove('d-none'); return; }
ul.innerHTML = rows.map(m =>
`<li class="px-3 py-2 small" style="cursor:pointer;border-bottom:1px solid #f1f5f9"
onmousedown="seleccionarMedico(${m.id},'${escJs(m.codigo)}','${escJs(m.nombres)} ${escJs(m.apellidos)}','${escJs(m.cod_especialidad||'')}')">
<span class="fw-semibold">${esc(m.nombres)} ${esc(m.apellidos)}</span>
<span class="text-muted ms-1">[${esc(m.codigo)}]</span>
${m.cod_especialidad ? `<span class="badge text-bg-light ms-1" style="font-size:.7rem">${esc(m.cod_especialidad)}</span>` : ''}
</li>`
).join('');
ul.classList.remove('d-none');
}, 250);
}
function seleccionarMedico(id, codigo, nombre, esp) {
document.getElementById('inp-medico-id').value = id;
document.getElementById('medico-badge-txt').textContent = nombre + (esp ? ' · ' + esp : '') + ' [' + codigo + ']';
document.getElementById('medico-seleccionado').classList.remove('d-none');
document.getElementById('medico-buscador').classList.add('d-none');
document.getElementById('medico-resultados').classList.add('d-none');
}
function quitarMedico() {
document.getElementById('inp-medico-id').value = '';
document.getElementById('medico-seleccionado').classList.add('d-none');
document.getElementById('medico-buscador').classList.remove('d-none');
document.getElementById('inp-buscar-medico').value = '';
}
function resetMedico() {
quitarMedico();
}
function escJs(s) { return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); }
function toggleSoloMuestras() {
const solo = document.getElementById('chk-solo-muestras').checked;
document.querySelectorAll('.exam-chk').forEach(c => { c.checked = false; c.disabled = solo; });
document.getElementById('lista-examenes').style.opacity = solo ? '0.4' : '';
}
function resetCheckboxes() {
document.querySelectorAll('.exam-chk').forEach(c => c.checked = false);
document.getElementById('chk-solo-muestras').checked = false;
document.querySelectorAll('.exam-chk').forEach(c => { c.checked = false; c.disabled = false; });
document.getElementById('lista-examenes').style.opacity = '';
document.getElementById('sel-pago').value = '';
document.getElementById('inp-total').value = '';
document.getElementById('inp-obs').value = '';
resetPagoCombinado();
resetMedico();
}
function toggleTodosExamenes(val) {
document.querySelectorAll('.exam-chk').forEach(c => c.checked = val);
@@ -1610,6 +1779,7 @@ function escHtml(str) {
d.appendChild(document.createTextNode(String(str)));
return d.innerHTML;
}
const esc = escHtml;
function resetPlaceholder() {
const ph = document.getElementById('ficha-placeholder');
@@ -1617,6 +1787,18 @@ function resetPlaceholder() {
<i class="fas fa-ticket-alt fa-3x mb-3" style="color:#cbd5e1"></i>
<p class="mb-0 fw-semibold">Sin turno activo</p>
<small class="text-muted">Haga clic en "Llamar siguiente" o en <i class="fas fa-bell"></i> de un turno de la cola</small>`;
volverACola();
}
function esMobile() { return window.innerWidth <= 900; }
function mostrarFichaMobile() {
if (!esMobile()) return;
document.getElementById('rec-ficha').classList.add('mobile-visible');
document.querySelector('.rec-cola').classList.add('mobile-oculta');
}
function volverACola() {
document.getElementById('rec-ficha').classList.remove('mobile-visible');
document.querySelector('.rec-cola').classList.remove('mobile-oculta');
}
function mostrarError(msg) {
@@ -0,0 +1,289 @@
<?php
/**
* modules/turnero/views/verificar_paciente.php
* Verificación de identidad del paciente por OTP — dentro del ERP.
*/
require_once APP_ROOT . '/config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
Layout::open('Verificar Paciente', 'fas fa-id-card');
?>
<style>
.vp-wrap {
min-height: calc(100vh - 120px);
display: flex; align-items: center; justify-content: center;
padding: 24px 16px;
background: #f1f5f9;
}
.vp-card {
background: #fff;
border-radius: 20px;
box-shadow: 0 8px 32px rgba(0,0,0,.10);
width: 100%; max-width: 420px;
overflow: hidden;
}
.vp-header {
background: linear-gradient(135deg, var(--brand-dark, #0d47a1), var(--brand, #1565c0));
padding: 24px 28px 20px;
text-align: center;
position: relative;
}
.vp-header::after {
content: ''; position: absolute; bottom: -1px; left: 0; right: 0;
height: 20px; background: #fff; border-radius: 20px 20px 0 0;
}
.vp-header h2 { color: #fff; font-size: 1.1rem; font-weight: 700; position: relative; z-index: 1; }
.vp-header p { color: rgba(255,255,255,.75); font-size: .8rem; margin-top: 4px; position: relative; z-index: 1; }
.vp-body { padding: 24px 28px 28px; }
.step { display: none; }
.step.active { display: block; animation: fadeUp .25s ease; }
@keyframes fadeUp { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
.step-icon { width:56px; height:56px; border-radius:50%; background:color-mix(in srgb,var(--brand,#1565c0) 12%,#fff); display:flex; align-items:center; justify-content:center; font-size:1.4rem; color:var(--brand,#1565c0); margin:0 auto 14px; }
.step-title { font-size:1rem; font-weight:700; color:#1e293b; text-align:center; margin-bottom:6px; }
.step-desc { font-size:.82rem; color:#64748b; text-align:center; line-height:1.55; margin-bottom:20px; }
.highlight { color:var(--brand,#1565c0); font-weight:600; }
.steps-indicator { display:flex; align-items:center; justify-content:center; gap:8px; margin-bottom:20px; }
.step-dot { width:8px; height:8px; border-radius:50%; background:#e2e8f0; transition:all .3s; }
.step-dot.done,.step-dot.active { background:var(--brand,#1565c0); }
.step-dot.active { transform:scale(1.4); }
.step-line { flex:1; max-width:40px; height:2px; background:#e2e8f0; border-radius:1px; transition:background .3s; }
.step-line.done { background:var(--brand,#1565c0); }
.input-wrap { position:relative; margin-bottom:14px; }
.input-wrap i { position:absolute; left:13px; top:50%; transform:translateY(-50%); color:#94a3b8; font-size:.9rem; pointer-events:none; }
.input-field { width:100%; padding:12px 12px 12px 38px; border:2px solid #e2e8f0; border-radius:10px; font-size:.93rem; color:#1e293b; background:#f8faff; outline:none; transition:border-color .2s, box-shadow .2s; }
.input-field:focus { border-color:var(--brand,#1565c0); box-shadow:0 0 0 3px color-mix(in srgb,var(--brand,#1565c0) 15%,transparent); background:#fff; }
.otp-wrap { display:flex; gap:8px; justify-content:center; margin-bottom:14px; }
.otp-digit { width:46px; height:54px; border:2px solid #e2e8f0; border-radius:10px; font-size:1.4rem; font-weight:700; text-align:center; color:var(--brand,#1565c0); background:#f8faff; outline:none; transition:border-color .2s, box-shadow .2s; }
.otp-digit:focus { border-color:var(--brand,#1565c0); box-shadow:0 0 0 3px color-mix(in srgb,var(--brand,#1565c0) 15%,transparent); background:#fff; }
.btn-primary { width:100%; padding:12px; background:linear-gradient(135deg,var(--brand-dark,#0d47a1),var(--brand,#1565c0)); color:#fff; border:none; border-radius:10px; font-size:.93rem; font-weight:700; cursor:pointer; display:flex; align-items:center; justify-content:center; gap:8px; transition:opacity .2s, transform .15s; margin-top:4px; }
.btn-primary:hover:not(:disabled) { opacity:.9; transform:translateY(-1px); }
.btn-primary:disabled { opacity:.6; cursor:not-allowed; }
.btn-link { background:none; border:none; color:var(--brand,#1565c0); font-size:.82rem; font-weight:600; cursor:pointer; text-decoration:underline; margin-top:10px; display:block; text-align:center; }
.vp-error { background:#fef2f2; border:1px solid #fca5a5; border-radius:8px; padding:10px 14px; font-size:.83rem; color:#dc2626; margin-bottom:12px; display:none; }
.timer { font-size:.78rem; color:#94a3b8; text-align:center; margin-top:8px; }
.success-icon { width:64px; height:64px; border-radius:50%; background:#dcfce7; display:flex; align-items:center; justify-content:center; font-size:1.8rem; color:#16a34a; margin:0 auto 14px; }
</style>
<div class="vp-wrap">
<div class="vp-card">
<div class="vp-header">
<h2><i class="fas fa-id-card me-2"></i>Verificar Paciente</h2>
<p>Confirmación de identidad por código SMS</p>
</div>
<div class="vp-body">
<div class="steps-indicator">
<div class="step-dot active" id="dot-1"></div>
<div class="step-line" id="line-1"></div>
<div class="step-dot" id="dot-2"></div>
<div class="step-line" id="line-2"></div>
<div class="step-dot" id="dot-3"></div>
</div>
<div class="vp-error" id="vp-error"></div>
<!-- PASO 1: Cédula -->
<div class="step active" id="step-1">
<div class="step-icon"><i class="fas fa-id-card"></i></div>
<div class="step-title">Número de documento</div>
<div class="step-desc">Ingresa el documento del paciente para enviarle un código de verificación.</div>
<div class="input-wrap">
<i class="fas fa-hashtag"></i>
<input type="text" id="input-cedula" class="input-field" placeholder="Ej: 10234567"
inputmode="numeric" pattern="\d*" maxlength="12"
onkeydown="if(event.key==='Enter') enviarOtp()">
</div>
<button class="btn-primary" id="btn-enviar" onclick="enviarOtp()">
<i class="fas fa-paper-plane"></i> Enviar código
</button>
</div>
<!-- PASO 2: OTP -->
<div class="step" id="step-2">
<div class="step-icon"><i class="fas fa-mobile-alt"></i></div>
<div class="step-title">Código de verificación</div>
<div class="step-desc" id="desc-otp">
Ingresa el código de 6 dígitos enviado al número del paciente.
</div>
<div class="otp-wrap" id="otp-wrap">
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
</div>
<button class="btn-primary" id="btn-verificar" onclick="verificarOtp()">
<i class="fas fa-check-circle"></i> Verificar
</button>
<div class="timer">Código válido por <span id="countdown">5:00</span></div>
<button class="btn-link" id="btn-reenviar" onclick="reenviarOtp()" disabled>Reenviar código</button>
</div>
<!-- PASO 3: Éxito -->
<div class="step" id="step-3">
<div class="success-icon"><i class="fas fa-check"></i></div>
<div class="step-title">¡Verificación exitosa!</div>
<div class="step-desc">
Hola, <span class="highlight" id="nombre-paciente"></span>. Identidad verificada correctamente.
</div>
<button class="btn-primary" onclick="reiniciar()">
<i class="fas fa-rotate-left"></i> Nueva verificación
</button>
</div>
</div>
</div>
</div>
<script>
const BASE_URL = '<?= rtrim(BASE_URL, '/') ?>';
let _cedula = '', _nombre = '', _timer = null, _reenvioTimer = null;
document.querySelectorAll('.otp-digit').forEach((inp, i, arr) => {
inp.addEventListener('input', () => {
const val = inp.value.replace(/\D/g, '');
if (val.length > 1) {
const digits = val.slice(0, 6);
arr.forEach((d, j) => { d.value = digits[j] || ''; });
const last = Math.min(digits.length - 1, arr.length - 1);
arr[last].focus();
if (otpCompleto()) verificarOtp();
return;
}
inp.value = val;
if (inp.value && i < arr.length - 1) arr[i + 1].focus();
if (otpCompleto()) verificarOtp();
});
inp.addEventListener('keydown', e => {
if (e.key === 'Backspace' && !inp.value && i > 0) arr[i - 1].focus();
});
inp.addEventListener('paste', e => {
e.preventDefault();
const txt = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g,'').slice(0,6);
arr.forEach((d, j) => { d.value = txt[j] || ''; });
const last = Math.min(txt.length - 1, arr.length - 1);
if (last >= 0) arr[last].focus();
if (txt.length === 6) verificarOtp();
});
});
function otpCompleto() { return [...document.querySelectorAll('.otp-digit')].every(d => d.value); }
function getOtp() { return [...document.querySelectorAll('.otp-digit')].map(d => d.value).join(''); }
function clearOtp() { document.querySelectorAll('.otp-digit').forEach(d => d.value = ''); document.querySelectorAll('.otp-digit')[0].focus(); }
function showError(msg) { const el = document.getElementById('vp-error'); el.textContent = msg; el.style.display = 'block'; }
function hideError() { document.getElementById('vp-error').style.display = 'none'; }
function goStep(n) {
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
document.getElementById('step-' + n).classList.add('active');
for (let i = 1; i <= 3; i++) {
document.getElementById('dot-' + i).className = 'step-dot' + (i < n ? ' done' : i === n ? ' active' : '');
}
for (let i = 1; i <= 2; i++) {
document.getElementById('line-' + i).className = 'step-line' + (i < n ? ' done' : '');
}
hideError();
}
async function enviarOtp() {
const cedula = document.getElementById('input-cedula').value.trim();
if (!cedula) { showError('Ingresa el número de documento.'); return; }
hideError();
const btn = document.getElementById('btn-enviar');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Enviando…';
try {
const r = await fetch(BASE_URL + '/api/public/send_otp.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cedula }),
});
const d = await r.json();
if (!d.ok) { showError(d.error || 'Error al enviar el código.'); btn.disabled = false; btn.innerHTML = '<i class="fas fa-paper-plane"></i> Enviar código'; return; }
_cedula = cedula;
_nombre = d.nombre || '';
document.getElementById('desc-otp').innerHTML = `Código enviado al número <strong>${d.telefono || '***'}</strong> del paciente.`;
goStep(2);
iniciarContador(d.expira || 300);
iniciarReenvio();
setTimeout(() => document.querySelector('.otp-digit').focus(), 100);
} catch(e) { showError('Error de conexión.'); }
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Enviar código';
}
async function verificarOtp() {
if (!otpCompleto()) return;
hideError();
const btn = document.getElementById('btn-verificar');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Verificando…';
try {
const r = await fetch(BASE_URL + '/api/public/verify_otp.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cedula: _cedula, codigo: getOtp() }),
});
const d = await r.json();
if (!d.ok) { showError(d.error || 'Código incorrecto.'); clearOtp(); btn.disabled = false; btn.innerHTML = '<i class="fas fa-check-circle"></i> Verificar'; return; }
clearInterval(_timer); clearTimeout(_reenvioTimer);
document.getElementById('nombre-paciente').textContent = d.nombre || _nombre;
goStep(3);
} catch(e) { showError('Error de conexión.'); }
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-check-circle"></i> Verificar';
}
async function reenviarOtp() {
hideError();
const btn = document.getElementById('btn-reenviar');
btn.disabled = true; btn.textContent = 'Enviando…';
try {
const r = await fetch(BASE_URL + '/api/public/send_otp.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cedula: _cedula }),
});
const d = await r.json();
if (!d.ok) { showError(d.error || 'Error al reenviar.'); btn.disabled = false; btn.textContent = 'Reenviar código'; return; }
clearOtp();
iniciarContador(d.expira || 300);
iniciarReenvio();
} catch(e) { showError('Error de conexión.'); }
}
function iniciarContador(segundos) {
clearInterval(_timer);
const el = document.getElementById('countdown');
let remaining = segundos;
el.textContent = fmt(remaining);
_timer = setInterval(() => {
remaining--;
el.textContent = fmt(remaining);
if (remaining <= 0) { clearInterval(_timer); el.textContent = 'Expirado'; }
}, 1000);
}
function iniciarReenvio() {
clearTimeout(_reenvioTimer);
const btn = document.getElementById('btn-reenviar');
btn.disabled = true; btn.textContent = 'Reenviar en 60s…';
_reenvioTimer = setTimeout(() => { btn.disabled = false; btn.textContent = 'Reenviar código'; }, 60000);
}
function fmt(s) { return Math.floor(s/60) + ':' + String(s%60).padStart(2,'0'); }
function reiniciar() {
_cedula = ''; _nombre = '';
clearInterval(_timer); clearTimeout(_reenvioTimer);
document.getElementById('input-cedula').value = '';
clearOtp();
goStep(1);
}
</script>
<?php Layout::close(); ?>
+29 -19
View File
@@ -22,14 +22,23 @@ class WhatsAppService
private $db;
private $rateLimitMonitor;
public function __construct()
public function __construct($canal = null)
{
// Obtener configuración desde base de datos
$config = getWhatsAppConfigFromDB();
$this->token = $config['token']; // Usar token de BD
$this->phoneNumberId = $config['phone_number_id']; // Usar phone_number_id de BD
$this->apiUrl = $config['api_url'] ?: 'https://graph.facebook.com/v22.0/'; // Usar api_url de BD
$this->token = $config['token'];
$this->phoneNumberId = $config['phone_number_id'];
$this->apiUrl = $config['api_url'] ?: 'https://graph.facebook.com/v22.0/';
// Si se especifica canal 'turnero', usar el phone ID del número turnero
if ($canal === 'turnero') {
$turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
if ($turneroPhoneId) {
$this->phoneNumberId = $turneroPhoneId;
}
}
$this->db = Database::getInstance();
// Inicializar monitor de rate limit
@@ -698,39 +707,38 @@ class WhatsAppService
* @param bool $skipAutoSave Omitir guardado automático
* @param bool $isVoice Para audio: true = nota de voz con onda verde, false = audio normal
*/
public function sendMediaById($to, $mediaId, $mediaType, $caption = null, $filename = null, $skipAutoSave = false, $isVoice = false)
public function sendMediaById($to, $mediaId, $mediaType, $caption = null, $filename = null, $skipAutoSave = false, $isVoice = false, $meta = null)
{
$data = [
'messaging_product' => 'whatsapp',
'to' => $this->formatPhoneNumber($to),
'type' => $mediaType
];
$mediaData = ['id' => $mediaId];
if ($caption && in_array($mediaType, ['image', 'video', 'document'])) {
$mediaData['caption'] = $caption;
}
if ($filename && $mediaType === 'document') {
$mediaData['filename'] = $filename;
}
// NOTA: El parámetro 'ptt' (push-to-talk) para notas de voz NO está disponible
// en WhatsApp Cloud API cuando se usa media_id. Solo funciona en WhatsApp Business API On-Premise.
// Los mensajes de audio en formato .ogg con códec OPUS se mostrarán correctamente,
// pero sin la onda verde característica de las notas de voz.
if ($mediaType === 'audio' && $isVoice) {
error_log('sendMediaById: is_voice=true, pero ptt no soportado en Cloud API. Audio se enviará como archivo.');
}
$data[$mediaType] = $mediaData;
// Marcar para evitar guardado automático si se solicita
if (!empty($meta) && is_array($meta)) {
$data['__app_meta'] = $meta;
}
if ($skipAutoSave) {
$data['__skip_auto_save'] = true;
}
return $this->sendMessage($data);
}
@@ -890,6 +898,7 @@ class WhatsAppService
try {
$user = $this->getUserByPhone($data['to']);
if ($user) {
$appMeta = $data['__app_meta'] ?? [];
$messageData = [
'user_id' => $user['id'],
'message_id' => $response['conversations'][0]['id'],
@@ -897,7 +906,8 @@ class WhatsAppService
'message_type' => $data['type'],
'content' => $this->extractMessageContent($data),
'status' => 'sent',
'created_at' => date('Y-m-d H:i:s')
'created_at' => date('Y-m-d H:i:s'),
'canal' => $appMeta['canal'] ?? 'bot',
];
// Para mensajes multimedia, guardar el media_id si está disponible
+23 -2
View File
@@ -188,7 +188,8 @@ if (class_exists('ModuleRegistry', false)) {
$active = $_isActive($lnk['route']);
?>
<li><a href="<?= htmlspecialchars($href, ENT_QUOTES, 'UTF-8') ?>"
class="nav-link<?= $active ?>">
class="nav-link<?= $active ?>"
<?= !empty($lnk['target']) ? 'target="' . htmlspecialchars($lnk['target'], ENT_QUOTES, 'UTF-8') . '"' : '' ?>>
<i class="<?= htmlspecialchars($lnk['icon'], ENT_QUOTES, 'UTF-8') ?>"></i>
<?= htmlspecialchars($lnk['name'], ENT_QUOTES, 'UTF-8') ?></a></li>
<?php endforeach;
@@ -363,7 +364,13 @@ if (class_exists('ModuleRegistry', false)) {
.sidebar-section.collapsed .sb-chevron { transform: translateY(-50%) rotate(-90deg); }
/* Grupo colapsable */
.sb-group { overflow: hidden; transition: max-height .28s ease, opacity .2s ease; }
.sb-group {
overflow: hidden; transition: max-height .28s ease, opacity .2s ease;
background: color-mix(in srgb, var(--cat-color, transparent) 8%, transparent);
border-left: 2px solid color-mix(in srgb, var(--cat-color, transparent) 40%, transparent);
margin-left: 10px;
border-radius: 0 0 8px 8px;
}
.sb-group > ul { list-style: none; padding: 0 0 4px; margin: 0; }
.sb-group.collapsed { max-height: 0 !important; opacity: 0; pointer-events: none; }
</style>
@@ -495,6 +502,20 @@ if (class_exists('ModuleRegistry', false)) {
var el = document.getElementById('_sb_notrans');
if (el) el.remove();
});
// Restaurar posición de scroll del sidebar entre páginas
var nav = document.getElementById('erp-sidebar');
if (nav) {
var savedScroll = parseInt(localStorage.getItem('sb_scroll') || '0', 10);
if (savedScroll) nav.scrollTop = savedScroll;
// Guardar posición al navegar
document.querySelectorAll('#erp-sidebar .nav-link').forEach(function(link) {
link.addEventListener('click', function() {
localStorage.setItem('sb_scroll', nav.scrollTop);
});
});
}
});
})();
</script>
+163 -69
View File
@@ -1,4 +1,5 @@
<?php
ini_set('display_errors', 1); error_reporting(E_ALL); // ponytail: quitar después de depurar
/**
* ver_formulario_enviado.php — Ver respuesta de un formulario (imprimible / PDF)
* Acceso admin/enfermero: ver_formulario_enviado.php?id=ENVIO_ID (requiere sesión)
@@ -188,6 +189,10 @@ $datosCliente = json_decode($envio['datos_cliente'] ?? '{}', true) ?? [];
$datosPrefilled = json_decode($envio['datos_prefilled'] ?? '{}', true) ?? [];
$todos = array_merge($datosPrefilled, $datosCliente);
$modoEditar = $modoTurnero && $envio['estado'] !== 'firmado';
$embebido = isset($_GET['embed']) && $_GET['embed'] === '1';
// Pre-scan: formulario que solo requiere firma del profesional (sin firma paciente)
$_soloFirmaPro = !empty(array_filter($esquema, fn($c) => ($c['tipo'] ?? '') === 'firma_profesional'))
&& empty(array_filter($esquema, fn($c) => ($c['tipo'] ?? '') === 'firma'));
// Mapa id → label
$labelMap = [];
@@ -298,6 +303,31 @@ function esc2(mixed $v): string {
.hash-seal-header { background: #000 !important; -webkit-print-color-adjust:exact; print-color-adjust:exact; }
}
<?php if ($embebido): ?>
/* ── Modo embebido (iframe) ── */
body { background: #fff; font-size: 12px; }
.action-bar { display: none !important; }
.doc-wrap { margin: 0; border-radius: 0; box-shadow: none; max-width: 100%; }
.doc-header { padding: 10px 14px 8px; }
.doc-header-text h3 { font-size: 14px; }
.doc-header-text h4 { font-size: 12px; }
.doc-body { padding: 12px 14px; }
.doc-footer { display: none; }
.section-title { font-size: 10px; margin: 14px 0 8px; }
.campo-label { font-size: 11px; }
.campo-valor { font-size: 12px; }
.campo-edit { margin-bottom: 8px; }
.campo-edit label { font-size: 11px; margin-bottom: 2px; }
.campo-edit .form-control,
.campo-edit .form-select { font-size: 12px; padding: 3px 7px; }
.campo-edit .form-check-label { font-size: 12px; }
.campo-row { padding: 3px 0; }
.turnero-cv { height: 110px !important; }
.btn { font-size: 12px; padding: 3px 10px; }
.hash-seal { display: none; }
.alert { font-size: 12px; padding: 6px 10px; }
<?php endif; ?>
/* ── Canvas firma profesional ───────────────────── */
.firma-pro-widget { max-width: 520px; margin-top: 8px; }
.fpw-canvas { border: 2px solid #198754; border-radius: 8px; background: #f8fff9;
@@ -456,6 +486,7 @@ function esc2(mixed $v): string {
$_renderedFirmaProfesional = false;
$_renderedFirmaPaciente = false;
$_esquemaTieneFirmaPaciente = false;
$_esquemaTieneFirmaAlguna = false; // true si hay cualquier campo firma/firma_profesional
$_firmaGlobalPacienteUsada = false; // la firma global solo va al primer campo firma
foreach ($esquema as $campo):
@@ -470,6 +501,12 @@ function esc2(mixed $v): string {
if (!$cid) continue;
$isPro = ($tipo === 'firma_profesional');
// Marcar presencia de campos firma en el esquema
$_esquemaTieneFirmaAlguna = true;
if (!$isPro) {
$_esquemaTieneFirmaPaciente = true;
}
// La firma profesional global solo se muestra en el PRIMER campo firma_profesional.
// Campos posteriores (ej. desistimiento) solo muestran su propia firma por campo.
$fSvg = $isPro
@@ -481,13 +518,11 @@ function esc2(mixed $v): string {
$fLabel = htmlspecialchars($campo['label'] ?? ($isPro ? 'Firma profesional' : 'Firma paciente'));
// Campo paciente sin firma:
// - En turnero pendiente: el PRIMER campo firma muestra el canvas, los demás se omiten.
// - En turnero pendiente: todos los campos firma muestran canvas.
// - En vista normal: omitir campos sin firma.
if (!$fSvg && !$fFoto && !$isPro) {
if ($modoTurnero && $modoEditar && !$_esquemaTieneFirmaPaciente) {
$_esquemaTieneFirmaPaciente = true; // solo el primero muestra canvas
} else {
continue; // campos firma adicionales (desistimiento) sin firma → omitir
if (!($modoTurnero && $modoEditar)) {
continue; // no-turnero: omitir campos sin firma
}
}
@@ -510,30 +545,35 @@ function esc2(mixed $v): string {
<img src="<?= htmlspecialchars($fFoto) ?>" alt="Foto - <?= $fLabel ?>">
</div>
<?php elseif (!$isPro && $modoTurnero && $modoEditar): ?>
<div id="turneroFirmaWidget">
<div class="turnero-firma-item no-print" data-cid="<?= esc2($cid) ?>">
<p class="text-muted small mb-2"><i class="fas fa-pen me-1"></i>He leído el documento. Dibuje su firma:</p>
<canvas id="turneroCv" width="500" height="160"
<canvas class="turnero-cv" width="500" height="160"
style="border:2px solid #1565c0;border-radius:8px;background:#f0f4ff;
cursor:crosshair;display:block;max-width:100%;touch-action:none"></canvas>
<div class="mt-2 d-flex gap-2 flex-wrap">
<button id="turneroLimpiar" class="btn btn-outline-secondary btn-sm">
<button class="btn btn-outline-secondary btn-sm turnero-limpiar">
<i class="fas fa-eraser me-1"></i>Limpiar
</button>
<button id="turneroFirmar" class="btn btn-success fw-semibold">
<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento
<button class="btn btn-success fw-semibold turnero-firmar">
<i class="fas fa-check-circle me-1"></i>Confirmar: <?= $fLabel ?>
</button>
</div>
<div id="turneroMsg" class="mt-2 small"></div>
<div class="turnero-msg mt-2 small"></div>
</div>
<?php elseif ($isPro): ?>
<?php if (!$modoTurnero && !$modoPublico && !$yaHayCanvasPro): ?>
<!-- Canvas del profesional: solo aparece una vez -->
<?php
// Mostrar canvas pro si: admin normal, O turnero+embebido+sesión activa+solo firma pro
$_mostrarCanvasPro = !$yaHayCanvasPro && (
(!$modoTurnero && !$modoPublico) ||
($modoTurnero && $embebido && isUserLoggedIn() && $_soloFirmaPro && $modoEditar)
);
if ($_mostrarCanvasPro): ?>
<!-- Canvas del profesional -->
<div class="firma-pro-widget no-print" id="fpw-<?= htmlspecialchars($cid) ?>"
data-envio="<?= (int)$envio['id'] ?>" data-campo="<?= htmlspecialchars($cid) ?>"
<?php if ($modoTurnero): ?>
data-turno="<?= (int)$tcRow['turno_id'] ?>"
data-formulario="<?= (int)$tcRow['formulario_id'] ?>"
<?php endif; ?>>
data-solo-pro="<?= ($modoTurnero && $_soloFirmaPro) ? '1' : '0' ?>"
data-turno="<?= isset($tcRow) ? (int)$tcRow['turno_id'] : '' ?>"
data-formulario="<?= isset($tcRow) ? (int)$tcRow['formulario_id'] : '' ?>">
<p class="text-muted small mb-2"><i class="fas fa-pen me-1"></i>Dibuje su firma en el recuadro:</p>
<canvas class="fpw-canvas" width="500" height="150"></canvas>
<div class="mt-2 d-flex gap-2">
@@ -598,7 +638,7 @@ function esc2(mixed $v): string {
</div>
<?php continue; endif;
if ($tipo === 'radio'):
$opts = $campo['opciones'] ?? [];
$opts = $campo['opciones'] ?? $campo['options'] ?? [];
?>
<div class="campo-edit">
<label><?= $label ?></label>
@@ -613,7 +653,7 @@ function esc2(mixed $v): string {
</div>
<?php continue; endif;
if ($tipo === 'checkbox' || $tipo === 'lista_marcable'):
$opts = $campo['opciones'] ?? [];
$opts = $campo['opciones'] ?? $campo['options'] ?? $campo['items'] ?? [];
$checkedArr = is_array($prefill) ? $prefill
: (is_string($prefill) && $prefill !== '' ? (json_decode($prefill, true) ?: [$prefill]) : []);
?>
@@ -630,7 +670,7 @@ function esc2(mixed $v): string {
</div>
<?php continue; endif;
if ($tipo === 'select'):
$opts = $campo['opciones'] ?? [];
$opts = $campo['opciones'] ?? $campo['options'] ?? [];
?>
<div class="campo-edit">
<label><?= $label ?></label>
@@ -642,15 +682,31 @@ function esc2(mixed $v): string {
</select>
</div>
<?php continue; endif;
// texto, numero, fecha, hora, y cualquier otro → input
// Calculados: pre-llenar si no tienen valor aún
if ($tipo === 'fecha_hoy' && $prefill === '') {
$prefill = date('Y-m-d');
}
if ($tipo === 'edad' && $prefill === '') {
$fnac = $paciente['fecha_nacimiento'] ?? '';
if ($fnac) {
$hoy = new DateTime();
$bday = new DateTime($fnac);
$prefill = (string)$hoy->diff($bday)->y;
}
}
// texto, numero, fecha, hora, y calculados → input
$inputType = match($tipo) {
'numero' => 'number', 'fecha' => 'date', 'hora' => 'time', default => 'text'
'numero', 'edad' => 'number',
'fecha', 'fecha_hoy' => 'date',
'hora' => 'time',
default => 'text'
};
?>
<div class="campo-edit">
<label><?= $label ?></label>
<input type="<?= $inputType ?>" class="form-control form-control-sm"
name="<?= esc2($cid) ?>" value="<?= esc2($prefill) ?>"<?= $req ?>>
name="<?= esc2($cid) ?>" value="<?= esc2($prefill) ?>"<?= $req ?>
<?= in_array($tipo, ['edad']) ? 'min="0" max="120"' : '' ?>>
</div>
<?php continue;
endif; // modoEditar
@@ -659,6 +715,16 @@ function esc2(mixed $v): string {
if ($tipo === 'linked') {
$lk = $campo['linked_key'] ?? '';
$valor = $paciente[$lk] ?? $todos[$cid] ?? null;
} elseif ($tipo === 'fecha_hoy') {
$valor = $todos[$cid] ?? date('Y-m-d');
} elseif ($tipo === 'edad') {
$valor = $todos[$cid] ?? '';
if ($valor === '') {
$fnac = $paciente['fecha_nacimiento'] ?? '';
if ($fnac) {
$valor = (string)(new DateTime())->diff(new DateTime($fnac))->y;
}
}
} else {
$valor = $todos[$cid] ?? null;
}
@@ -734,9 +800,9 @@ function esc2(mixed $v): string {
</div>
<?php endif; ?>
<!-- ── Widget firma turnero (fallback si el esquema no tiene campo firma) ── -->
<?php if ($modoTurnero && $envio['estado'] !== 'firmado' && !$_esquemaTieneFirmaPaciente): ?>
<div class="mt-4 no-print" id="turneroFirmaWidget">
<!-- ── Widget firma turnero (fallback solo si el esquema no tiene NINGÚN campo firma) ── -->
<?php if ($modoTurnero && $envio['estado'] !== 'firmado' && !$_esquemaTieneFirmaAlguna): ?>
<div class="mt-4 no-print turnero-firma-item" data-cid="__firma_global">
<div class="section-title" style="color:#1565c0">
<i class="fas fa-pen me-1"></i>Firma del paciente / responsable
</div>
@@ -744,19 +810,19 @@ function esc2(mixed $v): string {
He leído y comprendido el contenido de este documento.
Por favor dibuje su firma en el recuadro:
</p>
<canvas id="turneroCv" width="500" height="160"
<canvas class="turnero-cv" width="500" height="160"
style="border:2px solid #1565c0;border-radius:8px;background:#f0f4ff;
cursor:crosshair;display:block;max-width:100%;touch-action:none">
</canvas>
<div class="mt-2 d-flex gap-2 flex-wrap">
<button id="turneroLimpiar" class="btn btn-outline-secondary btn-sm">
<button class="btn btn-outline-secondary btn-sm turnero-limpiar">
<i class="fas fa-eraser me-1"></i>Limpiar
</button>
<button id="turneroFirmar" class="btn btn-success fw-semibold">
<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento
<button class="btn btn-success fw-semibold turnero-firmar">
<i class="fas fa-check-circle me-1"></i>Confirmar y firmar
</button>
</div>
<div id="turneroMsg" class="mt-2 small"></div>
<div class="turnero-msg mt-2 small"></div>
</div>
<?php elseif ($modoTurnero && $envio['estado'] === 'firmado'): ?>
<div class="alert alert-success mt-4 d-flex align-items-center gap-2 no-print">
@@ -834,7 +900,6 @@ function esc2(mixed $v): string {
btnSave.addEventListener('click', function() {
const svg = canvas.toDataURL('image/png');
// Verificar que no esté vacío (al menos 1000 bytes de data)
if (svg.length < 1000) {
msg.innerHTML = '<span class="text-danger"><i class="fas fa-exclamation-triangle me-1"></i>Por favor dibuje su firma antes de guardar.</span>';
return;
@@ -845,12 +910,32 @@ function esc2(mixed $v): string {
const turnoId = widget.dataset.turno;
const formularioId = widget.dataset.formulario;
const soloPro = widget.dataset.soloPro === '1';
const isTurnero = !!(turnoId && formularioId);
const saveUrl = isTurnero
? 'modules/turnero/api/firmar_profesional_consentimiento.php'
: 'api/lab/firmar_profesional.php';
const savePayload = isTurnero
? { turno_id: parseInt(turnoId), formulario_id: parseInt(formularioId), svg: svg }
// Recopilar campos del formulario cuando el pro es el firmante final
var datosRespuestas = {};
if (soloPro) {
document.querySelectorAll('[name]').forEach(function(el) {
var raw = el.name;
var isArr = raw.slice(-2) === '[]';
var name = isArr ? raw.slice(0, -2) : raw;
if (el.type === 'checkbox') {
if (el.checked) { if (!Array.isArray(datosRespuestas[name])) datosRespuestas[name] = []; datosRespuestas[name].push(el.value); }
} else if (el.type === 'radio') {
if (el.checked) datosRespuestas[name] = el.value;
} else if (el.value !== '') {
datosRespuestas[name] = el.value;
}
});
}
const savePayload = isTurnero
? { turno_id: parseInt(turnoId), formulario_id: parseInt(formularioId), svg: svg,
...(soloPro ? { solo_profesional: true, datos_respuestas: datosRespuestas } : {}) }
: { envio_id: envioId, campo_id: campoId, svg: svg };
fetch(saveUrl, {
@@ -861,18 +946,27 @@ function esc2(mixed $v): string {
.then(function(r){ return r.json(); })
.then(function(data) {
if (data.ok) {
// Reemplazar el canvas con la imagen firmada
const img = document.createElement('img');
img.src = svg;
img.alt = 'Firma profesional';
img.style.maxHeight = '140px';
img.style.maxWidth = '340px';
img.style.display = 'block';
const box = document.createElement('div');
box.className = 'firma-box';
box.style.borderColor = '#198754';
box.appendChild(img);
widget.replaceWith(box);
if (soloPro) {
// Modo solo-profesional: mostrar éxito y notificar al padre
document.querySelectorAll('.firma-pro-widget, .campo-edit, .section-title').forEach(function(el) {
el.style.display = 'none';
});
var ok = document.createElement('div');
ok.className = 'alert alert-success mt-4 d-flex align-items-center gap-2 no-print';
ok.innerHTML = '<i class="fas fa-check-circle fs-4"></i><div><strong>Firmado correctamente.</strong><br>Puede cerrar esta ventana.</div>';
widget.parentNode.insertBefore(ok, widget.nextSibling);
widget.style.display = 'none';
try { window.parent.postMessage({ type: 'turneroFirmado' }, '*'); } catch(e) {}
} else {
// Reemplazar canvas con imagen firmada
var img = document.createElement('img');
img.src = svg; img.alt = 'Firma profesional';
img.style.maxHeight = '140px'; img.style.maxWidth = '340px'; img.style.display = 'block';
var box = document.createElement('div');
box.className = 'firma-box'; box.style.borderColor = '#198754';
box.appendChild(img);
widget.replaceWith(box);
}
} else {
msg.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>' + (data.error||'Error al guardar') + '</span>';
btnSave.disabled = false;
@@ -888,16 +982,17 @@ function esc2(mixed $v): string {
});
})();
/* ── Firma de consentimiento turnero ────────────────────────────────── */
(function () {
var canvas = document.getElementById('turneroCv');
var widget = document.getElementById('turneroFirmaWidget');
/* ── Firma de consentimiento turnero (soporta múltiples campos firma) ── */
document.querySelectorAll('.turnero-firma-item').forEach(function(widget) {
var canvas = widget.querySelector('.turnero-cv');
if (!canvas) return;
var cid = widget.dataset.cid || '__firma';
var ctx = canvas.getContext('2d');
var btnLimp = document.getElementById('turneroLimpiar');
var btnFirm = document.getElementById('turneroFirmar');
var msgEl = document.getElementById('turneroMsg');
var btnLimp = widget.querySelector('.turnero-limpiar');
var btnFirm = widget.querySelector('.turnero-firmar');
var msgEl = widget.querySelector('.turnero-msg');
var drawing = false;
var btnLabel = btnFirm ? btnFirm.innerHTML : '';
(function scaleCanvas() {
var ratio = window.devicePixelRatio || 1;
@@ -928,12 +1023,12 @@ function esc2(mixed $v): string {
canvas.addEventListener('touchmove', function(e){ e.preventDefault(); if(!drawing) return; var p=getPos(e); ctx.lineTo(p.x,p.y); ctx.stroke(); }, {passive:false});
canvas.addEventListener('touchend', function(){ drawing=false; });
btnLimp.addEventListener('click', function() {
if (btnLimp) btnLimp.addEventListener('click', function() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
msgEl.textContent = '';
});
btnFirm.addEventListener('click', function() {
if (btnFirm) btnFirm.addEventListener('click', function() {
var png = canvas.toDataURL('image/png');
if (png.length < 1500) {
msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-exclamation-triangle me-1"></i>Por favor dibuje su firma antes de confirmar.</span>';
@@ -943,23 +1038,20 @@ function esc2(mixed $v): string {
btnFirm.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando...';
msgEl.textContent = '';
// Recopilar respuestas de campos interactivos
var campos = {};
document.querySelectorAll('[name]').forEach(function(el) {
var rawName = el.name;
var isArr = rawName.slice(-2) === '[]';
var name = isArr ? rawName.slice(0, -2) : rawName;
if (el.type === 'checkbox') {
if (el.checked) {
if (!Array.isArray(campos[name])) campos[name] = [];
campos[name].push(el.value);
}
if (el.checked) { if (!Array.isArray(campos[name])) campos[name] = []; campos[name].push(el.value); }
} else if (el.type === 'radio') {
if (el.checked) campos[name] = el.value;
} else if (el.value !== '') {
campos[name] = el.value;
}
});
campos[cid + '_svg'] = png; // identificar qué campo firmó
fetch(window.location.href, {
method: 'POST',
@@ -969,26 +1061,28 @@ function esc2(mixed $v): string {
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) {
widget.outerHTML =
'<div class="alert alert-success mt-4 d-flex align-items-center gap-2 no-print">' +
'<i class="fas fa-check-circle fs-4"></i>' +
'<div><strong>Consentimiento firmado correctamente.</strong><br>' +
'Puede cerrar esta ventana.</div></div>';
// Notificar ventana padre si está en iframe/modal
// Ocultar todos los demás widgets de firma (ya no se puede firmar dos veces)
document.querySelectorAll('.turnero-firma-item').forEach(function(w) {
w.style.display = 'none';
});
var ok = document.createElement('div');
ok.className = 'alert alert-success mt-4 d-flex align-items-center gap-2 no-print';
ok.innerHTML = '<i class="fas fa-check-circle fs-4"></i><div><strong>Firmado correctamente.</strong><br>Puede cerrar esta ventana.</div>';
widget.parentNode.insertBefore(ok, widget.nextSibling);
try { window.parent.postMessage({ type: 'turneroFirmado' }, '*'); } catch(e) {}
} else {
msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>' + (data.error || 'Error al guardar') + '</span>';
btnFirm.disabled = false;
btnFirm.innerHTML = '<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento';
btnFirm.innerHTML = btnLabel;
}
})
.catch(function() {
msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>Error de conexión.</span>';
btnFirm.disabled = false;
btnFirm.innerHTML = '<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento';
btnFirm.innerHTML = btnLabel;
});
});
})();
});
</script>
</body>
</html>
+18 -5
View File
@@ -416,6 +416,7 @@ body::after { width: 400px; height: 400px; bottom: -100px; left: -100px; }
</div>
<script>
const BASE_URL = '<?= rtrim(BASE_URL, '/') ?>';
let _cedula = '';
let _nombre = '';
let _timer = null;
@@ -424,7 +425,17 @@ let _reenvioTimer = null;
// ── OTP input navegación automática ──────────────────────────────────
document.querySelectorAll('.otp-digit').forEach((inp, i, arr) => {
inp.addEventListener('input', () => {
inp.value = inp.value.replace(/\D/g, '').slice(-1);
const val = inp.value.replace(/\D/g, '');
if (val.length > 1) {
// Pega múltiples dígitos (mobile/autofill)
const digits = val.slice(0, 6);
arr.forEach((d, j) => { d.value = digits[j] || ''; });
const last = Math.min(digits.length - 1, arr.length - 1);
arr[last].focus();
if (otpCompleto()) verificarOtp();
return;
}
inp.value = val;
if (inp.value && i < arr.length - 1) arr[i + 1].focus();
if (otpCompleto()) verificarOtp();
});
@@ -435,7 +446,9 @@ document.querySelectorAll('.otp-digit').forEach((inp, i, arr) => {
e.preventDefault();
const txt = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g,'').slice(0,6);
arr.forEach((d, j) => { d.value = txt[j] || ''; });
if (txt.length === 6) { arr[5].focus(); verificarOtp(); }
const last = Math.min(txt.length - 1, arr.length - 1);
if (last >= 0) arr[last].focus();
if (txt.length === 6) verificarOtp();
});
});
@@ -480,7 +493,7 @@ async function enviarOtp() {
hideError();
try {
const r = await fetch('api/public/send_otp.php', {
const r = await fetch(BASE_URL + '/api/public/send_otp.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cedula }),
@@ -514,7 +527,7 @@ async function verificarOtp() {
hideError();
try {
const r = await fetch('api/public/verify_otp.php', {
const r = await fetch(BASE_URL + '/api/public/verify_otp.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cedula: _cedula, codigo: getOtp() }),
@@ -546,7 +559,7 @@ async function reenviarOtp() {
clearOtp();
try {
const r = await fetch('api/public/send_otp.php', {
const r = await fetch(BASE_URL + '/api/public/send_otp.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cedula: _cedula }),
});