49 Commits
Author SHA1 Message Date
LizandroandClaude Sonnet 4.6 9749d726fa feat(turnero): cambio de estado y reapertura de turnos desde historial
Agrega botón de cambio de estado en cada fila del historial de turnos.
Admins (sin role_id) pueden reabrir turnos finalizados/ausentes/cancelados.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 01:43:29 +00:00
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
31 changed files with 2298 additions and 390 deletions
+7 -4
View File
@@ -64,12 +64,14 @@ try {
$sql .= " $sql .= "
FROM users u 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 ( LEFT JOIN (
SELECT t1.* FROM conversations t1 SELECT t1.* FROM conversations t1
JOIN ( 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 ) 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"; ) lm ON lm.user_id = u.id";
// Agregar condición de búsqueda si existe // Agregar condición de búsqueda si existe
@@ -82,6 +84,7 @@ try {
SELECT 1 FROM conversations c2 SELECT 1 FROM conversations c2
WHERE c2.user_id = u.id WHERE c2.user_id = u.id
AND c2.content LIKE ? AND c2.content LIKE ?
AND (c2.canal IS NULL OR c2.canal = 'bot')
) )
)"; )";
} }
@@ -147,9 +150,9 @@ try {
} else { } else {
// Conteo normal sin búsqueda // Conteo normal sin búsqueda
if ($filter === 'unread') { 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 { } 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; $total = isset($totalRow['count']) ? intval($totalRow['count']) : 0;
+1 -1
View File
@@ -27,7 +27,7 @@ try {
u.name u.name
FROM conversations c FROM conversations c
JOIN users u ON c.user_id = u.id 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 ORDER BY c.created_at DESC
LIMIT 10" LIMIT 10"
); );
+2 -2
View File
@@ -76,7 +76,7 @@ try {
c.reaction_to_message_id as reaction_to_message_id 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 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) // 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; $beforeId = isset($_GET['before_id']) ? intval($_GET['before_id']) : null;
@@ -158,7 +158,7 @@ try {
status, status,
created_at created_at
FROM conversations FROM conversations
WHERE user_id = :user_id WHERE user_id = :user_id AND (canal IS NULL OR canal = 'bot')
ORDER BY created_at ASC", ORDER BY created_at ASC",
['user_id' => $userId] ['user_id' => $userId]
); );
+15
View File
@@ -22,6 +22,21 @@ try {
jsonOk(['id' => (int)$datos['id']], 'Formulario eliminado'); 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 // Editar
if (!empty($datos['id'])) { if (!empty($datos['id'])) {
$id = (int)$datos['id']; $id = (int)$datos['id'];
+34 -25
View File
@@ -98,6 +98,11 @@ class WhatsAppWebhook {
} }
private function processconversations($value) { 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) // Aceptar tanto payloads con 'conversations' como con 'messages' (WhatsApp varía según la integración)
$items = []; $items = [];
if (isset($value['conversations']) && is_array($value['conversations'])) { if (isset($value['conversations']) && is_array($value['conversations'])) {
@@ -299,7 +304,8 @@ class WhatsAppWebhook {
'content' => $messageText, 'content' => $messageText,
'media_url' => $mediaUrl, 'media_url' => $mediaUrl,
'status' => 'received', '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 // 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) // Notificaciones y SSE solo para el canal principal (no turnero)
try { if (!$isTurnero) {
$this->db->insert('notifications', [ 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'], 'user_id' => $user['id'],
'type' => 'incoming_message', 'phone_number' => $user['phone_number'],
'name' => $user['name'] ?? ($phoneNumber ?? ''),
'message' => substr($messageText, 0, 250), 'message' => substr($messageText, 0, 250),
'data' => json_encode(['message_id' => $messageId]), 'message_type' => $messageType,
'is_read' => 0, 'timestamp' => date('Y-m-d H:i:s')
'created_at' => date('Y-m-d H:i:s')
]); ]);
} catch (Exception $e) {
error_log('Failed to create notification: ' . $e->getMessage());
} }
// Empujar evento SSE en tiempo real // Procesar con bot solo si el mensaje llegó al número principal
$this->pushSSEEvent('new_message', [ if (!$isTurnero) {
'user_id' => $user['id'], try {
'phone_number' => $user['phone_number'], $this->botService->processMessage($user, $messageText, $messageType);
'name' => $user['name'] ?? $from, } catch (Exception $e) {
'message' => substr($messageText, 0, 250), error_log("Bot processing failed: " . $e->getMessage());
'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());
} }
} }
+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 * NO REQUIERE AUTENTICACIÓN. Acceso via token: ?t=TOKEN
*/ */
$token = trim($_GET['t'] ?? ''); $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> <!DOCTYPE html>
<html lang="es"> <html lang="es">
@@ -550,6 +555,35 @@ function renderCampos(esquema, prefilled) {
<label class="form-check-label">${esc(o)}</label> <label class="form-check-label">${esc(o)}</label>
</div>`).join(''); </div>`).join('');
html += wrapOpen + `<div class="mb-3">${lbl}${opts}${linkedNote}</div>` + wrapClose; 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 { } else {
const t = c.tipo === 'numero' ? 'number' const t = c.tipo === 'numero' ? 'number'
: (c.tipo === 'fecha' || c.linked_key === 'fecha_nacimiento') ? 'date' : (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 { background: #c1c9d4; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #8fa0b5; } ::-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) { @media (max-width: 900px) {
#panel-palette { width: 170px; min-width: 170px; } #panel-palette { width: 170px; min-width: 170px; }
#panel-preview { width: 260px; min-width: 260px; } #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 ─────────────────────────────── */ /* ── Pantalla de éxito ─────────────────────────────── */
#success-screen { #success-screen {
@@ -295,6 +332,8 @@ $formId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
<div id="palette"></div> <div id="palette"></div>
<div class="palette-group-title" style="margin-top:12px">Vinculados al paciente</div> <div class="palette-group-title" style="margin-top:12px">Vinculados al paciente</div>
<div id="palette-linked"></div> <div id="palette-linked"></div>
<div class="palette-group-title" style="margin-top:12px">Calculados</div>
<div id="palette-calc"></div>
</div> </div>
</div> </div>
@@ -479,6 +518,10 @@ const TIPOS_CAMPO = [
{ tipo:'parrafo_inline', label:'Párrafo con campos', icon:'fa-align-left' }, { tipo:'parrafo_inline', label:'Párrafo con campos', icon:'fa-align-left' },
{ tipo:'lista_marcable', label:'Lista marcable', icon:'fa-list-ol' }, { 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 = [ const TIPOS_LINKED = [
{ key:'nombre_completo', label:'Nombre completo', icon:'fa-user' }, { key:'nombre_completo', label:'Nombre completo', icon:'fa-user' },
{ key:'numero_documento', label:'N.º documento', icon:'fa-id-card' }, { key:'numero_documento', label:'N.º documento', icon:'fa-id-card' },
@@ -529,25 +572,27 @@ function renderPalette() {
$('palette').innerHTML = TIPOS_CAMPO.map(t => ` $('palette').innerHTML = TIPOS_CAMPO.map(t => `
<div class="palette-item" draggable="true" <div class="palette-item" draggable="true"
data-tipo="${t.tipo}" 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} <i class="fas ${t.icon}"></i>${t.label}
</div>`).join(''); </div>`).join('');
$('palette-linked').innerHTML = TIPOS_LINKED.map(t => ` $('palette-linked').innerHTML = TIPOS_LINKED.map(t => `
<div class="palette-item linked" draggable="true" <div class="palette-item linked" draggable="true"
data-linked="${t.key}" data-tipo="linked" data-linked="${t.key}"
ondragstart="palDragStart(event,'linked','${t.key}')"> ondragstart="palDragStart(event,'linked','${t.key}')"
onclick="agregarCampo({tipo:'linked',linked:'${t.key}'})">
<i class="fas ${t.icon}"></i>${t.label} <i class="fas ${t.icon}"></i>${t.label}
</div>`).join(''); </div>`).join('');
// Click también agrega campo (además del drag) $('palette-calc').innerHTML = TIPOS_CALC.map(t => `
document.querySelectorAll('.palette-item').forEach(el => { <div class="palette-item" draggable="true"
el.addEventListener('click', () => { data-tipo="${t.tipo}"
const tipo = el.dataset.tipo || 'linked'; ondragstart="palDragStart(event,'${t.tipo}')"
const linked = el.dataset.linked || null; onclick="agregarCampo({tipo:'${t.tipo}',linked:null})"
agregarCampo({ tipo, linked }); 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…' }; campo = { id, tipo:'parrafo_inline', contenido:'Yo, {nombre_completo}, con N.º de identificación {numero_documento}, declaro que…' };
} else if (tipo === 'lista_marcable') { } 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 }; 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 { } else {
campo = { id, tipo, label:'Campo sin título', placeholder:'', required:false }; 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', texto:'fa-font', textarea:'fa-align-left', numero:'fa-hashtag',
fecha:'fa-calendar', hora:'fa-clock', select:'fa-list', radio:'fa-dot-circle', 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', 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() { function renderCanvas() {
@@ -647,29 +697,139 @@ function renderCanvas() {
</button> </button>
</div>`; </div>`;
// Reordenar drag // Reordenar — HTML5 drag (mouse desktop)
div.setAttribute('draggable', 'true');
div.addEventListener('dragstart', e => { div.addEventListener('dragstart', e => {
if (e.target.closest('.fi-btn')) { e.preventDefault(); return; }
_dragSrc = idx; _dragTipo = null; _dragSrc = idx; _dragTipo = null;
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
div.classList.add('dragging'); div.classList.add('dragging');
}); });
div.addEventListener('dragend', () => div.classList.remove('dragging')); div.addEventListener('dragend', () => {
div.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect='move'; }); 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 => { div.addEventListener('drop', e => {
e.stopPropagation(); e.stopPropagation();
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
fi.classList.remove('drop-above','drop-below'));
if (_dragSrc !== null && _dragSrc !== idx) { if (_dragSrc !== null && _dragSrc !== idx) {
const r = div.getBoundingClientRect();
const before = e.clientY < r.top + r.height / 2;
const [moved] = _campos.splice(_dragSrc, 1); 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; _dragSrc = null;
renderCanvas(); renderCanvas(); renderPreview();
renderPreview();
} }
}); });
// Reordenar — Pointer Events (touch / tablet)
initPointerDrag(div, idx);
zone.appendChild(div); 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) { function eliminarCampo(idx) {
_campos.splice(idx, 1); _campos.splice(idx, 1);
renderCanvas(); renderCanvas();
@@ -765,6 +925,20 @@ function renderCampoPreview(c) {
).join(''); ).join('');
return `<div class="preview-field">${lbl}${items}</div>`; 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'; const t = c.tipo==='numero'?'number':c.tipo==='fecha'?'date':c.tipo==='hora'?'time':'text';
return `<div class="preview-field">${lbl} return `<div class="preview-field">${lbl}
<input type="${t}" disabled placeholder="${esc(c.placeholder||'')}"></div>`; <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':''}> <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> <label class="form-check-label small" for="ce-required">Requiere al menos una selección</label>
</div>`; </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') { } else if (c.tipo === 'firma' || c.tipo === 'firma_profesional') {
if (c.tipo === 'firma_profesional') { if (c.tipo === 'firma_profesional') {
const cm = c.modos || ['canvas']; const cm = c.modos || ['canvas'];
+17
View File
@@ -445,6 +445,8 @@ function renderFormularios() {
<i class="fas fa-paper-plane me-1"></i>Enviar <i class="fas fa-paper-plane me-1"></i>Enviar
</button> </button>
${PUEDE_ESCRIBIR ? ` ${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})" <button class="btn btn-sm btn-outline-primary" onclick="builder.editar(${f.id})"
title="Editar diseño"><i class="fas fa-edit"></i></button> 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)}')" <button class="btn btn-sm btn-outline-danger" onclick="confirmarBorrar(${f.id},'${esc(f.nombre)}')"
@@ -456,6 +458,21 @@ function renderFormularios() {
`).join(''); `).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) { async function confirmarBorrar(id, nombre) {
if (!confirm(`¿Eliminar el formulario "${nombre}"?\nLos envíos existentes se conservarán.`)) return; if (!confirm(`¿Eliminar el formulario "${nombre}"?\nLos envíos existentes se conservarán.`)) return;
const r = await fetch('api/lab/save_formulario.php', { const r = await fetch('api/lab/save_formulario.php', {
@@ -0,0 +1,2 @@
ALTER TABLE turnero_lugares
ADD COLUMN IF NOT EXISTS formulario_modo ENUM('link','embebido') NOT NULL DEFAULT 'link';
+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;
+1
View File
@@ -11,6 +11,7 @@ $pendientes = [
'20260703_solo_muestras.sql', '20260703_solo_muestras.sql',
'20260703_medicos.sql', '20260703_medicos.sql',
'20260703_medicos_seed.sql', '20260703_medicos_seed.sql',
'20260703_solicitud_medico.sql',
]; ];
$pdo = Database::getInstance()->getConnection(); $pdo = Database::getInstance()->getConnection();
+53 -3
View File
@@ -64,10 +64,60 @@ try {
$estadoActual = $turno['estado']; $estadoActual = $turno['estado'];
// Estados finales no modificables // Estados finales — solo admin (sin role_id) puede reabrir
if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) { if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) {
$pdo->rollBack(); $roleId = $_SESSION['admin_user']['role_id'] ?? null;
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422); if (!empty($roleId)) {
$pdo->rollBack();
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422);
}
$permitidosAdmin = ['en_servicio', 'en_espera_lugar', 'en_recepcion', 'espera'];
if (!in_array($nuevoEstado, $permitidosAdmin, true)) {
$pdo->rollBack();
jsonError("Estado destino inválido para reapertura. Opciones: " . implode(', ', $permitidosAdmin), 422);
}
$sets = ['estado = ?'];
$binds = [$nuevoEstado];
if ($nuevoEstado === 'en_servicio') {
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = COALESCE(inicio_lugar_at, NOW())';
$sets[] = 'atendido_lugar_por = COALESCE(atendido_lugar_por, ?)';
$binds[] = adminId();
} elseif ($nuevoEstado === 'en_espera_lugar') {
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = NULL';
$sets[] = 'llamado_lugar_at = NULL';
} elseif ($nuevoEstado === 'espera') {
$sets[] = 'llamado_recepcion_at = NULL';
$sets[] = 'inicio_recepcion_at = NULL';
$sets[] = 'fin_recepcion_at = NULL';
$sets[] = 'llamado_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = NULL';
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'atendido_recepcion_por = NULL';
$sets[] = 'atendido_lugar_por = NULL';
$sets[] = 'recepcion_desk_id = NULL';
}
$sql = 'UPDATE turnero_turnos SET ' . implode(', ', $sets) . ' WHERE id = ?';
$binds[] = $turnoId;
$pdo->prepare($sql)->execute($binds);
$pdo->commit();
$stmt = $pdo->prepare(
'SELECT t.*, p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, p.color AS prioridad_color
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.id = ?'
);
$stmt->execute([$turnoId]);
$turnoActualizado = $stmt->fetch(PDO::FETCH_ASSOC);
notificarSSE((int) $turnoActualizado['sesion_id']);
jsonOk(['turno' => $turnoActualizado], "Turno reabierto: estado actualizado a '{$nuevoEstado}'");
} }
// Verificar que la transición sea válida // Verificar que la transición sea válida
+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()]);
}
+21 -36
View File
@@ -1,61 +1,46 @@
<?php <?php
/** /**
* API - Enviar mensaje desde el número turnero * API - Enviar mensaje desde el número turnero
* Soporta: text, template (básico) * Soporta: text, template
*/ */
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once __DIR__ . '/../../../services/WhatsAppService.php'; require_once __DIR__ . '/../../../services/WhatsAppService.php';
requireAuthentication(); requireAuthentication();
header('Content-Type: application/json; charset=utf-8'); 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; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Método no permitido']);
exit;
}
try { try {
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
if (!$input) {
$input = $_POST;
}
$userId = intval($input['user_id'] ?? 0); $userId = intval($input['user_id'] ?? 0);
$message = trim($input['message'] ?? ''); $type = $input['type'] ?? 'text';
$message = trim($input['message'] ?? '');
$template = trim($input['template'] ?? '');
$lang = trim($input['lang'] ?? 'es_CO');
$params = $input['params'] ?? [];
if (!$userId || $message === '') { if (!$userId || ($type === 'text' && $message === '') || ($type === 'template' && $template === '')) {
http_response_code(400); http_response_code(400); echo json_encode(['success'=>false,'error'=>'Parámetros insuficientes']); exit;
echo json_encode(['success' => false, 'error' => 'user_id y message son requeridos']);
exit;
} }
$db = Database::getInstance(); $db = Database::getInstance();
$user = $db->fetch("SELECT phone_number, name FROM users WHERE id = :id", ['id' => $userId]); $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; }
if (!$user) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Usuario no encontrado']);
exit;
}
// Verificar que el número turnero esté configurado
$turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', ''); $turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
if (empty($turneroPhoneId)) { if (empty($turneroPhoneId)) { http_response_code(503); echo json_encode(['success'=>false,'error'=>'Número turnero no configurado']); exit; }
http_response_code(503);
echo json_encode(['success' => false, 'error' => 'El número de WhatsApp del turnero no está configurado']);
exit;
}
$operatorId = $_SESSION['admin_id'] ?? $_SESSION['user_id'] ?? null; $operatorId = $_SESSION['admin_id'] ?? $_SESSION['user_id'] ?? null;
$wa = new WhatsAppService('turnero');
$wa = new WhatsAppService('turnero'); if ($type === 'template') {
$response = $wa->sendTextMessage( $response = $wa->sendTemplateMessage($user['phone_number'], $template, $lang, $params);
$user['phone_number'], } else {
$message, $extra = ['canal' => 'turnero'];
$operatorId ? ['operator_id' => $operatorId, 'canal' => 'turnero'] : ['canal' => 'turnero'] if ($operatorId) $extra['operator_id'] = $operatorId;
); $response = $wa->sendTextMessage($user['phone_number'], $message, $extra);
}
echo json_encode(['success' => true, 'whatsapp_response' => $response]); echo json_encode(['success' => true, 'whatsapp_response' => $response]);
+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);
}
+13 -10
View File
@@ -32,6 +32,7 @@ $obs = isset($datos['observaciones']) ? trim((string) $datos['observac
$numOrden = isset($datos['numero_orden']) ? trim((string) $datos['numero_orden']) : null; $numOrden = isset($datos['numero_orden']) ? trim((string) $datos['numero_orden']) : null;
$embarazada = !empty($datos['embarazada']) ? 1 : 0; $embarazada = !empty($datos['embarazada']) ? 1 : 0;
$soloMuestras = !empty($datos['solo_muestras']) ? 1 : 0; $soloMuestras = !empty($datos['solo_muestras']) ? 1 : 0;
$medicoId = !empty($datos['medico_id']) ? (int)$datos['medico_id'] : null;
$pagosDetalle = null; $pagosDetalle = null;
if ($metodoPago === 'combinado' && isset($datos['pagos_detalle']) && is_array($datos['pagos_detalle'])) { if ($metodoPago === 'combinado' && isset($datos['pagos_detalle']) && is_array($datos['pagos_detalle'])) {
$pd = $datos['pagos_detalle']; $pd = $datos['pagos_detalle'];
@@ -93,13 +94,15 @@ try {
jsonError('Lugar destino no encontrado o inactivo.', 404); jsonError('Lugar destino no encontrado o inactivo.', 404);
} }
// Verificar que todos los exam_tipo_ids existen // Verificar que todos los exam_tipo_ids existen (se omite si solo_muestras y sin exámenes)
$in = implode(',', array_fill(0, count($examIds), '?')); if (!empty($examIds)) {
$stmt = $pdo->prepare("SELECT COUNT(*) FROM exam_tipos WHERE id IN ({$in}) AND activo = 1"); $in = implode(',', array_fill(0, count($examIds), '?'));
$stmt->execute($examIds); $stmt = $pdo->prepare("SELECT COUNT(*) FROM exam_tipos WHERE id IN ({$in}) AND activo = 1");
if ((int)$stmt->fetchColumn() !== count($examIds)) { $stmt->execute($examIds);
$pdo->rollBack(); if ((int)$stmt->fetchColumn() !== count($examIds)) {
jsonError('Uno o más exámenes no existen o están inactivos.', 422); $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) ── // ── Insertar o reemplazar solicitud (DELETE + INSERT para poder re-guardar) ──
@@ -114,12 +117,12 @@ try {
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
"INSERT INTO turnero_solicitudes "INSERT INTO turnero_solicitudes
(turno_id, paciente_id, lugar_id, numero_orden, total_cobrado, metodo_pago, pagos_detalle, observaciones, embarazada, solo_muestras, creado_por) (turno_id, paciente_id, lugar_id, numero_orden, total_cobrado, metodo_pago, pagos_detalle, observaciones, embarazada, solo_muestras, medico_id, creado_por)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
); );
$stmt->execute([ $stmt->execute([
$turnoId, $pacienteId, $lugarId, $numOrden, $turnoId, $pacienteId, $lugarId, $numOrden,
$total, $metodoPago ?: null, $pagosDetalle, $obs ?: null, $embarazada, $soloMuestras, adminId(), $total, $metodoPago ?: null, $pagosDetalle, $obs ?: null, $embarazada, $soloMuestras, $medicoId, adminId(),
]); ]);
$solicitudId = (int) $pdo->lastInsertId(); $solicitudId = (int) $pdo->lastInsertId();
+83 -37
View File
@@ -102,62 +102,108 @@ try {
notificarSSE($sesionId); 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) { if ($pacienteTel) {
try { try {
$stmtCfg = $pdo->prepare( $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(); $stmtCfg->execute();
$cfgWA = $stmtCfg->fetchAll(PDO::FETCH_KEY_PAIR); $cfgWA = $stmtCfg->fetchAll(PDO::FETCH_KEY_PAIR);
$waKioskoEnabled = ($cfgWA['turnero_wa_kiosko_enabled'] ?? '0') === '1'; $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) { } catch (\Throwable $e) {
$waKioskoEnabled = true; $waKioskoEnabled = true;
$waTemplate = 'consentimiento_turno';
$waLang = 'es';
} }
} }
if ($pacienteTel && $waKioskoEnabled) { if ($pacienteTel && $waKioskoEnabled) {
try { // WhatsAppService::formatPhoneNumber maneja el formato colombiano correctamente
$cfgWA = []; $cel = preg_replace('/[^0-9]/', '', $pacienteTel);
$stmtCfg = $pdo->prepare(
"SELECT clave, valor FROM lab_config WHERE clave IN ('turnero_wa_template','turnero_wa_template_muestra','turnero_wa_lang')" // Marcar como enviado si vamos a mandar WA
); if ($enlaceConsentimiento) {
$stmtCfg->execute(); try {
$cfgWA = $stmtCfg->fetchAll(PDO::FETCH_KEY_PAIR); $pdo->prepare(
// Tipo F = solo muestra pendiente, sin consentimiento → plantilla diferente "UPDATE turnero_consentimientos SET estado='enviado', enviado_at=NOW()
$esSoloMuestra = ($prioCodigo === 'F'); WHERE turno_id=? AND estado='pendiente'"
$waTemplate = $esSoloMuestra )->execute([$turnoId]);
? ($cfgWA['turnero_wa_template_muestra'] ?? 'turno_muestra_pendiente') } catch (\Throwable $_) {}
: ($cfgWA['turnero_wa_template'] ?? 'consentimiento_turno');
$waLang = $cfgWA['turnero_wa_lang'] ?? 'es_CO';
} catch (\Throwable $e) {
$waTemplate = 'consentimiento_turno';
$waLang = 'es_CO';
} }
$cel = preg_replace('/[\s\-\.]/', '', $pacienteTel);
if (!str_starts_with($cel, '+')) {
$cel = '+57' . ltrim($cel, '0');
}
require_once __DIR__ . '/../../../services/WhatsAppService.php'; require_once __DIR__ . '/../../../services/WhatsAppService.php';
$nombreWA = $pacienteNombre ?: 'Paciente';
try { try {
$wa = new WhatsAppService(); $wa = new WhatsAppService('turnero');
// Enviar solo el código, sin nombre/apellido $rawComps = [
$wa->sendTemplateMessage( ['type' => 'body', 'parameters' => [['type' => 'text', 'text' => $codigo]]]
$cel, ];
$waTemplate, if ($conToken) {
$waLang, $rawComps[] = [
[ 'type' => 'button',
'Paciente', 'sub_type' => 'url',
$codigo, 'index' => '0',
'', 'parameters' => [['type' => 'text', 'text' => $conToken]],
], ];
[] }
); $wa->sendTemplateMessage($cel, $waTemplate, $waLang, [], [], $rawComps);
} catch (\Throwable $eTmpl) { } catch (\Throwable $eTmpl) {
try { try {
$mensajeTexto = "Su turno *{$codigo}* ha sido registrado. Preséntese al laboratorio."; $msg = "Hola {$nombreWA}, su turno *{$codigo}* ha sido registrado.";
$wa->sendTextMessage($cel, $mensajeTexto); if ($enlaceConsentimiento) {
$msg .= "\n\nPor favor firme su consentimiento informado antes de ser atendido:\n{$enlaceConsentimiento}";
}
$wa->sendTextMessage($cel, $msg);
} catch (\Throwable $eTxt) { } catch (\Throwable $eTxt) {
// Silenciar // Silenciar
} }
@@ -16,6 +16,9 @@ $body = inputJson();
$turnoId = (int)($body['turno_id'] ?? 0); $turnoId = (int)($body['turno_id'] ?? 0);
$formularioId = (int)($body['formulario_id'] ?? 0); $formularioId = (int)($body['formulario_id'] ?? 0);
$svg = $body['svg'] ?? ''; $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 (!$turnoId) jsonError('turno_id requerido.');
if (!$formularioId) jsonError('formulario_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); if (!$tc) jsonError('Consentimiento no encontrado.', 404);
$stmt = $pdo->prepare( if ($soloPro) {
"UPDATE turnero_consentimientos // El profesional es el firmante final: guardar datos + marcar como firmado
SET firma_profesional_svg = ?, firmado_profesional_at = NOW() $pdo->prepare(
WHERE turno_id = ? AND formulario_id = ?" "UPDATE turnero_consentimientos
); SET firma_profesional_svg = ?, firmado_profesional_at = NOW(),
$stmt->execute([$svg, $turnoId, $formularioId]); 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']); notificarSSE((int)$tc['sesion_id']);
+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.firmado_at,
(tc.firma_profesional_svg IS NOT NULL) AS tiene_firma_profesional, (tc.firma_profesional_svg IS NOT NULL) AS tiene_firma_profesional,
tc.firmado_profesional_at, tc.firmado_profesional_at,
f.nombre AS formulario_nombre f.nombre AS formulario_nombre,
f.esquema AS formulario_esquema
FROM turnero_consentimientos tc FROM turnero_consentimientos tc
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
WHERE tc.turno_id = ? WHERE tc.turno_id = ?
@@ -46,6 +47,22 @@ $stmt = $pdo->prepare(
$stmt->execute([$turnoId]); $stmt->execute([$turnoId]);
$consentimientos = $stmt->fetchAll(PDO::FETCH_ASSOC); $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 = [ $respuesta = [
'turno_id' => $turnoId, 'turno_id' => $turnoId,
'turno_estado' => $turno['estado'], 'turno_estado' => $turno['estado'],
@@ -56,10 +73,14 @@ $respuesta = [
if ($incluirSolicitud) { if ($incluirSolicitud) {
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
"SELECT s.id, s.turno_id, s.paciente_id, s.lugar_id, "SELECT s.id, s.turno_id, s.paciente_id, s.lugar_id,
s.total_cobrado, s.metodo_pago, s.observaciones, s.embarazada, s.creado_at, s.total_cobrado, s.metodo_pago, s.observaciones, s.embarazada, s.solo_muestras, s.medico_id, s.creado_at,
l.nombre AS lugar_nombre 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 FROM turnero_solicitudes s
LEFT JOIN turnero_lugares l ON l.id = s.lugar_id 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 = ? WHERE s.turno_id = ?
LIMIT 1" LIMIT 1"
); );
@@ -167,7 +188,8 @@ if ($incluirSolicitud) {
tc.estado, tc.enviado_at, tc.firmado_at, tc.estado, tc.enviado_at, tc.firmado_at,
(tc.firma_profesional_svg IS NOT NULL) AS tiene_firma_profesional, (tc.firma_profesional_svg IS NOT NULL) AS tiene_firma_profesional,
tc.firmado_profesional_at, tc.firmado_profesional_at,
f.nombre AS formulario_nombre f.nombre AS formulario_nombre,
f.esquema AS formulario_esquema
FROM turnero_consentimientos tc FROM turnero_consentimientos tc
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
WHERE tc.turno_id = ? WHERE tc.turno_id = ?
@@ -175,6 +197,20 @@ if ($incluirSolicitud) {
); );
$stmtC->execute([$turnoId]); $stmtC->execute([$turnoId]);
$consentimientos = $stmtC->fetchAll(PDO::FETCH_ASSOC); $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; $respuesta['consentimientos'] = $consentimientos;
} }
} }
+6 -4
View File
@@ -52,6 +52,8 @@ $sortOrder = (int)($input['sort_order'] ?? 99);
$activo = (int)($input['activo'] ?? 1); $activo = (int)($input['activo'] ?? 1);
$tipo = in_array($input['tipo'] ?? '', ['recepcion', 'muestras'], true) $tipo = in_array($input['tipo'] ?? '', ['recepcion', 'muestras'], true)
? $input['tipo'] : 'muestras'; ? $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']) $formularioIds = isset($input['formulario_ids']) && is_array($input['formulario_ids'])
? array_filter(array_map('intval', $input['formulario_ids']), fn($v) => $v > 0) ? array_filter(array_map('intval', $input['formulario_ids']), fn($v) => $v > 0)
: []; : [];
@@ -64,15 +66,15 @@ $pdo->beginTransaction();
try { try {
if ($id) { if ($id) {
$stmt = $pdo->prepare( $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; $lugarId = $id;
} else { } else {
$stmt = $pdo->prepare( $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(); $lugarId = (int) $pdo->lastInsertId();
} }
+31 -66
View File
@@ -32,52 +32,31 @@ if ($turnoId <= 0) jsonError('turno_id inválido.');
$pdo = db(); $pdo = db();
// ── 1. Cargar turno y su solicitud ──────────────────────────── // ── 1. Cargar turno (con fallback de paciente desde kiosko) ──
$stmt = $pdo->prepare( $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, t.estado,
s.id AS solicitud_id, COALESCE(p_sol.telefono, p_tur.telefono) AS pac_celular,
s.paciente_id, COALESCE(p_sol.nombre_completo, p_tur.nombre_completo) AS pac_nombre
s.lugar_id,
p.telefono AS pac_telefono,
p.telefono AS pac_celular,
p.nombre_completo AS pac_nombre
FROM turnero_turnos t FROM turnero_turnos t
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id 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 = ?" WHERE t.id = ?"
); );
$stmt->execute([$turnoId]); $stmt->execute([$turnoId]);
$turno = $stmt->fetch(PDO::FETCH_ASSOC); $turno = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$turno) jsonError('Turno no encontrado.', 404); 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: preferencia paciente BD → kiosko
$celular = $turno['pac_celular'] ?? $turno['pac_telefono'] ?? $turno['paciente_cel'] ?? null; $celular = $turno['pac_celular'] ?: $turno['paciente_cel'] ?: null;
if (!$celular) { 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) // ── 2. Obtener formularios configurados para este turno ───────
$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 ───────
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
"SELECT tc.formulario_id, f.nombre AS formulario_nombre "SELECT tc.formulario_id, f.nombre AS formulario_nombre
FROM turnero_consentimientos tc FROM turnero_consentimientos tc
@@ -124,14 +103,16 @@ try {
WHERE id = ?" WHERE id = ?"
); );
$wa = new WhatsAppService(); $wa = new WhatsAppService('turnero');
// URL base del sistema (para generar el enlace de firma) $waTemplate = 'consentimiento_turno';
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http') $stmtLang = $pdo->prepare("SELECT language_code FROM message_templates WHERE template_name = ? LIMIT 1");
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost'); $stmtLang->execute([$waTemplate]);
// Si BASE_URL está definida, úsala $waLang = $stmtLang->fetchColumn() ?: 'es';
if (defined('BASE_URL')) {
$baseUrl = rtrim(BASE_URL, '/'); $baseUrl = defined('BASE_URL') ? rtrim(BASE_URL, '/') : (
} (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http')
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost')
);
foreach ($formulariosRequeridos as $form) { foreach ($formulariosRequeridos as $form) {
$formularioId = (int) $form['formulario_id']; $formularioId = (int) $form['formulario_id'];
@@ -161,39 +142,23 @@ try {
$consentId = (int) $pdo->lastInsertId(); $consentId = (int) $pdo->lastInsertId();
} }
// ── Construir enlace de firma ──────────────────────────
$enlaceFirma = $baseUrl . '/ver_formulario_enviado.php?token=' . urlencode($token); $enlaceFirma = $baseUrl . '/ver_formulario_enviado.php?token=' . urlencode($token);
$nombrePac = $turno['pac_nombre'] ?: ($turno['paciente_nombre'] ?? 'Paciente');
// ── Nombre del paciente para el mensaje ─────────────── // ── Enviar con template (mismo formato que create_turno.php) ──
$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.
$enviado = false; $enviado = false;
try { try {
$wa->sendTemplateMessage( $rawComps = [
$celular, ['type' => 'body', 'parameters' => [['type' => 'text', 'text' => $turno['codigo']]]],
'consentimiento_turno', ['type' => 'button', 'sub_type' => 'url', 'index' => '0',
'es', 'parameters' => [['type' => 'text', 'text' => $token]]],
// Parámetros del body: {{1}} = nombre, {{2}} = nombre formulario, {{3}} = código turno ];
[ $wa->sendTemplateMessage($celular, $waTemplate, $waLang, [], [], $rawComps);
htmlspecialchars($nombrePac, ENT_QUOTES),
htmlspecialchars($formularioNom, ENT_QUOTES),
$turno['codigo'],
],
// Parámetro del header o botón URL (URL del enlace)
[$enlaceFirma]
);
$enviado = true; $enviado = true;
} catch (\Throwable $eTemplate) { } catch (\Throwable $eTemplate) {
// Fallback: enviar mensaje de texto plano // Fallback texto plano
try { try {
$mensajeTexto = "Hola {$nombrePac}, le informamos que para su turno *{$turno['codigo']}* " $mensajeTexto = "Hola {$nombrePac}, su turno *{$turno['codigo']}* requiere firma de consentimiento:\n{$enlaceFirma}";
. "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.";
$wa->sendTextMessage($celular, $mensajeTexto); $wa->sendTextMessage($celular, $mensajeTexto);
$enviado = true; $enviado = true;
} catch (\Throwable $eTexto) { } catch (\Throwable $eTexto) {
+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.');
+413 -19
View File
@@ -167,6 +167,90 @@ html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMa
} }
.typing-indicator { font-size: .75rem; color: #888; padding: 2px 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; } .back-btn { display: none; background: none; border: none; color: #fff; font-size: 1.1rem; cursor: pointer; padding: 4px 8px; }
@media (max-width: 680px) { @media (max-width: 680px) {
@@ -243,9 +327,34 @@ html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMa
<div id="typingIndicator" class="typing-indicator" style="display:none">Escribiendo…</div> <div id="typingIndicator" class="typing-indicator" style="display:none">Escribiendo…</div>
<div class="chat-input-area"> <!-- 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…" <textarea id="msgInput" rows="1" placeholder="Escribe un mensaje…"
onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea> 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' ?>> <button class="btn-send" id="btnSend" onclick="sendMessage()" <?= $turneroPhoneId ? '' : 'disabled' ?>>
<i class="fas fa-paper-plane"></i> <i class="fas fa-paper-plane"></i>
</button> </button>
@@ -255,11 +364,43 @@ html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMa
</div><!-- /chat-window --> </div><!-- /chat-window -->
</div><!-- /chat-shell --> </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> <script>
const API_LIST = 'modules/turnero/api/chat_get_list.php'; const API_LIST = 'modules/turnero/api/chat_get_list.php';
const API_MESSAGES = 'modules/turnero/api/chat_get_messages.php'; const API_MESSAGES = 'modules/turnero/api/chat_get_messages.php';
const API_SEND = 'modules/turnero/api/chat_send_message.php'; const API_SEND = 'modules/turnero/api/chat_send_message.php';
const API_READ = 'modules/turnero/api/chat_mark_read.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 BASE = (function() {
const s = window.location.pathname; const s = window.location.pathname;
return s.replace(/\/erp\.php.*$/, '/') || '/'; return s.replace(/\/erp\.php.*$/, '/') || '/';
@@ -384,10 +525,12 @@ function closeChatMobile() {
// ── Mensajes ────────────────────────────────────────────────────────────────── // ── Mensajes ──────────────────────────────────────────────────────────────────
async function fetchMessages() { async function fetchMessages() {
if (!state.activeUserId) return; if (!state.activeUserId) return;
const url = BASE + API_MESSAGES + '?user_id=' + state.activeUserId + '&limit=50'; const forUser = state.activeUserId; // captura antes del await
const url = BASE + API_MESSAGES + '?user_id=' + forUser + '&limit=50';
try { try {
const res = await fetch(url); const res = await fetch(url);
const json = await res.json(); const json = await res.json();
if (forUser !== state.activeUserId) return; // usuario cambió mientras esperaba
if (!json.success) return; if (!json.success) return;
state.messages = json.data; state.messages = json.data;
state.hasMore = json.has_more; state.hasMore = json.has_more;
@@ -400,14 +543,16 @@ async function fetchMessages() {
async function loadMoreMessages() { async function loadMoreMessages() {
if (!state.activeUserId || !state.earliest) return; if (!state.activeUserId || !state.earliest) return;
const forUser = state.activeUserId;
const url = BASE + API_MESSAGES const url = BASE + API_MESSAGES
+ '?user_id=' + state.activeUserId + '?user_id=' + forUser
+ '&limit=50' + '&limit=50'
+ '&before=' + encodeURIComponent(state.earliest) + '&before=' + encodeURIComponent(state.earliest)
+ (state.earliestId ? '&before_id=' + state.earliestId : ''); + (state.earliestId ? '&before_id=' + state.earliestId : '');
try { try {
const res = await fetch(url); const res = await fetch(url);
const json = await res.json(); const json = await res.json();
if (forUser !== state.activeUserId) return;
if (!json.success || !json.data.length) { if (!json.success || !json.data.length) {
document.getElementById('loadMoreBtn').style.display = 'none'; document.getElementById('loadMoreBtn').style.display = 'none';
return; return;
@@ -422,13 +567,16 @@ async function loadMoreMessages() {
async function pollMessages() { async function pollMessages() {
if (!state.activeUserId || !state.lastPollTime) return; if (!state.activeUserId || !state.lastPollTime) return;
const forUser = state.activeUserId;
const sinceTime = state.lastPollTime;
const url = BASE + API_MESSAGES const url = BASE + API_MESSAGES
+ '?user_id=' + state.activeUserId + '?user_id=' + forUser
+ '&limit=50' + '&limit=50'
+ '&since=' + encodeURIComponent(state.lastPollTime); + '&since=' + encodeURIComponent(sinceTime);
try { try {
const res = await fetch(url); const res = await fetch(url);
const json = await res.json(); const json = await res.json();
if (forUser !== state.activeUserId) return; // usuario cambió mientras esperaba
if (!json.success || !json.data.length) return; if (!json.success || !json.data.length) return;
json.data.forEach(m => { json.data.forEach(m => {
if (!state.messages.find(x => x.id === m.id)) { if (!state.messages.find(x => x.id === m.id)) {
@@ -437,7 +585,7 @@ async function pollMessages() {
} }
}); });
state.lastPollTime = json.data[json.data.length - 1].created_at; state.lastPollTime = json.data[json.data.length - 1].created_at;
markRead(state.activeUserId); markRead(forUser);
loadContacts(document.getElementById('searchInput').value.trim(), true); loadContacts(document.getElementById('searchInput').value.trim(), true);
} catch(e) {} } catch(e) {}
} }
@@ -476,33 +624,56 @@ function appendMessage(msg) {
} }
function buildBubble(msg) { function buildBubble(msg) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'msg-bubble ' + (msg.direction === 'outgoing' ? 'outgoing' : 'incoming'); div.className = 'msg-bubble ' + (msg.direction === 'outgoing' ? 'outgoing' : 'incoming');
div.dataset.msgId = msg.id; 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 = ''; let content = '';
if (msg.message_type === 'image' && (msg.local_file || msg.media_url_external)) { if (msg.message_type === 'image' && mediaSrc) {
const src = msg.local_file ? ('../' + msg.local_file) : msg.media_url_external; content = `<img class="msg-img" src="${escHtml(mediaSrc)}" loading="lazy"
content += `<img class="msg-media-thumb" src="${escHtml(src)}" onclick="window.open('${escHtml(src)}','_blank')" alt="imagen">`; onclick="window.open('${escHtml(mediaSrc)}','_blank')" alt="imagen">`;
if (msg.content) content += `<div>${escHtml(msg.content)}</div>`; if (msg.content) content += `<div style="font-size:.82rem;margin-top:2px">${escHtml(msg.content)}</div>`;
} else if (['audio','video','document'].includes(msg.message_type) && (msg.local_file || msg.media_url_external)) { } else if (msg.message_type === 'video' && mediaSrc) {
const src = msg.local_file ? ('../' + msg.local_file) : msg.media_url_external; content = `<video class="msg-video" src="${escHtml(mediaSrc)}" controls preload="metadata"></video>`;
const icon = msg.message_type === 'audio' ? 'fa-file-audio' : msg.message_type === 'video' ? 'fa-file-video' : 'fa-file-alt'; if (msg.content) content += `<div style="font-size:.82rem;margin-top:2px">${escHtml(msg.content)}</div>`;
content += `<a class="msg-media-link" href="${escHtml(src)}" target="_blank"> } else if (msg.message_type === 'audio' && mediaSrc) {
<i class="fas ${icon}"></i> ${escHtml(msg.filename || msg.message_type)} 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>`; </a>`;
} else { } else {
content = nl2br(escHtml(msg.content || '')); content = nl2br(escHtml(msg.content || ''));
} }
const time = msg.created_at ? formatTimeFull(msg.created_at) : ''; const time = msg.created_at ? formatTimeFull(msg.created_at) : '';
div.innerHTML = content + `<div class="msg-time">${time}</div>`; 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; return div;
} }
// ── Enviar ──────────────────────────────────────────────────────────────────── // ── Enviar texto ─────────────────────────────────────────────────────────────
async function sendMessage() { async function sendMessage() {
// Si hay archivo adjunto, enviar como media
if (attachFile) { await sendMedia(); return; }
const input = document.getElementById('msgInput'); const input = document.getElementById('msgInput');
const message = input.value.trim(); const message = input.value.trim();
if (!message || !state.activeUserId) return; if (!message || !state.activeUserId) return;
@@ -534,6 +705,229 @@ async function sendMessage() {
} }
} }
// ── 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) { async function markRead(userId) {
try { try {
await fetch(BASE + API_READ, { await fetch(BASE + API_READ, {
+184 -5
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"> <span class="badge <?= $lu['activo'] ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary' ?> ms-1">
<?= $lu['activo'] ? 'Activo' : 'Inactivo' ?> <?= $lu['activo'] ? 'Activo' : 'Inactivo' ?>
</span> </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> <i class="fas fa-pencil-alt"></i>
</button> </button>
<button class="btn-icon text-danger" onclick="eliminarLugar(<?= $lu['id'] ?>, '<?= addslashes($lu['nombre']) ?>')" title="Eliminar"> <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"> <button class="btn-icon" onclick="copiarUrlDisplay(<?= $lu['id'] ?>)" title="Copiar URL pantalla TV de este lugar">
<i class="fas fa-tv"></i> <i class="fas fa-tv"></i>
</button> </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> <i class="fas fa-pencil-alt"></i>
</button> </button>
<button class="btn-icon text-danger" onclick="eliminarLugar(<?= $lu['id'] ?>, '<?= addslashes($lu['nombre']) ?>')" title="Eliminar"> <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"> <div class="mb-2 mt-3">
<label class="form-label small fw-semibold"> <label class="form-label small fw-semibold">
<i class="fas fa-file-signature me-1 text-warning"></i> <i class="fas fa-file-signature me-1 text-warning"></i>
Consentimientos requeridos para este lugar Formularios de consentimiento
</label> </label>
<div id="edit-lu-consents" class="border rounded p-2" style="max-height:160px;overflow-y:auto;background:#fffbeb"> <div id="edit-lu-consents" class="border rounded p-2" style="max-height:160px;overflow-y:auto;background:#fffbeb">
<?php foreach ($formulariosCons as $fc): ?> <?php foreach ($formulariosCons as $fc): ?>
@@ -352,6 +352,23 @@ $tab = $_GET['tab'] ?? 'lugares';
<small class="text-muted">No hay formularios activos configurados.</small> <small class="text-muted">No hay formularios activos configurados.</small>
<?php endif; ?> <?php endif; ?>
</div> </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> </div>
<div class="modal-footer py-2 px-3"> <div class="modal-footer py-2 px-3">
@@ -794,6 +811,87 @@ $tab = $_GET['tab'] ?? 'lugares';
</div> </div>
</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 TAB 5 — PANTALLA TV
════════════════════════════════════════ --> ════════════════════════════════════════ -->
@@ -905,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; if (id) body.id = id;
try { try {
@@ -920,7 +1021,7 @@ async function guardarLugar(tipoOrId = 'muestras', id = null) {
} catch (e) { toast('Error de conexión', 'error'); } } 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-id').value = id;
document.getElementById('edit-lu-tipo').value = tipo; document.getElementById('edit-lu-tipo').value = tipo;
document.getElementById('edit-lu-nombre').value = nombre; document.getElementById('edit-lu-nombre').value = nombre;
@@ -936,6 +1037,10 @@ function editarLugar(id, nombre, desc, activo, orden, tipo = 'muestras') {
chk.checked = formIds.includes(parseInt(chk.value)); 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(); new bootstrap.Modal(document.getElementById('modalEditarLugar')).show();
} }
@@ -1179,6 +1284,80 @@ async function borrarTvVideo() {
} catch(e) { toast('Error de conexión', 'error'); } } 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() { async function guardarWhatsApp() {
const template = document.getElementById('wa-template')?.value.trim(); const template = document.getElementById('wa-template')?.value.trim();
const templateMuestra = document.getElementById('wa-template-muestra')?.value.trim(); const templateMuestra = document.getElementById('wa-template-muestra')?.value.trim();
+98 -2
View File
@@ -111,6 +111,21 @@ Layout::open('Historial de Turnos', 'fas fa-history');
.doc-pill.enviado { background:#fef9c3; color:#78350f; border:1px solid #fde68a; } .doc-pill.enviado { background:#fef9c3; color:#78350f; border:1px solid #fde68a; }
.doc-pill.pendiente{ background:#f1f5f9; color:#64748b; border:1px solid #e2e8f0; } .doc-pill.pendiente{ background:#f1f5f9; color:#64748b; border:1px solid #e2e8f0; }
.docs-empty { font-size:.78rem; color:#94a3b8; font-style:italic; } .docs-empty { font-size:.78rem; color:#94a3b8; font-style:italic; }
/* ── Modal cambio de estado ── */
.estado-modal-backdrop {
position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:1050;
display:flex; align-items:center; justify-content:center;
}
.estado-modal {
background:#fff; border-radius:14px; padding:24px 28px; width:340px;
box-shadow:0 20px 60px rgba(0,0,0,.2);
}
.estado-modal h5 { font-size:.95rem; font-weight:700; margin-bottom:4px; }
.estado-modal .sub { font-size:.78rem; color:#64748b; margin-bottom:16px; }
.estado-modal select { width:100%; padding:8px 10px; border:1px solid #e2e8f0;
border-radius:8px; font-size:.85rem; margin-bottom:16px; }
.estado-modal .actions { display:flex; gap:8px; justify-content:flex-end; }
</style> </style>
<!-- ── Encabezado ────────────────────────────────────────────── --> <!-- ── Encabezado ────────────────────────────────────────────── -->
@@ -445,12 +460,17 @@ function renderTabla(turnos) {
<td><span class="eb ${cls}">${lbl}</span></td> <td><span class="eb ${cls}">${lbl}</span></td>
<td class="text-nowrap">${espMin}</td> <td class="text-nowrap">${espMin}</td>
<td class="text-nowrap">${srvMin}</td> <td class="text-nowrap">${srvMin}</td>
<td> <td class="text-nowrap">
<button class="btn btn-sm btn-outline-secondary py-0 px-2" <button class="btn btn-sm btn-outline-secondary py-0 px-2 me-1"
onclick="toggleDetalle('${rowId}', this, ${t.id})" onclick="toggleDetalle('${rowId}', this, ${t.id})"
title="Ver detalles"> title="Ver detalles">
<i class="fas fa-chevron-down" style="font-size:.65rem"></i> <i class="fas fa-chevron-down" style="font-size:.65rem"></i>
</button> </button>
<button class="btn btn-sm btn-outline-warning py-0 px-2"
onclick="abrirModalEstado(${t.id}, '${t.estado}', '${nombre.replace(/'/g,\'\\\'')}')"
title="Cambiar estado">
<i class="fas fa-exchange-alt" style="font-size:.65rem"></i>
</button>
</td> </td>
</tr> </tr>
<tr id="${rowId}" class="detail-row" style="display:none"> <tr id="${rowId}" class="detail-row" style="display:none">
@@ -641,6 +661,82 @@ function exportarCSV() {
window.open(`${API}export_historial.php?${params}`, '_blank'); window.open(`${API}export_historial.php?${params}`, '_blank');
} }
// ── Cambio de estado ─────────────────────────────────────────
const ESTADOS_OPCIONES = {
espera : [{v:'en_recepcion', l:'En recepción'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
en_recepcion : [{v:'en_espera_lugar', l:'Esp. lugar'}, {v:'espera', l:'Espera'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
en_espera_lugar : [{v:'en_servicio', l:'En servicio'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
en_servicio : [{v:'finalizado', l:'Finalizado'}, {v:'en_espera_lugar', l:'Esp. lugar'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
// estados finales: reapertura (admin)
finalizado : [{v:'en_servicio', l:'Reabrir → En servicio'}, {v:'en_espera_lugar', l:'Reabrir → Esp. lugar'}, {v:'espera', l:'Reabrir → Espera'}],
ausente : [{v:'espera', l:'Reabrir → Espera'}, {v:'en_servicio', l:'Reabrir → En servicio'}],
cancelado : [{v:'espera', l:'Reabrir → Espera'}, {v:'en_servicio', l:'Reabrir → En servicio'}],
};
let _modalTurnoId = null;
function abrirModalEstado(turnoId, estadoActual, nombre) {
_modalTurnoId = turnoId;
const opciones = ESTADOS_OPCIONES[estadoActual] || [];
if (!opciones.length) { alert('No hay transiciones disponibles para este estado.'); return; }
const sel = opciones.map(o => `<option value="${o.v}">${o.l}</option>`).join('');
const esReopetura = ['finalizado','ausente','cancelado'].includes(estadoActual);
const backdrop = document.createElement('div');
backdrop.className = 'estado-modal-backdrop';
backdrop.id = 'estadoModalBackdrop';
backdrop.innerHTML = `
<div class="estado-modal" onclick="event.stopPropagation()">
<h5><i class="fas fa-exchange-alt me-2 text-warning"></i>Cambiar estado del turno</h5>
<div class="sub">${nombre}${esReopetura ? ' <span class="badge bg-warning text-dark ms-1">Reapertura admin</span>' : ''}</div>
<select id="selectNuevoEstado">${sel}</select>
<div class="actions">
<button class="btn btn-sm btn-outline-secondary" onclick="cerrarModalEstado()">Cancelar</button>
<button class="btn btn-sm btn-warning" onclick="confirmarCambioEstado()">
<i class="fas fa-check me-1"></i>Confirmar
</button>
</div>
</div>`;
backdrop.addEventListener('click', cerrarModalEstado);
document.body.appendChild(backdrop);
}
function cerrarModalEstado() {
document.getElementById('estadoModalBackdrop')?.remove();
_modalTurnoId = null;
}
async function confirmarCambioEstado() {
const nuevoEstado = document.getElementById('selectNuevoEstado').value;
if (!_modalTurnoId || !nuevoEstado) return;
const btn = document.querySelector('#estadoModalBackdrop .btn-warning');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando…';
try {
const res = await fetch(`${API}cambiar_estado.php`, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ turno_id: _modalTurnoId, nuevo_estado: nuevoEstado }),
});
const json = await res.json();
if (json.ok) {
cerrarModalEstado();
buscar(_state.page);
} else {
alert('Error: ' + (json.error || 'No se pudo cambiar el estado.'));
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-check me-1"></i>Confirmar';
}
} catch(e) {
alert('Error de conexión');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-check me-1"></i>Confirmar';
}
}
// ── Helpers visuales ────────────────────────────────────────── // ── Helpers visuales ──────────────────────────────────────────
function mostrarSpinner() { function mostrarSpinner() {
document.getElementById('tbody').innerHTML = document.getElementById('tbody').innerHTML =
+152 -28
View File
@@ -17,19 +17,21 @@ if (!isUserLoggedIn()) {
try { try {
$pdo = Database::getInstance()->getConnection(); $pdo = Database::getInstance()->getConnection();
$lugares = $pdo->query( $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); )->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable) { } catch (\Throwable) {
$lugares = []; $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 // Resolver nombre y modo del lugar para el título
$lugarNombre = 'Estación de Servicio'; $lugarNombre = 'Estación de Servicio';
$lugarFormModo = 'link'; // 'link' | 'embebido'
foreach ($lugares as $l) { foreach ($lugares as $l) {
if ((int)$l['id'] === $lugarIdParam) { if ((int)$l['id'] === $lugarIdParam) {
$lugarNombre = $l['nombre']; $lugarNombre = $l['nombre'];
$lugarFormModo = $l['formulario_modo'] ?? 'link';
break; break;
} }
} }
@@ -299,6 +301,12 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
<i class="fas fa-baby me-1"></i>Paciente embarazada <i class="fas fa-baby me-1"></i>Paciente embarazada
</span> </span>
</div> </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"> <div id="bloque-pac-sin" class="text-muted small">
<i class="fas fa-info-circle me-1"></i>Sin paciente vinculado <i class="fas fa-info-circle me-1"></i>Sin paciente vinculado
@@ -313,7 +321,8 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
</div> </div>
</div> </div>
<!-- ── Sección: Consentimientos ── -->
<div class="ficha-sec" id="sec-consent"> <div class="ficha-sec" id="sec-consent">
<h6><i class="fas fa-file-signature me-1"></i>Consentimientos informados</h6> <h6><i class="fas fa-file-signature me-1"></i>Consentimientos informados</h6>
@@ -373,6 +382,38 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
</div><!-- /lugar-layout --> </div><!-- /lugar-layout -->
</main> </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> <script>
// ── Estado global ───────────────────────────────────────────── // ── Estado global ─────────────────────────────────────────────
@@ -383,9 +424,10 @@ let hayPendientes = false;
let pollingColaId = null; let pollingColaId = null;
let pollingConsentId = null; let pollingConsentId = null;
const API = '<?= BASE_URL ?>modules/turnero/api/'; const API = '<?= BASE_URL ?>modules/turnero/api/';
const BASE_WA = '<?= BASE_URL ?>'; const BASE_WA = '<?= BASE_URL ?>';
const LUGAR_NOMBRE = document.getElementById('lbl-lugar-titulo')?.textContent || '<?= addslashes($lugarNombre) ?>'; const LUGAR_NOMBRE = document.getElementById('lbl-lugar-titulo')?.textContent || '<?= addslashes($lugarNombre) ?>';
const LUGAR_FORM_MODO = '<?= $lugarFormModo ?>';
// ── Arranque ────────────────────────────────────────────────── // ── Arranque ──────────────────────────────────────────────────
@@ -477,7 +519,7 @@ function renderCola(snap) {
const esMuestra = t.solo_muestras == 1; const esMuestra = t.solo_muestras == 1;
const muestraBadge = esMuestra const muestraBadge = esMuestra
? `<div style="font-size:.6rem;font-weight:700;color:#ea580c;letter-spacing:.04em;margin-top:1px"> ? `<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>TOMA DE MUESTRAS <i class="fas fa-plus-square me-1"></i>ENTREGA DE MUESTRAS
</div>` </div>`
: ''; : '';
const cardStyle = esMuestra const cardStyle = esMuestra
@@ -517,6 +559,9 @@ async function seleccionarSinLlamar(turnoId) {
await cargarFichaSolicitud(t.id); await cargarFichaSolicitud(t.id);
clearInterval(pollingConsentId); clearInterval(pollingConsentId);
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000); pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
if (LUGAR_FORM_MODO === 'embebido' && lugarId) {
cargarFormEmbebido(t.id);
}
mostrarFichaMobile(); mostrarFichaMobile();
} }
@@ -590,6 +635,12 @@ async function abrirFicha(turno) {
// Iniciar polling de consentimientos // Iniciar polling de consentimientos
clearInterval(pollingConsentId); clearInterval(pollingConsentId);
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000); pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
// Formulario embebido
if (LUGAR_FORM_MODO === 'embebido' && lugarId) {
cargarFormEmbebido(turno.id);
}
mostrarFichaMobile(); mostrarFichaMobile();
} }
@@ -610,6 +661,16 @@ async function cargarFichaSolicitud(turnoId) {
const badgeEmb = document.getElementById('pac-embarazada'); const badgeEmb = document.getElementById('pac-embarazada');
if (badgeEmb) badgeEmb.classList.toggle('d-none', !(sol && sol.embarazada == 1)); 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) { if (pac) {
document.getElementById('bloque-pac-info').style.display = ''; document.getElementById('bloque-pac-info').style.display = '';
document.getElementById('bloque-pac-sin').classList.add('d-none'); document.getElementById('bloque-pac-sin').classList.add('d-none');
@@ -685,29 +746,40 @@ function renderConsentimientos(lista) {
const idJs = parseInt(c.id) || 0; const idJs = parseInt(c.id) || 0;
const tId = parseInt(c.turno_id) || (turnoActivo?.id) || 0; const tId = parseInt(c.turno_id) || (turnoActivo?.id) || 0;
// Firmar aquí: solo si no completado y hay token // Botón ver (firmado)
const btnFirmar = (!ya && c.token) const btnVer = (ya && c.token)
? `<button class="btn btn-outline-primary" title="Firmar aquí" ? `<a href="${BASE_WA}ver_formulario_enviado.php?token=${token}" target="_blank"
onclick="abrirFirmaPresencial('${token}', ${idJs}, ${nomJs.replace(/"/g,'&quot;')})"> class="btn btn-outline-secondary" title="Ver firmado">
<i class="fas fa-signature"></i> Firmar <i class="fas fa-eye"></i> Ver
</button>` : ''; </a>` : '';
// WA / Ver: según estado // Acción principal según modo y estado
const btnWa = ya let btnAccion = '';
? (c.token ? `<a href="${BASE_WA}ver_formulario_enviado.php?token=${token}" target="_blank" if (!ya && c.token) {
class="btn btn-outline-secondary" title="Ver firmado"> if (LUGAR_FORM_MODO === 'embebido') {
<i class="fas fa-eye"></i> Ver // Modo embebido: abrir modal directamente
</a>` : '') btnAccion = `<button class="btn btn-primary" title="Abrir formulario"
: `<button class="btn btn-outline-success" title="Reenviar por WhatsApp" onclick="abrirModalConsentimientoPorToken('${token}')">
onclick="reenviarConsentimiento(${tId})"> <i class="fas fa-pen me-1"></i>Firmar
<i class="fab fa-whatsapp"></i> WA </button>`;
</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}"> return `<div class="consent-row ${c.estado}" data-consent-id="${c.id}">
<i class="fas ${ico}"></i> <i class="fas ${ico}"></i>
<span class="nom-form">${escHtml(c.formulario_nombre || 'Consentimiento')}</span> <span class="nom-form">${escHtml(c.formulario_nombre || 'Consentimiento')}</span>
<span class="c-badge">${label}</span> <span class="c-badge">${label}</span>
<div class="acciones-consent">${btnFirmar}${btnWa}</div> <div class="acciones-consent">${btnAccion}${btnVer}</div>
</div>`; </div>`;
}).join(''); }).join('');
} }
@@ -818,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() { function resetFicha() {
clearInterval(pollingConsentId); clearInterval(pollingConsentId);
turnoActivo = null; turnoActivo = null;
tieneConsent = false; tieneConsent = false;
hayPendientes = false; hayPendientes = false;
_consentTokenCache = null;
cerrarModalConsentimiento();
document.getElementById('ficha-turno').classList.add('d-none'); document.getElementById('ficha-turno').classList.add('d-none');
document.getElementById('ficha-placeholder').classList.remove('d-none'); document.getElementById('ficha-placeholder').classList.remove('d-none');
+109 -25
View File
@@ -404,8 +404,12 @@ document.addEventListener('DOMContentLoaded', function() {
<!-- Si el kiosko capturó un nombre --> <!-- Si el kiosko capturó un nombre -->
<div id="bloque-nombre-kiosko" class="d-none mb-2"> <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 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>
<div id="bloque-pac-no-vinculado"> <div id="bloque-pac-no-vinculado">
@@ -438,16 +442,6 @@ document.addEventListener('DOMContentLoaded', function() {
</div> </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>
<!-- Historial colapsable del paciente --> <!-- Historial colapsable del paciente -->
<div id="sec-historial-pac" class="d-none"> <div id="sec-historial-pac" class="d-none">
<button class="hist-toggle-btn" id="btn-hist-toggle" onclick="toggleHistorialPaciente()"> <button class="hist-toggle-btn" id="btn-hist-toggle" onclick="toggleHistorialPaciente()">
@@ -463,6 +457,37 @@ document.addEventListener('DOMContentLoaded', function() {
</div> </div>
</div> </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>
</div> </div>
@@ -1017,10 +1042,11 @@ function abrirFicha(turno) {
document.getElementById('badge-turno-activo').classList.remove('d-none'); document.getElementById('badge-turno-activo').classList.remove('d-none');
document.getElementById('badge-codigo').textContent = turno.codigo; document.getElementById('badge-codigo').textContent = turno.codigo;
// Nombre del kiosko // Nombre del kiosko (valor capturado en kiosko)
if (turno.paciente_nombre) { if (turno.paciente_nombre) {
document.getElementById('bloque-nombre-kiosko').classList.remove('d-none'); document.getElementById('bloque-nombre-kiosko').classList.remove('d-none');
document.getElementById('lbl-nombre-kiosko').textContent = turno.paciente_nombre; 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; document.getElementById('inp-buscar-pac').value = turno.paciente_nombre;
} }
@@ -1133,13 +1159,18 @@ function seleccionarPaciente(pac) {
} }
document.getElementById('bloque-pac-no-vinculado').classList.add('d-none'); document.getElementById('bloque-pac-no-vinculado').classList.add('d-none');
document.getElementById('bloque-pac-seleccionado').classList.remove('d-none'); document.getElementById('bloque-pac-seleccionado').classList.remove('d-none');
document.getElementById('lbl-pac-nombre').textContent = const pacNombre = (pac.full_name || pac.nombre_completo || (pac.nombre||'') + ' ' + (pac.apellido||'')).trim();
(pac.full_name || (pac.nombre||'') + ' ' + (pac.apellido||'')).trim(); document.getElementById('lbl-pac-nombre').textContent = pacNombre;
document.getElementById('lbl-pac-doc').textContent = document.getElementById('lbl-pac-doc').textContent =
(pac.tipo_documento||'') + ' ' + (pac.documento||pac.numero_documento||''); (pac.tipo_documento||'') + ' ' + (pac.documento||pac.numero_documento||'');
document.getElementById('lbl-pac-cel').textContent = document.getElementById('lbl-pac-cel').textContent =
pac.telefono || pac.celular || ''; pac.telefono || pac.celular || '';
document.getElementById('lista-pacientes-res').innerHTML = ''; 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 // Mostrar historial del paciente
const secHist = document.getElementById('sec-historial-pac'); const secHist = document.getElementById('sec-historial-pac');
@@ -1179,6 +1210,7 @@ function desvincularPaciente() {
_historialPacienteCargado = false; _historialPacienteCargado = false;
document.getElementById('bloque-pac-no-vinculado').classList.remove('d-none'); document.getElementById('bloque-pac-no-vinculado').classList.remove('d-none');
document.getElementById('bloque-pac-seleccionado').classList.add('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('sec-historial-pac').classList.add('d-none');
document.getElementById('hist-pac-body').classList.add('d-none'); document.getElementById('hist-pac-body').classList.add('d-none');
document.getElementById('btn-hist-toggle').classList.remove('open'); document.getElementById('btn-hist-toggle').classList.remove('open');
@@ -1212,7 +1244,7 @@ async function cargarHistorialPaciente(pacienteId) {
const content = document.getElementById('hist-pac-content'); 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>'; 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 { 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(); const json = await res.json();
if (!json.ok || !json.turnos || !json.turnos.length) { 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>'; content.innerHTML = '<div class="text-center text-muted py-3 small"><i class="fas fa-inbox me-1"></i>Sin visitas anteriores registradas</div>';
@@ -1293,14 +1325,16 @@ async function guardarSolicitud() {
const lugarId = parseInt(document.getElementById('sel-lugar').value); const lugarId = parseInt(document.getElementById('sel-lugar').value);
if (!lugarId) { mostrarError('Seleccione el lugar destino.'); return; } 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; 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)); const examIds = Array.from(document.querySelectorAll('.exam-chk:checked')).map(c => parseInt(c.value));
if (!soloMuestras && !examIds.length) { mostrarError('Seleccione al menos un examen.'); return; } if (!soloMuestras && !examIds.length) { mostrarError('Seleccione al menos un examen.'); return; }
@@ -1328,6 +1362,7 @@ async function guardarSolicitud() {
observaciones: document.getElementById('inp-obs').value.trim() || null, observaciones: document.getElementById('inp-obs').value.trim() || null,
embarazada: document.getElementById('chk-embarazada').checked ? 1 : 0, embarazada: document.getElementById('chk-embarazada').checked ? 1 : 0,
solo_muestras: soloMuestras ? 1 : 0, solo_muestras: soloMuestras ? 1 : 0,
medico_id: parseInt(document.getElementById('inp-medico-id').value) || null,
}), }),
}); });
const json = await res.json(); const json = await res.json();
@@ -1406,9 +1441,9 @@ function renderConsentimientos(lista) {
<i class="fab fa-whatsapp"></i> WhatsApp <i class="fab fa-whatsapp"></i> WhatsApp
</button>`; </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 = ''; 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; const tienePro = c.tiene_firma_profesional == 1 || c.tiene_firma_profesional === true;
if (tienePro) { if (tienePro) {
profRow = `<div class="consent-prof-row"> profRow = `<div class="consent-prof-row">
@@ -1546,7 +1581,7 @@ async function refrescarConsentimientos(turnoId) {
// ── Enviar consentimientos (todos) ─────────────────────────── // ── Enviar consentimientos (todos) ───────────────────────────
async function enviarConsentimientosTodos() { async function enviarConsentimientosTodos() {
if (!turnoActivo || !solicitudActiva) return; if (!turnoActivo) return;
const btn = document.getElementById('btn-reenviar-consent'); const btn = document.getElementById('btn-reenviar-consent');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Enviando…'; btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Enviando…';
@@ -1672,6 +1707,53 @@ async function soltarTurno() {
} }
// ── Helpers ─────────────────────────────────────────────────── // ── 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() { function toggleSoloMuestras() {
const solo = document.getElementById('chk-solo-muestras').checked; const solo = document.getElementById('chk-solo-muestras').checked;
document.querySelectorAll('.exam-chk').forEach(c => { c.checked = false; c.disabled = solo; }); document.querySelectorAll('.exam-chk').forEach(c => { c.checked = false; c.disabled = solo; });
@@ -1686,6 +1768,7 @@ function resetCheckboxes() {
document.getElementById('inp-total').value = ''; document.getElementById('inp-total').value = '';
document.getElementById('inp-obs').value = ''; document.getElementById('inp-obs').value = '';
resetPagoCombinado(); resetPagoCombinado();
resetMedico();
} }
function toggleTodosExamenes(val) { function toggleTodosExamenes(val) {
document.querySelectorAll('.exam-chk').forEach(c => c.checked = val); document.querySelectorAll('.exam-chk').forEach(c => c.checked = val);
@@ -1696,6 +1779,7 @@ function escHtml(str) {
d.appendChild(document.createTextNode(String(str))); d.appendChild(document.createTextNode(String(str)));
return d.innerHTML; return d.innerHTML;
} }
const esc = escHtml;
function resetPlaceholder() { function resetPlaceholder() {
const ph = document.getElementById('ficha-placeholder'); const ph = document.getElementById('ficha-placeholder');
+21 -11
View File
@@ -22,14 +22,23 @@ class WhatsAppService
private $db; private $db;
private $rateLimitMonitor; private $rateLimitMonitor;
public function __construct() public function __construct($canal = null)
{ {
// Obtener configuración desde base de datos // Obtener configuración desde base de datos
$config = getWhatsAppConfigFromDB(); $config = getWhatsAppConfigFromDB();
$this->token = $config['token']; // Usar token de BD $this->token = $config['token'];
$this->phoneNumberId = $config['phone_number_id']; // Usar phone_number_id de BD $this->phoneNumberId = $config['phone_number_id'];
$this->apiUrl = $config['api_url'] ?: 'https://graph.facebook.com/v22.0/'; // Usar api_url de BD $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(); $this->db = Database::getInstance();
// Inicializar monitor de rate limit // Inicializar monitor de rate limit
@@ -698,7 +707,7 @@ class WhatsAppService
* @param bool $skipAutoSave Omitir guardado automático * @param bool $skipAutoSave Omitir guardado automático
* @param bool $isVoice Para audio: true = nota de voz con onda verde, false = audio normal * @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 = [ $data = [
'messaging_product' => 'whatsapp', 'messaging_product' => 'whatsapp',
@@ -716,17 +725,16 @@ class WhatsAppService
$mediaData['filename'] = $filename; $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) { if ($mediaType === 'audio' && $isVoice) {
error_log('sendMediaById: is_voice=true, pero ptt no soportado en Cloud API. Audio se enviará como archivo.'); error_log('sendMediaById: is_voice=true, pero ptt no soportado en Cloud API. Audio se enviará como archivo.');
} }
$data[$mediaType] = $mediaData; $data[$mediaType] = $mediaData;
// Marcar para evitar guardado automático si se solicita if (!empty($meta) && is_array($meta)) {
$data['__app_meta'] = $meta;
}
if ($skipAutoSave) { if ($skipAutoSave) {
$data['__skip_auto_save'] = true; $data['__skip_auto_save'] = true;
} }
@@ -890,6 +898,7 @@ class WhatsAppService
try { try {
$user = $this->getUserByPhone($data['to']); $user = $this->getUserByPhone($data['to']);
if ($user) { if ($user) {
$appMeta = $data['__app_meta'] ?? [];
$messageData = [ $messageData = [
'user_id' => $user['id'], 'user_id' => $user['id'],
'message_id' => $response['conversations'][0]['id'], 'message_id' => $response['conversations'][0]['id'],
@@ -897,7 +906,8 @@ class WhatsAppService
'message_type' => $data['type'], 'message_type' => $data['type'],
'content' => $this->extractMessageContent($data), 'content' => $this->extractMessageContent($data),
'status' => 'sent', '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 // Para mensajes multimedia, guardar el media_id si está disponible
+163 -69
View File
@@ -1,4 +1,5 @@
<?php <?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) * ver_formulario_enviado.php — Ver respuesta de un formulario (imprimible / PDF)
* Acceso admin/enfermero: ver_formulario_enviado.php?id=ENVIO_ID (requiere sesión) * 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) ?? []; $datosPrefilled = json_decode($envio['datos_prefilled'] ?? '{}', true) ?? [];
$todos = array_merge($datosPrefilled, $datosCliente); $todos = array_merge($datosPrefilled, $datosCliente);
$modoEditar = $modoTurnero && $envio['estado'] !== 'firmado'; $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 // Mapa id → label
$labelMap = []; $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; } .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 ───────────────────── */ /* ── Canvas firma profesional ───────────────────── */
.firma-pro-widget { max-width: 520px; margin-top: 8px; } .firma-pro-widget { max-width: 520px; margin-top: 8px; }
.fpw-canvas { border: 2px solid #198754; border-radius: 8px; background: #f8fff9; .fpw-canvas { border: 2px solid #198754; border-radius: 8px; background: #f8fff9;
@@ -456,6 +486,7 @@ function esc2(mixed $v): string {
$_renderedFirmaProfesional = false; $_renderedFirmaProfesional = false;
$_renderedFirmaPaciente = false; $_renderedFirmaPaciente = false;
$_esquemaTieneFirmaPaciente = false; $_esquemaTieneFirmaPaciente = false;
$_esquemaTieneFirmaAlguna = false; // true si hay cualquier campo firma/firma_profesional
$_firmaGlobalPacienteUsada = false; // la firma global solo va al primer campo firma $_firmaGlobalPacienteUsada = false; // la firma global solo va al primer campo firma
foreach ($esquema as $campo): foreach ($esquema as $campo):
@@ -470,6 +501,12 @@ function esc2(mixed $v): string {
if (!$cid) continue; if (!$cid) continue;
$isPro = ($tipo === 'firma_profesional'); $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. // La firma profesional global solo se muestra en el PRIMER campo firma_profesional.
// Campos posteriores (ej. desistimiento) solo muestran su propia firma por campo. // Campos posteriores (ej. desistimiento) solo muestran su propia firma por campo.
$fSvg = $isPro $fSvg = $isPro
@@ -481,13 +518,11 @@ function esc2(mixed $v): string {
$fLabel = htmlspecialchars($campo['label'] ?? ($isPro ? 'Firma profesional' : 'Firma paciente')); $fLabel = htmlspecialchars($campo['label'] ?? ($isPro ? 'Firma profesional' : 'Firma paciente'));
// Campo paciente sin firma: // 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. // - En vista normal: omitir campos sin firma.
if (!$fSvg && !$fFoto && !$isPro) { if (!$fSvg && !$fFoto && !$isPro) {
if ($modoTurnero && $modoEditar && !$_esquemaTieneFirmaPaciente) { if (!($modoTurnero && $modoEditar)) {
$_esquemaTieneFirmaPaciente = true; // solo el primero muestra canvas continue; // no-turnero: omitir campos sin firma
} else {
continue; // campos firma adicionales (desistimiento) sin firma → omitir
} }
} }
@@ -510,30 +545,35 @@ function esc2(mixed $v): string {
<img src="<?= htmlspecialchars($fFoto) ?>" alt="Foto - <?= $fLabel ?>"> <img src="<?= htmlspecialchars($fFoto) ?>" alt="Foto - <?= $fLabel ?>">
</div> </div>
<?php elseif (!$isPro && $modoTurnero && $modoEditar): ?> <?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> <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; style="border:2px solid #1565c0;border-radius:8px;background:#f0f4ff;
cursor:crosshair;display:block;max-width:100%;touch-action:none"></canvas> cursor:crosshair;display:block;max-width:100%;touch-action:none"></canvas>
<div class="mt-2 d-flex gap-2 flex-wrap"> <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 <i class="fas fa-eraser me-1"></i>Limpiar
</button> </button>
<button id="turneroFirmar" class="btn btn-success fw-semibold"> <button class="btn btn-success fw-semibold turnero-firmar">
<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento <i class="fas fa-check-circle me-1"></i>Confirmar: <?= $fLabel ?>
</button> </button>
</div> </div>
<div id="turneroMsg" class="mt-2 small"></div> <div class="turnero-msg mt-2 small"></div>
</div> </div>
<?php elseif ($isPro): ?> <?php elseif ($isPro): ?>
<?php if (!$modoTurnero && !$modoPublico && !$yaHayCanvasPro): ?> <?php
<!-- Canvas del profesional: solo aparece una vez --> // 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) ?>" <div class="firma-pro-widget no-print" id="fpw-<?= htmlspecialchars($cid) ?>"
data-envio="<?= (int)$envio['id'] ?>" data-campo="<?= htmlspecialchars($cid) ?>" data-envio="<?= (int)$envio['id'] ?>" data-campo="<?= htmlspecialchars($cid) ?>"
<?php if ($modoTurnero): ?> data-solo-pro="<?= ($modoTurnero && $_soloFirmaPro) ? '1' : '0' ?>"
data-turno="<?= (int)$tcRow['turno_id'] ?>" data-turno="<?= isset($tcRow) ? (int)$tcRow['turno_id'] : '' ?>"
data-formulario="<?= (int)$tcRow['formulario_id'] ?>" data-formulario="<?= isset($tcRow) ? (int)$tcRow['formulario_id'] : '' ?>">
<?php endif; ?>>
<p class="text-muted small mb-2"><i class="fas fa-pen me-1"></i>Dibuje su firma en el recuadro:</p> <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> <canvas class="fpw-canvas" width="500" height="150"></canvas>
<div class="mt-2 d-flex gap-2"> <div class="mt-2 d-flex gap-2">
@@ -598,7 +638,7 @@ function esc2(mixed $v): string {
</div> </div>
<?php continue; endif; <?php continue; endif;
if ($tipo === 'radio'): if ($tipo === 'radio'):
$opts = $campo['opciones'] ?? []; $opts = $campo['opciones'] ?? $campo['options'] ?? [];
?> ?>
<div class="campo-edit"> <div class="campo-edit">
<label><?= $label ?></label> <label><?= $label ?></label>
@@ -613,7 +653,7 @@ function esc2(mixed $v): string {
</div> </div>
<?php continue; endif; <?php continue; endif;
if ($tipo === 'checkbox' || $tipo === 'lista_marcable'): if ($tipo === 'checkbox' || $tipo === 'lista_marcable'):
$opts = $campo['opciones'] ?? []; $opts = $campo['opciones'] ?? $campo['options'] ?? $campo['items'] ?? [];
$checkedArr = is_array($prefill) ? $prefill $checkedArr = is_array($prefill) ? $prefill
: (is_string($prefill) && $prefill !== '' ? (json_decode($prefill, true) ?: [$prefill]) : []); : (is_string($prefill) && $prefill !== '' ? (json_decode($prefill, true) ?: [$prefill]) : []);
?> ?>
@@ -630,7 +670,7 @@ function esc2(mixed $v): string {
</div> </div>
<?php continue; endif; <?php continue; endif;
if ($tipo === 'select'): if ($tipo === 'select'):
$opts = $campo['opciones'] ?? []; $opts = $campo['opciones'] ?? $campo['options'] ?? [];
?> ?>
<div class="campo-edit"> <div class="campo-edit">
<label><?= $label ?></label> <label><?= $label ?></label>
@@ -642,15 +682,31 @@ function esc2(mixed $v): string {
</select> </select>
</div> </div>
<?php continue; endif; <?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) { $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"> <div class="campo-edit">
<label><?= $label ?></label> <label><?= $label ?></label>
<input type="<?= $inputType ?>" class="form-control form-control-sm" <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> </div>
<?php continue; <?php continue;
endif; // modoEditar endif; // modoEditar
@@ -659,6 +715,16 @@ function esc2(mixed $v): string {
if ($tipo === 'linked') { if ($tipo === 'linked') {
$lk = $campo['linked_key'] ?? ''; $lk = $campo['linked_key'] ?? '';
$valor = $paciente[$lk] ?? $todos[$cid] ?? null; $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 { } else {
$valor = $todos[$cid] ?? null; $valor = $todos[$cid] ?? null;
} }
@@ -734,9 +800,9 @@ function esc2(mixed $v): string {
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- ── Widget firma turnero (fallback si el esquema no tiene campo firma) ── --> <!-- ── Widget firma turnero (fallback solo si el esquema no tiene NINGÚN campo firma) ── -->
<?php if ($modoTurnero && $envio['estado'] !== 'firmado' && !$_esquemaTieneFirmaPaciente): ?> <?php if ($modoTurnero && $envio['estado'] !== 'firmado' && !$_esquemaTieneFirmaAlguna): ?>
<div class="mt-4 no-print" id="turneroFirmaWidget"> <div class="mt-4 no-print turnero-firma-item" data-cid="__firma_global">
<div class="section-title" style="color:#1565c0"> <div class="section-title" style="color:#1565c0">
<i class="fas fa-pen me-1"></i>Firma del paciente / responsable <i class="fas fa-pen me-1"></i>Firma del paciente / responsable
</div> </div>
@@ -744,19 +810,19 @@ function esc2(mixed $v): string {
He leído y comprendido el contenido de este documento. He leído y comprendido el contenido de este documento.
Por favor dibuje su firma en el recuadro: Por favor dibuje su firma en el recuadro:
</p> </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; style="border:2px solid #1565c0;border-radius:8px;background:#f0f4ff;
cursor:crosshair;display:block;max-width:100%;touch-action:none"> cursor:crosshair;display:block;max-width:100%;touch-action:none">
</canvas> </canvas>
<div class="mt-2 d-flex gap-2 flex-wrap"> <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 <i class="fas fa-eraser me-1"></i>Limpiar
</button> </button>
<button id="turneroFirmar" class="btn btn-success fw-semibold"> <button class="btn btn-success fw-semibold turnero-firmar">
<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento <i class="fas fa-check-circle me-1"></i>Confirmar y firmar
</button> </button>
</div> </div>
<div id="turneroMsg" class="mt-2 small"></div> <div class="turnero-msg mt-2 small"></div>
</div> </div>
<?php elseif ($modoTurnero && $envio['estado'] === 'firmado'): ?> <?php elseif ($modoTurnero && $envio['estado'] === 'firmado'): ?>
<div class="alert alert-success mt-4 d-flex align-items-center gap-2 no-print"> <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() { btnSave.addEventListener('click', function() {
const svg = canvas.toDataURL('image/png'); const svg = canvas.toDataURL('image/png');
// Verificar que no esté vacío (al menos 1000 bytes de data)
if (svg.length < 1000) { 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>'; 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; return;
@@ -845,12 +910,32 @@ function esc2(mixed $v): string {
const turnoId = widget.dataset.turno; const turnoId = widget.dataset.turno;
const formularioId = widget.dataset.formulario; const formularioId = widget.dataset.formulario;
const soloPro = widget.dataset.soloPro === '1';
const isTurnero = !!(turnoId && formularioId); const isTurnero = !!(turnoId && formularioId);
const saveUrl = isTurnero const saveUrl = isTurnero
? 'modules/turnero/api/firmar_profesional_consentimiento.php' ? 'modules/turnero/api/firmar_profesional_consentimiento.php'
: 'api/lab/firmar_profesional.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 }; : { envio_id: envioId, campo_id: campoId, svg: svg };
fetch(saveUrl, { fetch(saveUrl, {
@@ -861,18 +946,27 @@ function esc2(mixed $v): string {
.then(function(r){ return r.json(); }) .then(function(r){ return r.json(); })
.then(function(data) { .then(function(data) {
if (data.ok) { if (data.ok) {
// Reemplazar el canvas con la imagen firmada if (soloPro) {
const img = document.createElement('img'); // Modo solo-profesional: mostrar éxito y notificar al padre
img.src = svg; document.querySelectorAll('.firma-pro-widget, .campo-edit, .section-title').forEach(function(el) {
img.alt = 'Firma profesional'; el.style.display = 'none';
img.style.maxHeight = '140px'; });
img.style.maxWidth = '340px'; var ok = document.createElement('div');
img.style.display = 'block'; ok.className = 'alert alert-success mt-4 d-flex align-items-center gap-2 no-print';
const box = document.createElement('div'); ok.innerHTML = '<i class="fas fa-check-circle fs-4"></i><div><strong>Firmado correctamente.</strong><br>Puede cerrar esta ventana.</div>';
box.className = 'firma-box'; widget.parentNode.insertBefore(ok, widget.nextSibling);
box.style.borderColor = '#198754'; widget.style.display = 'none';
box.appendChild(img); try { window.parent.postMessage({ type: 'turneroFirmado' }, '*'); } catch(e) {}
widget.replaceWith(box); } 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 { } else {
msg.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>' + (data.error||'Error al guardar') + '</span>'; msg.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>' + (data.error||'Error al guardar') + '</span>';
btnSave.disabled = false; btnSave.disabled = false;
@@ -888,16 +982,17 @@ function esc2(mixed $v): string {
}); });
})(); })();
/* ── Firma de consentimiento turnero ────────────────────────────────── */ /* ── Firma de consentimiento turnero (soporta múltiples campos firma) ── */
(function () { document.querySelectorAll('.turnero-firma-item').forEach(function(widget) {
var canvas = document.getElementById('turneroCv'); var canvas = widget.querySelector('.turnero-cv');
var widget = document.getElementById('turneroFirmaWidget');
if (!canvas) return; if (!canvas) return;
var cid = widget.dataset.cid || '__firma';
var ctx = canvas.getContext('2d'); var ctx = canvas.getContext('2d');
var btnLimp = document.getElementById('turneroLimpiar'); var btnLimp = widget.querySelector('.turnero-limpiar');
var btnFirm = document.getElementById('turneroFirmar'); var btnFirm = widget.querySelector('.turnero-firmar');
var msgEl = document.getElementById('turneroMsg'); var msgEl = widget.querySelector('.turnero-msg');
var drawing = false; var drawing = false;
var btnLabel = btnFirm ? btnFirm.innerHTML : '';
(function scaleCanvas() { (function scaleCanvas() {
var ratio = window.devicePixelRatio || 1; 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('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; }); canvas.addEventListener('touchend', function(){ drawing=false; });
btnLimp.addEventListener('click', function() { if (btnLimp) btnLimp.addEventListener('click', function() {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
msgEl.textContent = ''; msgEl.textContent = '';
}); });
btnFirm.addEventListener('click', function() { if (btnFirm) btnFirm.addEventListener('click', function() {
var png = canvas.toDataURL('image/png'); var png = canvas.toDataURL('image/png');
if (png.length < 1500) { 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>'; 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...'; btnFirm.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando...';
msgEl.textContent = ''; msgEl.textContent = '';
// Recopilar respuestas de campos interactivos
var campos = {}; var campos = {};
document.querySelectorAll('[name]').forEach(function(el) { document.querySelectorAll('[name]').forEach(function(el) {
var rawName = el.name; var rawName = el.name;
var isArr = rawName.slice(-2) === '[]'; var isArr = rawName.slice(-2) === '[]';
var name = isArr ? rawName.slice(0, -2) : rawName; var name = isArr ? rawName.slice(0, -2) : rawName;
if (el.type === 'checkbox') { if (el.type === 'checkbox') {
if (el.checked) { if (el.checked) { if (!Array.isArray(campos[name])) campos[name] = []; campos[name].push(el.value); }
if (!Array.isArray(campos[name])) campos[name] = [];
campos[name].push(el.value);
}
} else if (el.type === 'radio') { } else if (el.type === 'radio') {
if (el.checked) campos[name] = el.value; if (el.checked) campos[name] = el.value;
} else if (el.value !== '') { } else if (el.value !== '') {
campos[name] = el.value; campos[name] = el.value;
} }
}); });
campos[cid + '_svg'] = png; // identificar qué campo firmó
fetch(window.location.href, { fetch(window.location.href, {
method: 'POST', method: 'POST',
@@ -969,26 +1061,28 @@ function esc2(mixed $v): string {
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (data.ok) { if (data.ok) {
widget.outerHTML = // Ocultar todos los demás widgets de firma (ya no se puede firmar dos veces)
'<div class="alert alert-success mt-4 d-flex align-items-center gap-2 no-print">' + document.querySelectorAll('.turnero-firma-item').forEach(function(w) {
'<i class="fas fa-check-circle fs-4"></i>' + w.style.display = 'none';
'<div><strong>Consentimiento firmado correctamente.</strong><br>' + });
'Puede cerrar esta ventana.</div></div>'; var ok = document.createElement('div');
// Notificar ventana padre si está en iframe/modal 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) {} try { window.parent.postMessage({ type: 'turneroFirmado' }, '*'); } catch(e) {}
} else { } else {
msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>' + (data.error || 'Error al guardar') + '</span>'; msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>' + (data.error || 'Error al guardar') + '</span>';
btnFirm.disabled = false; btnFirm.disabled = false;
btnFirm.innerHTML = '<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento'; btnFirm.innerHTML = btnLabel;
} }
}) })
.catch(function() { .catch(function() {
msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>Error de conexión.</span>'; msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>Error de conexión.</span>';
btnFirm.disabled = false; btnFirm.disabled = false;
btnFirm.innerHTML = '<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento'; btnFirm.innerHTML = btnLabel;
}); });
}); });
})(); });
</script> </script>
</body> </body>
</html> </html>