Compare commits
49
Commits
3b378c0d75
...
master
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
+14
-5
@@ -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,7 +395,8 @@ class WhatsAppWebhook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear notificación para UI (nuevo mensaje entrante)
|
// Notificaciones y SSE solo para el canal principal (no turnero)
|
||||||
|
if (!$isTurnero) {
|
||||||
try {
|
try {
|
||||||
$this->db->insert('notifications', [
|
$this->db->insert('notifications', [
|
||||||
'user_id' => $user['id'],
|
'user_id' => $user['id'],
|
||||||
@@ -403,23 +410,25 @@ class WhatsAppWebhook {
|
|||||||
error_log('Failed to create notification: ' . $e->getMessage());
|
error_log('Failed to create notification: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empujar evento SSE en tiempo real
|
|
||||||
$this->pushSSEEvent('new_message', [
|
$this->pushSSEEvent('new_message', [
|
||||||
'user_id' => $user['id'],
|
'user_id' => $user['id'],
|
||||||
'phone_number' => $user['phone_number'],
|
'phone_number' => $user['phone_number'],
|
||||||
'name' => $user['name'] ?? $from,
|
'name' => $user['name'] ?? ($phoneNumber ?? ''),
|
||||||
'message' => substr($messageText, 0, 250),
|
'message' => substr($messageText, 0, 250),
|
||||||
'message_type' => $messageType,
|
'message_type' => $messageType,
|
||||||
'timestamp' => date('Y-m-d H:i:s')
|
'timestamp' => date('Y-m-d H:i:s')
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Procesar con bot (protección contra excepciones externas)
|
// Procesar con bot solo si el mensaje llegó al número principal
|
||||||
|
if (!$isTurnero) {
|
||||||
try {
|
try {
|
||||||
$this->botService->processMessage($user, $messageText, $messageType);
|
$this->botService->processMessage($user, $messageText, $messageType);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Bot processing failed: " . $e->getMessage());
|
error_log("Bot processing failed: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Procesar estados de mensajes (entregado, leído, etc.)
|
// Procesar estados de mensajes (entregado, leído, etc.)
|
||||||
if (isset($value['statuses'])) {
|
if (isset($value['statuses'])) {
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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'
|
||||||
|
|||||||
+205
-19
@@ -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,26 +697,136 @@ 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
// 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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
zone.appendChild(div);
|
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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'];
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -64,12 +64,62 @@ 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)) {
|
||||||
|
$roleId = $_SESSION['admin_user']['role_id'] ?? null;
|
||||||
|
if (!empty($roleId)) {
|
||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422);
|
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
|
||||||
$permitidos = TRANSICIONES[$estadoActual] ?? [];
|
$permitidos = TRANSICIONES[$estadoActual] ?? [];
|
||||||
if (!in_array($nuevoEstado, $permitidos, true)) {
|
if (!in_array($nuevoEstado, $permitidos, true)) {
|
||||||
|
|||||||
@@ -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()]);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
$type = $input['type'] ?? 'text';
|
||||||
$message = trim($input['message'] ?? '');
|
$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');
|
||||||
$response = $wa->sendTextMessage(
|
|
||||||
$user['phone_number'],
|
if ($type === 'template') {
|
||||||
$message,
|
$response = $wa->sendTemplateMessage($user['phone_number'], $template, $lang, $params);
|
||||||
$operatorId ? ['operator_id' => $operatorId, 'canal' => 'turnero'] : ['canal' => 'turnero']
|
} else {
|
||||||
);
|
$extra = ['canal' => 'turnero'];
|
||||||
|
if ($operatorId) $extra['operator_id'] = $operatorId;
|
||||||
|
$response = $wa->sendTextMessage($user['phone_number'], $message, $extra);
|
||||||
|
}
|
||||||
|
|
||||||
echo json_encode(['success' => true, 'whatsapp_response' => $response]);
|
echo json_encode(['success' => true, 'whatsapp_response' => $response]);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,7 +94,8 @@ 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)
|
||||||
|
if (!empty($examIds)) {
|
||||||
$in = implode(',', array_fill(0, count($examIds), '?'));
|
$in = implode(',', array_fill(0, count($examIds), '?'));
|
||||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM exam_tipos WHERE id IN ({$in}) AND activo = 1");
|
$stmt = $pdo->prepare("SELECT COUNT(*) FROM exam_tipos WHERE id IN ({$in}) AND activo = 1");
|
||||||
$stmt->execute($examIds);
|
$stmt->execute($examIds);
|
||||||
@@ -101,6 +103,7 @@ try {
|
|||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
jsonError('Uno o más exámenes no existen o están inactivos.', 422);
|
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) ──
|
||||||
$stmt = $pdo->prepare("SELECT id FROM turnero_solicitudes WHERE turno_id = ?");
|
$stmt = $pdo->prepare("SELECT id FROM turnero_solicitudes WHERE turno_id = ?");
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
// WhatsAppService::formatPhoneNumber maneja el formato colombiano correctamente
|
||||||
|
$cel = preg_replace('/[^0-9]/', '', $pacienteTel);
|
||||||
|
|
||||||
|
// Marcar como enviado si vamos a mandar WA
|
||||||
|
if ($enlaceConsentimiento) {
|
||||||
try {
|
try {
|
||||||
$cfgWA = [];
|
$pdo->prepare(
|
||||||
$stmtCfg = $pdo->prepare(
|
"UPDATE turnero_consentimientos SET estado='enviado', enviado_at=NOW()
|
||||||
"SELECT clave, valor FROM lab_config WHERE clave IN ('turnero_wa_template','turnero_wa_template_muestra','turnero_wa_lang')"
|
WHERE turno_id=? AND estado='pendiente'"
|
||||||
);
|
)->execute([$turnoId]);
|
||||||
$stmtCfg->execute();
|
} catch (\Throwable $_) {}
|
||||||
$cfgWA = $stmtCfg->fetchAll(PDO::FETCH_KEY_PAIR);
|
|
||||||
// Tipo F = solo muestra pendiente, sin consentimiento → plantilla diferente
|
|
||||||
$esSoloMuestra = ($prioCodigo === 'F');
|
|
||||||
$waTemplate = $esSoloMuestra
|
|
||||||
? ($cfgWA['turnero_wa_template_muestra'] ?? 'turno_muestra_pendiente')
|
|
||||||
: ($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) {
|
||||||
|
// El profesional es el firmante final: guardar datos + marcar como firmado
|
||||||
|
$pdo->prepare(
|
||||||
|
"UPDATE turnero_consentimientos
|
||||||
|
SET firma_profesional_svg = ?, firmado_profesional_at = NOW(),
|
||||||
|
estado = 'firmado', firmado_at = NOW(),
|
||||||
|
datos_respuestas = COALESCE(?, datos_respuestas)
|
||||||
|
WHERE turno_id = ? AND formulario_id = ?"
|
||||||
|
)->execute([$svg, $datosResp, $turnoId, $formularioId]);
|
||||||
|
} else {
|
||||||
|
$pdo->prepare(
|
||||||
"UPDATE turnero_consentimientos
|
"UPDATE turnero_consentimientos
|
||||||
SET firma_profesional_svg = ?, firmado_profesional_at = NOW()
|
SET firma_profesional_svg = ?, firmado_profesional_at = NOW()
|
||||||
WHERE turno_id = ? AND formulario_id = ?"
|
WHERE turno_id = ? AND formulario_id = ?"
|
||||||
);
|
)->execute([$svg, $turnoId, $formularioId]);
|
||||||
$stmt->execute([$svg, $turnoId, $formularioId]);
|
}
|
||||||
|
|
||||||
notificarSSE((int)$tc['sesion_id']);
|
notificarSSE((int)$tc['sesion_id']);
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.');
|
||||||
+411
-17
@@ -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) {}
|
||||||
}
|
}
|
||||||
@@ -479,30 +627,53 @@ 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, {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
+142
-18
@@ -17,7 +17,7 @@ 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 = [];
|
||||||
@@ -25,11 +25,13 @@ try {
|
|||||||
|
|
||||||
$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 ─────────────────────────────────────────────
|
||||||
@@ -386,6 +427,7 @@ 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,'"')})">
|
|
||||||
<i class="fas fa-signature"></i> Firmar
|
|
||||||
</button>` : '';
|
|
||||||
|
|
||||||
// WA / Ver: según estado
|
|
||||||
const btnWa = ya
|
|
||||||
? (c.token ? `<a href="${BASE_WA}ver_formulario_enviado.php?token=${token}" target="_blank"
|
|
||||||
class="btn btn-outline-secondary" title="Ver firmado">
|
class="btn btn-outline-secondary" title="Ver firmado">
|
||||||
<i class="fas fa-eye"></i> Ver
|
<i class="fas fa-eye"></i> Ver
|
||||||
</a>` : '')
|
</a>` : '';
|
||||||
: `<button class="btn btn-outline-success" title="Reenviar por WhatsApp"
|
|
||||||
|
// Acción principal según modo y estado
|
||||||
|
let btnAccion = '';
|
||||||
|
if (!ya && c.token) {
|
||||||
|
if (LUGAR_FORM_MODO === 'embebido') {
|
||||||
|
// Modo embebido: abrir modal directamente
|
||||||
|
btnAccion = `<button class="btn btn-primary" title="Abrir formulario"
|
||||||
|
onclick="abrirModalConsentimientoPorToken('${token}')">
|
||||||
|
<i class="fas fa-pen me-1"></i>Firmar
|
||||||
|
</button>`;
|
||||||
|
} else {
|
||||||
|
// Modo link: firmar presencial o reenviar WA
|
||||||
|
btnAccion = `<button class="btn btn-outline-primary" title="Firmar aquí"
|
||||||
|
onclick="abrirFirmaPresencial('${token}', ${idJs}, ${nomJs.replace(/"/g,'"')})">
|
||||||
|
<i class="fas fa-signature"></i> Firmar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-success" title="Reenviar por WhatsApp"
|
||||||
onclick="reenviarConsentimiento(${tId})">
|
onclick="reenviarConsentimiento(${tId})">
|
||||||
<i class="fab fa-whatsapp"></i> WA
|
<i class="fab fa-whatsapp"></i> WA
|
||||||
</button>`;
|
</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');
|
||||||
|
|||||||
@@ -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 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;
|
const consentPendientes = Array.from(document.querySelectorAll('.consent-item.pendiente, .consent-item.enviado, .consent-item.visto')).length;
|
||||||
if (consentPendientes > 0) {
|
if (consentPendientes > 0) {
|
||||||
mostrarError('⚠️ El paciente debe firmar los consentimientos del lugar antes de guardar la solicitud.');
|
mostrarError('⚠️ El paciente debe firmar los consentimientos del lugar antes de guardar la solicitud.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const soloMuestras = document.getElementById('chk-solo-muestras').checked;
|
|
||||||
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');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+160
-66
@@ -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';
|
||||||
|
|
||||||
|
// 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
|
const savePayload = isTurnero
|
||||||
? { turno_id: parseInt(turnoId), formulario_id: parseInt(formularioId), svg: svg }
|
? { 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';
|
||||||
|
try { window.parent.postMessage({ type: 'turneroFirmado' }, '*'); } catch(e) {}
|
||||||
|
} else {
|
||||||
|
// Reemplazar canvas con imagen firmada
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.src = svg; img.alt = 'Firma profesional';
|
||||||
|
img.style.maxHeight = '140px'; img.style.maxWidth = '340px'; img.style.display = 'block';
|
||||||
|
var box = document.createElement('div');
|
||||||
|
box.className = 'firma-box'; box.style.borderColor = '#198754';
|
||||||
box.appendChild(img);
|
box.appendChild(img);
|
||||||
widget.replaceWith(box);
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user