Compare commits
72
Commits
6adbcc3059
...
339ca8a40d
@@ -64,24 +64,27 @@ try {
|
||||
|
||||
$sql .= "
|
||||
FROM users u
|
||||
LEFT JOIN conversations c ON c.user_id = u.id
|
||||
LEFT JOIN conversations c ON c.user_id = u.id AND (c.canal IS NULL OR c.canal = 'bot')
|
||||
LEFT JOIN (
|
||||
SELECT t1.* FROM conversations t1
|
||||
JOIN (
|
||||
SELECT user_id, MAX(created_at) AS last_time FROM conversations GROUP BY user_id
|
||||
SELECT user_id, MAX(created_at) AS last_time FROM conversations
|
||||
WHERE (canal IS NULL OR canal = 'bot') GROUP BY user_id
|
||||
) t2 ON t1.user_id = t2.user_id AND t1.created_at = t2.last_time
|
||||
WHERE (t1.canal IS NULL OR t1.canal = 'bot')
|
||||
) lm ON lm.user_id = u.id";
|
||||
|
||||
|
||||
// Agregar condición de búsqueda si existe
|
||||
if (!empty($search)) {
|
||||
$sql .= "
|
||||
WHERE (
|
||||
u.name LIKE ?
|
||||
OR u.phone_number LIKE ?
|
||||
u.name LIKE ?
|
||||
OR u.phone_number LIKE ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM conversations c2
|
||||
WHERE c2.user_id = u.id
|
||||
SELECT 1 FROM conversations c2
|
||||
WHERE c2.user_id = u.id
|
||||
AND c2.content LIKE ?
|
||||
AND (c2.canal IS NULL OR c2.canal = 'bot')
|
||||
)
|
||||
)";
|
||||
}
|
||||
@@ -147,9 +150,9 @@ try {
|
||||
} else {
|
||||
// Conteo normal sin búsqueda
|
||||
if ($filter === 'unread') {
|
||||
$totalRow = $db->fetch("SELECT COUNT(DISTINCT user_id) AS count FROM conversations c WHERE c.direction = 'incoming' AND (c.is_read IS NULL OR c.is_read = 0)");
|
||||
$totalRow = $db->fetch("SELECT COUNT(DISTINCT user_id) AS count FROM conversations c WHERE c.direction = 'incoming' AND (c.is_read IS NULL OR c.is_read = 0) AND (c.canal IS NULL OR c.canal = 'bot')");
|
||||
} else {
|
||||
$totalRow = $db->fetch("SELECT COUNT(DISTINCT user_id) AS count FROM conversations");
|
||||
$totalRow = $db->fetch("SELECT COUNT(DISTINCT user_id) AS count FROM conversations WHERE (canal IS NULL OR canal = 'bot')");
|
||||
}
|
||||
}
|
||||
$total = isset($totalRow['count']) ? intval($totalRow['count']) : 0;
|
||||
|
||||
@@ -27,7 +27,7 @@ try {
|
||||
u.name
|
||||
FROM conversations c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.content IS NOT NULL AND c.content != ''
|
||||
WHERE c.content IS NOT NULL AND c.content != '' AND (c.canal IS NULL OR c.canal = 'bot')
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT 10"
|
||||
);
|
||||
|
||||
@@ -74,9 +74,9 @@ try {
|
||||
c.reply_to_message_id as reply_to_message_id,
|
||||
c.reaction_emoji as reaction_emoji,
|
||||
c.reaction_to_message_id as reaction_to_message_id
|
||||
FROM conversations c
|
||||
FROM conversations c
|
||||
LEFT JOIN users u ON c.user_id = u.id
|
||||
WHERE c.user_id = :user_id";
|
||||
WHERE c.user_id = :user_id AND (c.canal IS NULL OR c.canal = 'bot')";
|
||||
|
||||
// Soporte para paginación estable: before (timestamp) y before_id (id del mensaje más antiguo del bloque)
|
||||
$beforeId = isset($_GET['before_id']) ? intval($_GET['before_id']) : null;
|
||||
@@ -157,8 +157,8 @@ try {
|
||||
mime_type,
|
||||
status,
|
||||
created_at
|
||||
FROM conversations
|
||||
WHERE user_id = :user_id
|
||||
FROM conversations
|
||||
WHERE user_id = :user_id AND (canal IS NULL OR canal = 'bot')
|
||||
ORDER BY created_at ASC",
|
||||
['user_id' => $userId]
|
||||
);
|
||||
|
||||
@@ -22,6 +22,21 @@ try {
|
||||
jsonOk(['id' => (int)$datos['id']], 'Formulario eliminado');
|
||||
}
|
||||
|
||||
// Clonar
|
||||
if (!empty($datos['id']) && !empty($datos['clonar'])) {
|
||||
$original = $form->obtener((int)$datos['id']);
|
||||
if (!$original) jsonError('Formulario no encontrado', 404);
|
||||
$nuevoId = $form->crear([
|
||||
'nombre' => 'Copia de ' . $original['nombre'],
|
||||
'descripcion' => $original['descripcion'] ?? null,
|
||||
'categoria' => $original['categoria'] ?? 'otro',
|
||||
'esquema' => $original['esquema'] ?? '[]',
|
||||
'permite_firma' => $original['permite_firma'] ?? 0,
|
||||
'requiere_firma' => $original['requiere_firma'] ?? 0,
|
||||
], $admin);
|
||||
jsonOk(['id' => $nuevoId], 'Formulario clonado');
|
||||
}
|
||||
|
||||
// Editar
|
||||
if (!empty($datos['id'])) {
|
||||
$id = (int)$datos['id'];
|
||||
|
||||
@@ -71,7 +71,7 @@ try {
|
||||
|
||||
// Enviar SMS
|
||||
$numero = preg_replace('/\D/', '', $pac['telefono']);
|
||||
$mensaje = "Tu código de verificación de Laboratorio es: {$codigo}. Válido por 5 minutos.";
|
||||
$mensaje = "{$codigo} es tu codigo de verificacion, Laboratorio Ximena Caicedo";
|
||||
$payload = json_encode(['numero' => $numero, 'mensaje' => $mensaje], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ch = curl_init($smsUrl . '/api/sms/send');
|
||||
|
||||
+34
-25
@@ -98,6 +98,11 @@ class WhatsAppWebhook {
|
||||
}
|
||||
|
||||
private function processconversations($value) {
|
||||
// Detectar si este mensaje llegó al número turnero
|
||||
$receivingPhoneId = $value['metadata']['phone_number_id'] ?? null;
|
||||
$turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
|
||||
$isTurnero = $receivingPhoneId && $turneroPhoneId && ($receivingPhoneId === $turneroPhoneId);
|
||||
|
||||
// Aceptar tanto payloads con 'conversations' como con 'messages' (WhatsApp varía según la integración)
|
||||
$items = [];
|
||||
if (isset($value['conversations']) && is_array($value['conversations'])) {
|
||||
@@ -299,7 +304,8 @@ class WhatsAppWebhook {
|
||||
'content' => $messageText,
|
||||
'media_url' => $mediaUrl,
|
||||
'status' => 'received',
|
||||
'is_read' => 0
|
||||
'is_read' => 0,
|
||||
'canal' => $isTurnero ? 'turnero' : 'bot',
|
||||
];
|
||||
|
||||
// Guardar whatsapp_media_id si el mediaUrl es un ID numérico de WhatsApp
|
||||
@@ -389,35 +395,38 @@ class WhatsAppWebhook {
|
||||
}
|
||||
}
|
||||
|
||||
// Crear notificación para UI (nuevo mensaje entrante)
|
||||
try {
|
||||
$this->db->insert('notifications', [
|
||||
// Notificaciones y SSE solo para el canal principal (no turnero)
|
||||
if (!$isTurnero) {
|
||||
try {
|
||||
$this->db->insert('notifications', [
|
||||
'user_id' => $user['id'],
|
||||
'type' => 'incoming_message',
|
||||
'message' => substr($messageText, 0, 250),
|
||||
'data' => json_encode(['message_id' => $messageId]),
|
||||
'is_read' => 0,
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
error_log('Failed to create notification: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$this->pushSSEEvent('new_message', [
|
||||
'user_id' => $user['id'],
|
||||
'type' => 'incoming_message',
|
||||
'phone_number' => $user['phone_number'],
|
||||
'name' => $user['name'] ?? ($phoneNumber ?? ''),
|
||||
'message' => substr($messageText, 0, 250),
|
||||
'data' => json_encode(['message_id' => $messageId]),
|
||||
'is_read' => 0,
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
'message_type' => $messageType,
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
error_log('Failed to create notification: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Empujar evento SSE en tiempo real
|
||||
$this->pushSSEEvent('new_message', [
|
||||
'user_id' => $user['id'],
|
||||
'phone_number' => $user['phone_number'],
|
||||
'name' => $user['name'] ?? $from,
|
||||
'message' => substr($messageText, 0, 250),
|
||||
'message_type' => $messageType,
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
// Procesar con bot (protección contra excepciones externas)
|
||||
try {
|
||||
$this->botService->processMessage($user, $messageText, $messageType);
|
||||
} catch (Exception $e) {
|
||||
error_log("Bot processing failed: " . $e->getMessage());
|
||||
// Procesar con bot solo si el mensaje llegó al número principal
|
||||
if (!$isTurnero) {
|
||||
try {
|
||||
$this->botService->processMessage($user, $messageText, $messageType);
|
||||
} catch (Exception $e) {
|
||||
error_log("Bot processing failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,11 +133,12 @@ body {
|
||||
|
||||
.sidebar-menu .nav-link.active {
|
||||
color: var(--cat-color, var(--brand-dark, #0d47a1));
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--cat-color, #fff) 10%, #fff);
|
||||
font-weight: 700;
|
||||
font-size: 0.93rem;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||
transform: translateX(3px);
|
||||
border-left: 3px solid var(--cat-color, var(--brand, #1565c0));
|
||||
transform: translateX(4px);
|
||||
border-left: 4px solid var(--cat-color, var(--brand, #1565c0));
|
||||
}
|
||||
|
||||
.sidebar-menu .nav-link i {
|
||||
@@ -152,11 +153,11 @@ body {
|
||||
.sidebar-menu li.sidebar-section {
|
||||
border-bottom: none;
|
||||
padding: 10px 30px 6px;
|
||||
font-size: 0.65rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
@@ -173,6 +174,8 @@ body {
|
||||
border-left-color: var(--cat-color, rgba(255,255,255,.7));
|
||||
background: rgba(255,255,255,.1);
|
||||
color: #fff;
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.sidebar-menu li.sidebar-section .sb-cat-label { display:inline-flex; align-items:center; gap:4px; }
|
||||
.sidebar-menu li.sidebar-section::after { display: none; }
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
$token = trim($_GET['t'] ?? '');
|
||||
// Si el token es un UUID (viene del turnero vía plantilla WA), redirigir al flujo de consentimiento
|
||||
if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $token)) {
|
||||
header('Location: ver_formulario_enviado.php?token=' . urlencode($token));
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
@@ -550,6 +555,35 @@ function renderCampos(esquema, prefilled) {
|
||||
<label class="form-check-label">${esc(o)}</label>
|
||||
</div>`).join('');
|
||||
html += wrapOpen + `<div class="mb-3">${lbl}${opts}${linkedNote}</div>` + wrapClose;
|
||||
} else if (c.tipo === 'fecha_hoy') {
|
||||
let calcVal = pad[c.id] || '';
|
||||
if (!calcVal) {
|
||||
const now = new Date();
|
||||
calcVal = now.getFullYear() + '-'
|
||||
+ String(now.getMonth()+1).padStart(2,'0') + '-'
|
||||
+ String(now.getDate()).padStart(2,'0');
|
||||
}
|
||||
html += wrapOpen + `<div class="mb-3">${lbl}
|
||||
<input type="date" class="form-control" style="background:#fff9e6;border-color:#ffc107"
|
||||
name="${c.id}" value="${esc(calcVal)}" ${req}>
|
||||
</div>` + wrapClose;
|
||||
} else if (c.tipo === 'edad') {
|
||||
let calcVal = pad[c.id] || '';
|
||||
if (!calcVal) {
|
||||
const fnac = pad['__paciente']?.fecha_nacimiento || pad.fecha_nacimiento || '';
|
||||
if (fnac) {
|
||||
const hoy = new Date();
|
||||
const bday = new Date(fnac);
|
||||
let edad = hoy.getFullYear() - bday.getFullYear();
|
||||
const m = hoy.getMonth() - bday.getMonth();
|
||||
if (m < 0 || (m === 0 && hoy.getDate() < bday.getDate())) edad--;
|
||||
calcVal = edad >= 0 ? String(edad) : '';
|
||||
}
|
||||
}
|
||||
html += wrapOpen + `<div class="mb-3">${lbl}
|
||||
<input type="number" min="0" max="120" class="form-control" style="background:#fff9e6;border-color:#ffc107"
|
||||
name="${c.id}" value="${esc(calcVal)}" ${req} placeholder="Edad en años">
|
||||
</div>` + wrapClose;
|
||||
} else {
|
||||
const t = c.tipo === 'numero' ? 'number'
|
||||
: (c.tipo === 'fecha' || c.linked_key === 'fecha_nacimiento') ? 'date'
|
||||
|
||||
+204
-18
@@ -215,10 +215,47 @@ $formId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
::-webkit-scrollbar-thumb { background: #c1c9d4; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #8fa0b5; }
|
||||
|
||||
/* ── Drop indicators (touch drag) ─────────────────────── */
|
||||
.field-item.touch-dragging { opacity: .3; }
|
||||
.field-item.drop-above::before {
|
||||
content: ''; position: absolute; top: -4px; left: 0; right: 0;
|
||||
height: 3px; background: #1565c0; border-radius: 2px; pointer-events: none;
|
||||
}
|
||||
.field-item.drop-below::after {
|
||||
content: ''; position: absolute; bottom: -4px; left: 0; right: 0;
|
||||
height: 3px; background: #1565c0; border-radius: 2px; pointer-events: none;
|
||||
}
|
||||
#drag-ghost {
|
||||
position: fixed; pointer-events: none; z-index: 9999;
|
||||
background: #fff; border: 1.5px solid #1565c0; border-radius: 8px;
|
||||
box-shadow: 0 8px 28px rgba(21,101,192,.25);
|
||||
padding: 9px 12px; display: flex; align-items: center; gap: 8px;
|
||||
font-size: 12.5px; font-weight: 600; color: #1565c0;
|
||||
opacity: .92; transition: none;
|
||||
}
|
||||
|
||||
/* ── Palette tap hint ──────────────────────────────────── */
|
||||
.palette-item { touch-action: none; }
|
||||
@media (pointer: coarse) {
|
||||
.palette-item::after {
|
||||
content: '+'; margin-left: auto; font-size: 15px;
|
||||
font-weight: 700; color: #1565c0; opacity: .5;
|
||||
}
|
||||
.field-item .fi-handle {
|
||||
font-size: 18px; padding: 4px 6px;
|
||||
color: #90a4ae;
|
||||
}
|
||||
.field-item .fi-btn { padding: 6px 8px; font-size: 14px; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#panel-palette { width: 170px; min-width: 170px; }
|
||||
#panel-preview { width: 260px; min-width: 260px; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
#panel-preview { display: none; }
|
||||
#panel-palette { width: 140px; min-width: 140px; }
|
||||
}
|
||||
|
||||
/* ── Pantalla de éxito ─────────────────────────────── */
|
||||
#success-screen {
|
||||
@@ -295,6 +332,8 @@ $formId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
<div id="palette"></div>
|
||||
<div class="palette-group-title" style="margin-top:12px">Vinculados al paciente</div>
|
||||
<div id="palette-linked"></div>
|
||||
<div class="palette-group-title" style="margin-top:12px">Calculados</div>
|
||||
<div id="palette-calc"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -479,6 +518,10 @@ const TIPOS_CAMPO = [
|
||||
{ tipo:'parrafo_inline', label:'Párrafo con campos', icon:'fa-align-left' },
|
||||
{ tipo:'lista_marcable', label:'Lista marcable', icon:'fa-list-ol' },
|
||||
];
|
||||
const TIPOS_CALC = [
|
||||
{ tipo:'fecha_hoy', label:'Fecha actual', icon:'fa-calendar-day' },
|
||||
{ tipo:'edad', label:'Edad del paciente', icon:'fa-user-clock' },
|
||||
];
|
||||
const TIPOS_LINKED = [
|
||||
{ key:'nombre_completo', label:'Nombre completo', icon:'fa-user' },
|
||||
{ key:'numero_documento', label:'N.º documento', icon:'fa-id-card' },
|
||||
@@ -529,25 +572,27 @@ function renderPalette() {
|
||||
$('palette').innerHTML = TIPOS_CAMPO.map(t => `
|
||||
<div class="palette-item" draggable="true"
|
||||
data-tipo="${t.tipo}"
|
||||
ondragstart="palDragStart(event,'${t.tipo}')">
|
||||
ondragstart="palDragStart(event,'${t.tipo}')"
|
||||
onclick="agregarCampo({tipo:'${t.tipo}',linked:null})">
|
||||
<i class="fas ${t.icon}"></i>${t.label}
|
||||
</div>`).join('');
|
||||
|
||||
$('palette-linked').innerHTML = TIPOS_LINKED.map(t => `
|
||||
<div class="palette-item linked" draggable="true"
|
||||
data-linked="${t.key}"
|
||||
ondragstart="palDragStart(event,'linked','${t.key}')">
|
||||
data-tipo="linked" data-linked="${t.key}"
|
||||
ondragstart="palDragStart(event,'linked','${t.key}')"
|
||||
onclick="agregarCampo({tipo:'linked',linked:'${t.key}'})">
|
||||
<i class="fas ${t.icon}"></i>${t.label}
|
||||
</div>`).join('');
|
||||
|
||||
// Click también agrega campo (además del drag)
|
||||
document.querySelectorAll('.palette-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const tipo = el.dataset.tipo || 'linked';
|
||||
const linked = el.dataset.linked || null;
|
||||
agregarCampo({ tipo, linked });
|
||||
});
|
||||
});
|
||||
$('palette-calc').innerHTML = TIPOS_CALC.map(t => `
|
||||
<div class="palette-item" draggable="true"
|
||||
data-tipo="${t.tipo}"
|
||||
ondragstart="palDragStart(event,'${t.tipo}')"
|
||||
onclick="agregarCampo({tipo:'${t.tipo}',linked:null})"
|
||||
style="color:#856404">
|
||||
<i class="fas ${t.icon}"></i>${t.label}
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
@@ -595,6 +640,10 @@ function agregarCampo({ tipo, linked }) {
|
||||
campo = { id, tipo:'parrafo_inline', contenido:'Yo, {nombre_completo}, con N.º de identificación {numero_documento}, declaro que…' };
|
||||
} else if (tipo === 'lista_marcable') {
|
||||
campo = { id, tipo:'lista_marcable', label:'Marque con X la prueba solicitada', items:['Opción 1','Opción 2'], required:false };
|
||||
} else if (tipo === 'fecha_hoy') {
|
||||
campo = { id, tipo:'fecha_hoy', label:'Fecha', required:false };
|
||||
} else if (tipo === 'edad') {
|
||||
campo = { id, tipo:'edad', label:'Edad', required:false };
|
||||
} else {
|
||||
campo = { id, tipo, label:'Campo sin título', placeholder:'', required:false };
|
||||
}
|
||||
@@ -610,7 +659,8 @@ const TIPO_ICON = {
|
||||
texto:'fa-font', textarea:'fa-align-left', numero:'fa-hashtag',
|
||||
fecha:'fa-calendar', hora:'fa-clock', select:'fa-list', radio:'fa-dot-circle',
|
||||
checkbox:'fa-check-square', firma:'fa-signature', firma_profesional:'fa-user-md', separador:'fa-minus', linked:'fa-link',
|
||||
parrafo:'fa-paragraph', parrafo_inline:'fa-align-left', lista_marcable:'fa-list-ol'
|
||||
parrafo:'fa-paragraph', parrafo_inline:'fa-align-left', lista_marcable:'fa-list-ol',
|
||||
fecha_hoy:'fa-calendar-day', edad:'fa-user-clock'
|
||||
};
|
||||
|
||||
function renderCanvas() {
|
||||
@@ -647,29 +697,139 @@ function renderCanvas() {
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
// Reordenar drag
|
||||
// Reordenar — HTML5 drag (mouse desktop)
|
||||
div.setAttribute('draggable', 'true');
|
||||
div.addEventListener('dragstart', e => {
|
||||
if (e.target.closest('.fi-btn')) { e.preventDefault(); return; }
|
||||
_dragSrc = idx; _dragTipo = null;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
div.classList.add('dragging');
|
||||
});
|
||||
div.addEventListener('dragend', () => div.classList.remove('dragging'));
|
||||
div.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect='move'; });
|
||||
div.addEventListener('dragend', () => {
|
||||
div.classList.remove('dragging');
|
||||
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
|
||||
fi.classList.remove('drop-above','drop-below'));
|
||||
});
|
||||
div.addEventListener('dragover', e => {
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
||||
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
|
||||
fi.classList.remove('drop-above','drop-below'));
|
||||
const r = div.getBoundingClientRect();
|
||||
div.classList.add(e.clientY < r.top + r.height / 2 ? 'drop-above' : 'drop-below');
|
||||
});
|
||||
div.addEventListener('drop', e => {
|
||||
e.stopPropagation();
|
||||
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
|
||||
fi.classList.remove('drop-above','drop-below'));
|
||||
if (_dragSrc !== null && _dragSrc !== idx) {
|
||||
const r = div.getBoundingClientRect();
|
||||
const before = e.clientY < r.top + r.height / 2;
|
||||
const [moved] = _campos.splice(_dragSrc, 1);
|
||||
_campos.splice(idx, 0, moved);
|
||||
const dest = _dragSrc < idx ? (before ? idx - 1 : idx) : (before ? idx : idx + 1);
|
||||
_campos.splice(dest, 0, moved);
|
||||
_dragSrc = null;
|
||||
renderCanvas();
|
||||
renderPreview();
|
||||
renderCanvas(); renderPreview();
|
||||
}
|
||||
});
|
||||
// Reordenar — Pointer Events (touch / tablet)
|
||||
initPointerDrag(div, idx);
|
||||
|
||||
zone.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// POINTER DRAG (touch + tablet)
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
let _pDrag = null;
|
||||
|
||||
function initPointerDrag(div, idx) {
|
||||
const handle = div.querySelector('.fi-handle');
|
||||
if (!handle) return;
|
||||
|
||||
handle.addEventListener('pointerdown', e => {
|
||||
// Solo si es touch o el botón primario del mouse (pero el mouse usa HTML5)
|
||||
if (e.pointerType === 'mouse') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
_dragSrc = idx; _dragTipo = null;
|
||||
const rect = div.getBoundingClientRect();
|
||||
|
||||
// Crear ghost
|
||||
const ghost = document.createElement('div');
|
||||
ghost.id = 'drag-ghost';
|
||||
ghost.innerHTML = div.querySelector('.fi-icon').outerHTML +
|
||||
'<span>' + (div.querySelector('.fi-label')?.textContent || '') + '</span>';
|
||||
ghost.style.top = rect.top + 'px';
|
||||
ghost.style.left = rect.left + 'px';
|
||||
ghost.style.width = rect.width + 'px';
|
||||
document.body.appendChild(ghost);
|
||||
|
||||
div.classList.add('touch-dragging');
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
|
||||
_pDrag = { idx, div, ghost, offsetY: e.clientY - rect.top };
|
||||
});
|
||||
|
||||
handle.addEventListener('pointermove', e => {
|
||||
if (!_pDrag || _pDrag.idx !== idx) return;
|
||||
e.preventDefault();
|
||||
const { ghost } = _pDrag;
|
||||
ghost.style.top = (e.clientY - _pDrag.offsetY) + 'px';
|
||||
|
||||
// Indicador de posición
|
||||
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
|
||||
fi.classList.remove('drop-above','drop-below'));
|
||||
ghost.style.display = 'none';
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
ghost.style.display = '';
|
||||
const target = el?.closest('.field-item');
|
||||
if (target && target !== _pDrag.div) {
|
||||
const r = target.getBoundingClientRect();
|
||||
target.classList.add(e.clientY < r.top + r.height / 2 ? 'drop-above' : 'drop-below');
|
||||
}
|
||||
});
|
||||
|
||||
const endDrag = e => {
|
||||
if (!_pDrag || _pDrag.idx !== idx) return;
|
||||
const { div: origDiv, ghost } = _pDrag;
|
||||
|
||||
ghost.style.display = 'none';
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
ghost.remove();
|
||||
origDiv.classList.remove('touch-dragging');
|
||||
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
|
||||
fi.classList.remove('drop-above','drop-below'));
|
||||
|
||||
const target = el?.closest('.field-item');
|
||||
if (target && target !== origDiv) {
|
||||
const targetIdx = parseInt(target.dataset.idx);
|
||||
const r = target.getBoundingClientRect();
|
||||
const before = e.clientY < r.top + r.height / 2;
|
||||
if (!isNaN(targetIdx) && targetIdx !== idx) {
|
||||
const [moved] = _campos.splice(idx, 1);
|
||||
const dest = idx < targetIdx
|
||||
? (before ? targetIdx - 1 : targetIdx)
|
||||
: (before ? targetIdx : targetIdx + 1);
|
||||
_campos.splice(dest, 0, moved);
|
||||
renderCanvas(); renderPreview();
|
||||
}
|
||||
}
|
||||
_pDrag = null; _dragSrc = null;
|
||||
};
|
||||
|
||||
handle.addEventListener('pointerup', endDrag);
|
||||
handle.addEventListener('pointercancel', e => {
|
||||
if (!_pDrag || _pDrag.idx !== idx) return;
|
||||
_pDrag.ghost.remove();
|
||||
_pDrag.div.classList.remove('touch-dragging');
|
||||
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
|
||||
fi.classList.remove('drop-above','drop-below'));
|
||||
_pDrag = null; _dragSrc = null;
|
||||
});
|
||||
}
|
||||
|
||||
function eliminarCampo(idx) {
|
||||
_campos.splice(idx, 1);
|
||||
renderCanvas();
|
||||
@@ -765,6 +925,20 @@ function renderCampoPreview(c) {
|
||||
).join('');
|
||||
return `<div class="preview-field">${lbl}${items}</div>`;
|
||||
}
|
||||
if (c.tipo === 'fecha_hoy') {
|
||||
const hoy = new Date();
|
||||
const val = hoy.getFullYear()+'-'+String(hoy.getMonth()+1).padStart(2,'0')+'-'+String(hoy.getDate()).padStart(2,'0');
|
||||
return `<div class="preview-field">${lbl}
|
||||
<input type="date" value="${val}" disabled style="background:#fff9e6;border-color:#ffc107">
|
||||
<small style="color:#856404;font-size:9px"><i class="fas fa-calendar-day"></i> Fecha actual — editable</small>
|
||||
</div>`;
|
||||
}
|
||||
if (c.tipo === 'edad') {
|
||||
return `<div class="preview-field">${lbl}
|
||||
<input type="number" placeholder="Ej: 34" disabled style="background:#fff9e6;border-color:#ffc107">
|
||||
<small style="color:#856404;font-size:9px"><i class="fas fa-user-clock"></i> Edad calculada del paciente — editable</small>
|
||||
</div>`;
|
||||
}
|
||||
const t = c.tipo==='numero'?'number':c.tipo==='fecha'?'date':c.tipo==='hora'?'time':'text';
|
||||
return `<div class="preview-field">${lbl}
|
||||
<input type="${t}" disabled placeholder="${esc(c.placeholder||'')}"></div>`;
|
||||
@@ -878,6 +1052,18 @@ function abrirEditorCampo(idx) {
|
||||
<input class="form-check-input" type="checkbox" id="ce-required" ${c.required?'checked':''}>
|
||||
<label class="form-check-label small" for="ce-required">Requiere al menos una selección</label>
|
||||
</div>`;
|
||||
} else if (c.tipo === 'fecha_hoy' || c.tipo === 'edad') {
|
||||
html += `<div class="alert alert-warning py-2 small mb-2">
|
||||
<i class="fas fa-calculator me-1"></i>
|
||||
${c.tipo === 'fecha_hoy'
|
||||
? 'Se pre-llenará con la fecha actual al abrir el formulario.'
|
||||
: 'Se pre-llenará con la edad calculada a partir de la fecha de nacimiento del paciente.'}
|
||||
El paciente puede editarlo.
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="ce-required" ${c.required?'checked':''}>
|
||||
<label class="form-check-label small" for="ce-required">Campo obligatorio</label>
|
||||
</div>`;
|
||||
} else if (c.tipo === 'firma' || c.tipo === 'firma_profesional') {
|
||||
if (c.tipo === 'firma_profesional') {
|
||||
const cm = c.modos || ['canvas'];
|
||||
|
||||
@@ -445,6 +445,8 @@ function renderFormularios() {
|
||||
<i class="fas fa-paper-plane me-1"></i>Enviar
|
||||
</button>
|
||||
${PUEDE_ESCRIBIR ? `
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="clonarFormulario(${f.id},'${esc(f.nombre)}')"
|
||||
title="Clonar formulario"><i class="fas fa-copy"></i></button>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="builder.editar(${f.id})"
|
||||
title="Editar diseño"><i class="fas fa-edit"></i></button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="confirmarBorrar(${f.id},'${esc(f.nombre)}')"
|
||||
@@ -456,6 +458,21 @@ function renderFormularios() {
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function clonarFormulario(id, nombre) {
|
||||
if (!confirm(`¿Clonar el formulario "${nombre}"?\nSe creará una copia con el nombre "Copia de ${nombre}".`)) return;
|
||||
const r = await fetch('api/lab/save_formulario.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ id, clonar: true }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success || d.ok) {
|
||||
showToast('✅ Formulario clonado');
|
||||
cargarFormularios();
|
||||
} else {
|
||||
alert(d.error || 'Error al clonar');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmarBorrar(id, nombre) {
|
||||
if (!confirm(`¿Eliminar el formulario "${nombre}"?\nLos envíos existentes se conservarán.`)) return;
|
||||
const r = await fetch('api/lab/save_formulario.php', {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
/**
|
||||
* Migración: Activar número WhatsApp del Turnero
|
||||
* Phone Number ID: 1154129601114656
|
||||
* Ya registrado en Meta — solo queda guardarlo en system_config.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
try {
|
||||
saveConfigToDB('whatsapp_phone_number_id_turnero', '1154129601114656');
|
||||
echo "OK: whatsapp_phone_number_id_turnero = 1154129601114656\n";
|
||||
} catch (Exception $e) {
|
||||
echo "ERROR: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migración: agregar columna `canal` a la tabla conversations
|
||||
-- Permite distinguir mensajes del bot (canal='bot') del turnero (canal='turnero')
|
||||
-- Todos los registros existentes quedan como 'bot' (sin pérdida de datos)
|
||||
|
||||
ALTER TABLE `conversations`
|
||||
ADD COLUMN IF NOT EXISTS `canal` VARCHAR(20) NOT NULL DEFAULT 'bot' AFTER `status`;
|
||||
|
||||
ALTER TABLE `conversations`
|
||||
ADD INDEX IF NOT EXISTS `idx_conversations_canal` (`canal`);
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE turnero_solicitudes
|
||||
ADD COLUMN embarazada TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT 'Indica si la paciente está embarazada al momento de la solicitud'
|
||||
AFTER observaciones;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE turnero_lugares
|
||||
ADD COLUMN IF NOT EXISTS formulario_modo ENUM('link','embebido') NOT NULL DEFAULT 'link';
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE IF NOT EXISTS medicos (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
codigo VARCHAR(20) NOT NULL UNIQUE,
|
||||
nombres VARCHAR(100) NOT NULL,
|
||||
apellidos VARCHAR(100) NOT NULL,
|
||||
direccion VARCHAR(200) DEFAULT NULL,
|
||||
telefonos VARCHAR(50) DEFAULT NULL,
|
||||
email VARCHAR(100) DEFAULT NULL,
|
||||
beeper VARCHAR(50) DEFAULT NULL,
|
||||
cod_especialidad VARCHAR(10) DEFAULT NULL,
|
||||
cod_ciudad VARCHAR(10) DEFAULT NULL,
|
||||
docidmedico VARCHAR(30) DEFAULT NULL,
|
||||
activo TINYINT(1) NOT NULL DEFAULT 1,
|
||||
creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
actualizado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,447 @@
|
||||
-- Seed: 444 médicos importados desde DBLAB_XIMENA_FB25.FDB
|
||||
INSERT INTO medicos (codigo, nombres, apellidos, direccion, telefonos, email, beeper, cod_especialidad, cod_ciudad, docidmedico) VALUES
|
||||
('REHA', 'Rehabilitar', '.', '', '', '', '', '.', '54001', ''),
|
||||
('ULP', 'Urb. Las Palmas', '.', '', '', '', '', '.', '54001', ''),
|
||||
('YEAR', 'Yezmin E', 'Abrahim R', 'Calle 18 1ae-31', '', '', '5717607-3005658', 'PSQ', '54001', ''),
|
||||
('JAG', 'Johanna Carolina', 'Acosta Guio', 'Cl 14a 1e-41 Caobos', '5943124-593244', '', '', 'NL', '54001', '52693593'),
|
||||
('AF', 'Andres', 'Afanador', '', '', '', '', '.', '54001', ''),
|
||||
('OA', 'Omar Javier', 'Albarracin Acosta', '', '', '', '', 'NLF', '54001', ''),
|
||||
('JEAG', 'Johan E', 'Alfaro Garzon', '', '', '', '', 'GEN', '54001', ''),
|
||||
('HOAM', 'Hugo Oswaldo', 'Alvarado Montañez', 'Cm San Jose Consultorio 213b', '', '', '', 'END', '54001', ''),
|
||||
('JAAA', 'Javier Alexis', 'Alvarez Arciniegas', 'Sanaty Ips', '5955421', '', '', 'GEN', '54001', '88190024'),
|
||||
('AAD', 'Arevalo Duran', 'Alvaro', '', '', '', '', 'DE', '54001', ''),
|
||||
('HAS', 'Hector', 'Amaya Santiago', '', '', '', '', 'GEN', '54001', ''),
|
||||
('EAC', 'Emiro', 'Andrade Chaparro', 'Calle 13 No 2e', '', '', '', 'PLA', '54001', ''),
|
||||
('VAA', 'Victor', 'Antolinez Ayala', '', '', '', '', 'QX', '54001', '19486001'),
|
||||
('VEAA', 'Victor Enrique', 'Antolinez Ayala', '', '', '', '', 'QX', '54001', '19486001'),
|
||||
('MAAQ', 'Maria Alejandra', 'Aranda Quintero', '', '', '', '', 'GEN', '54001', '1090529115'),
|
||||
('CJA', 'Carlos Jose', 'Arciniegas Pinto', 'Cm Jerico', '', '', '', 'CA', '54001', ''),
|
||||
('IAR', 'Isnardo', 'Ardila Rueda', '', '', '', '', '.', '54001', ''),
|
||||
('NJA', 'Nestor Julian', 'Arenas', 'Uronorte', '', '', '', 'URO', '54001', ''),
|
||||
('AA', 'Alvaro', 'Arevalo Duran', '4346/91', '', '', '', 'DE', '54001', '88135768'),
|
||||
('SA', 'Susana', 'Arias', 'Calle 13 No,1e-44 Caobos Cons', '3144392817', 'info@centranut.com', '3144392817', 'NUT', '54001', ''),
|
||||
('AFAS', 'Andres Felipe', 'Arias Sanchez', 'Calle 19 No 15e-41', '5771188', 'infectopedces@gmail.com', '3502165991', 'PE', '54001', ''),
|
||||
('JA', 'Jairo', 'Ascencio', 'Cm Samanes', '', '', '', 'PE', '54001', ''),
|
||||
('999', 'No', 'Asignado', '.', NULL, NULL, NULL, '.', '23001', NULL),
|
||||
('JASE', 'Jose Antonio', 'Assaf Elcure', '', '', '', '', '.', '54001', ''),
|
||||
('LAP', 'Liliana', 'Atuesca Palacio', 'Centro Med Norte Cons 100', '', 'lituescapa@hotmail.com', '', 'PE', '54001', ''),
|
||||
('HOAZ', 'Hollman Omar', 'Avendaño Zambrano', '', '', '', '', 'GI', '54001', '19494294'),
|
||||
('MYAC', 'Mabel Yaneth', 'Avila C', 'Bucaramanga', '', '', '', 'DE', '54001', ''),
|
||||
('AJAS', 'Ambar Johana', 'Avila Salazar', '', '', '', '', 'GAS', '54001', '469330'),
|
||||
('EFAS', 'Edgar Fernando', 'Ayala Sierra', '', '', '', '', 'MI', '54001', '1054091243'),
|
||||
('OBC', 'Orlando', 'Ballen Caceres', 'Cooneuro', '5943165-3135859398', '', '', 'NL', '54001', ''),
|
||||
('ABR', 'Angelica Maria', 'Barba Rueda', 'Calle 10 15e-41 Urb La Esperan', '', '', '', 'PE', '54001', ''),
|
||||
('LAB', 'Liliana', 'Barbosa', '', '', '', '', '.', '54001', ''),
|
||||
('MEB', 'Maria Eugenia', 'Barbosa R', 'Cm Ume', '3005731327', '', '', 'PE', '54001', '9410/86'),
|
||||
('FCBH', 'Franklin Camilo', 'Baron Hernandez', 'Ecopetrol', '', '', '', 'GEN', '54001', '1019131702'),
|
||||
('CRVJ', 'Carlos Roberto', 'Baron Jaimes', 'Calle 9 Av 11e', '', '', '', 'HE', '54001', ''),
|
||||
('AB', 'Alvaro', 'Barrera Prada', 'Cl 7 9e-15 Colsag', '', 'abarreramd@hotmail.com', '', 'EN', '54001', '79533170'),
|
||||
('ATBC', 'Angel Tercero', 'Barreto Castilla', 'Vitta', '', '', '', 'ORT', '54001', '1022384187'),
|
||||
('JBM', 'Johandry', 'Bastidas', '', '', '', '', 'GAS', '54001', ''),
|
||||
('AJBR', 'Alain Jasaf', 'Bautista', 'Cm Jerico Cons 603', '', 'alainjasaf@yahoo.es', '', 'RE', '54001', ''),
|
||||
('JEBG', 'Jaime Ernesto', 'Bautista Gomez', 'Av 0 No 12-05 Con 207', '', '', '', 'OTO', '54001', ''),
|
||||
('SB', 'Samuel Enrique', 'Bautista V', 'Csj Torre B Cons 311b', '3015884166', 'samuelbautistamd@hotmail.com', '3124247628', 'GI', '54001', ''),
|
||||
('JB', 'Josue', 'Becerra', 'Uronorte', '', '', '', 'URO', '54001', ''),
|
||||
('LCBA', 'Luis Carlos', 'Becerra A', 'Jerico', '', '', '', 'ORT', '54001', ''),
|
||||
('PBO', 'Paulo', 'Becerra O.', '', '', '', '', 'FI', '54001', ''),
|
||||
('OBCH', 'Otmaro', 'Belalcázar Chaves', '', '', '', '', 'MI', '54001', '98364362'),
|
||||
('GB', 'Giovanny', 'Beltran', 'Cll 14 A 1e-41 Caobos', '', '', '', '.', '54001', ''),
|
||||
('SMBD', 'Sergio Mauricio', 'Beltran Diaz', '', '', '', '', '.', '54001', ''),
|
||||
('FB', 'Federico', 'Bencardino Carpio', 'Cm Riviera', '', '', '', 'URO', '54001', '13373080'),
|
||||
('FBA', 'Fabio', 'Berbesi Alvarez', 'Cm Norte Con 309', '', '', '', 'NEU', '54001', '13443663'),
|
||||
('JAB', 'Jose Alexander', 'Bermudez', 'Cooneuro', '', '', '', 'NL', '54001', ''),
|
||||
('FMB', 'Felix Martin', 'Bermudez Santaella', 'Cm Norte Cons 306', '', '', '', 'PE', '54001', ''),
|
||||
('DAB', 'Diego Andres', 'Blanco Fuentes', 'Cm Jerico Cons 406', '', 'drblancoped@gmail.com', '', 'PE', '54001', '88254168'),
|
||||
('MTBF', 'Miguel Tonno', 'Botta Fernandez', 'Uronorte', '', '', '', 'URO', '54001', ''),
|
||||
('GBF', 'Giancarlo', 'Botta Fernandez', 'Cm Samanes Cons 404', '', '', '', 'ORT', '54001', ''),
|
||||
('JCGM', 'Juan Carlos', 'Brahim Muñoz', '', '3005561734', '', '', 'CGR', '54001', '15310'),
|
||||
('JIBT', 'Jose Ignacio', 'Bravo Torres', '', '', '', '', 'CGR', '54001', '19333825'),
|
||||
('LMBA', 'Luz Marina', 'Buenaer Arevalo', '', '5753685', '', '', 'BAC', '54001', ''),
|
||||
('RAB', 'Ricardo Andres', 'Bustamente', '', '', '', '', 'MG', '54001', '1234093720'),
|
||||
('SCO', 'Sergio', 'Caceres Orozco', 'Calle 17 No. 1e-138', '', '', '', 'DE', '54001', '19488514'),
|
||||
('MECO', 'Maria Eugenia', 'Caceres Orozco', '', '', '', '', 'BAC', '54001', ''),
|
||||
('CMFNT', 'Comfanorte', 'Caja De Compensacion', '000', '000', '', '', '.', '54001', '000'),
|
||||
('RCG', 'Rossana', 'Calderon Garcia', '', '', '', '', 'PE', '54001', '1051442516'),
|
||||
('OCH', 'Oswaldo', 'Calvache H', 'Edi Ingrid Cons 205', '', '', '', '.', '54001', ''),
|
||||
('LHC', 'Luis', 'Campos', 'Cucuta', '', '', '', 'MG', '54001', '88196186'),
|
||||
('PSCG', 'Pilar Sofia', 'Cardenas Garcia', '', '', '', '', '.', '54001', ''),
|
||||
('ECR', 'Edgar', 'Cardona Reyes', 'Calle 15a No 1e-31', '', 'edgarcardonareyes@hotmail.com', '', '.', '54001', ''),
|
||||
('AFCZ', 'Andres Felipe', 'Cardona Zorrilla', '', '', '', '', 'ON', '54001', '79940178'),
|
||||
('LECJ', 'Leonardo Enrique', 'Carrascal Jacome', '', '', '', '', 'NUT', '54001', ''),
|
||||
('FJCB', 'Fernando Jose', 'Carrasco Blanco', 'Ctro Espe Santa Ana', '3202745025', '', '', 'CA', '54001', '13487485'),
|
||||
('ROSSYCG', 'Rossy', 'Carrillo Gutierrez', '', '', '', '', 'CAR', '54001', '1127348421'),
|
||||
('JACM', 'Jose Armando', 'Carrillo M', 'Jerico Cons 708', '', '', '', '.', '54001', ''),
|
||||
('LMCQ', 'Lucia M', 'Carrillo Quintero', '', '', '', '', 'GEN', '54001', '55250333'),
|
||||
('GECF', 'Gustavo E', 'Carvajal Franklin', 'Av12 4-38 Cons 102', '3202745025', '', '', 'GAS', '54001', ''),
|
||||
('LACA', 'Luis Alfonso', 'Casanova Arambula', 'Fresenius', '', '', '', 'URO', '54001', ''),
|
||||
('TCG', 'Tatyiana', 'Casilimas Garzon', '', '', '', '', 'MG', '54001', '39703462'),
|
||||
('HCM', 'Hector', 'Castaño Moreno', '', '', '', '', 'MC', '54001', '10255796'),
|
||||
('IECG', 'Ivan Enrique', 'Castiblanco Gomez', '', '', '', '', 'OTO', '54001', '764'),
|
||||
('NC', 'Noe', 'Castro Gomez', 'Cm San Jose', '5821111', '', '', 'MI', '54001', '13255814'),
|
||||
('CCL', 'Carlos', 'Castro Lobo', 'Colegioio Medico Of 304', '', '', '', 'GI', '54001', ''),
|
||||
('NGCP', 'Nelson G', 'Castro Perez', 'Cm San Jose Cons 305a', '', '', '', '.', '54001', ''),
|
||||
('DLCR', 'Derly Liseth', 'Castro Rojas', '', '', '', '', '.', '54001', ''),
|
||||
('AGZ', 'Agustin', 'Castro Zapata', '', '5714417', '', '', 'CA', '54001', '13451600'),
|
||||
('IVCG', 'Ithala Valentina', 'Celis Gutierrez', 'Cucuta', '', '', '', 'MG', '54001', '1004921250'),
|
||||
('LAC', 'Luis Antonio', 'Cely', 'Fresenius', '', '', '', 'NF', '54001', '13504412'),
|
||||
('XMCM', 'Ximena Marcela', 'Cervantes M', 'Av 1e No. 11-52', '5951155-3144587047', 'dra.ximenacervantes@gmail.com', '3505125885', 'PE', '54001', '52262577'),
|
||||
('CEC', 'Carlos Enrique', 'Chacon', 'Clinica Norte Cardiologia', '', '', '', 'CAR', '54001', ''),
|
||||
('ICF', 'Ivan', 'Chacon Florez', 'Santa Ana', '', '', '', '.', '54001', ''),
|
||||
('MCR', 'Miguel Alfonso', 'Chahin Rueda', 'Cm Calle 15 Cons 5', '5831598', 'mchahinr@yahoo.es', '3002412558', 'MI', '54001', '79351230'),
|
||||
('AC', 'Adriana', 'Chaves', 'Uronorte', '', '', '', 'URO', '54001', ''),
|
||||
('JAC', 'Johanna Andrea', 'Chavez', '', '', '', '', '.', '54001', ''),
|
||||
('ANCHM', 'Angie Natalia', 'Chona Marquez', '', '', '', '', 'GEN', '54001', '1094285502'),
|
||||
('FAC', 'Fernando A', 'Cianci', '', '', '', '', 'CGR', '54001', ''),
|
||||
('RDCC', 'Richard D', 'Claro C', '', '', '', '', 'PE', '54001', '1127953864'),
|
||||
('RJCJ', 'Roberto Jose', 'Claro Jure', 'Avenida 1 No 11-58', '', '', '', 'GEN', '54001', ''),
|
||||
('PC', 'Perfect', 'Clinic', '', '', '', '', '.', '54001', ''),
|
||||
('PECP', 'Pablo Enrique', 'Colmenares Porras', '', '', '', '', 'DE', '54001', '13243877'),
|
||||
('CMC', 'Centro Medico', 'Colsanitas', 'Av 0 No 15-56', '', '', '', '.', '54001', '9010416914'),
|
||||
('CCC', 'Cecilia', 'Concha Cortes', 'Cm Norte Cons 201', '5718716-5712367', 'concha_cecilia@yahoo.com', '', 'MG', '54001', '39689931'),
|
||||
('LFCB', 'Luis Fernando', 'Conde Buitrago', 'Cm Norte Cons 203', '', '', '', 'CGR', '54001', ''),
|
||||
('DACD', 'Daniel Alexander', 'Contreras Duarte', '', '', '', '', 'MG', '54001', ''),
|
||||
('AMCF', 'Armando Moises', 'Contreras Fernandez', 'Cucuta', '', '', '', 'NL', '54001', '37339724'),
|
||||
('GACG', 'Gustavo Adolfo', 'Contreras Garcia', 'Cll 7 9e-15 Colsag', '', 'gustacon@yahoo.es', '', 'GAS', '54001', ''),
|
||||
('SYCL', 'Sindy Yulieth', 'Contreras Leal', '', '', '', '', 'MG', '54001', '1090391219'),
|
||||
('KCO', 'Krisell', 'Contreras Omaña', '', '', '', '', 'MI', '54001', ''),
|
||||
('TC', 'Tulia', 'Copete', 'Calle 8 5e-68 Urb Sayago Rivie', '5921372-3155501207', '', '', 'ES', '54001', ''),
|
||||
('JGCB', 'Javier Gonzalo', 'Corona Bueno', 'Jerico Con 602', '5721047-3209809045', '', '', '.', '54001', ''),
|
||||
('JC', 'Julio Ernesto', 'Coronel Becerra', 'Avenida O No 15-29', '5716818-5718301', '', '', 'CA', '54001', '34320'),
|
||||
('NSCF', 'Nicol Stefanny', 'Corredor Figueredo', 'Av 1 17-93 Con 09', '3166176214-5833746', '', '', 'GI', '54001', '00150'),
|
||||
('IPSFC', 'Ips Figuras', 'Cucuta', '', '', '', '', 'ES', '54001', ''),
|
||||
('AYC', 'Andres Yesid', 'Cuellar', '', '', '', '', 'GEN', '54001', '1090501619'),
|
||||
('MJCR', 'María Juliana', 'Cáceres Rueda', 'Clinica De La Piel Caobos', '3168350578', 'caceresderma@gmail.com', '3168350578', 'DE', '54001', '1090455804'),
|
||||
('DFD', 'Diego Fernando', 'Dallos', '', '', '', '', 'URO', '54001', ''),
|
||||
('CDLR', 'Carolina', 'De La Rosa', 'Calle 14a No 2e-22', '3507282887', '', '', 'GAS', '54001', '37392625'),
|
||||
('JADR', 'Javier Alfonso', 'De La Rosa Pareja', 'Calle 11 Av 0, Cons 706', '', '', '', 'GAS', '54001', ''),
|
||||
('CENS', 'Centrales Electricas', 'Del Norte De Santander', '', '', '', '', 'MG', '54001', ''),
|
||||
('GADS', 'Gustavo Adolfo', 'Delgado Sierra', 'Avenida 11a No 8a-39 Cosn 103', '', 'gadelgados@bt.unal.edu.co', '', '.', '54001', ''),
|
||||
('ADC', 'Armando', 'Diaz Cardenas', 'Cmn', '5717286-3173790402', '', '', 'OTO', '54001', '13448326'),
|
||||
('MDC', 'Manuel', 'Diaz Caro', 'Uronorte', '', '', '', 'URO', '54001', ''),
|
||||
('ADD', 'Alberto', 'Diaz Diaz', '', '', '', '', 'OF', '54001', ''),
|
||||
('ADV', 'Armando', 'Diaz Vergel', '', '', '', '', '.', '54001', ''),
|
||||
('CLAUDIA', 'Claudia', 'Dominguez', '', '', '', '', 'MG', '54001', ''),
|
||||
('MLDI', 'Maria Liliana', 'Dorado Illera', '', '', '', '', 'MG', '54001', ''),
|
||||
('CIDB', 'Clara Ines', 'Duarte Barreto', 'Colegio Medico', '', '', '', 'BAC', '54001', ''),
|
||||
('AMDL', 'Angelica Maria', 'Duque Leal', '', '', '', '', 'GIN', '54001', ''),
|
||||
('FDO', 'Frankiln', 'Duran Omeara', '', '', '', '', '.', '54001', ''),
|
||||
('JFDP', 'Javier F', 'Duran Pachon', '', '', '', '', 'GI', '54001', '13509603'),
|
||||
('EEL', 'Emilio', 'Escalante Leiva', '', '', '', '', 'GI', '54001', ''),
|
||||
('LEEL', 'Luis Emilio', 'Escalante Luzardo', 'Cm San Jose', '', 'luisemilioescalante@hotmail.com', '', 'GI', '54001', ''),
|
||||
('GEP', 'Gilary Andrea', 'Eslava Prieto', '', '', '', '', 'MG', '54001', '1094281517'),
|
||||
('ZMEO', 'Zully M', 'Espinel O', 'Cm San Jose Cons 301a', '', '', '', 'NUT', '54001', ''),
|
||||
('CFET', 'Claudia Fernanda', 'Estupiñan Trujillo', '', '', '', '', 'MI', '54001', ''),
|
||||
('EJFJ', 'Eduardo Jose', 'Fajardo Jaimes', '', '', '', '', 'MG', '54001', ''),
|
||||
('RAFP', 'Rafael Alberto', 'Fandiño Prada', 'Coneuro', '', '', '', 'QX', '54001', ''),
|
||||
('JCFR', 'Juan Carlos', 'Fernandez Romero', '', '', '', '', 'PLA', '54001', ''),
|
||||
('LFU', 'Leonardo', 'Fernandez Ustariz', 'Unidad Hematologica', '', '', '', '.', '54001', ''),
|
||||
('COFD', 'Carlos Omar', 'Figueredo Diettes', 'Av 0 20-38 B Blanco', '5730619-5719143-', 'drcofigueredod@hotmail.com', '3052433956', 'ON', '54001', '91277299'),
|
||||
('JFM', 'Joaquin', 'Figueredo Molina', 'Avenida 1 No 15-92', '', 'ceginob@hotmail.com', '', 'GI', '54001', ''),
|
||||
('CAFM', 'Carlos Arturo', 'Figueredo Molina', '', '', '', '', 'GI', '54001', ''),
|
||||
('MSF', 'Maria Sofia', 'Figueroa', '', '', '', '', 'GEN', '54001', ''),
|
||||
('IPS', 'Ips', 'Figuras', '', '', '', '', '.', '54001', ''),
|
||||
('TF', 'Tatiana', 'Florez', '', '', '', '', 'OF', '54001', ''),
|
||||
('GFE', 'Gabriel', 'Florez Echeverria', 'Cll 16 1e-110 Caobos', '', '', '', 'OF', '54001', ''),
|
||||
('SCF', 'Silvia Carolina', 'Florez Faillace', 'Clinica San Diego', '', '', '', 'OF', '54001', ''),
|
||||
('MLF', 'Martha Lucy', 'Florez Jaimes', '', '', '', '', 'GEN', '54001', ''),
|
||||
('SLFM', 'Sandra Liliana', 'Florez Muñoz', '', '5740015', '', '3156687785', 'PE', '54001', ''),
|
||||
('MF', 'Martha Lucia', 'Florez Nuncira', 'Cm Norte Con 308', '', '', '3154740868', 'GI', '54001', '27604104'),
|
||||
('GAFC', 'German A', 'Foliaco Colmenares', 'Calle 15a # 1e-55', '', '', '', 'PSQ', '54001', ''),
|
||||
('RF', 'Rafael Dario', 'Forero', '', '', '', '', 'GI', '54001', ''),
|
||||
('FOTO', 'Fotomilenio', 'Fotomilenio', '', '3208557309', '', '', 'GEN', '54001', '27102020'),
|
||||
('DLFR', 'Dabeiba Luz', 'Freile Rada', 'Ecopetrol', '', '', '3103410325', 'CGR', '54001', '32822325'),
|
||||
('PFT', 'Pedro Alonso', 'Fuentes Torrado', 'Cm Jerico Cons 401', '', '', '', 'ORT', '54001', ''),
|
||||
('MAFT', 'Maria Alexandra', 'Fuentes Troya', '', '', '', '', 'CGR', '54001', '59817259'),
|
||||
('XGR', 'Martha Ximena', 'Galeano Ramirez', 'Cm San Jose Cosn 511b', '', '', '', 'GI', '54001', ''),
|
||||
('MLG', 'Martha Lucia', 'Gallardo', '', '', '', '', 'BAC', '54001', ''),
|
||||
('PGC', 'Pablo', 'Galvis Centurion', 'Cm San Jose Con 511', '', '', '', 'GI', '54001', '88211939'),
|
||||
('MGM', 'Mario A', 'Galvis Mantilla', 'Avenid 9e No 6-56 Cons 2', '5727258-5407300', '', '3002137949', 'GI', '54001', '91230980'),
|
||||
('EMG', 'Edgar Mauricio', 'Garcia Acosta', 'Clinica San Diego', '', '', '', 'PLA', '54001', ''),
|
||||
('JGM', 'Jorge', 'Garcia M', '', '', '', '', '.', '54001', ''),
|
||||
('JLGM', 'Jorge Luis', 'Garcia Menco', '', '', '', '', 'MI', '54001', '8565776'),
|
||||
('KBGP', 'Karen Bibiana', 'Garcia Peña', '', '', '', '', 'URO', '54001', '1020786225'),
|
||||
('007', 'Yohanna', 'Garcia Quintero', 'Calle 10bn #4a-75 El Bosque', '3114685804', 'yohannagarciaq.salud@gmail.com', '3114685804', 'NUT', '54001', '37291582'),
|
||||
('TGR', 'Tatiana', 'Garcia Rey', 'Bucaramanga', '', '', '', 'OTO', '54001', '1018418737'),
|
||||
('CJGS', 'Cesar Julio', 'Garcia Sandoval', '', '', '', '', 'MI', '54001', ''),
|
||||
('HGT', 'Harold H', 'Garcia Touchie', 'Cm Norte Cons 201', '5718716-5712367', 'hhgarciat@gmail.com', '', 'EN', '54001', '13447683'),
|
||||
('EMGV', 'Eliana', 'Garcia Villamizar', '', '', '', '', 'GIN', '54001', '53007073'),
|
||||
('MAG', 'Margarita', 'Gelvez', '', '', '', '', 'ES', '54001', ''),
|
||||
('79792615', 'Julian Arturo', 'Gil Forero', '', '', '', '', 'EN', '54001', '79792615'),
|
||||
('SGA', 'Sandra', 'Giraldo Aristizabal', 'Av 11e 5an-71', '', 'sandragiraldo97@hotmail.com', '', '.', '54001', ''),
|
||||
('JGP', 'Jessika', 'Gomez', '', '', '', '', 'EN', '54001', '1127611155'),
|
||||
('JMGR', 'José Manuel', 'Gomez', '', '', '', '', 'MC', '54001', '13479231'),
|
||||
('MTGB', 'Marco Tulio', 'Gomez Botello', 'Coneuro-jerico', '', '', '', 'NL', '54001', ''),
|
||||
('MIGC', 'Maria Ines', 'Gomez C', 'Cm San Jose Cons 401b-freseniu', '', '', '5730137', '.', '54001', ''),
|
||||
('RGF', 'Ramiro Hernando', 'Gomez Franco', 'Cm Negomon', '', '', '', 'GAS', '54001', ''),
|
||||
('CEGF', 'Carlos Eduardo', 'Gomez Franco', 'Cm Negomon', '', '', '', 'GI', '54001', '13447569'),
|
||||
('MCGM', 'Maria Camila', 'Gomez Morales', '', '', '', '', 'FI', '54001', ''),
|
||||
('SRGP', 'Samuel Ricardo', 'Gomez Pinzon', 'Cm Samanes Cons 107a', '', '', '', 'MI', '54001', ''),
|
||||
('RLGR', 'Rosa Leonor', 'Gomez Rodriguez', '', '', '', '', 'MG', '54001', '60447600'),
|
||||
('NG', 'Nataly', 'Gonzalez', '', '', '', '', '.', '54001', ''),
|
||||
('GGC', 'Guillermo', 'Gonzalez Castro', '', '', '', '', '.', '54001', ''),
|
||||
('ATG', 'Ana Teresa', 'Govin', '', '', '', '', 'HEM', '54001', ''),
|
||||
('JRG', 'Jose Ricardo', 'Granados', '', '', '', '', 'NEU', '54001', ''),
|
||||
('JRGQ', 'Jorge Ricardo', 'Granados Quiñones', '', '', '', '', 'NEU', '54001', '88222482'),
|
||||
('AGS', 'Alvaro', 'Granados Santafe', 'Csj Torre B Cons 314b', '5717768', '', '', 'RE', '54001', '19295956'),
|
||||
('AMGG', 'Alex Mauricio', 'Grandados Gomez', '', '', '', '', 'GEN', '54001', ''),
|
||||
('AGB', 'Amparo', 'Grosso Bonilla', '', '3204947894', '', '', '.', '54001', '46364237'),
|
||||
('GG', 'Gerson', 'Guarin', '', '', '', '', 'MI', '54001', ''),
|
||||
('SG', 'Sonia', 'Gutierrez', '', '', '', '', 'BAC', '54001', ''),
|
||||
('AG', 'Alvaro', 'Gutierrez', '', '', '', '', 'OF', '54001', '5521/82'),
|
||||
('JPG', 'Judith Patricia', 'Gutierrez', '', '', '', '', 'QX', '54001', '60322196'),
|
||||
('AGA', 'Andres', 'Gutierrez Aparicio', 'Bogota', '', '', '', '.', '54001', ''),
|
||||
('WG', 'Wilson', 'Gutierrez Ramirez', 'Cm Norte Cons 310', '', 'wilsongutierrez@gmail.com', '', 'CL', '54001', ''),
|
||||
('DGGS', 'Daniel Giovanny', 'Gutierrez Silva', '', '3187857873', 'danielgutierrez68@hotmail.com', '', 'CGR', '54001', '6373'),
|
||||
('JAHT', 'Jose Antonio', 'Hakim Tawil', 'Hospital Santafé De Bogotá', '2152308', 'teleconsultajaht@gmail.com', '', 'CCC', '11001', '10992'),
|
||||
('NHS', 'Nabil', 'Hamdam S', '', '', '', '', '.', '54001', ''),
|
||||
('WH', 'William', 'Heredia', '', '', '', '', 'MG', '54001', ''),
|
||||
('EHF', 'Esteban', 'Hernandez Florez', '', '', '', '', 'HE', '54001', ''),
|
||||
('JGHR', 'José Gregorio', 'Hernandez Ruiz', '', '', '', '', 'MI', '54001', '79721183'),
|
||||
('AHT', 'Arturo', 'Hernandez T', 'Cc San Jose Cons 309b', '', '', '', '.', '54001', ''),
|
||||
('AYHV', 'Andrea Yiseth', 'Hernandez Vasquez', '', '', '', '', '.', '54001', ''),
|
||||
('CHG', 'Claudia', 'Hurtado', '', '', '', '', 'BAC', '54001', ''),
|
||||
('NAIY', 'Nestor Alfredo', 'Ibarra Yañez', '54203-2012', '', '', '3232060912', 'AN', '54001', '80719996'),
|
||||
('100', 'Firma', 'Ilegible', 'Cl', '0', NULL, NULL, '.', '23001', NULL),
|
||||
('PRO', 'Proesmed', 'Ips', 'Calle 16a No 2e-25 Caobos', '3184414653', 'proesmedips@gmail.com', '5726402', 'MG', '54001', '9010023367'),
|
||||
('MIS', 'Mario', 'Izquierdo Sandoval', 'Cm San Jose Cons 101a', '', '', '', 'FI', '54001', '17115809'),
|
||||
('JXJ', 'Jennifer Ximena', 'Jaimes', '', '', '', '', '.', '54001', ''),
|
||||
('CAJO', 'Carlos Arturo', 'Jaimes O', '', '', '', '', 'MG', '54001', ''),
|
||||
('JJD', 'Javier', 'Jimenez Duarte', 'Clinica Sandiego', '5960150', '', '', 'OTO', '54001', '79982271'),
|
||||
('DEJH', 'Diego Enrique', 'Jimenez Hernandez', '', '', '', '', 'GEN', '54001', '1127056461'),
|
||||
('GJP', 'German', 'Jimenez Pallares', '', '', '', '', 'ALE', '54001', ''),
|
||||
('CLD', 'Calidad', 'Lab Ximena C', '', '', '', '', 'GEN', '54001', ''),
|
||||
('AUTO', 'Autoinmunity', 'Laboratorio', '', '', '', '', 'GEN', '54001', ''),
|
||||
('NLA', 'Nell', 'Lara Arroyo', '', '', '', '', 'FI', '54001', ''),
|
||||
('CALG', 'Carlos Alberto', 'Larios Garcia', '', '', '', '', 'URO', '54001', '79557883'),
|
||||
('GENERAL', 'Rodolfo Ramon', 'Leiva Barrera', '', '3165329192', '', '', 'GEN', '54001', ''),
|
||||
('JALN', 'Jairo Francisco', 'Lizarazo Niño', 'Cooneuro', '', '', '', 'NL', '54001', ''),
|
||||
('FDLP', 'Freddy Duvan', 'Lizcano Pabon', '', '', '', '', 'GEN', '54001', '1005053470'),
|
||||
('GL', 'Gabriel', 'Lobo', 'Avenida 9e No 6-58 La Riviera', '5779990-3112786695', '', '', 'CL', '54001', ''),
|
||||
('MAL', 'Manuel Alejandro', 'Lobo', '', '', '', '', 'URO', '54001', ''),
|
||||
('LAL', 'Luis Alberto', 'Lobo Jacome', 'Calle 16 # 0e-15', '', '', '', 'URO', '54001', ''),
|
||||
('RLR', 'Roberto', 'Lobo Rodriguez', 'Av 0 15-25 Cons 201', '', 'robertolobo05@hotmail.com', '', 'ORT', '54001', ''),
|
||||
('CGL', 'Carlos German', 'Lopez', '', '', '', '', 'GIN', '54001', '79866975'),
|
||||
('EL', 'Eduard', 'Lopez', '', '', '', '', 'OF', '54001', ''),
|
||||
('SBLG', 'Sandra Belen', 'Lopez Gomez', 'Cm San Jose Cons 310b', '', '', '', '.', '54001', ''),
|
||||
('ALG', 'Astrid', 'Lopez Gomez', '', '', '', '', 'GI', '54001', ''),
|
||||
('DJLM', 'Deivis Jesus', 'Lopez Melo', 'Gastroquirurgica', '5955775', '', '', 'CGR', '54001', '88258550'),
|
||||
('LFPG', 'Parra Gonzalez', 'Luis Fernando', '', '', '', '', 'CL', '54001', ''),
|
||||
('JM', 'Jaime', 'Machicado Herrera', 'Cm Norte', '5712622', 'clinicamachicado@gmail.com', '', 'GIN', '54001', ''),
|
||||
('SMV', 'Santiago', 'Machicado Villamizar', 'Cm Norte', '', '', '', 'GI', '54001', '1125638019'),
|
||||
('AM', 'Alexandra', 'Madariaga La Roche', 'Cm Jerico', '', '', '', 'GI', '54001', ''),
|
||||
('MAMR', 'María Alexandra', 'Madariaga Laroche', '', '', '', '', 'ON', '54001', '60449946'),
|
||||
('HM', 'Harvey', 'Manosalva', 'Calle 16 1e-42 Caobos', '5711047-3005641394', '', '', 'RA', '54001', ''),
|
||||
('AMMN', 'Adolfo Mario', 'Manotas Navarro', '', '', '', '', 'GEN', '54001', ''),
|
||||
('MFMD', 'Miguel Fabian', 'Mantilla Duran', 'Av 1 No 17-73 Cosn 504', '5891315-3153255040', 'dr.migue@gmail.com', '', 'ORT', '54001', ''),
|
||||
('MM', 'Magreth', 'Mariejo', '', '', '', '', '.', '54001', ''),
|
||||
('LEMM', 'Leonor Eugenia', 'Mariño Murillo', '', '', '', '', 'CAR', '54001', '63556483'),
|
||||
('JHM', 'Johandry', 'Marquez', '', '', '', '', 'GAS', '54001', ''),
|
||||
('SPMN', 'Sandra Patricia', 'Martin Niño', 'Calle 7 No 10e-72', '5777676', '', '3156785070', 'GAS', '54001', '7039'),
|
||||
('MAM', 'Maria Amparo', 'Martinez', '', '', '', '', 'GIN', '54001', ''),
|
||||
('VLM', 'Victor Leonardo', 'Martinez B', 'Cc Bodytech Local 103a', '', 'victorl_martinez@hotmail.com', '', 'MG', '54001', ''),
|
||||
('JKMR', 'Jessica Katherine', 'Martinez Rojas', '', '', '', '', 'MG', '54001', '1090413776'),
|
||||
('GM', 'Gabriel F', 'Matamoros Barreto', 'Calle 21b No 08-106', '', 'pielmedicoslspo@hotmail.com', '', 'DE', '54001', ''),
|
||||
('LFM', 'Luis Felipe', 'Matamoros Barreto', 'Cm San Jose Cons 511', '', '', '', 'MC', '54001', '13488285'),
|
||||
('AMV', 'Alexy', 'Maza Villadiego', '', '', '', '', 'ODO', '54001', '73143772'),
|
||||
('NMA', 'Nelson', 'Mejia Alvarez', '', '', '', '', '.', '54001', ''),
|
||||
('MCMF', 'Maria Claudia', 'Mejia Fajardo', '', '', '', '', 'MG', '54001', '1037588460'),
|
||||
('JPM', 'Julieth Patricia', 'Meneses', '', '', '', '', 'GEN', '54001', ''),
|
||||
('BLK', 'Belkis', 'Meneses', '', '', '', '', 'MI', '54001', '37339724'),
|
||||
('JMN', 'Juleith Patricia', 'Meneses Navarro', '', '', '', '', 'MC', '54001', ''),
|
||||
('JMP', 'Juliana', 'Meneses Perez', 'Clinica San Diego', '', '', '', 'OF', '54001', ''),
|
||||
('JJMC', 'Jorge Jose', 'Mirep Corona', '', '', '', '', 'OTO', '54001', ''),
|
||||
('WRMR', 'Wiliam R', 'Mogollon Rodriguez', '', '', '', '', 'CL', '54001', ''),
|
||||
('MMMM', 'Maria Monica', 'Molano Melo', '', '', '', '', 'GEN', '54001', ''),
|
||||
('WAPV', 'Miguel Angel', 'Molina Lazaro', 'Tibu', '', '', '', 'MG', '54001', '1090516493'),
|
||||
('JAM', 'Juan Andres', 'Monsalve', '', '', '', '', 'NL', '54001', '91537583'),
|
||||
('NXM', 'Nohora Ximena', 'Monsalve', 'Cm Norte', '5713690-5712367', '', '', 'PSQ', '54001', '63549320'),
|
||||
('CM', 'Laboratorio Carlos', 'Montoya Y Suarez Sas', '', '', '', '', 'BAC', '54001', ''),
|
||||
('YMG', 'Yadira', 'Mora G', '', '', '', '', 'GEN', '54001', ''),
|
||||
('JAMM', 'Jose Alfonso', 'Mora Morante', '', '', '', '', '.', '54001', ''),
|
||||
('PM', 'Maria Del Pilar', 'Mora Urbina', 'Clinica San Diego', '', '', '', 'OF', '54001', ''),
|
||||
('CHMU', 'Carlos Humberto', 'Mora Urbina', 'Coneuro', '', '', '', 'QX', '54001', ''),
|
||||
('JOMV', 'Javier O', 'Mora V.', 'Av 1e 3-237 Ceiba', '5945523', '', '', 'GI', '54001', ''),
|
||||
('MGMS', 'Marcos Gabriel', 'Morales Soto', '', '', '', '', 'HE', '54001', ''),
|
||||
('AMT', 'Alexander', 'Moreno', '', '', '', '', 'DE', '54001', '1245'),
|
||||
('FAM', 'Francisco A', 'Moreno', '', '', '', '', 'MG', '54001', ''),
|
||||
('AMF', 'Alexander', 'Moreno Figueredo', '', '', '', '', '.', '54001', ''),
|
||||
('JMO', 'Juliana', 'Moreno Olaya', 'Rts Caobos', '', '', '', 'NF', '54001', '1098647415'),
|
||||
('LAMV', 'Luis Alberto', 'Moreno Vera', '', '', '', '', 'CV', '54001', ''),
|
||||
('MEM', 'Manuel Eduardo', 'Moros Vera', 'Calle 7 No 10e-72', '5777676-3156785070', 'gastroquirurgicaltda@yahoo.com', '', 'GAS', '54001', '1051'),
|
||||
('LFMA', 'Luis Fernando', 'Muñoz Acosta', '', '', '', '', '.', '54001', ''),
|
||||
('GAMD', 'German Alberto', 'Muñoz Duran', '', '', '', '', 'NF', '54001', ''),
|
||||
('LFMG', 'Luis Fernando', 'Muñoz Gomez', '', '', '', '', '.', '54001', ''),
|
||||
('003', 'Sandra Fabiola', 'Muñoz Peñaloza', '', '58 424-1215638', 'dra.sandramunoz.p@gmail.com', '', 'CGR', '54001', '11017693'),
|
||||
('ESNR', 'Edwin Sebastian', 'Navarro Rodriguez', '', '', '', '', 'GEN', '54001', '1005384395'),
|
||||
('FNM', 'Fabian', 'Niño Monsalve', '', '', '', '', '.', '54001', ''),
|
||||
('FN', 'Freddy', 'Niño Prato', 'Cm San Jose Cons 302', '', '', '', 'EN', '54001', '88220061-3'),
|
||||
('JNV', 'Jenifer', 'Numa Valdes', '', '', '', '', '.', '54001', ''),
|
||||
('AOC', 'Alvaro', 'Ochoa Cuberos', '', '', '', '', 'GIN', '54001', ''),
|
||||
('CO', 'Carlos', 'Olivares', '', '', '', '', '.', '54001', ''),
|
||||
('FO', 'Felipe', 'Ordoñez', '', '', '', '', '.', '54001', ''),
|
||||
('JCO', 'Juan Carlos', 'Ortega', 'Cm Jerico', '', '', '', 'CA', '54001', ''),
|
||||
('LAOG', 'Laura Alejandra', 'Ortega Gonzalez', '', '', '', '', '.', '54001', ''),
|
||||
('NPC', 'Nancy', 'Pacheco Caceres', '', '', '', '', 'PE', '54001', ''),
|
||||
('RP1', 'Rafael', 'Padilla', 'Jerico', '', '', '', 'MG', '54001', ''),
|
||||
('OP', 'Omar', 'Paez', '', '', '', '', 'RE', '54001', '13351535'),
|
||||
('VP', 'Victor', 'Paez', '', '', '', '', 'MI', '54001', ''),
|
||||
('ZP', 'Zamir', 'Paez', 'Avenida 1 No 17-73 Cosn 702', '', '', '3504797908', 'CL', '54001', ''),
|
||||
('JP', 'Juan', 'Paez', 'Av 2e No 17a-02 Caobos', '3024449734', 'dr.juanpaez@medicinafuncional.com', '', 'GEN', '54001', ''),
|
||||
('LFPC', 'Luis Fernando', 'Paez Carrascal', '', '3138106405', '', '', 'GEN', '54001', '3545'),
|
||||
('FPC', 'Fernando', 'Paez Carrascal', '', '3124861765', 'ferpaezcarrascal@gmail.com', '3124861765', 'ON', '54001', ''),
|
||||
('GSPM', 'Gamal Salin', 'Paez Mojica', '', '', '', '', 'CGR', '54001', '1019012573'),
|
||||
('AMPP', 'Adriana', 'Paez Paez', '', '', '', '', 'GI', '54001', '1026259061'),
|
||||
('EPS', 'Efrain', 'Paez Suz', 'Av 12e-4-388 Con 104', '5745532-3002099889', '', '', 'MI', '54001', '13446107'),
|
||||
('DP', 'Deyanira', 'Paipilla', '', '', '', '', 'FIS', '54001', ''),
|
||||
('CEPT', 'Camilo Ernesto', 'Palencia Tejedor', 'Calle 13a No 1e-112', '5955421', '', '', 'MI', '54001', '7125'),
|
||||
('CFPA', 'Carlos Fernando', 'Panqueva Arias', '', '3106066503', '', '3106066503', 'PE', '54001', '1090389125'),
|
||||
('OAPP', 'Oscar Antonio', 'Parada Parada', 'Av 1 17-28', '', '', '', 'RA', '54001', '591'),
|
||||
('OFPR', 'Oscar Fernando', 'Parada Rojas', '', '', '', '', 'NEU', '54001', '79133328'),
|
||||
('WPV', 'Wolfgang', 'Parada V.', '', '3144422314', '', '', 'PLA', '54001', ''),
|
||||
('WLL', 'William', 'Parada Vecino', '', '', '', '', 'GEN', '54001', ''),
|
||||
('SP', 'Siliana', 'Parejo', '', '', '', '', 'GEN', '54001', ''),
|
||||
('MAP', 'Miguel Antonio', 'Parra', '', '', '', '3166207878', 'GEN', '54001', ''),
|
||||
('WAP', 'William Alfonso', 'Parra', '', '', '', '', 'GEN', '54001', ''),
|
||||
('LP', 'Leonardo', 'Perez', '', '', '', '', '.', '54001', ''),
|
||||
('WPB', 'William', 'Perez B', '', '', '', '', 'GEN', '54001', ''),
|
||||
('HPL', 'Hugo', 'Perez Lizcano', '', '', '', '', '.', '54001', ''),
|
||||
('JCP', 'Juan Carlos', 'Pertuz', '', '', '', '', 'NL', '54001', ''),
|
||||
('CIP', 'Carlos Ivan', 'Peñaranda', '', '', '', '', '.', '54001', ''),
|
||||
('JAP', 'Jesus Andres', 'Peñaranda', '', '', '', '', 'GEN', '54001', ''),
|
||||
('JAPG', 'Jesus Andres', 'Peñaranda Granados', 'Tiub', '', '', '', 'MG', '54810', '1091808592'),
|
||||
('CFPH', 'Carlos Felipe', 'Peñaranda Henao', 'Calle 17 No 8-88', '5831193', 'retinodrperanda@gmail.com', '3013417494', 'OF', '54001', ''),
|
||||
('WFP', 'Wilson Fernando', 'Picon', '', '', '', '', '.', '54001', ''),
|
||||
('OAP', 'Omar Alejandro', 'Pinzon', '', '', '', '', 'MG', '54001', ''),
|
||||
('DMPR', 'Diana Marcela', 'Pinzon Rincon', '', '', '', '', 'FI', '54001', ''),
|
||||
('JMPS', 'Jose Manuel', 'Pinzon Sarria', '', '', '', '', 'ORT', '54001', ''),
|
||||
('RP', 'Ricardo', 'Plazas', 'Jerico', '5717495-5725256', '', '', 'ON', '54001', ''),
|
||||
('CP', 'Cesar', 'Pompeyo', '', '', '', '', 'GEN', '54001', ''),
|
||||
('MAUAP', 'Maury Alejandra', 'Porras', '', '', '', '', 'PE', '54001', '1127057554'),
|
||||
('LPPS', 'Lina Patricia', 'Pradilla Suarez', 'Ecopetrol', '', '', '', 'MI', '54001', ''),
|
||||
('MNQ', 'Mario', 'Quintero Ocariz', 'Clinica Cancerología', '5835932-5835933', 'mariofqo@yahoo.es', '', 'HE', '54001', '13479403'),
|
||||
('MFQO', 'Mario Fernando', 'Quintero Ocariz', '', '', '', '', 'HE', '54001', '13479403'),
|
||||
('DR', 'Daniel', 'Ramirez', '', '', '', '', 'MI', '54001', ''),
|
||||
('JRF', 'Javier', 'Ramirez Figueroa', 'Calle 13a No 2e-87', '5943165-3135859398', 'javierr@hotmail.com', '3135859398', 'RE', '54001', '13446690'),
|
||||
('IRG', 'Igor', 'Ramirez Gomez', 'Cm Santa Ana Cons 104', '5743785-', 'igorramirezgomez@hotmail.com', '3005714812', 'GIN', '54001', '8745'),
|
||||
('ERG', 'Emilio', 'Ramirez Gomez', '', '', '', '', 'GAS', '54001', ''),
|
||||
('ARM', 'Alvaro', 'Ramirez Morelli', 'Cl 7 9e-15 Colsag', '5773167', '', '', 'PE', '54001', '13253656'),
|
||||
('GRM', 'Gerardo', 'Ramirez Morelli', '', '', '', '', 'ORT', '54001', ''),
|
||||
('LARO', 'Luis Antonio', 'Ramirez Ortega', '', '', '', '', 'MI', '54001', '1232407096'),
|
||||
('JURR', 'Jairo Uriel', 'Ramirez Ramirez', '', '', '', '', 'MG', '54001', '1098712304'),
|
||||
('CR', 'María Cristina', 'Ramos', 'Jerico Cons 404', '3182285662', 'contyacto@dracristinaramos.com.co', '', 'ORT', '54001', ''),
|
||||
('CRP', 'Candelaria', 'Ramos Padilla', '', '', '', '', 'BAC', '54001', ''),
|
||||
('OR', 'Omar', 'Rangel', '', '', '', '', '.', '54001', ''),
|
||||
('ERO', 'Emma', 'Reyes Oviedo', 'Cm San Jose Cons 407', '', 'reyesemma@hotmail.com', '', 'DE', '54001', ''),
|
||||
('JARR', 'Julio A', 'Reyes Ramon', 'Mz 2 Lote 12a 1-3 Claret', '', '', '', 'URO', '54001', ''),
|
||||
('MAR', 'Maira Alejandra', 'Rincon', '', '', 'mairita_a@hotmail.com', '', 'URO', '54001', ''),
|
||||
('FRU', 'Fredy Alberto', 'Rivero', '', '', '', '', 'CV', '54001', '7570687'),
|
||||
('FARU', 'Fabio Alberto', 'Rivero U', '', '', '', '', 'CV', '54001', '7570687'),
|
||||
('MRU', 'Monica', 'Rivero Ustariz', '', '', '', '', 'AN', '54001', ''),
|
||||
('YJY', 'Yeisson Yinary', 'Rodriguez', '', '', '', '', 'GEN', '54001', ''),
|
||||
('JR', 'Julian', 'Rodriguez', 'Tibu', '', '', '', 'GEN', '54001', ''),
|
||||
('JARB', 'Jaime A', 'Rodriguez B.', 'Av 0 21-38 Local 105', '', '', '', '.', '54001', ''),
|
||||
('SRG', 'Santiago', 'Rodriguez Garcia', 'Calle 18 1e', '', '', '', 'CP', '54001', '736'),
|
||||
('MZR', 'Michel Zarick', 'Rodriguez Ortega', '', '', '', '', 'MG', '54001', '1193388012'),
|
||||
('MRQ', 'Martha Janice', 'Rodriguez Quintero', '', '', '', '', 'OF', '54001', '44202'),
|
||||
('GR', 'Gabriel', 'Rodriguez R', 'Anenida 2 No 17-94', '5835932-5835933', '', '', 'ON', '54001', '8702987'),
|
||||
('HRR', 'Hernando', 'Rodriguez Ramirez', 'Calle 9 # 6e''21', '', '', '', 'ON', '54001', ''),
|
||||
('ARV', 'Asid', 'Rodriguez Villanueva', '', '', '', '', 'CGR', '54001', ''),
|
||||
('BR', 'Beatriz', 'Rojas', '', '', '', '', 'GEN', '54001', ''),
|
||||
('DMRA', 'Diana Maria', 'Rojas Aguilar', '', '', '', '', 'G', '54001', '1032382184'),
|
||||
('CARD', 'Carlos Augusto', 'Rojas Diaz', '', '', '', '', 'ON', '54001', ''),
|
||||
('RDR', 'Rafael Dario', 'Rolon', '', '', '', '', 'GIN', '54001', ''),
|
||||
('HMPA', 'Heydi Milena', 'Rolon Ascanio', 'Av 0 No 13-99', '', '', '', 'MG', '54001', ''),
|
||||
('TR', 'Tatiana', 'Romero', '', '', '', '', 'OF', '54001', '52049553'),
|
||||
('GRL', 'Laboratorio Gabriela', 'Romero Luna', 'Av 9 11-27', '5731865-', 'gaby_romero_luna@hotmail.com', '', 'BAC', '54001', '111'),
|
||||
('ACR', 'Ana Cecilia', 'Roso', '', '', '', '', 'MG', '54001', '0024335'),
|
||||
('AR', 'Anderson', 'Rozo', '', '', '', '', 'FI', '54001', ''),
|
||||
('DGRC', 'Diana Guadalupe', 'Rueda Caceres', 'Av 2e No 17a-02 Local 2', '', 'dianiseu02@hotmail.com', '', 'PE', '54001', '1090379335'),
|
||||
('ECO', 'Econorte', 'S.a.s', 'Calle 10 1-19 Cc Ortegon L 104', '3160447199', '', '3160447199', 'RA', '54001', 'Rm 1228'),
|
||||
('AMSB', 'Ana Maria', 'Saad Brahim', 'Cm Jerico', '', '', '', 'MI', '54001', '60351958'),
|
||||
('XS', 'Ximena', 'Saieh', '', '', '', '', 'FI', '54001', ''),
|
||||
('CFSS', 'Carlos F', 'Saieh S', 'C M Santa Ana Cons 106', '', '', '', 'GI', '54001', ''),
|
||||
('ES', 'Edgar', 'Salas', 'Calle 13 No 1e-81', '3174270921', '', '', 'ODO', '54001', '88197687'),
|
||||
('MLS', 'Martha Liliana', 'Salgar', 'Calle 13 1e-74 Cons 205', '5722457', '', '', 'BAC', '54001', ''),
|
||||
('ESV', 'Edgar Jose', 'Salgar Villamizar', 'Cm San Jose Cons 103', '', '', '', 'GAS', '54001', '13221189'),
|
||||
('CESL', 'Carlos E', 'Sanabria L', '', '', '', '', 'HE', '54001', ''),
|
||||
('ASL', 'Antonio', 'Sanabria Labrador', '', '', '', '', 'GEN', '54001', ''),
|
||||
('SANA', 'Sanaty', 'Sanaty', 'Calle 13a No 1e-112', '3183722711', '', '3185762377', 'MG', '54001', ''),
|
||||
('ESA', 'Eugenio', 'Sanchez Arias', 'Calle 17 No 2e-06 Caobos', '5726711', 'eugeniosanchez13@hotmail.com', '3158037484', 'ES', '54001', ''),
|
||||
('RASC', 'Ramon Alberto', 'Sanchez Carballo', 'Ave 11e No 8a-155', '3158112330', '', '', 'PE', '54001', '79502155'),
|
||||
('333', 'Robinson', 'Sanchez Garcia', '', '', '', '', 'CA', '54001', '91492347'),
|
||||
('MJSP', 'Milton Javier', 'Sanchez Perez', 'Calle 13 A No 1e-112 Caobos Sa', '5955421', '', '', 'GEN', '54001', ''),
|
||||
('JS', 'Jaime', 'Sanchez R', '', '', '', '', 'ORT', '54001', ''),
|
||||
('MPSC', 'María Paula', 'Santos Contreras', '', '', '', '', 'MG', '54001', '1000943027'),
|
||||
('PS', 'Paulo A', 'Santos Rivera', 'Uronorte', '5722722', '', '', 'URO', '54001', '88225847'),
|
||||
('CASR', 'Carlos Augusto', 'Sarmiento Riveros', '', '', '', '', 'CGR', '54001', ''),
|
||||
('MS', 'Mauricio', 'Sarrazola', 'Cm San Jose Cons 510b', '', '', '', 'ALE', '54001', '72125164'),
|
||||
('JKSR', 'Johann', 'Schloeter', '', '', '', '', 'GAS', '54001', '655171'),
|
||||
('PSR', 'Pablo', 'Segura R', '', '', '', '', '.', '54001', ''),
|
||||
('AS', 'Angel', 'Sepulveda', '', '', '', '', 'FI', '54001', ''),
|
||||
('SS', 'Sandra', 'Sepulveda', '', '', '', '', 'NF', '54001', ''),
|
||||
('DSS', 'Diego', 'Serna Suarez', '', '', '', '', 'GEN', '54001', '1082924044'),
|
||||
('JCSC', 'Juan Carlos', 'Serrano Casas', '', '', '', '', 'FI', '54001', ''),
|
||||
('GSN', 'Gabriel', 'Sierra', '', '', '', '', 'NL', '54001', ''),
|
||||
('KLSCL', 'Karen Liliana', 'Sierra Claro', '', '', '', '', 'MG', '54001', '1094283456'),
|
||||
('JIS', 'Jesus Ivan', 'Sierra Laguado', '', '', '', '', 'GIN', '54001', ''),
|
||||
('GS', 'German', 'Silva', '', '', '', '', 'ODO', '54001', ''),
|
||||
('MSB', 'Melissa', 'Silva', '', '', '', '', 'MI', '54001', '60447247'),
|
||||
('ASMB', 'Aura Melisa', 'Silva Barbosa', '', '', '', '', 'EN', '54001', '60447247'),
|
||||
('JFSP', 'Juan Fernando', 'Silva Perez', '', '', '', '', 'URO', '54001', ''),
|
||||
('CS', 'Claudia', 'Sinisterra', '', '', '', '', '.', '54001', ''),
|
||||
('VS', 'Virgelma', 'Solano', '', '', '', '', 'BAC', '54001', ''),
|
||||
('JHSE', 'Jesus Hernando', 'Solano Espinosa', '', '', '', '', 'GIN', '54001', '88154570'),
|
||||
('AMSM', 'Angela Maria', 'Solano Moreno', 'Pamplona', '', '', '', 'PE', '54001', '52816457'),
|
||||
('YSV', 'Yuanet', 'Solias Villalta', '', '6075770337', '', '3054803890', 'DE', '54001', ''),
|
||||
('LCS', 'Luis Carlos', 'Soto', '', '', '', '', 'CV', '54001', ''),
|
||||
('CAS', 'Carlos Andres', 'Suarez', 'Csj Cons 401b', '5836283-3202400048', '', '', 'ALT', '54001', '88208825'),
|
||||
('FOSC', 'Fabio O', 'Suarez Castrillon', '', '', '', '', 'URO', '54001', ''),
|
||||
('ESM', 'Elizabeth', 'Suarez Marin', 'Jerico Cons 504', '', '', '', 'PE', '54001', ''),
|
||||
('JSS', 'Juan', 'Sus Slim', 'Vias Digestivas Riviera', '', '', '', 'CGR', '54001', ''),
|
||||
('MATV', 'Manuel Alberto', 'Tellez Vargas', 'Foscal', '', '', '', 'GEN', '68001', '13352271'),
|
||||
('TIBU', 'Tibu', 'Tibu', '', '', '', '', 'GEN', '54001', ''),
|
||||
('YPT', 'Yeimy Paola', 'Torrado', '', '', '', '', 'MI', '54001', ''),
|
||||
('LYTE', 'Leidy Yajaira', 'Torres', '', '', '', '', 'FI', '54001', ''),
|
||||
('KTA', 'Katerin', 'Torres Auce', '', '', '', '', 'GIN', '54001', ''),
|
||||
('METR', 'Milton E', 'Torres Ramirez', '', '3015898818', '', '', 'MC', '54001', '80173645'),
|
||||
('GT', 'Gustavo Eduardo', 'Tovar Ovallo', 'Sanaty', '', 'gtovarovallos@gmail.com', '', 'MC', '54001', ''),
|
||||
('MLTV', 'Magda Lucero', 'Trujillo Vargas', '', '', '', '', 'PE', '54001', ''),
|
||||
('SU', 'Sergio Enrique', 'Urbina Echeverry', 'Cm Jerico', '', 'serurbina@hotmail.com', '', 'GI', '54001', ''),
|
||||
('AEUF', 'Alvaro Eduardo', 'Uribe Figueroa', '', '', '', '', 'URO', '54001', '1090396165'),
|
||||
('MAUG', 'Mauricio Alfonso', 'Uribe Gil', '', '', '', '', 'GEN', '54001', '13496272'),
|
||||
('SUG', 'Sandra Milena', 'Uribe Granados', 'Av. 1 #17-73', '315 4255404', '', '', 'PE', '54001', ''),
|
||||
('HUM', 'Humberto', 'Uribe Morelly', '', '', '', '', 'CL', '54001', ''),
|
||||
('LUM', 'Leidy', 'Uscategui Mendez', '', '', '', '', 'MG', '54001', '1026570678'),
|
||||
('SUM', 'Sandra', 'Uscategui Moreno', '', '', '', '', 'FI', '54001', ''),
|
||||
('RHUO', 'Rosmery Hysleyne', 'Uzcategui Ortega', '', '', '', '', 'GEN', '54001', '1093797829'),
|
||||
('ANV', 'Angela', 'Valbuena', '', '', '', '', 'GI', '54001', ''),
|
||||
('ZV', 'Zenair', 'Valero', '', '', '', '', '.', '54001', '487892'),
|
||||
('JJV', 'Juan Jose', 'Vanegas Acevedo', 'Avenida 1 No 15-04', '', 'clinicasandiegocucuta@gmail.com', '', 'OF', '54001', ''),
|
||||
('JJVG', 'Juan Jose', 'Vargas', 'Coneuro', '', '', '', 'NL', '54001', 'Gelvis'),
|
||||
('JCV', 'Juan Carlos', 'Vargas', '', '', '', '', 'NF', '54001', ''),
|
||||
('DCVA', 'Diana Carolina', 'Vargas Angel', '', '', '', '', 'NF', '54001', '1032438553'),
|
||||
('DDVJ', 'David Dario', 'Varon Jaimes', '', '', '', '', 'NF', '54001', ''),
|
||||
('BVH', 'Bernardo', 'Vega Henao', '', '', '', '', 'GIN', '54001', ''),
|
||||
('GJVS', 'Gerardo Jose', 'Vega Sosa', 'Vitta Consul 803', '', '', '', 'GI', '54001', '1127660821'),
|
||||
('JFVA', 'Joaquin Fernando', 'Velez Ascanio', '', '', '', '', 'NL', '54001', '1065564961'),
|
||||
('JCVM', 'Juan Carlos', 'Vergel Martinez', 'Calle 16 No 0e-15', '5715082-5715092', '', '3103042108', 'GIN', '54001', '1090390783'),
|
||||
('LFVT', 'Luis Freddy', 'Vergel Torrents', '', '', '', '', 'GIN', '54001', ''),
|
||||
('AV', 'Analizar', 'Veterinario', '', '', '', '', '.', '54001', ''),
|
||||
('JV', 'Jeyson', 'Vidal', '', '', '', '', 'MI', '54001', ''),
|
||||
('JIVP', 'Jorge Isaac', 'Villabona Perez', 'Cl 14 12-77', '3157520392', '', '', '.', '54001', ''),
|
||||
('ENVT', 'Ever Nai', 'Villada Toro', '', '', '', '', 'NL', '54001', '10113193'),
|
||||
('10113193', 'Ever Nai', 'Villada Toro', '', '', '', '', 'NL', '54001', '10113193'),
|
||||
('SV', 'Sergio', 'Villamizar', '', '', '', '', 'MI', '54001', ''),
|
||||
('DVB', 'Diana', 'Villamizar Bacca', 'Calle 1 No 1e-91 Faroles', '', 'dradianavillamizar@gmail.com', '', 'PE', '54001', ''),
|
||||
('OAVG', 'Orlando Afranio', 'Villamizar Galvis', '', '', '', '', 'GIN', '54001', '13459776'),
|
||||
('HV', 'Hernando Antonio', 'Villamizar Gomez', 'Calle 15 Av 0', '', '', '', 'PE', '54001', ''),
|
||||
('OVJ', 'Orlando', 'Villamizar Jaimes', 'Av 1 17-14', '5834206-3152589452', '', '', 'GIN', '54001', '13459776'),
|
||||
('JHVP', 'Jairo Hunmberto', 'Villamizar Peñaranda', '', '', '', '', 'DE', '54001', ''),
|
||||
('GMV', 'Giovanna Maria', 'Villamizar Real', 'Clinica San Diego', '5960150', 'clinicasandiegocucuta@gtmail.com', '3175018617', 'OF', '54001', '32764821'),
|
||||
('EV', 'Enrique', 'Villamizar Zuñiga', '', '', '', '', 'PE', '54001', ''),
|
||||
('JY', 'Julian', 'Yañez Hartman', 'Cm Jerico', '', '', '', 'GI', '54001', '80096866'),
|
||||
('JCZS', 'Juan Carlos', 'Zabala Sierra', '', '', '', '', '.', '54001', ''),
|
||||
('AEZH', 'Aleyda E', 'Zabaleta Hernández', 'Avenida 1 No 6-49', '', '', '3123699223', 'MC', '54001', '5431'),
|
||||
('JDFZ', 'Juan De Francisco', 'Zambrano', 'Cra 16 No,84a-09 Cons 712', '', '', '3118134483', 'GAS', '54001', '79140821'),
|
||||
('JGZJ', 'Juan Guillermo', 'Zapata Jaramillo', '', '', '', '', '.', '54001', '')
|
||||
ON DUPLICATE KEY UPDATE nombres=VALUES(nombres);
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE turnero_solicitudes
|
||||
MODIFY COLUMN metodo_pago ENUM(
|
||||
'efectivo','transferencia','tarjeta','eps','cortesia','combinado'
|
||||
) DEFAULT NULL,
|
||||
ADD COLUMN pagos_detalle JSON DEFAULT NULL
|
||||
COMMENT 'Desglose de pago combinado {efectivo, transferencia, tarjeta}'
|
||||
AFTER metodo_pago;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE turnero_solicitudes
|
||||
ADD COLUMN solo_muestras TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT 'Paciente solo entrega muestras, sin exámenes a registrar'
|
||||
AFTER embarazada;
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
/**
|
||||
* Ejecuta las migraciones SQL pendientes.
|
||||
* Uso: php migrations/run_pendientes.php
|
||||
*/
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
$pendientes = [
|
||||
'20260702_turnero_solicitudes_embarazada.sql',
|
||||
'20260703_pago_combinado.sql',
|
||||
'20260703_solo_muestras.sql',
|
||||
'20260703_medicos.sql',
|
||||
'20260703_medicos_seed.sql',
|
||||
'20260703_solicitud_medico.sql',
|
||||
];
|
||||
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
echo "\n── Migraciones pendientes ──────────────────\n\n";
|
||||
|
||||
foreach ($pendientes as $file) {
|
||||
$path = __DIR__ . '/' . $file;
|
||||
if (!file_exists($path)) {
|
||||
echo " ⚠ No encontrado: $file\n";
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$pdo->exec(file_get_contents($path));
|
||||
echo " ✅ $file\n";
|
||||
} catch (PDOException $e) {
|
||||
// Si ya existe la columna/tabla, no es error crítico
|
||||
$msg = $e->getMessage();
|
||||
if (str_contains($msg, 'Duplicate column') || str_contains($msg, 'already exists')) {
|
||||
echo " ⏭ $file (ya aplicada)\n";
|
||||
} else {
|
||||
echo " ❌ $file → $msg\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n── Listo ───────────────────────────────────\n\n";
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok'=>false,'error'=>'Método no permitido']); exit;
|
||||
}
|
||||
|
||||
$datos = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$id = !empty($datos['id']) ? (int)$datos['id'] : 0;
|
||||
|
||||
if (!$id) { echo json_encode(['ok'=>false,'error'=>'ID inválido.']); exit; }
|
||||
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
$stmt = $pdo->prepare("DELETE FROM medicos WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
|
||||
if ($stmt->rowCount()) {
|
||||
echo json_encode(['ok'=>true,'mensaje'=>'Médico eliminado.']);
|
||||
} else {
|
||||
echo json_encode(['ok'=>false,'error'=>'Médico no encontrado.']);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
$buscar = trim($_GET['q'] ?? '');
|
||||
|
||||
if ($buscar !== '') {
|
||||
$like = '%' . $buscar . '%';
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT id, codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico, activo
|
||||
FROM medicos
|
||||
WHERE nombres LIKE ? OR apellidos LIKE ? OR codigo LIKE ? OR docidmedico LIKE ?
|
||||
ORDER BY apellidos, nombres"
|
||||
);
|
||||
$stmt->execute([$like, $like, $like, $like]);
|
||||
} else {
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico, activo
|
||||
FROM medicos ORDER BY apellidos, nombres"
|
||||
);
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok'=>false,'error'=>'Método no permitido']); exit;
|
||||
}
|
||||
|
||||
$datos = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$id = !empty($datos['id']) ? (int) $datos['id'] : null;
|
||||
$codigo = strtoupper(trim($datos['codigo'] ?? ''));
|
||||
$nombres = trim($datos['nombres'] ?? '');
|
||||
$apellidos = trim($datos['apellidos'] ?? '');
|
||||
$especialidad = trim($datos['cod_especialidad'] ?? '');
|
||||
$docid = trim($datos['docidmedico'] ?? '');
|
||||
$telefonos = trim($datos['telefonos'] ?? '');
|
||||
$email = trim($datos['email'] ?? '');
|
||||
|
||||
if (!$codigo) { echo json_encode(['ok'=>false,'error'=>'El código es obligatorio.']); exit; }
|
||||
if (!$nombres) { echo json_encode(['ok'=>false,'error'=>'Los nombres son obligatorios.']); exit; }
|
||||
if (!$apellidos) { echo json_encode(['ok'=>false,'error'=>'Los apellidos son obligatorios.']); exit; }
|
||||
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
|
||||
if ($id) {
|
||||
// Editar
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE medicos SET codigo=?, nombres=?, apellidos=?, telefonos=?, email=?, cod_especialidad=?, docidmedico=?
|
||||
WHERE id=?"
|
||||
);
|
||||
$stmt->execute([$codigo, $nombres, $apellidos, $telefonos ?: null, $email ?: null, $especialidad ?: null, $docid ?: null, $id]);
|
||||
echo json_encode(['ok'=>true,'mensaje'=>'Médico actualizado correctamente.']);
|
||||
} else {
|
||||
// Crear — verificar código único
|
||||
$existe = $pdo->prepare("SELECT id FROM medicos WHERE codigo = ?");
|
||||
$existe->execute([$codigo]);
|
||||
if ($existe->fetch()) {
|
||||
echo json_encode(['ok'=>false,'error'=>"El código '$codigo' ya existe."]); exit;
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO medicos (codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->execute([$codigo, $nombres, $apellidos, $telefonos ?: null, $email ?: null, $especialidad ?: null, $docid ?: null]);
|
||||
echo json_encode(['ok'=>true,'id'=>(int)$pdo->lastInsertId(),'mensaje'=>'Médico creado correctamente.']);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php return [
|
||||
'slug' => 'medicos',
|
||||
'name' => 'Médicos',
|
||||
'icon' => 'fas fa-user-md',
|
||||
'category' => 'clinico',
|
||||
'route' => '/erp.php?m=medicos&v=index',
|
||||
'is_active' => true,
|
||||
'sort_order' => 51,
|
||||
'oleada' => 0,
|
||||
'description' => 'Gestión del catálogo de médicos',
|
||||
'links' => [
|
||||
['name' => 'Médicos', 'icon' => 'fas fa-user-md', 'route' => '/erp.php?m=medicos&v=index'],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
require_once APP_ROOT . '/config/config.php';
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
|
||||
Layout::open('Médicos', 'fas fa-user-md');
|
||||
$API = BASE_URL . 'modules/medicos/api/';
|
||||
?>
|
||||
<style>
|
||||
body { background: #f1f5f9; }
|
||||
.page-header {
|
||||
background: #fff; border-bottom: 1px solid #e2e8f0;
|
||||
padding: 14px 24px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
}
|
||||
.page-header h1 { font-size: 1.18rem; font-weight: 700; color: #1e293b; margin: 0; flex: 1; }
|
||||
.content-wrap { max-width: 1100px; margin: 24px auto; padding: 0 16px 48px; }
|
||||
.card-box { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; }
|
||||
.card-box .toolbar {
|
||||
padding: 14px 16px; display: flex; gap: 10px; align-items: center;
|
||||
border-bottom: 1px solid #e2e8f0; flex-wrap: wrap;
|
||||
}
|
||||
.card-box .toolbar input { max-width: 260px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
||||
thead th { background: #f8fafc; padding: 10px 14px; font-size: .72rem; text-transform: uppercase;
|
||||
letter-spacing: .07em; color: #64748b; border-bottom: 1px solid #e2e8f0; text-align: left; }
|
||||
tbody td { padding: 10px 14px; border-bottom: 1px solid #f1f5f9; color: #1e293b; vertical-align: middle; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover td { background: #f8fafc; }
|
||||
.badge-esp { background: #eff6ff; color: #1d4ed8; border-radius: 20px; padding: 2px 10px;
|
||||
font-size: .75rem; font-weight: 600; }
|
||||
.acciones { display: flex; gap: 6px; }
|
||||
#tbl-empty { text-align: center; padding: 3rem; color: #94a3b8; }
|
||||
</style>
|
||||
|
||||
<div class="page-header">
|
||||
<i class="fas fa-user-md" style="font-size:1.3rem;color:#6366f1"></i>
|
||||
<h1>Médicos</h1>
|
||||
<button class="btn btn-primary btn-sm ms-auto" onclick="abrirModal()">
|
||||
<i class="fas fa-plus me-1"></i>Nuevo médico
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="content-wrap">
|
||||
<div class="card-box">
|
||||
<div class="toolbar">
|
||||
<input type="search" id="inp-buscar" class="form-control form-control-sm"
|
||||
placeholder="Buscar por nombre, código o documento…" oninput="buscar()">
|
||||
<span class="text-muted small ms-auto" id="lbl-total"></span>
|
||||
</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table id="tbl-medicos">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Código</th>
|
||||
<th>Nombres</th>
|
||||
<th>Apellidos</th>
|
||||
<th>Especialidad</th>
|
||||
<th>Teléfonos</th>
|
||||
<th>Doc. ID</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbl-body">
|
||||
<tr id="tbl-empty"><td colspan="6">Cargando…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Modal agregar / editar ══════════════════════════════════════ -->
|
||||
<div class="modal fade" id="modalMedico" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modal-titulo">Nuevo médico</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="modal-alert" class="alert alert-danger d-none py-2"></div>
|
||||
<input type="hidden" id="med-id">
|
||||
<div class="row g-3">
|
||||
<div class="col-4">
|
||||
<label class="form-label small fw-semibold">Código <span class="text-danger">*</span></label>
|
||||
<input type="text" id="med-codigo" class="form-control form-control-sm"
|
||||
placeholder="Ej: MED001" maxlength="20">
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<label class="form-label small fw-semibold">Doc. ID Médico</label>
|
||||
<input type="text" id="med-docid" class="form-control form-control-sm"
|
||||
placeholder="Número de documento" maxlength="30">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-semibold">Nombres <span class="text-danger">*</span></label>
|
||||
<input type="text" id="med-nombres" class="form-control form-control-sm"
|
||||
placeholder="Nombres" maxlength="100">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-semibold">Apellidos <span class="text-danger">*</span></label>
|
||||
<input type="text" id="med-apellidos" class="form-control form-control-sm"
|
||||
placeholder="Apellidos" maxlength="100">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-semibold">Teléfonos</label>
|
||||
<input type="text" id="med-telefonos" class="form-control form-control-sm"
|
||||
placeholder="Teléfonos" maxlength="50">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-semibold">Email</label>
|
||||
<input type="email" id="med-email" class="form-control form-control-sm"
|
||||
placeholder="correo@ejemplo.com" maxlength="100">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Código especialidad</label>
|
||||
<input type="text" id="med-esp" class="form-control form-control-sm"
|
||||
placeholder="Ej: GEN, PED, GIN…" maxlength="10">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="btn-guardar" onclick="guardar()">
|
||||
<i class="fas fa-save me-1"></i>Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Modal confirmar eliminar ══════════════════════════════════════ -->
|
||||
<div class="modal fade" id="modalEliminar" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title text-danger"><i class="fas fa-trash me-1"></i>Eliminar médico</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
¿Eliminar a <strong id="del-nombre"></strong>? Esta acción no se puede deshacer.
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" id="btn-confirmar-del" onclick="confirmarEliminar()">
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '<?= $API ?>';
|
||||
let _modal, _modalDel, _pendingDelId, _buscarTimer;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
_modal = new bootstrap.Modal(document.getElementById('modalMedico'));
|
||||
_modalDel = new bootstrap.Modal(document.getElementById('modalEliminar'));
|
||||
cargar();
|
||||
});
|
||||
|
||||
async function cargar(q = '') {
|
||||
const url = API + 'list.php' + (q ? '?q=' + encodeURIComponent(q) : '');
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
if (!json.ok) return;
|
||||
renderTabla(json.data);
|
||||
}
|
||||
|
||||
function buscar() {
|
||||
clearTimeout(_buscarTimer);
|
||||
_buscarTimer = setTimeout(() => cargar(document.getElementById('inp-buscar').value.trim()), 280);
|
||||
}
|
||||
|
||||
function renderTabla(rows) {
|
||||
const body = document.getElementById('tbl-body');
|
||||
document.getElementById('lbl-total').textContent = rows.length + ' médico(s)';
|
||||
if (!rows.length) {
|
||||
body.innerHTML = '<tr id="tbl-empty"><td colspan="6">Sin resultados.</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = rows.map(m => `
|
||||
<tr>
|
||||
<td><code>${esc(m.codigo)}</code></td>
|
||||
<td>${esc(m.nombres)}</td>
|
||||
<td>${esc(m.apellidos)}</td>
|
||||
<td>${m.cod_especialidad ? `<span class="badge-esp">${esc(m.cod_especialidad)}</span>` : '<span class="text-muted">—</span>'}</td>
|
||||
<td style="font-size:.82rem;color:#475569">${esc(m.telefonos || '—')}</td>
|
||||
<td>${esc(m.docidmedico || '—')}</td>
|
||||
<td>
|
||||
<div class="acciones">
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-2" onclick='abrirEditar(${JSON.stringify(m)})'>
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-2" onclick="pedirEliminar(${m.id}, '${esc(m.nombres)} ${esc(m.apellidos)}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function abrirModal(m = null) {
|
||||
document.getElementById('modal-titulo').textContent = m ? 'Editar médico' : 'Nuevo médico';
|
||||
document.getElementById('modal-alert').classList.add('d-none');
|
||||
document.getElementById('med-id').value = m?.id ?? '';
|
||||
document.getElementById('med-codigo').value = m?.codigo ?? '';
|
||||
document.getElementById('med-nombres').value = m?.nombres ?? '';
|
||||
document.getElementById('med-apellidos').value = m?.apellidos ?? '';
|
||||
document.getElementById('med-telefonos').value = m?.telefonos ?? '';
|
||||
document.getElementById('med-email').value = m?.email ?? '';
|
||||
document.getElementById('med-esp').value = m?.cod_especialidad ?? '';
|
||||
document.getElementById('med-docid').value = m?.docidmedico ?? '';
|
||||
_modal.show();
|
||||
setTimeout(() => document.getElementById('med-codigo').focus(), 300);
|
||||
}
|
||||
|
||||
function abrirEditar(m) { abrirModal(m); }
|
||||
|
||||
async function guardar() {
|
||||
const alerta = document.getElementById('modal-alert');
|
||||
alerta.classList.add('d-none');
|
||||
|
||||
const payload = {
|
||||
id: document.getElementById('med-id').value || null,
|
||||
codigo: document.getElementById('med-codigo').value.trim(),
|
||||
nombres: document.getElementById('med-nombres').value.trim(),
|
||||
apellidos: document.getElementById('med-apellidos').value.trim(),
|
||||
telefonos: document.getElementById('med-telefonos').value.trim(),
|
||||
email: document.getElementById('med-email').value.trim(),
|
||||
cod_especialidad: document.getElementById('med-esp').value.trim(),
|
||||
docidmedico: document.getElementById('med-docid').value.trim(),
|
||||
};
|
||||
|
||||
const btn = document.getElementById('btn-guardar');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando…';
|
||||
|
||||
try {
|
||||
const res = await fetch(API + 'save.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) {
|
||||
alerta.textContent = json.error;
|
||||
alerta.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
_modal.hide();
|
||||
cargar(document.getElementById('inp-buscar').value.trim());
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar';
|
||||
}
|
||||
}
|
||||
|
||||
function pedirEliminar(id, nombre) {
|
||||
_pendingDelId = id;
|
||||
document.getElementById('del-nombre').textContent = nombre;
|
||||
_modalDel.show();
|
||||
}
|
||||
|
||||
async function confirmarEliminar() {
|
||||
const btn = document.getElementById('btn-confirmar-del');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(API + 'delete.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: _pendingDelId }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.ok) {
|
||||
_modalDel.hide();
|
||||
cargar(document.getElementById('inp-buscar').value.trim());
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
<?php Layout::close(); ?>
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/**
|
||||
* API - Lista de contactos del chat turnero
|
||||
* Devuelve usuarios que tienen conversaciones con canal='turnero',
|
||||
* con el último mensaje y conteo de no leídos.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
requireAuthentication();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$limit = max(1, min(200, intval($_GET['limit'] ?? 50)));
|
||||
$page = max(1, intval($_GET['page'] ?? 1));
|
||||
$offset = ($page - 1) * $limit;
|
||||
$search = trim($_GET['search'] ?? '');
|
||||
|
||||
$whereClauses = ["c.canal = 'turnero'"];
|
||||
$params = [];
|
||||
|
||||
if ($search !== '') {
|
||||
$whereClauses[] = "(u.name LIKE :s1 OR u.phone_number LIKE :s2)";
|
||||
$params[':s1'] = '%' . $search . '%';
|
||||
$params[':s2'] = '%' . $search . '%';
|
||||
}
|
||||
|
||||
$where = implode(' AND ', $whereClauses);
|
||||
|
||||
$sql = "SELECT
|
||||
u.id AS user_id,
|
||||
COALESCE(u.name, u.phone_number) AS name,
|
||||
u.phone_number,
|
||||
u.avatar_url,
|
||||
lm.content AS last_message,
|
||||
lm.direction AS last_direction,
|
||||
lm.message_type AS last_message_type,
|
||||
lm.created_at AS last_time,
|
||||
IFNULL(SUM(CASE WHEN c.direction = 'incoming' AND (c.is_read IS NULL OR c.is_read = 0) THEN 1 ELSE 0 END), 0) AS unread_count
|
||||
FROM users u
|
||||
JOIN conversations c ON c.user_id = u.id AND c.canal = 'turnero'
|
||||
LEFT JOIN (
|
||||
SELECT t1.*
|
||||
FROM conversations t1
|
||||
JOIN (
|
||||
SELECT user_id, MAX(created_at) AS last_time
|
||||
FROM conversations
|
||||
WHERE canal = 'turnero'
|
||||
GROUP BY user_id
|
||||
) t2 ON t1.user_id = t2.user_id AND t1.created_at = t2.last_time AND t1.canal = 'turnero'
|
||||
) lm ON lm.user_id = u.id
|
||||
WHERE {$where}
|
||||
GROUP BY u.id
|
||||
ORDER BY lm.created_at DESC
|
||||
LIMIT {$limit} OFFSET {$offset}";
|
||||
|
||||
$rows = $db->fetchAll($sql, $params);
|
||||
|
||||
$totalRow = $db->fetch(
|
||||
"SELECT COUNT(DISTINCT c.user_id) AS cnt FROM conversations c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE c.canal = 'turnero'" . ($search !== '' ? " AND (u.name LIKE :s1 OR u.phone_number LIKE :s2)" : ''),
|
||||
$search !== '' ? [':s1' => '%'.$search.'%', ':s2' => '%'.$search.'%'] : []
|
||||
);
|
||||
|
||||
$data = array_map(function($r) {
|
||||
return [
|
||||
'user_id' => intval($r['user_id']),
|
||||
'name' => $r['name'],
|
||||
'phone_number' => $r['phone_number'],
|
||||
'avatar_url' => $r['avatar_url'] ?? null,
|
||||
'last_message' => $r['last_message'] ?? '',
|
||||
'last_direction' => $r['last_direction'] ?? 'incoming',
|
||||
'last_message_type' => $r['last_message_type'] ?? 'text',
|
||||
'last_time' => $r['last_time'] ?? null,
|
||||
'unread_count' => intval($r['unread_count']),
|
||||
];
|
||||
}, $rows ?: []);
|
||||
|
||||
$total = intval($totalRow['cnt'] ?? 0);
|
||||
$hasMore = ($page * $limit) < $total;
|
||||
|
||||
echo json_encode(['success' => true, 'data' => $data, 'total' => $total, 'has_more' => $hasMore, 'page' => $page]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('chat_get_list.php: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Error interno']);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
/**
|
||||
* API - Mensajes de un contacto en el canal turnero
|
||||
* Soporta paginación hacia atrás (before / before_id) y polling (since).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
requireAuthentication();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
|
||||
try {
|
||||
$userId = intval($_GET['user_id'] ?? 0);
|
||||
if (!$userId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'user_id requerido']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$limit = max(1, min(200, intval($_GET['limit'] ?? 50)));
|
||||
$before = !empty($_GET['before']) ? $_GET['before'] : null;
|
||||
$beforeId = !empty($_GET['before_id']) ? intval($_GET['before_id']) : null;
|
||||
$since = !empty($_GET['since']) ? $_GET['since'] : null;
|
||||
|
||||
$tsRegex = '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/';
|
||||
if ($before && !preg_match($tsRegex, $before)) {
|
||||
http_response_code(400); echo json_encode(['success' => false, 'error' => 'before inválido']); exit;
|
||||
}
|
||||
if ($since && !preg_match($tsRegex, $since)) {
|
||||
http_response_code(400); echo json_encode(['success' => false, 'error' => 'since inválido']); exit;
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
$params = ['user_id' => $userId];
|
||||
|
||||
$sql = "SELECT
|
||||
c.id, c.user_id, c.content, c.message_id,
|
||||
c.media_url, c.local_file, c.local_thumb,
|
||||
c.filename, c.mime_type,
|
||||
c.direction, c.message_type, c.status,
|
||||
c.created_at, COALESCE(c.is_read, 0) AS is_read,
|
||||
c.reply_to_message_id, c.reaction_emoji, c.reaction_to_message_id,
|
||||
u.phone_number AS user_phone
|
||||
FROM conversations c
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE c.user_id = :user_id AND c.canal = 'turnero'";
|
||||
|
||||
if ($since) {
|
||||
$sql .= " AND c.created_at > :since";
|
||||
$params['since'] = $since;
|
||||
$sql .= " ORDER BY c.created_at ASC, c.id ASC LIMIT {$limit}";
|
||||
} else {
|
||||
if ($before) {
|
||||
$sql .= " AND (c.created_at < :before_lt";
|
||||
$params['before_lt'] = $before;
|
||||
if ($beforeId) {
|
||||
$sql .= " OR (c.created_at = :before_eq AND c.id < :before_id)";
|
||||
$params['before_eq'] = $before;
|
||||
$params['before_id'] = $beforeId;
|
||||
}
|
||||
$sql .= ")";
|
||||
}
|
||||
$sql .= " ORDER BY c.created_at DESC, c.id DESC LIMIT {$limit}";
|
||||
}
|
||||
|
||||
$rows = $db->fetchAll($sql, $params);
|
||||
|
||||
if (!$since) {
|
||||
$rows = array_reverse($rows ?: []);
|
||||
}
|
||||
|
||||
$hasMore = count($rows) === $limit;
|
||||
$earliest = $rows[0]['created_at'] ?? null;
|
||||
$earliestId = $rows[0]['id'] ?? null;
|
||||
|
||||
$data = array_map(function($m) {
|
||||
$mediaUrl = $m['media_url'] ?? null;
|
||||
$external = null;
|
||||
if ($mediaUrl) {
|
||||
if (preg_match('#^https?://#i', $mediaUrl)) {
|
||||
$external = $mediaUrl;
|
||||
} else {
|
||||
$external = '../../../api/get_media.php?id=' . urlencode($mediaUrl);
|
||||
}
|
||||
}
|
||||
return [
|
||||
'id' => intval($m['id']),
|
||||
'user_id' => intval($m['user_id']),
|
||||
'content' => $m['content'] ?? '',
|
||||
'message_id' => $m['message_id'] ?? null,
|
||||
'local_file' => $m['local_file'] ?? null,
|
||||
'local_thumb' => $m['local_thumb'] ?? null,
|
||||
'media_url_external' => $external,
|
||||
'filename' => $m['filename'] ?? null,
|
||||
'mime_type' => $m['mime_type'] ?? null,
|
||||
'user_phone' => $m['user_phone'] ?? null,
|
||||
'direction' => $m['direction'] ?? 'incoming',
|
||||
'message_type' => $m['message_type'] ?? 'text',
|
||||
'status' => $m['status'] ?? 'received',
|
||||
'created_at' => $m['created_at'],
|
||||
'is_read' => intval($m['is_read']),
|
||||
'reply_to_message_id' => $m['reply_to_message_id'] ?? null,
|
||||
'reaction_emoji' => $m['reaction_emoji'] ?? null,
|
||||
'reaction_to_message_id' => $m['reaction_to_message_id'] ?? null,
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
'has_more' => $hasMore,
|
||||
'earliest' => $earliest,
|
||||
'earliest_id' => $earliestId,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('chat_get_messages.php: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Error interno']);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
/**
|
||||
* API - Marcar como leídos los mensajes entrantes del canal turnero de un usuario
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
requireAuthentication();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Método no permitido']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$userId = intval($input['user_id'] ?? 0);
|
||||
|
||||
if (!$userId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'user_id requerido']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
$db->query(
|
||||
"UPDATE conversations SET is_read = 1
|
||||
WHERE user_id = :uid AND canal = 'turnero' AND direction = 'incoming' AND (is_read IS NULL OR is_read = 0)",
|
||||
['uid' => $userId]
|
||||
);
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('chat_mark_read.php: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Error interno']);
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/**
|
||||
* API - Enviar mensaje desde el número turnero
|
||||
* Soporta: text, template
|
||||
*/
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once __DIR__ . '/../../../services/WhatsAppService.php';
|
||||
requireAuthentication();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['success'=>false,'error'=>'Método no permitido']); exit; }
|
||||
|
||||
try {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
|
||||
$userId = intval($input['user_id'] ?? 0);
|
||||
$type = $input['type'] ?? 'text';
|
||||
$message = trim($input['message'] ?? '');
|
||||
$template = trim($input['template'] ?? '');
|
||||
$lang = trim($input['lang'] ?? 'es_CO');
|
||||
$params = $input['params'] ?? [];
|
||||
|
||||
if (!$userId || ($type === 'text' && $message === '') || ($type === 'template' && $template === '')) {
|
||||
http_response_code(400); echo json_encode(['success'=>false,'error'=>'Parámetros insuficientes']); exit;
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
$user = $db->fetch("SELECT phone_number, name FROM users WHERE id = :id", ['id' => $userId]);
|
||||
if (!$user) { http_response_code(404); echo json_encode(['success'=>false,'error'=>'Usuario no encontrado']); exit; }
|
||||
|
||||
$turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
|
||||
if (empty($turneroPhoneId)) { http_response_code(503); echo json_encode(['success'=>false,'error'=>'Número turnero no configurado']); exit; }
|
||||
|
||||
$operatorId = $_SESSION['admin_id'] ?? $_SESSION['user_id'] ?? null;
|
||||
$wa = new WhatsAppService('turnero');
|
||||
|
||||
if ($type === 'template') {
|
||||
$response = $wa->sendTemplateMessage($user['phone_number'], $template, $lang, $params);
|
||||
} else {
|
||||
$extra = ['canal' => 'turnero'];
|
||||
if ($operatorId) $extra['operator_id'] = $operatorId;
|
||||
$response = $wa->sendTextMessage($user['phone_number'], $message, $extra);
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'whatsapp_response' => $response]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('chat_send_message.php: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -27,18 +27,30 @@ $examIds = isset($datos['exam_tipo_ids']) && is_array($datos['exam_tipo_ids'
|
||||
? array_filter(array_map('intval', $datos['exam_tipo_ids'])) : [];
|
||||
$total = isset($datos['total_cobrado']) && $datos['total_cobrado'] !== null
|
||||
? (float) $datos['total_cobrado'] : null;
|
||||
$metodoPago = isset($datos['metodo_pago']) ? trim((string) $datos['metodo_pago']) : null;
|
||||
$obs = isset($datos['observaciones']) ? trim((string) $datos['observaciones']) : null;
|
||||
$numOrden = isset($datos['numero_orden']) ? trim((string) $datos['numero_orden']) : null;
|
||||
$metodoPago = isset($datos['metodo_pago']) ? trim((string) $datos['metodo_pago']) : null;
|
||||
$obs = isset($datos['observaciones']) ? trim((string) $datos['observaciones']) : null;
|
||||
$numOrden = isset($datos['numero_orden']) ? trim((string) $datos['numero_orden']) : null;
|
||||
$embarazada = !empty($datos['embarazada']) ? 1 : 0;
|
||||
$soloMuestras = !empty($datos['solo_muestras']) ? 1 : 0;
|
||||
$medicoId = !empty($datos['medico_id']) ? (int)$datos['medico_id'] : null;
|
||||
$pagosDetalle = null;
|
||||
if ($metodoPago === 'combinado' && isset($datos['pagos_detalle']) && is_array($datos['pagos_detalle'])) {
|
||||
$pd = $datos['pagos_detalle'];
|
||||
$pagosDetalle = json_encode([
|
||||
'efectivo' => max(0, (float)($pd['efectivo'] ?? 0)),
|
||||
'transferencia' => max(0, (float)($pd['transferencia'] ?? 0)),
|
||||
'tarjeta' => max(0, (float)($pd['tarjeta'] ?? 0)),
|
||||
]);
|
||||
}
|
||||
if ($numOrden === '') $numOrden = null;
|
||||
|
||||
// ── Validaciones ──────────────────────────────────────────────
|
||||
if ($turnoId <= 0) jsonError('turno_id inválido.');
|
||||
if ($pacienteId <= 0) jsonError('paciente_id inválido.');
|
||||
if ($lugarId <= 0) jsonError('lugar_id inválido.');
|
||||
if (empty($examIds)) jsonError('Debe seleccionar al menos un examen.');
|
||||
if (empty($examIds) && !$soloMuestras) jsonError('Debe seleccionar al menos un examen.');
|
||||
|
||||
$metodosValidos = ['efectivo', 'transferencia', 'tarjeta', 'eps', 'cortesia', ''];
|
||||
$metodosValidos = ['efectivo', 'transferencia', 'tarjeta', 'eps', 'cortesia', 'combinado', ''];
|
||||
if ($metodoPago && !in_array($metodoPago, $metodosValidos, true)) {
|
||||
jsonError('metodo_pago inválido.');
|
||||
}
|
||||
@@ -82,13 +94,15 @@ try {
|
||||
jsonError('Lugar destino no encontrado o inactivo.', 404);
|
||||
}
|
||||
|
||||
// Verificar que todos los exam_tipo_ids existen
|
||||
$in = implode(',', array_fill(0, count($examIds), '?'));
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM exam_tipos WHERE id IN ({$in}) AND activo = 1");
|
||||
$stmt->execute($examIds);
|
||||
if ((int)$stmt->fetchColumn() !== count($examIds)) {
|
||||
$pdo->rollBack();
|
||||
jsonError('Uno o más exámenes no existen o están inactivos.', 422);
|
||||
// Verificar que todos los exam_tipo_ids existen (se omite si solo_muestras y sin exámenes)
|
||||
if (!empty($examIds)) {
|
||||
$in = implode(',', array_fill(0, count($examIds), '?'));
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM exam_tipos WHERE id IN ({$in}) AND activo = 1");
|
||||
$stmt->execute($examIds);
|
||||
if ((int)$stmt->fetchColumn() !== count($examIds)) {
|
||||
$pdo->rollBack();
|
||||
jsonError('Uno o más exámenes no existen o están inactivos.', 422);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Insertar o reemplazar solicitud (DELETE + INSERT para poder re-guardar) ──
|
||||
@@ -103,12 +117,12 @@ try {
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO turnero_solicitudes
|
||||
(turno_id, paciente_id, lugar_id, numero_orden, total_cobrado, metodo_pago, observaciones, creado_por)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
(turno_id, paciente_id, lugar_id, numero_orden, total_cobrado, metodo_pago, pagos_detalle, observaciones, embarazada, solo_muestras, medico_id, creado_por)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->execute([
|
||||
$turnoId, $pacienteId, $lugarId, $numOrden,
|
||||
$total, $metodoPago ?: null, $obs ?: null, adminId(),
|
||||
$total, $metodoPago ?: null, $pagosDetalle, $obs ?: null, $embarazada, $soloMuestras, $medicoId, adminId(),
|
||||
]);
|
||||
$solicitudId = (int) $pdo->lastInsertId();
|
||||
|
||||
|
||||
@@ -102,58 +102,108 @@ try {
|
||||
|
||||
notificarSSE($sesionId);
|
||||
|
||||
// ── Enviar notificación WhatsApp con la plantilla configurada ──
|
||||
// ── Crear registro de consentimiento (siempre, con o sin teléfono) ──
|
||||
$enlaceConsentimiento = null;
|
||||
$esSoloMuestra = ($prioCodigo === 'F');
|
||||
if (!$esSoloMuestra) {
|
||||
try {
|
||||
$stmtForms = $pdo->prepare(
|
||||
"SELECT tlc.formulario_id, l.formulario_modo
|
||||
FROM turnero_lugar_consentimientos tlc
|
||||
JOIN lab_formularios f ON f.id = tlc.formulario_id AND f.is_active = 1
|
||||
JOIN turnero_lugares l ON l.id = tlc.lugar_id AND l.tipo = 'recepcion' AND l.activo = 1
|
||||
ORDER BY tlc.formulario_id ASC
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmtForms->execute();
|
||||
$formRow = $stmtForms->fetch(\PDO::FETCH_ASSOC);
|
||||
|
||||
if ($formRow) {
|
||||
$conToken = sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0,0xffff), mt_rand(0,0xffff),
|
||||
mt_rand(0,0xffff),
|
||||
mt_rand(0,0x0fff)|0x4000,
|
||||
mt_rand(0,0x3fff)|0x8000,
|
||||
mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff)
|
||||
);
|
||||
$pdo->prepare(
|
||||
"INSERT IGNORE INTO turnero_consentimientos
|
||||
(turno_id, formulario_id, token, estado)
|
||||
VALUES (?, ?, ?, 'pendiente')"
|
||||
)->execute([$turnoId, (int)$formRow['formulario_id'], $conToken]);
|
||||
|
||||
// Solo generar enlace si el modo es 'link' (embebido no usa WA)
|
||||
if (($formRow['formulario_modo'] ?? 'link') === 'link') {
|
||||
$baseUrl = defined('BASE_URL') ? rtrim(BASE_URL, '/') : '';
|
||||
$enlaceConsentimiento = $baseUrl . '/ver_formulario_enviado.php?token=' . urlencode($conToken);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Sin consentimiento disponible, continuar sin link
|
||||
}
|
||||
}
|
||||
|
||||
// ── Enviar notificación WhatsApp ──
|
||||
if ($pacienteTel) {
|
||||
try {
|
||||
$stmtCfg = $pdo->prepare(
|
||||
"SELECT clave, valor FROM lab_config WHERE clave IN ('turnero_wa_template','turnero_wa_lang','turnero_wa_kiosko_enabled')"
|
||||
"SELECT clave, valor FROM lab_config WHERE clave IN ('turnero_wa_template','turnero_wa_template_muestra','turnero_wa_lang','turnero_wa_kiosko_enabled')"
|
||||
);
|
||||
$stmtCfg->execute();
|
||||
$cfgWA = $stmtCfg->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$waKioskoEnabled = ($cfgWA['turnero_wa_kiosko_enabled'] ?? '0') === '1';
|
||||
$waTemplate = $esSoloMuestra
|
||||
? ($cfgWA['turnero_wa_template_muestra'] ?? 'turno_muestra_pendiente')
|
||||
: ($cfgWA['turnero_wa_template'] ?? 'consentimiento_turno');
|
||||
// Usar idioma real de la plantilla aprobada (puede diferir del config)
|
||||
$stmtLang = $pdo->prepare("SELECT language_code FROM message_templates WHERE template_name = ? LIMIT 1");
|
||||
$stmtLang->execute([$waTemplate]);
|
||||
$waLang = $stmtLang->fetchColumn() ?: ($cfgWA['turnero_wa_lang'] ?? 'es');
|
||||
} catch (\Throwable $e) {
|
||||
$waKioskoEnabled = true;
|
||||
$waTemplate = 'consentimiento_turno';
|
||||
$waLang = 'es';
|
||||
}
|
||||
}
|
||||
|
||||
if ($pacienteTel && $waKioskoEnabled) {
|
||||
try {
|
||||
$cfgWA = [];
|
||||
$stmtCfg = $pdo->prepare(
|
||||
"SELECT clave, valor FROM lab_config WHERE clave IN ('turnero_wa_template','turnero_wa_lang')"
|
||||
);
|
||||
$stmtCfg->execute();
|
||||
$cfgWA = $stmtCfg->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$waTemplate = $cfgWA['turnero_wa_template'] ?? 'consentimiento_turno';
|
||||
$waLang = $cfgWA['turnero_wa_lang'] ?? 'es_CO';
|
||||
} catch (\Throwable $e) {
|
||||
$waTemplate = 'consentimiento_turno';
|
||||
$waLang = 'es_CO';
|
||||
// WhatsAppService::formatPhoneNumber maneja el formato colombiano correctamente
|
||||
$cel = preg_replace('/[^0-9]/', '', $pacienteTel);
|
||||
|
||||
// Marcar como enviado si vamos a mandar WA
|
||||
if ($enlaceConsentimiento) {
|
||||
try {
|
||||
$pdo->prepare(
|
||||
"UPDATE turnero_consentimientos SET estado='enviado', enviado_at=NOW()
|
||||
WHERE turno_id=? AND estado='pendiente'"
|
||||
)->execute([$turnoId]);
|
||||
} catch (\Throwable $_) {}
|
||||
}
|
||||
|
||||
$cel = preg_replace('/[\s\-\.]/', '', $pacienteTel);
|
||||
if (!str_starts_with($cel, '+')) {
|
||||
$cel = '+57' . ltrim($cel, '0');
|
||||
}
|
||||
require_once __DIR__ . '/../../../services/WhatsAppService.php';
|
||||
$nombreWA = $pacienteNombre ?: 'Paciente';
|
||||
try {
|
||||
$wa = new WhatsAppService();
|
||||
// Enviar solo el código, sin nombre/apellido
|
||||
$wa->sendTemplateMessage(
|
||||
$cel,
|
||||
$waTemplate,
|
||||
$waLang,
|
||||
[
|
||||
'Paciente',
|
||||
$codigo,
|
||||
'',
|
||||
],
|
||||
[]
|
||||
);
|
||||
$wa = new WhatsAppService('turnero');
|
||||
$rawComps = [
|
||||
['type' => 'body', 'parameters' => [['type' => 'text', 'text' => $codigo]]]
|
||||
];
|
||||
if ($conToken) {
|
||||
$rawComps[] = [
|
||||
'type' => 'button',
|
||||
'sub_type' => 'url',
|
||||
'index' => '0',
|
||||
'parameters' => [['type' => 'text', 'text' => $conToken]],
|
||||
];
|
||||
}
|
||||
$wa->sendTemplateMessage($cel, $waTemplate, $waLang, [], [], $rawComps);
|
||||
} catch (\Throwable $eTmpl) {
|
||||
try {
|
||||
$mensajeTexto = "Su turno *{$codigo}* ha sido registrado. Preséntese al laboratorio.";
|
||||
$wa->sendTextMessage($cel, $mensajeTexto);
|
||||
$msg = "Hola {$nombreWA}, su turno *{$codigo}* ha sido registrado.";
|
||||
if ($enlaceConsentimiento) {
|
||||
$msg .= "\n\nPor favor firme su consentimiento informado antes de ser atendido:\n{$enlaceConsentimiento}";
|
||||
}
|
||||
$wa->sendTextMessage($cel, $msg);
|
||||
} catch (\Throwable $eTxt) {
|
||||
// Silenciar
|
||||
}
|
||||
@@ -188,6 +238,7 @@ try {
|
||||
'prioridad_codigo' => $prioCodigo,
|
||||
'posicion_cola' => $posicion,
|
||||
'sesion_id' => $sesionId,
|
||||
'paciente_nombre' => $pacienteNombre ?: $pacienteDoc,
|
||||
],
|
||||
], "Turno $codigo asignado correctamente");
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ $body = inputJson();
|
||||
$turnoId = (int)($body['turno_id'] ?? 0);
|
||||
$formularioId = (int)($body['formulario_id'] ?? 0);
|
||||
$svg = $body['svg'] ?? '';
|
||||
$soloPro = !empty($body['solo_profesional']);
|
||||
$datosResp = (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']))
|
||||
? json_encode($body['datos_respuestas'], JSON_UNESCAPED_UNICODE) : null;
|
||||
|
||||
if (!$turnoId) jsonError('turno_id requerido.');
|
||||
if (!$formularioId) jsonError('formulario_id requerido.');
|
||||
@@ -37,12 +40,22 @@ $tc = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$tc) jsonError('Consentimiento no encontrado.', 404);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE turnero_consentimientos
|
||||
SET firma_profesional_svg = ?, firmado_profesional_at = NOW()
|
||||
WHERE turno_id = ? AND formulario_id = ?"
|
||||
);
|
||||
$stmt->execute([$svg, $turnoId, $formularioId]);
|
||||
if ($soloPro) {
|
||||
// El profesional es el firmante final: guardar datos + marcar como firmado
|
||||
$pdo->prepare(
|
||||
"UPDATE turnero_consentimientos
|
||||
SET firma_profesional_svg = ?, firmado_profesional_at = NOW(),
|
||||
estado = 'firmado', firmado_at = NOW(),
|
||||
datos_respuestas = COALESCE(?, datos_respuestas)
|
||||
WHERE turno_id = ? AND formulario_id = ?"
|
||||
)->execute([$svg, $datosResp, $turnoId, $formularioId]);
|
||||
} else {
|
||||
$pdo->prepare(
|
||||
"UPDATE turnero_consentimientos
|
||||
SET firma_profesional_svg = ?, firmado_profesional_at = NOW()
|
||||
WHERE turno_id = ? AND formulario_id = ?"
|
||||
)->execute([$svg, $turnoId, $formularioId]);
|
||||
}
|
||||
|
||||
notificarSSE((int)$tc['sesion_id']);
|
||||
|
||||
|
||||
@@ -50,21 +50,20 @@ if ($sesionCerrada) {
|
||||
// ── Cola según área ───────────────────────────────────────────
|
||||
if ($area === 'recepcion') {
|
||||
// Recepción: todos los turnos en espera/en_recepcion
|
||||
$inEstados = "'espera', 'en_recepcion'";
|
||||
$filtroLugar = '';
|
||||
$bindsCola = [$sesionId];
|
||||
|
||||
$inEstados = "'espera', 'en_recepcion'";
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id, t.codigo, t.numero, t.estado, t.paciente_nombre,
|
||||
t.recepcion_desk_id, t.creado_at, t.llamado_recepcion_at, t.llamado_lugar_at,
|
||||
p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre,
|
||||
p.color AS prioridad_color, p.orden_peso
|
||||
p.color AS prioridad_color, p.orden_peso,
|
||||
COALESCE(s.solo_muestras, 0) AS solo_muestras
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
|
||||
WHERE t.sesion_id = ? AND t.estado IN ($inEstados)
|
||||
ORDER BY p.orden_peso ASC, t.creado_at ASC"
|
||||
);
|
||||
$stmt->execute($bindsCola);
|
||||
$stmt->execute([$sesionId]);
|
||||
} else {
|
||||
// Lugar: cola = en_espera_lugar del grupo + en_servicio en ESTA estación
|
||||
$grupoIds = lugarIdsDeGrupo($lugarId);
|
||||
@@ -73,13 +72,15 @@ if ($area === 'recepcion') {
|
||||
$cols = "t.id, t.codigo, t.numero, t.estado, t.paciente_nombre,
|
||||
t.recepcion_desk_id, t.creado_at, t.llamado_recepcion_at, t.llamado_lugar_at,
|
||||
p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre,
|
||||
p.color AS prioridad_color, p.orden_peso";
|
||||
p.color AS prioridad_color, p.orden_peso,
|
||||
COALESCE(s.solo_muestras, 0) AS solo_muestras";
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT * FROM (
|
||||
(SELECT $cols
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
|
||||
WHERE t.sesion_id = ?
|
||||
AND t.estado = 'en_espera_lugar'
|
||||
AND t.lugar_destino_id IN ($inLugares))
|
||||
@@ -87,11 +88,12 @@ if ($area === 'recepcion') {
|
||||
(SELECT $cols
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
|
||||
WHERE t.sesion_id = ?
|
||||
AND t.estado = 'en_servicio'
|
||||
AND t.lugar_destino_id = ?)
|
||||
) AS cola_union
|
||||
ORDER BY orden_peso ASC, creado_at ASC"
|
||||
ORDER BY solo_muestras DESC, orden_peso ASC, creado_at ASC"
|
||||
);
|
||||
$stmt->execute([$sesionId, $sesionId, $lugarId]);
|
||||
}
|
||||
|
||||
@@ -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.firma_profesional_svg IS NOT NULL) AS tiene_firma_profesional,
|
||||
tc.firmado_profesional_at,
|
||||
f.nombre AS formulario_nombre
|
||||
f.nombre AS formulario_nombre,
|
||||
f.esquema AS formulario_esquema
|
||||
FROM turnero_consentimientos tc
|
||||
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||
WHERE tc.turno_id = ?
|
||||
@@ -46,6 +47,22 @@ $stmt = $pdo->prepare(
|
||||
$stmt->execute([$turnoId]);
|
||||
$consentimientos = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Compute requiere_firma_profesional from schema; strip raw esquema from response
|
||||
foreach ($consentimientos as &$c) {
|
||||
$esquema = $c['formulario_esquema'] ?? null;
|
||||
$campos = [];
|
||||
if ($esquema) {
|
||||
$decoded = json_decode($esquema, true);
|
||||
$campos = is_array($decoded) ? $decoded : ($decoded['campos'] ?? []);
|
||||
}
|
||||
$c['requiere_firma_profesional'] = !empty(array_filter(
|
||||
$campos,
|
||||
fn($f) => ($f['tipo'] ?? '') === 'firma_profesional'
|
||||
));
|
||||
unset($c['formulario_esquema']);
|
||||
}
|
||||
unset($c);
|
||||
|
||||
$respuesta = [
|
||||
'turno_id' => $turnoId,
|
||||
'turno_estado' => $turno['estado'],
|
||||
@@ -56,10 +73,14 @@ $respuesta = [
|
||||
if ($incluirSolicitud) {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT s.id, s.turno_id, s.paciente_id, s.lugar_id,
|
||||
s.total_cobrado, s.metodo_pago, s.observaciones, s.creado_at,
|
||||
l.nombre AS lugar_nombre
|
||||
s.total_cobrado, s.metodo_pago, s.observaciones, s.embarazada, s.solo_muestras, s.medico_id, s.creado_at,
|
||||
l.nombre AS lugar_nombre,
|
||||
CONCAT(m.nombres, ' ', m.apellidos) AS medico_nombre,
|
||||
m.cod_especialidad AS medico_especialidad,
|
||||
m.codigo AS medico_codigo
|
||||
FROM turnero_solicitudes s
|
||||
LEFT JOIN turnero_lugares l ON l.id = s.lugar_id
|
||||
LEFT JOIN medicos m ON m.id = s.medico_id
|
||||
WHERE s.turno_id = ?
|
||||
LIMIT 1"
|
||||
);
|
||||
@@ -167,7 +188,8 @@ if ($incluirSolicitud) {
|
||||
tc.estado, tc.enviado_at, tc.firmado_at,
|
||||
(tc.firma_profesional_svg IS NOT NULL) AS tiene_firma_profesional,
|
||||
tc.firmado_profesional_at,
|
||||
f.nombre AS formulario_nombre
|
||||
f.nombre AS formulario_nombre,
|
||||
f.esquema AS formulario_esquema
|
||||
FROM turnero_consentimientos tc
|
||||
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||
WHERE tc.turno_id = ?
|
||||
@@ -175,6 +197,20 @@ if ($incluirSolicitud) {
|
||||
);
|
||||
$stmtC->execute([$turnoId]);
|
||||
$consentimientos = $stmtC->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($consentimientos as &$c) {
|
||||
$esquema = $c['formulario_esquema'] ?? null;
|
||||
$campos = [];
|
||||
if ($esquema) {
|
||||
$decoded = json_decode($esquema, true);
|
||||
$campos = is_array($decoded) ? $decoded : ($decoded['campos'] ?? []);
|
||||
}
|
||||
$c['requiere_firma_profesional'] = !empty(array_filter(
|
||||
$campos,
|
||||
fn($f) => ($f['tipo'] ?? '') === 'firma_profesional'
|
||||
));
|
||||
unset($c['formulario_esquema']);
|
||||
}
|
||||
unset($c);
|
||||
$respuesta['consentimientos'] = $consentimientos;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ $sortOrder = (int)($input['sort_order'] ?? 99);
|
||||
$activo = (int)($input['activo'] ?? 1);
|
||||
$tipo = in_array($input['tipo'] ?? '', ['recepcion', 'muestras'], true)
|
||||
? $input['tipo'] : 'muestras';
|
||||
$formModo = in_array($input['formulario_modo'] ?? '', ['link', 'embebido'], true)
|
||||
? $input['formulario_modo'] : 'link';
|
||||
$formularioIds = isset($input['formulario_ids']) && is_array($input['formulario_ids'])
|
||||
? array_filter(array_map('intval', $input['formulario_ids']), fn($v) => $v > 0)
|
||||
: [];
|
||||
@@ -64,15 +66,15 @@ $pdo->beginTransaction();
|
||||
try {
|
||||
if ($id) {
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE turnero_lugares SET nombre=?, tipo=?, descripcion=?, sort_order=?, activo=? WHERE id=?'
|
||||
'UPDATE turnero_lugares SET nombre=?, tipo=?, descripcion=?, sort_order=?, activo=?, formulario_modo=? WHERE id=?'
|
||||
);
|
||||
$stmt->execute([$nombre, $tipo, $desc ?: null, $sortOrder, $activo, $id]);
|
||||
$stmt->execute([$nombre, $tipo, $desc ?: null, $sortOrder, $activo, $formModo, $id]);
|
||||
$lugarId = $id;
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO turnero_lugares (nombre, tipo, descripcion, sort_order, activo) VALUES (?, ?, ?, ?, ?)'
|
||||
'INSERT INTO turnero_lugares (nombre, tipo, descripcion, sort_order, activo, formulario_modo) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$nombre, $tipo, $desc ?: null, $sortOrder, $activo]);
|
||||
$stmt->execute([$nombre, $tipo, $desc ?: null, $sortOrder, $activo, $formModo]);
|
||||
$lugarId = (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
|
||||
@@ -32,52 +32,31 @@ if ($turnoId <= 0) jsonError('turno_id inválido.');
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// ── 1. Cargar turno y su solicitud ────────────────────────────
|
||||
// ── 1. Cargar turno (con fallback de paciente desde kiosko) ──
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id, t.codigo, t.paciente_nombre, t.paciente_cel,
|
||||
"SELECT t.id, t.codigo, t.paciente_nombre, t.paciente_cel, t.paciente_id,
|
||||
t.estado,
|
||||
s.id AS solicitud_id,
|
||||
s.paciente_id,
|
||||
s.lugar_id,
|
||||
p.telefono AS pac_telefono,
|
||||
p.telefono AS pac_celular,
|
||||
p.nombre_completo AS pac_nombre
|
||||
COALESCE(p_sol.telefono, p_tur.telefono) AS pac_celular,
|
||||
COALESCE(p_sol.nombre_completo, p_tur.nombre_completo) AS pac_nombre
|
||||
FROM turnero_turnos t
|
||||
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
|
||||
LEFT JOIN lab_pacientes p ON p.id = s.paciente_id
|
||||
LEFT JOIN lab_pacientes p_sol ON p_sol.id = s.paciente_id
|
||||
LEFT JOIN lab_pacientes p_tur ON p_tur.id = t.paciente_id
|
||||
WHERE t.id = ?"
|
||||
);
|
||||
$stmt->execute([$turnoId]);
|
||||
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$turno) jsonError('Turno no encontrado.', 404);
|
||||
if (!$turno['solicitud_id']) jsonError('El turno no tiene solicitud registrada. Guarde la solicitud primero.', 422);
|
||||
|
||||
// Determinar celular de contacto (preferencia: paciente → kiosko)
|
||||
$celular = $turno['pac_celular'] ?? $turno['pac_telefono'] ?? $turno['paciente_cel'] ?? null;
|
||||
// Celular: preferencia paciente BD → kiosko
|
||||
$celular = $turno['pac_celular'] ?: $turno['paciente_cel'] ?: null;
|
||||
if (!$celular) {
|
||||
jsonError('El paciente no tiene número de celular registrado. Actualice el paciente antes de enviar.', 422);
|
||||
jsonError('El paciente no tiene número de celular registrado.', 422);
|
||||
}
|
||||
$celular = preg_replace('/[^0-9]/', '', $celular);
|
||||
|
||||
// Normalizar celular (quitar espacios / guiones, agregar +57 si no tiene código)
|
||||
$celular = preg_replace('/[\s\-\.]/', '', $celular);
|
||||
if (!str_starts_with($celular, '+')) {
|
||||
// Asumir Colombia si no tiene prefijo internacional
|
||||
$celular = '+57' . ltrim($celular, '0');
|
||||
}
|
||||
|
||||
// ── 2. Obtener exámenes de la solicitud ───────────────────────
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT exam_tipo_id FROM turnero_examen_items WHERE solicitud_id = ?"
|
||||
);
|
||||
$stmt->execute([$turno['solicitud_id']]);
|
||||
$examIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
if (empty($examIds)) {
|
||||
jsonError('La solicitud no tiene exámenes registrados.', 422);
|
||||
}
|
||||
|
||||
// ── 3. Obtener formularios configurados para este turno ───────
|
||||
// ── 2. Obtener formularios configurados para este turno ───────
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT tc.formulario_id, f.nombre AS formulario_nombre
|
||||
FROM turnero_consentimientos tc
|
||||
@@ -124,14 +103,16 @@ try {
|
||||
WHERE id = ?"
|
||||
);
|
||||
|
||||
$wa = new WhatsAppService();
|
||||
// URL base del sistema (para generar el enlace de firma)
|
||||
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http')
|
||||
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
|
||||
// Si BASE_URL está definida, úsala
|
||||
if (defined('BASE_URL')) {
|
||||
$baseUrl = rtrim(BASE_URL, '/');
|
||||
}
|
||||
$wa = new WhatsAppService('turnero');
|
||||
$waTemplate = 'consentimiento_turno';
|
||||
$stmtLang = $pdo->prepare("SELECT language_code FROM message_templates WHERE template_name = ? LIMIT 1");
|
||||
$stmtLang->execute([$waTemplate]);
|
||||
$waLang = $stmtLang->fetchColumn() ?: 'es';
|
||||
|
||||
$baseUrl = defined('BASE_URL') ? rtrim(BASE_URL, '/') : (
|
||||
(isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http')
|
||||
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost')
|
||||
);
|
||||
|
||||
foreach ($formulariosRequeridos as $form) {
|
||||
$formularioId = (int) $form['formulario_id'];
|
||||
@@ -161,39 +142,23 @@ try {
|
||||
$consentId = (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
// ── Construir enlace de firma ──────────────────────────
|
||||
$enlaceFirma = $baseUrl . '/ver_formulario_enviado.php?token=' . urlencode($token);
|
||||
$nombrePac = $turno['pac_nombre'] ?: ($turno['paciente_nombre'] ?? 'Paciente');
|
||||
|
||||
// ── Nombre del paciente para el mensaje ───────────────
|
||||
$nombrePac = $turno['pac_nombre'] ?? $turno['paciente_nombre'] ?? 'Paciente';
|
||||
|
||||
// ── Enviar mensaje WhatsApp ────────────────────────────
|
||||
// Intentamos enviar con template "consentimiento_turno".
|
||||
// Si el template no existe, enviamos texto plano como fallback.
|
||||
// ── Enviar con template (mismo formato que create_turno.php) ──
|
||||
$enviado = false;
|
||||
try {
|
||||
$wa->sendTemplateMessage(
|
||||
$celular,
|
||||
'consentimiento_turno',
|
||||
'es',
|
||||
// Parámetros del body: {{1}} = nombre, {{2}} = nombre formulario, {{3}} = código turno
|
||||
[
|
||||
htmlspecialchars($nombrePac, ENT_QUOTES),
|
||||
htmlspecialchars($formularioNom, ENT_QUOTES),
|
||||
$turno['codigo'],
|
||||
],
|
||||
// Parámetro del header o botón URL (URL del enlace)
|
||||
[$enlaceFirma]
|
||||
);
|
||||
$rawComps = [
|
||||
['type' => 'body', 'parameters' => [['type' => 'text', 'text' => $turno['codigo']]]],
|
||||
['type' => 'button', 'sub_type' => 'url', 'index' => '0',
|
||||
'parameters' => [['type' => 'text', 'text' => $token]]],
|
||||
];
|
||||
$wa->sendTemplateMessage($celular, $waTemplate, $waLang, [], [], $rawComps);
|
||||
$enviado = true;
|
||||
} catch (\Throwable $eTemplate) {
|
||||
// Fallback: enviar mensaje de texto plano
|
||||
// Fallback texto plano
|
||||
try {
|
||||
$mensajeTexto = "Hola {$nombrePac}, le informamos que para su turno *{$turno['codigo']}* "
|
||||
. "debe firmar el siguiente consentimiento informado:\n\n"
|
||||
. "*{$formularioNom}*\n\n"
|
||||
. "Puede firmarlo en el siguiente enlace:\n{$enlaceFirma}\n\n"
|
||||
. "Si ya firmó este documento presencial, ignore este mensaje.";
|
||||
$mensajeTexto = "Hola {$nombrePac}, su turno *{$turno['codigo']}* requiere firma de consentimiento:\n{$enlaceFirma}";
|
||||
$wa->sendTextMessage($celular, $mensajeTexto);
|
||||
$enviado = true;
|
||||
} catch (\Throwable $eTexto) {
|
||||
|
||||
@@ -80,15 +80,19 @@ if ($accion === 'reabrir') {
|
||||
|
||||
// ── CONFIG WHATSAPP ──────────────────────────────────────────
|
||||
if ($accion === 'config_wa') {
|
||||
$template = trim($input['template'] ?? '');
|
||||
$lang = trim($input['lang'] ?? 'es_CO');
|
||||
$waKiosko = (int)($input['wa_kiosko'] ?? 0);
|
||||
$waConsent = (int)($input['wa_consent'] ?? 0);
|
||||
$template = trim($input['template'] ?? '');
|
||||
$templateMuestra = trim($input['template_muestra'] ?? '');
|
||||
$lang = trim($input['lang'] ?? 'es_CO');
|
||||
$waKiosko = (int)($input['wa_kiosko'] ?? 0);
|
||||
$waConsent = (int)($input['wa_consent'] ?? 0);
|
||||
|
||||
if ($template === '') jsonError('El nombre de plantilla es requerido');
|
||||
if (!preg_match('/^[a-z0-9_]{1,64}$/i', $template)) {
|
||||
jsonError('Nombre de plantilla inválido — solo letras, números y guiones bajos');
|
||||
}
|
||||
if ($templateMuestra !== '' && !preg_match('/^[a-z0-9_]{1,64}$/i', $templateMuestra)) {
|
||||
jsonError('Nombre de plantilla muestra inválido — solo letras, números y guiones bajos');
|
||||
}
|
||||
if (!preg_match('/^[a-z]{2}_[A-Z]{2}$/i', $lang)) {
|
||||
jsonError('Código de idioma inválido (formato: es_CO)');
|
||||
}
|
||||
@@ -99,6 +103,7 @@ if ($accion === 'config_wa') {
|
||||
ON DUPLICATE KEY UPDATE valor = VALUES(valor)'
|
||||
);
|
||||
$upsert->execute(['turnero_wa_template', $template]);
|
||||
$upsert->execute(['turnero_wa_template_muestra', $templateMuestra]);
|
||||
$upsert->execute(['turnero_wa_lang', $lang]);
|
||||
$upsert->execute(['turnero_wa_kiosko_enabled', (string)$waKiosko]);
|
||||
$upsert->execute(['turnero_wa_consent_enabled', (string)$waConsent]);
|
||||
|
||||
@@ -13,9 +13,17 @@ $pacienteId = (int)($d['paciente_id'] ?? 0);
|
||||
|
||||
if (!$turnoId || !$pacienteId) jsonError('turno_id y paciente_id requeridos.');
|
||||
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare('UPDATE turnero_turnos SET paciente_id = ? WHERE id = ?');
|
||||
$stmt->execute([$pacienteId, $turnoId]);
|
||||
$pdo = db();
|
||||
|
||||
// Obtener nombre real del paciente
|
||||
$nombre = $pdo->prepare('SELECT nombre_completo FROM lab_pacientes WHERE id = ?');
|
||||
$nombre->execute([$pacienteId]);
|
||||
$nombreCompleto = $nombre->fetchColumn() ?: null;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE turnero_turnos SET paciente_id = ?, paciente_nombre = COALESCE(?, paciente_nombre) WHERE id = ?'
|
||||
);
|
||||
$stmt->execute([$pacienteId, $nombreCompleto, $turnoId]);
|
||||
|
||||
if (!$stmt->rowCount()) jsonError('Turno no encontrado.', 404);
|
||||
|
||||
|
||||
@@ -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.');
|
||||
@@ -29,7 +29,9 @@ $_trLinks = [];
|
||||
|
||||
// ── Recepcionista: solo sus escritorios + pantalla TV ─────────
|
||||
if ($_trIsRecep) {
|
||||
$_trLinks[] = ['name' => 'Pantalla TV', 'icon' => 'fas fa-tv', 'route' => '/erp.php?m=turnero&v=display_global'];
|
||||
$_trLinks[] = ['name' => 'Chat Turnero', 'icon' => 'fab fa-whatsapp', 'route' => '/erp.php?m=turnero&v=chat'];
|
||||
$_trLinks[] = ['name' => 'Verificar Paciente', 'icon' => 'fas fa-id-card', 'route' => '/erp.php?m=turnero&v=verificar_paciente'];
|
||||
$_trLinks[] = ['name' => 'Pantalla TV', 'icon' => 'fas fa-tv', 'route' => '/erp.php?m=turnero&v=display_global'];
|
||||
if (!empty($_trDesks)) {
|
||||
foreach ($_trDesks as $_d) {
|
||||
$_trLinks[] = [
|
||||
@@ -59,6 +61,8 @@ if ($_trIsRecep) {
|
||||
} else {
|
||||
$_trLinks[] = ['name' => 'Dashboard', 'icon' => 'fas fa-tachometer-alt', 'route' => '/erp.php?m=turnero&v=dashboard'];
|
||||
$_trLinks[] = ['name' => 'Historial', 'icon' => 'fas fa-history', 'route' => '/erp.php?m=turnero&v=historial'];
|
||||
$_trLinks[] = ['name' => 'Chat Turnero', 'icon' => 'fab fa-whatsapp', 'route' => '/erp.php?m=turnero&v=chat'];
|
||||
$_trLinks[] = ['name' => 'Verificar Paciente', 'icon' => 'fas fa-id-card', 'route' => '/erp.php?m=turnero&v=verificar_paciente'];
|
||||
$_trLinks[] = ['name' => 'Configuración', 'icon' => 'fas fa-sliders-h', 'route' => '/erp.php?m=turnero&v=configuracion'];
|
||||
$_trLinks[] = ['name' => 'Kiosko', 'icon' => 'fas fa-desktop', 'route' => '/erp.php?m=turnero&v=kiosko'];
|
||||
$_trLinks[] = ['name' => 'Pantalla TV Global', 'icon' => 'fas fa-th-large', 'route' => '/erp.php?m=turnero&v=display_global'];
|
||||
|
||||
@@ -0,0 +1,967 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/views/chat.php
|
||||
* Inbox del número WhatsApp del Turnero.
|
||||
* Permite ver y responder mensajes de pacientes que escriben al número turnero.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
|
||||
$turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<title>Chat Turnero — WhatsApp</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; }
|
||||
|
||||
.chat-shell { display: flex; height: 100%; }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
width: 340px; min-width: 280px; max-width: 380px;
|
||||
background: #fff; border-right: 1px solid #e9ecef;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 16px; background: #25d366; color: #fff;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.sidebar-header h2 { font-size: 1rem; flex: 1; }
|
||||
.sidebar-header .badge-config {
|
||||
font-size: .7rem; background: rgba(0,0,0,.2); padding: 3px 8px; border-radius: 12px;
|
||||
}
|
||||
.search-box { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; }
|
||||
.search-box input {
|
||||
width: 100%; padding: 8px 12px; border: 1px solid #e0e0e0;
|
||||
border-radius: 20px; font-size: .85rem; outline: none;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.search-box input:focus { border-color: #25d366; background: #fff; }
|
||||
|
||||
.contact-list { flex: 1; overflow-y: auto; }
|
||||
.contact-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 16px; cursor: pointer; border-bottom: 1px solid #f7f7f7;
|
||||
transition: background .15s;
|
||||
}
|
||||
.contact-item:hover { background: #f5f5f5; }
|
||||
.contact-item.active { background: #e7fce8; }
|
||||
.contact-avatar {
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
background: #25d366; color: #fff; display: flex; align-items: center;
|
||||
justify-content: center; font-weight: 700; font-size: 1.1rem; flex-shrink: 0;
|
||||
}
|
||||
.contact-info { flex: 1; min-width: 0; }
|
||||
.contact-name { font-weight: 600; font-size: .9rem; color: #111; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.contact-last { font-size: .78rem; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
|
||||
.contact-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
|
||||
.contact-time { font-size: .7rem; color: #aaa; }
|
||||
.badge-unread {
|
||||
background: #25d366; color: #fff; font-size: .65rem; font-weight: 700;
|
||||
padding: 2px 6px; border-radius: 10px; min-width: 18px; text-align: center;
|
||||
}
|
||||
.contact-empty { text-align: center; padding: 40px 16px; color: #aaa; font-size: .85rem; }
|
||||
|
||||
/* ── Chat window ── */
|
||||
.chat-window {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
background: #efeae2;
|
||||
}
|
||||
.chat-empty-state {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; color: #aaa; gap: 12px;
|
||||
}
|
||||
.chat-empty-state i { font-size: 3rem; color: #25d366; }
|
||||
.chat-empty-state p { font-size: .95rem; }
|
||||
|
||||
.chat-topbar {
|
||||
background: #fff; padding: 12px 16px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
border-bottom: 1px solid #e9ecef; min-height: 60px;
|
||||
position: sticky; top: 0; z-index: 10; flex-shrink: 0;
|
||||
}
|
||||
.chat-topbar-avatar {
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
background: #25d366; color: #fff; display: flex;
|
||||
align-items: center; justify-content: center; font-weight: 700;
|
||||
}
|
||||
.chat-topbar-info h3 { font-size: .95rem; font-weight: 600; }
|
||||
.chat-topbar-info span { font-size: .78rem; color: #888; }
|
||||
|
||||
.messages-area {
|
||||
flex: 1; overflow-y: auto; padding: 12px 16px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
min-height: 0; -webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.msg-bubble {
|
||||
max-width: 65%; padding: 8px 12px; border-radius: 8px;
|
||||
font-size: .88rem; line-height: 1.4; position: relative;
|
||||
word-break: break-word;
|
||||
}
|
||||
.msg-bubble.incoming {
|
||||
background: #fff; align-self: flex-start;
|
||||
border-bottom-left-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,.08);
|
||||
}
|
||||
.msg-bubble.outgoing {
|
||||
background: #d9fdd3; align-self: flex-end;
|
||||
border-bottom-right-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,.08);
|
||||
}
|
||||
.msg-time {
|
||||
font-size: .68rem; color: #aaa; text-align: right; margin-top: 3px;
|
||||
}
|
||||
.msg-type-badge {
|
||||
font-size: .7rem; color: #888; font-style: italic;
|
||||
}
|
||||
.msg-media-thumb {
|
||||
max-width: 220px; max-height: 180px; border-radius: 6px;
|
||||
display: block; margin-bottom: 4px; cursor: pointer;
|
||||
}
|
||||
.msg-media-link {
|
||||
display: flex; align-items: center; gap: 6px; font-size: .82rem; color: #1a73e8;
|
||||
text-decoration: none; padding: 4px 0;
|
||||
}
|
||||
.msg-media-link:hover { text-decoration: underline; }
|
||||
|
||||
.load-more-btn {
|
||||
align-self: center; padding: 6px 18px; font-size: .8rem;
|
||||
background: #fff; border: 1px solid #ddd; border-radius: 16px;
|
||||
cursor: pointer; color: #555;
|
||||
}
|
||||
.load-more-btn:hover { background: #f5f5f5; }
|
||||
|
||||
/* ── Input area ── */
|
||||
.chat-input-area {
|
||||
background: #f0f2f5; padding: 10px 16px;
|
||||
padding-bottom: max(10px, env(safe-area-inset-bottom));
|
||||
display: flex; align-items: flex-end; gap: 10px;
|
||||
border-top: 1px solid #e0e0e0; flex-shrink: 0;
|
||||
}
|
||||
.chat-input-area textarea {
|
||||
flex: 1; padding: 10px 14px; border: none; border-radius: 22px;
|
||||
background: #fff; font-size: .9rem; resize: none; outline: none;
|
||||
max-height: 120px; line-height: 1.4;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.btn-send {
|
||||
width: 42px; height: 42px; border-radius: 50%; border: none;
|
||||
background: #25d366; color: #fff; font-size: 1.1rem;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; transition: background .2s;
|
||||
}
|
||||
.btn-send:hover { background: #1ebe5c; }
|
||||
.btn-send:disabled { background: #ccc; cursor: default; }
|
||||
|
||||
/* ── Alertas ── */
|
||||
.alert-no-config {
|
||||
background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px;
|
||||
padding: 12px 16px; margin: 16px; font-size: .85rem; color: #856404;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.typing-indicator { font-size: .75rem; color: #888; padding: 2px 8px; }
|
||||
|
||||
/* ── Botones extra en input ── */
|
||||
.input-extra-btn {
|
||||
background: none; border: none; color: #888; font-size: 1.15rem;
|
||||
cursor: pointer; padding: 6px; border-radius: 50%; transition: background .15s, color .15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.input-extra-btn:hover { background: rgba(0,0,0,.06); color: #555; }
|
||||
|
||||
/* ── Panel emoji ── */
|
||||
.emoji-panel {
|
||||
position: absolute; bottom: 64px; left: 0;
|
||||
background: #fff; border: 1px solid #e0e0e0; border-radius: 12px;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,.15); padding: 10px;
|
||||
display: none; flex-wrap: wrap; gap: 2px; width: 280px; z-index: 50;
|
||||
}
|
||||
.emoji-panel.show { display: flex; }
|
||||
.emoji-panel span { font-size: 1.3rem; cursor: pointer; padding: 4px; border-radius: 6px; }
|
||||
.emoji-panel span:hover { background: #f0f2f5; }
|
||||
|
||||
/* ── Reaction picker ── */
|
||||
#reaction-picker {
|
||||
position: fixed; display: none; z-index: 9000;
|
||||
background: #fff; border-radius: 30px; padding: 6px 10px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.18);
|
||||
gap: 4px; align-items: center;
|
||||
}
|
||||
#reaction-picker.show { display: flex; }
|
||||
#reaction-picker span { font-size: 1.4rem; cursor: pointer; padding: 4px 6px; border-radius: 50%; transition: transform .1s; }
|
||||
#reaction-picker span:hover { transform: scale(1.3); }
|
||||
.msg-bubble { position: relative; }
|
||||
.msg-bubble:hover .react-btn { opacity: 1; }
|
||||
.react-btn {
|
||||
position: absolute; top: 4px; opacity: 0; transition: opacity .15s;
|
||||
background: #fff; border: 1px solid #e0e0e0; border-radius: 50%;
|
||||
width: 24px; height: 24px; font-size: .75rem; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.12);
|
||||
}
|
||||
.msg-bubble.incoming .react-btn { right: -28px; }
|
||||
.msg-bubble.outgoing .react-btn { left: -28px; }
|
||||
.msg-reaction { font-size: .95rem; margin-top: 2px; }
|
||||
|
||||
/* ── Preview adjunto ── */
|
||||
#attach-preview {
|
||||
display: none; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; background: #e9f5fe;
|
||||
border-top: 1px solid #bee3f8; font-size: .85rem;
|
||||
}
|
||||
#attach-preview img { width: 48px; height: 48px; object-fit: cover; border-radius: 6px; }
|
||||
#attach-preview .attach-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
#attach-preview .attach-remove { background: none; border: none; color: #e53e3e; cursor: pointer; font-size: 1rem; }
|
||||
|
||||
/* ── Recording indicator ── */
|
||||
#recording-bar {
|
||||
display: none; align-items: center; gap: 10px;
|
||||
padding: 8px 14px; background: #fff0f0; border-top: 1px solid #fed7d7;
|
||||
font-size: .85rem; color: #e53e3e;
|
||||
}
|
||||
#recording-bar .rec-dot { width: 10px; height: 10px; border-radius: 50%; background: #e53e3e; animation: blink 1s infinite; }
|
||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.2} }
|
||||
|
||||
/* ── Modal plantilla ── */
|
||||
#template-modal {
|
||||
display: none; position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0,0,0,.4); align-items: center; justify-content: center;
|
||||
}
|
||||
#template-modal.show { display: flex; }
|
||||
.tpl-card { background: #fff; border-radius: 14px; padding: 24px; width: 340px; box-shadow: 0 8px 32px rgba(0,0,0,.2); }
|
||||
.tpl-card h3 { font-size: 1rem; font-weight: 700; margin-bottom: 16px; }
|
||||
.tpl-field { margin-bottom: 12px; }
|
||||
.tpl-field label { font-size: .8rem; color: #555; font-weight: 600; display: block; margin-bottom: 4px; }
|
||||
.tpl-field input { width: 100%; padding: 8px 10px; border: 1.5px solid #e0e0e0; border-radius: 8px; font-size: .9rem; outline: none; }
|
||||
.tpl-field input:focus { border-color: #25d366; }
|
||||
.tpl-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
|
||||
.btn-tpl-cancel { padding: 8px 16px; background: #f5f5f5; border: none; border-radius: 8px; cursor: pointer; font-size: .88rem; }
|
||||
.btn-tpl-send { padding: 8px 16px; background: #25d366; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: .88rem; font-weight: 600; }
|
||||
|
||||
/* ── Media en burbuja ── */
|
||||
.msg-img { max-width: 220px; max-height: 200px; border-radius: 8px; cursor: pointer; display: block; margin-bottom: 3px; }
|
||||
.msg-video { max-width: 220px; border-radius: 8px; display: block; margin-bottom: 3px; }
|
||||
.msg-audio { width: 200px; margin-bottom: 3px; }
|
||||
.msg-doc-link { display: flex; align-items: center; gap: 7px; font-size: .83rem; color: #1a73e8; text-decoration: none; padding: 4px 0; }
|
||||
.msg-doc-link:hover { text-decoration: underline; }
|
||||
|
||||
.back-btn { display: none; background: none; border: none; color: #fff; font-size: 1.1rem; cursor: pointer; padding: 4px 8px; }
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.chat-shell { position: relative; overflow: hidden; }
|
||||
.sidebar { position: absolute; inset: 0; z-index: 10; transition: transform .2s ease; }
|
||||
.sidebar.mobile-hidden { transform: translateX(-100%); pointer-events: none; }
|
||||
.chat-window { position: absolute; inset: 0; z-index: 5; transform: translateX(100%); height: 100%; transition: transform .2s ease; }
|
||||
.chat-window.mobile-active { transform: translateX(0); }
|
||||
.back-btn { display: flex; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="chat-shell">
|
||||
|
||||
<!-- ── Sidebar: lista de contactos ── -->
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<a href="<?= BASE_URL ?>erp.php?m=turnero&v=dashboard"
|
||||
style="color:rgba(255,255,255,.7);font-size:.85rem;text-decoration:none;display:flex;align-items:center;gap:5px;margin-bottom:8px">
|
||||
<i class="fas fa-arrow-left"></i> Menú
|
||||
</a>
|
||||
<i class="fab fa-whatsapp" style="font-size:1.4rem"></i>
|
||||
<h2>Chat Turnero</h2>
|
||||
<?php if ($turneroPhoneId): ?>
|
||||
<span class="badge-config" title="Número turnero configurado">
|
||||
<i class="fas fa-check-circle"></i> Activo
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="badge-config" style="background:rgba(220,53,69,.6)" title="Sin configurar">
|
||||
<i class="fas fa-exclamation-circle"></i> Sin config
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="Buscar contacto…" autocomplete="off">
|
||||
</div>
|
||||
<div class="contact-list" id="contactList">
|
||||
<div class="contact-empty"><i class="fas fa-spinner fa-spin"></i><br>Cargando…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Ventana de chat ── -->
|
||||
<div class="chat-window" id="chatWindow">
|
||||
|
||||
<?php if (!$turneroPhoneId): ?>
|
||||
<div class="alert-no-config">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
El número de WhatsApp del Turnero no está configurado.
|
||||
<a href="/erp.php?m=turnero&v=configuracion" style="margin-left:8px;color:#856404;font-weight:600;">Configurar ahora</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="chat-empty-state" id="emptyState">
|
||||
<i class="fab fa-whatsapp"></i>
|
||||
<p>Selecciona un contacto para ver la conversación</p>
|
||||
</div>
|
||||
|
||||
<div id="activeChat" style="display:none; flex:1; flex-direction:column; overflow:hidden;">
|
||||
<div class="chat-topbar" id="chatTopbar">
|
||||
<button class="back-btn" onclick="closeChatMobile()"><i class="fas fa-arrow-left"></i></button>
|
||||
<div class="chat-topbar-avatar" id="topbarAvatar">?</div>
|
||||
<div class="chat-topbar-info">
|
||||
<h3 id="topbarName">—</h3>
|
||||
<span id="topbarPhone">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="messages-area" id="messagesArea">
|
||||
<button class="load-more-btn" id="loadMoreBtn" style="display:none" onclick="loadMoreMessages()">
|
||||
<i class="fas fa-chevron-up"></i> Ver mensajes anteriores
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="typingIndicator" class="typing-indicator" style="display:none">Escribiendo…</div>
|
||||
|
||||
<!-- Preview adjunto -->
|
||||
<div id="attach-preview">
|
||||
<img id="attachThumb" src="" alt="" style="display:none">
|
||||
<span class="attach-name" id="attachName"></span>
|
||||
<button class="attach-remove" onclick="clearAttach()">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Barra de grabación -->
|
||||
<div id="recording-bar">
|
||||
<div class="rec-dot"></div>
|
||||
<span>Grabando…</span>
|
||||
<span id="recTimer" style="font-weight:700;min-width:32px">0:00</span>
|
||||
<button onclick="stopRecording()" style="margin-left:auto;background:#e53e3e;color:#fff;border:none;border-radius:6px;padding:4px 12px;cursor:pointer;font-size:.82rem">Enviar</button>
|
||||
<button onclick="cancelRecording()" style="background:#f5f5f5;border:none;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:.82rem">Cancelar</button>
|
||||
</div>
|
||||
|
||||
<input type="file" id="fileInput" style="display:none"
|
||||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.csv"
|
||||
onchange="onFileSelected(event)">
|
||||
|
||||
<div class="chat-input-area" style="position:relative">
|
||||
<div class="emoji-panel" id="emojiPanel"></div>
|
||||
<button class="input-extra-btn" onclick="toggleEmojiPanel()" title="Emoji"><i class="fas fa-smile"></i></button>
|
||||
<button class="input-extra-btn" onclick="document.getElementById('fileInput').click()" title="Adjuntar"><i class="fas fa-paperclip"></i></button>
|
||||
<button class="input-extra-btn" id="micBtn" onclick="toggleRecording()" title="Grabar audio"><i class="fas fa-microphone"></i></button>
|
||||
<textarea id="msgInput" rows="1" placeholder="Escribe un mensaje…"
|
||||
onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
|
||||
<button class="input-extra-btn" onclick="openTemplatePicker()" title="Plantilla"><i class="fas fa-file-alt"></i></button>
|
||||
<button class="btn-send" id="btnSend" onclick="sendMessage()" <?= $turneroPhoneId ? '' : 'disabled' ?>>
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /chat-window -->
|
||||
</div><!-- /chat-shell -->
|
||||
|
||||
<!-- ── Reaction picker ── -->
|
||||
<div id="reaction-picker">
|
||||
<?php foreach (['👍','❤️','😂','😮','😢','🙏','🔥','👏'] as $em): ?>
|
||||
<span onclick="sendReactionEmoji('<?= $em ?>')"><?= $em ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- ── Modal plantilla ── -->
|
||||
<div id="template-modal">
|
||||
<div class="tpl-card">
|
||||
<h3><i class="fas fa-file-alt" style="color:#25d366;margin-right:6px"></i>Enviar Plantilla</h3>
|
||||
<div class="tpl-field">
|
||||
<label>Nombre de plantilla</label>
|
||||
<input type="text" id="tplName" placeholder="ej: consentimiento_turno">
|
||||
</div>
|
||||
<div class="tpl-field">
|
||||
<label>Idioma</label>
|
||||
<input type="text" id="tplLang" value="es_CO" placeholder="es_CO">
|
||||
</div>
|
||||
<div class="tpl-field">
|
||||
<label>Parámetros (JSON, opcional)</label>
|
||||
<input type="text" id="tplParams" placeholder='[{"type":"text","text":"valor"}]'>
|
||||
</div>
|
||||
<div class="tpl-actions">
|
||||
<button class="btn-tpl-cancel" onclick="closeTemplatePicker()">Cancelar</button>
|
||||
<button class="btn-tpl-send" onclick="sendTemplate()">Enviar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_LIST = 'modules/turnero/api/chat_get_list.php';
|
||||
const API_MESSAGES = 'modules/turnero/api/chat_get_messages.php';
|
||||
const API_SEND = 'modules/turnero/api/chat_send_message.php';
|
||||
const API_READ = 'modules/turnero/api/chat_mark_read.php';
|
||||
const API_MEDIA = 'modules/turnero/api/chat_upload_media.php';
|
||||
const API_REACT = 'modules/turnero/api/chat_react.php';
|
||||
const BASE = (function() {
|
||||
const s = window.location.pathname;
|
||||
return s.replace(/\/erp\.php.*$/, '/') || '/';
|
||||
})();
|
||||
|
||||
let state = {
|
||||
contacts: [],
|
||||
activeUserId: null,
|
||||
activeUser: null,
|
||||
messages: [],
|
||||
earliest: null,
|
||||
earliestId: null,
|
||||
hasMore: false,
|
||||
polling: null,
|
||||
lastPollTime: null,
|
||||
};
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadContacts();
|
||||
document.getElementById('searchInput').addEventListener('input', debounce(() => {
|
||||
loadContacts(document.getElementById('searchInput').value.trim());
|
||||
}, 300));
|
||||
setInterval(pollMessages, 4000);
|
||||
setInterval(() => loadContacts(document.getElementById('searchInput').value.trim(), true), 12000);
|
||||
});
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let t;
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
|
||||
// ── Contactos ─────────────────────────────────────────────────────────────────
|
||||
async function loadContacts(search = '', silent = false) {
|
||||
const url = BASE + API_LIST + '?limit=100' + (search ? '&search=' + encodeURIComponent(search) : '');
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
if (!json.success) return;
|
||||
state.contacts = json.data;
|
||||
renderContacts(json.data);
|
||||
} catch(e) {
|
||||
if (!silent) console.error('loadContacts:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderContacts(contacts) {
|
||||
const el = document.getElementById('contactList');
|
||||
if (!contacts.length) {
|
||||
el.innerHTML = '<div class="contact-empty"><i class="fas fa-comment-slash"></i><br>Sin conversaciones aún</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = contacts.map(c => {
|
||||
const initials = (c.name || c.phone_number).charAt(0).toUpperCase();
|
||||
const preview = escHtml(c.last_message || '—');
|
||||
const time = c.last_time ? formatTime(c.last_time) : '';
|
||||
const unread = c.unread_count > 0 ? `<span class="badge-unread">${c.unread_count}</span>` : '';
|
||||
const active = state.activeUserId === c.user_id ? ' active' : '';
|
||||
return `<div class="contact-item${active}" onclick="openChat(${c.user_id})">
|
||||
<div class="contact-avatar">${initials}</div>
|
||||
<div class="contact-info">
|
||||
<div class="contact-name">${escHtml(c.name || c.phone_number)}</div>
|
||||
<div class="contact-last">${preview}</div>
|
||||
</div>
|
||||
<div class="contact-meta">
|
||||
<span class="contact-time">${time}</span>
|
||||
${unread}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Abrir chat ────────────────────────────────────────────────────────────────
|
||||
async function openChat(userId) {
|
||||
state.activeUserId = userId;
|
||||
state.messages = [];
|
||||
state.earliest = null;
|
||||
state.earliestId = null;
|
||||
state.hasMore = false;
|
||||
state.lastPollTime = null;
|
||||
|
||||
const contact = state.contacts.find(c => c.user_id === userId);
|
||||
state.activeUser = contact;
|
||||
|
||||
document.getElementById('emptyState').style.display = 'none';
|
||||
const activeEl = document.getElementById('activeChat');
|
||||
activeEl.style.display = 'flex';
|
||||
activeEl.style.flexDirection = 'column';
|
||||
activeEl.style.overflow = 'hidden';
|
||||
activeEl.style.flex = '1';
|
||||
|
||||
const initials = contact ? (contact.name || contact.phone_number).charAt(0).toUpperCase() : '?';
|
||||
document.getElementById('topbarAvatar').textContent = initials;
|
||||
document.getElementById('topbarName').textContent = contact ? (contact.name || contact.phone_number) : '…';
|
||||
document.getElementById('topbarPhone').textContent = contact ? contact.phone_number : '';
|
||||
|
||||
document.querySelectorAll('.contact-item').forEach(el => el.classList.remove('active'));
|
||||
const items = document.querySelectorAll('.contact-item');
|
||||
items.forEach(el => { if (el.onclick.toString().includes(`openChat(${userId})`)) el.classList.add('active'); });
|
||||
|
||||
const area = document.getElementById('messagesArea');
|
||||
area.innerHTML = '<div style="text-align:center;padding:20px;color:#aaa"><i class="fas fa-spinner fa-spin"></i></div>';
|
||||
|
||||
await fetchMessages();
|
||||
markRead(userId);
|
||||
loadContacts(document.getElementById('searchInput').value.trim(), true);
|
||||
openChatMobile();
|
||||
}
|
||||
|
||||
function isMobile() { return window.innerWidth <= 680; }
|
||||
function openChatMobile() {
|
||||
if (!isMobile()) return;
|
||||
document.getElementById('sidebar').classList.add('mobile-hidden');
|
||||
document.getElementById('chatWindow').classList.add('mobile-active');
|
||||
}
|
||||
function closeChatMobile() {
|
||||
if (!isMobile()) return;
|
||||
document.getElementById('sidebar').classList.remove('mobile-hidden');
|
||||
document.getElementById('chatWindow').classList.remove('mobile-active');
|
||||
}
|
||||
|
||||
// ── Mensajes ──────────────────────────────────────────────────────────────────
|
||||
async function fetchMessages() {
|
||||
if (!state.activeUserId) return;
|
||||
const forUser = state.activeUserId; // captura antes del await
|
||||
const url = BASE + API_MESSAGES + '?user_id=' + forUser + '&limit=50';
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
if (forUser !== state.activeUserId) return; // usuario cambió mientras esperaba
|
||||
if (!json.success) return;
|
||||
state.messages = json.data;
|
||||
state.hasMore = json.has_more;
|
||||
state.earliest = json.earliest;
|
||||
state.earliestId = json.earliest_id;
|
||||
state.lastPollTime = json.data.length ? json.data[json.data.length - 1].created_at : null;
|
||||
renderMessages(json.data, false);
|
||||
} catch(e) { console.error('fetchMessages:', e); }
|
||||
}
|
||||
|
||||
async function loadMoreMessages() {
|
||||
if (!state.activeUserId || !state.earliest) return;
|
||||
const forUser = state.activeUserId;
|
||||
const url = BASE + API_MESSAGES
|
||||
+ '?user_id=' + forUser
|
||||
+ '&limit=50'
|
||||
+ '&before=' + encodeURIComponent(state.earliest)
|
||||
+ (state.earliestId ? '&before_id=' + state.earliestId : '');
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
if (forUser !== state.activeUserId) return;
|
||||
if (!json.success || !json.data.length) {
|
||||
document.getElementById('loadMoreBtn').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
state.messages = [...json.data, ...state.messages];
|
||||
state.hasMore = json.has_more;
|
||||
state.earliest = json.earliest;
|
||||
state.earliestId = json.earliest_id;
|
||||
prependMessages(json.data);
|
||||
} catch(e) { console.error('loadMoreMessages:', e); }
|
||||
}
|
||||
|
||||
async function pollMessages() {
|
||||
if (!state.activeUserId || !state.lastPollTime) return;
|
||||
const forUser = state.activeUserId;
|
||||
const sinceTime = state.lastPollTime;
|
||||
const url = BASE + API_MESSAGES
|
||||
+ '?user_id=' + forUser
|
||||
+ '&limit=50'
|
||||
+ '&since=' + encodeURIComponent(sinceTime);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
if (forUser !== state.activeUserId) return; // usuario cambió mientras esperaba
|
||||
if (!json.success || !json.data.length) return;
|
||||
json.data.forEach(m => {
|
||||
if (!state.messages.find(x => x.id === m.id)) {
|
||||
state.messages.push(m);
|
||||
appendMessage(m);
|
||||
}
|
||||
});
|
||||
state.lastPollTime = json.data[json.data.length - 1].created_at;
|
||||
markRead(forUser);
|
||||
loadContacts(document.getElementById('searchInput').value.trim(), true);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Render mensajes ───────────────────────────────────────────────────────────
|
||||
function renderMessages(msgs, prepend) {
|
||||
const area = document.getElementById('messagesArea');
|
||||
area.innerHTML = '';
|
||||
|
||||
const loadBtn = document.createElement('button');
|
||||
loadBtn.className = 'load-more-btn';
|
||||
loadBtn.id = 'loadMoreBtn';
|
||||
loadBtn.style.display = state.hasMore ? 'block' : 'none';
|
||||
loadBtn.onclick = loadMoreMessages;
|
||||
loadBtn.innerHTML = '<i class="fas fa-chevron-up"></i> Ver mensajes anteriores';
|
||||
area.appendChild(loadBtn);
|
||||
|
||||
msgs.forEach(m => area.appendChild(buildBubble(m)));
|
||||
area.scrollTop = area.scrollHeight;
|
||||
}
|
||||
|
||||
function prependMessages(msgs) {
|
||||
const area = document.getElementById('messagesArea');
|
||||
const oldTop = area.scrollHeight - area.scrollTop;
|
||||
const loadBtn = document.getElementById('loadMoreBtn');
|
||||
msgs.forEach(m => area.insertBefore(buildBubble(m), loadBtn.nextSibling));
|
||||
if (!state.hasMore) loadBtn.style.display = 'none';
|
||||
area.scrollTop = area.scrollHeight - oldTop;
|
||||
}
|
||||
|
||||
function appendMessage(msg) {
|
||||
const area = document.getElementById('messagesArea');
|
||||
const bubble = buildBubble(msg);
|
||||
area.appendChild(bubble);
|
||||
area.scrollTop = area.scrollHeight;
|
||||
}
|
||||
|
||||
function buildBubble(msg) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg-bubble ' + (msg.direction === 'outgoing' ? 'outgoing' : 'incoming');
|
||||
div.dataset.msgId = msg.id;
|
||||
div.dataset.waId = msg.message_id || '';
|
||||
|
||||
const mediaSrc = msg.local_file ? ('../' + msg.local_file) : (msg.media_url_external || null);
|
||||
let content = '';
|
||||
|
||||
if (msg.message_type === 'image' && mediaSrc) {
|
||||
content = `<img class="msg-img" src="${escHtml(mediaSrc)}" loading="lazy"
|
||||
onclick="window.open('${escHtml(mediaSrc)}','_blank')" alt="imagen">`;
|
||||
if (msg.content) content += `<div style="font-size:.82rem;margin-top:2px">${escHtml(msg.content)}</div>`;
|
||||
} else if (msg.message_type === 'video' && mediaSrc) {
|
||||
content = `<video class="msg-video" src="${escHtml(mediaSrc)}" controls preload="metadata"></video>`;
|
||||
if (msg.content) content += `<div style="font-size:.82rem;margin-top:2px">${escHtml(msg.content)}</div>`;
|
||||
} else if (msg.message_type === 'audio' && mediaSrc) {
|
||||
content = `<audio class="msg-audio" src="${escHtml(mediaSrc)}" controls preload="metadata"></audio>`;
|
||||
} else if (msg.message_type === 'document' && mediaSrc) {
|
||||
const icon = (msg.mime_type || '').includes('pdf') ? 'fa-file-pdf' :
|
||||
(msg.mime_type || '').includes('spreadsheet') || (msg.mime_type || '').includes('excel') ? 'fa-file-excel' :
|
||||
(msg.mime_type || '').includes('word') ? 'fa-file-word' : 'fa-file-alt';
|
||||
content = `<a class="msg-doc-link" href="${escHtml(mediaSrc)}" target="_blank">
|
||||
<i class="fas ${icon}" style="font-size:1.3rem"></i>
|
||||
<span>${escHtml(msg.filename || 'Documento')}</span>
|
||||
</a>`;
|
||||
} else {
|
||||
content = nl2br(escHtml(msg.content || ''));
|
||||
}
|
||||
|
||||
const time = msg.created_at ? formatTimeFull(msg.created_at) : '';
|
||||
const statusIcon = msg.direction === 'outgoing'
|
||||
? (msg.status === 'read' ? ' ✓✓' : msg.status === 'delivered' ? ' ✓✓' : ' ✓')
|
||||
: '';
|
||||
|
||||
const reactBtn = msg.message_id
|
||||
? `<button class="react-btn" onclick="showReactionPicker(event, '${escHtml(msg.message_id)}')">😊</button>`
|
||||
: '';
|
||||
|
||||
div.innerHTML = content
|
||||
+ `<div class="msg-time">${time}<span style="opacity:.7;font-size:.7rem">${statusIcon}</span></div>`
|
||||
+ (msg.reaction_emoji ? `<div class="msg-reaction">${escHtml(msg.reaction_emoji)}</div>` : '')
|
||||
+ reactBtn;
|
||||
return div;
|
||||
}
|
||||
|
||||
// ── Enviar texto ─────────────────────────────────────────────────────────────
|
||||
async function sendMessage() {
|
||||
// Si hay archivo adjunto, enviar como media
|
||||
if (attachFile) { await sendMedia(); return; }
|
||||
|
||||
const input = document.getElementById('msgInput');
|
||||
const message = input.value.trim();
|
||||
if (!message || !state.activeUserId) return;
|
||||
|
||||
const btn = document.getElementById('btnSend');
|
||||
btn.disabled = true;
|
||||
input.value = '';
|
||||
autoResize(input);
|
||||
|
||||
try {
|
||||
const res = await fetch(BASE + API_SEND, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: state.activeUserId, message })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) {
|
||||
alert('Error al enviar: ' + (json.error || 'desconocido'));
|
||||
input.value = message;
|
||||
} else {
|
||||
await pollMessages();
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Error de red al enviar el mensaje');
|
||||
input.value = message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Adjuntar archivo ──────────────────────────────────────────────────────────
|
||||
let attachFile = null;
|
||||
|
||||
function onFileSelected(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
attachFile = file;
|
||||
const prev = document.getElementById('attach-preview');
|
||||
const thumb = document.getElementById('attachThumb');
|
||||
const name = document.getElementById('attachName');
|
||||
name.textContent = file.name + ' (' + (file.size > 1048576 ? (file.size/1048576).toFixed(1) + ' MB' : Math.round(file.size/1024) + ' KB') + ')';
|
||||
if (file.type.startsWith('image/')) {
|
||||
thumb.src = URL.createObjectURL(file);
|
||||
thumb.style.display = 'block';
|
||||
} else {
|
||||
thumb.style.display = 'none';
|
||||
}
|
||||
prev.style.display = 'flex';
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
function clearAttach() {
|
||||
attachFile = null;
|
||||
document.getElementById('attach-preview').style.display = 'none';
|
||||
document.getElementById('attachThumb').src = '';
|
||||
}
|
||||
|
||||
async function sendMedia() {
|
||||
if (!attachFile || !state.activeUserId) return;
|
||||
const btn = document.getElementById('btnSend');
|
||||
btn.disabled = true;
|
||||
const caption = document.getElementById('msgInput').value.trim();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('user_id', state.activeUserId);
|
||||
fd.append('file', attachFile);
|
||||
if (caption) fd.append('caption', caption);
|
||||
|
||||
try {
|
||||
const res = await fetch(BASE + API_MEDIA, { method: 'POST', body: fd });
|
||||
const json = await res.json();
|
||||
if (!json.success) {
|
||||
alert('Error al enviar archivo: ' + (json.error || 'desconocido'));
|
||||
} else {
|
||||
clearAttach();
|
||||
document.getElementById('msgInput').value = '';
|
||||
autoResize(document.getElementById('msgInput'));
|
||||
await pollMessages();
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Error de red al enviar el archivo');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Emoji picker ──────────────────────────────────────────────────────────────
|
||||
const EMOJI_LIST = ['😀','😂','😍','😎','😢','😡','🤔','👍','👎','❤️','🔥','✅','⭐','🎉','🙏','💪','🤣','😅','😊','🥰','😘','🤗','😴','🤒','😷','💊','🏥','👨⚕️','👩⚕️','📋','📞','📅','⏰','💉','🩺'];
|
||||
|
||||
function toggleEmojiPanel() {
|
||||
const panel = document.getElementById('emojiPanel');
|
||||
if (!panel.classList.contains('show')) {
|
||||
panel.innerHTML = EMOJI_LIST.map(e => `<span onclick="insertEmoji('${e}')">${e}</span>`).join('');
|
||||
}
|
||||
panel.classList.toggle('show');
|
||||
}
|
||||
|
||||
function insertEmoji(em) {
|
||||
const inp = document.getElementById('msgInput');
|
||||
const pos = inp.selectionStart;
|
||||
inp.value = inp.value.slice(0, pos) + em + inp.value.slice(pos);
|
||||
inp.selectionStart = inp.selectionEnd = pos + em.length;
|
||||
inp.focus();
|
||||
autoResize(inp);
|
||||
document.getElementById('emojiPanel').classList.remove('show');
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const panel = document.getElementById('emojiPanel');
|
||||
if (panel.classList.contains('show') && !e.target.closest('.chat-input-area')) {
|
||||
panel.classList.remove('show');
|
||||
}
|
||||
const rp = document.getElementById('reaction-picker');
|
||||
if (rp.classList.contains('show') && !e.target.closest('#reaction-picker') && !e.target.closest('.react-btn')) {
|
||||
rp.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Reacciones ────────────────────────────────────────────────────────────────
|
||||
let _reactionTarget = null;
|
||||
|
||||
function showReactionPicker(e, waId) {
|
||||
e.stopPropagation();
|
||||
_reactionTarget = waId;
|
||||
const rp = document.getElementById('reaction-picker');
|
||||
rp.classList.add('show');
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
rp.style.top = (rect.top - 60) + 'px';
|
||||
rp.style.left = Math.max(4, rect.left - 80) + 'px';
|
||||
}
|
||||
|
||||
async function sendReactionEmoji(emoji) {
|
||||
document.getElementById('reaction-picker').classList.remove('show');
|
||||
if (!_reactionTarget || !state.activeUserId) return;
|
||||
try {
|
||||
await fetch(BASE + API_REACT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: state.activeUserId, message_id: _reactionTarget, emoji })
|
||||
});
|
||||
await pollMessages();
|
||||
} catch(e) { console.error('reaction:', e); }
|
||||
}
|
||||
|
||||
// ── Grabación de audio ────────────────────────────────────────────────────────
|
||||
let mediaRecorder = null, audioChunks = [], recInterval = null, recSeconds = 0;
|
||||
|
||||
async function toggleRecording() {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
stopRecording();
|
||||
} else {
|
||||
await startRecording();
|
||||
}
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
audioChunks = [];
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType: MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/ogg' });
|
||||
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
|
||||
mediaRecorder.start(100);
|
||||
|
||||
recSeconds = 0;
|
||||
document.getElementById('recording-bar').style.display = 'flex';
|
||||
document.getElementById('recTimer').textContent = '0:00';
|
||||
document.getElementById('micBtn').style.color = '#e53e3e';
|
||||
recInterval = setInterval(() => {
|
||||
recSeconds++;
|
||||
const m = Math.floor(recSeconds/60), s = recSeconds%60;
|
||||
document.getElementById('recTimer').textContent = m + ':' + String(s).padStart(2,'0');
|
||||
}, 1000);
|
||||
} catch(e) {
|
||||
alert('No se pudo acceder al micrófono: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
if (!mediaRecorder) return;
|
||||
clearInterval(recInterval);
|
||||
document.getElementById('recording-bar').style.display = 'none';
|
||||
document.getElementById('micBtn').style.color = '';
|
||||
|
||||
return new Promise(resolve => {
|
||||
mediaRecorder.onstop = async () => {
|
||||
const mime = mediaRecorder.mimeType || 'audio/webm';
|
||||
const blob = new Blob(audioChunks, { type: mime });
|
||||
const ext = mime.includes('ogg') ? 'ogg' : 'webm';
|
||||
const file = new File([blob], `audio_${Date.now()}.${ext}`, { type: mime });
|
||||
mediaRecorder.stream.getTracks().forEach(t => t.stop());
|
||||
mediaRecorder = null;
|
||||
|
||||
if (!state.activeUserId) { resolve(); return; }
|
||||
const fd = new FormData();
|
||||
fd.append('user_id', state.activeUserId);
|
||||
fd.append('file', file);
|
||||
fd.append('is_voice', '1');
|
||||
try {
|
||||
const res = await fetch(BASE + API_MEDIA, { method: 'POST', body: fd });
|
||||
const json = await res.json();
|
||||
if (json.success) await pollMessages();
|
||||
else alert('Error al enviar audio: ' + (json.error || ''));
|
||||
} catch(e) { alert('Error de red al enviar audio'); }
|
||||
resolve();
|
||||
};
|
||||
mediaRecorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
function cancelRecording() {
|
||||
if (!mediaRecorder) return;
|
||||
clearInterval(recInterval);
|
||||
mediaRecorder.stream.getTracks().forEach(t => t.stop());
|
||||
mediaRecorder = null;
|
||||
audioChunks = [];
|
||||
document.getElementById('recording-bar').style.display = 'none';
|
||||
document.getElementById('micBtn').style.color = '';
|
||||
}
|
||||
|
||||
// ── Plantilla ─────────────────────────────────────────────────────────────────
|
||||
function openTemplatePicker() {
|
||||
if (!state.activeUserId) return;
|
||||
document.getElementById('template-modal').classList.add('show');
|
||||
document.getElementById('tplName').focus();
|
||||
}
|
||||
function closeTemplatePicker() {
|
||||
document.getElementById('template-modal').classList.remove('show');
|
||||
}
|
||||
|
||||
async function sendTemplate() {
|
||||
const name = document.getElementById('tplName').value.trim();
|
||||
const lang = document.getElementById('tplLang').value.trim() || 'es_CO';
|
||||
const rawP = document.getElementById('tplParams').value.trim();
|
||||
if (!name || !state.activeUserId) return alert('Ingresa el nombre de la plantilla');
|
||||
|
||||
let params = [];
|
||||
if (rawP) {
|
||||
try { params = JSON.parse(rawP); } catch(e) { return alert('Los parámetros no son JSON válido'); }
|
||||
}
|
||||
|
||||
closeTemplatePicker();
|
||||
try {
|
||||
const res = await fetch(BASE + API_SEND, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: state.activeUserId, type: 'template', template: name, lang, params })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) alert('Error al enviar plantilla: ' + (json.error || ''));
|
||||
else await pollMessages();
|
||||
} catch(e) { alert('Error de red al enviar plantilla'); }
|
||||
}
|
||||
|
||||
async function markRead(userId) {
|
||||
try {
|
||||
await fetch(BASE + API_READ, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId })
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function handleKey(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||
}
|
||||
function autoResize(el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
||||
}
|
||||
function escHtml(str) {
|
||||
return String(str ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function nl2br(str) { return str.replace(/\n/g, '<br>'); }
|
||||
function formatTime(ts) {
|
||||
const d = new Date(ts.replace(' ', 'T'));
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) {
|
||||
return d.toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleDateString('es-CO', { day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
function formatTimeFull(ts) {
|
||||
const d = new Date(ts.replace(' ', 'T'));
|
||||
return d.toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -207,7 +207,7 @@ $tab = $_GET['tab'] ?? 'lugares';
|
||||
<span class="badge <?= $lu['activo'] ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary' ?> ms-1">
|
||||
<?= $lu['activo'] ? 'Activo' : 'Inactivo' ?>
|
||||
</span>
|
||||
<button class="btn-icon text-primary" onclick="editarLugar(<?= $lu['id'] ?>,'<?= addslashes($lu['nombre']) ?>','<?= addslashes($lu['descripcion'] ?? '') ?>',<?= $lu['activo'] ?>,<?= $lu['sort_order'] ?>,'recepcion')" title="Editar">
|
||||
<button class="btn-icon text-primary" onclick="editarLugar(<?= $lu['id'] ?>,'<?= addslashes($lu['nombre']) ?>','<?= addslashes($lu['descripcion'] ?? '') ?>',<?= $lu['activo'] ?>,<?= $lu['sort_order'] ?>,'recepcion','<?= $lu['formulario_modo'] ?? 'link' ?>')" title="Editar">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
<button class="btn-icon text-danger" onclick="eliminarLugar(<?= $lu['id'] ?>, '<?= addslashes($lu['nombre']) ?>')" title="Eliminar">
|
||||
@@ -268,7 +268,7 @@ $tab = $_GET['tab'] ?? 'lugares';
|
||||
<button class="btn-icon" onclick="copiarUrlDisplay(<?= $lu['id'] ?>)" title="Copiar URL pantalla TV de este lugar">
|
||||
<i class="fas fa-tv"></i>
|
||||
</button>
|
||||
<button class="btn-icon text-primary" onclick="editarLugar(<?= $lu['id'] ?>,'<?= addslashes($lu['nombre']) ?>','<?= addslashes($lu['descripcion'] ?? '') ?>',<?= $lu['activo'] ?>,<?= $lu['sort_order'] ?>,'muestras')" title="Editar">
|
||||
<button class="btn-icon text-primary" onclick="editarLugar(<?= $lu['id'] ?>,'<?= addslashes($lu['nombre']) ?>','<?= addslashes($lu['descripcion'] ?? '') ?>',<?= $lu['activo'] ?>,<?= $lu['sort_order'] ?>,'muestras','<?= $lu['formulario_modo'] ?? 'link' ?>')" title="Editar">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
<button class="btn-icon text-danger" onclick="eliminarLugar(<?= $lu['id'] ?>, '<?= addslashes($lu['nombre']) ?>')" title="Eliminar">
|
||||
@@ -334,7 +334,7 @@ $tab = $_GET['tab'] ?? 'lugares';
|
||||
<div class="mb-2 mt-3">
|
||||
<label class="form-label small fw-semibold">
|
||||
<i class="fas fa-file-signature me-1 text-warning"></i>
|
||||
Consentimientos requeridos para este lugar
|
||||
Formularios de consentimiento
|
||||
</label>
|
||||
<div id="edit-lu-consents" class="border rounded p-2" style="max-height:160px;overflow-y:auto;background:#fffbeb">
|
||||
<?php foreach ($formulariosCons as $fc): ?>
|
||||
@@ -352,6 +352,23 @@ $tab = $_GET['tab'] ?? 'lugares';
|
||||
<small class="text-muted">No hay formularios activos configurados.</small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-label small fw-semibold mb-1">Modo de presentación del formulario</label>
|
||||
<div class="d-flex gap-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="edit-lu-form-modo" id="edit-lu-modo-link" value="link" checked>
|
||||
<label class="form-check-label small" for="edit-lu-modo-link">
|
||||
<i class="fas fa-link me-1 text-primary"></i>Enviar por link / WA
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="edit-lu-form-modo" id="edit-lu-modo-embebido" value="embebido">
|
||||
<label class="form-check-label small" for="edit-lu-modo-embebido">
|
||||
<i class="fas fa-window-maximize me-1 text-success"></i>Embebido en pantalla
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-2 px-3">
|
||||
@@ -760,12 +777,19 @@ $tab = $_GET['tab'] ?? 'lugares';
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">Nombre de plantilla Meta aprobada</label>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">Plantilla con consentimiento</label>
|
||||
<input type="text" id="wa-template" class="form-control form-control-sm"
|
||||
value="<?= htmlspecialchars($cfg['turnero_wa_template'] ?? 'consentimiento_turno') ?>"
|
||||
placeholder="consentimiento_turno">
|
||||
<small class="text-muted">Nombre exacto en Meta Business Manager</small>
|
||||
<small class="text-muted">Consulta, toma de muestra, etc. — incluye link de firma</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold">Plantilla muestra pendiente</label>
|
||||
<input type="text" id="wa-template-muestra" class="form-control form-control-sm"
|
||||
value="<?= htmlspecialchars($cfg['turnero_wa_template_muestra'] ?? 'turno_muestra_pendiente') ?>"
|
||||
placeholder="turno_muestra_pendiente">
|
||||
<small class="text-muted">Solo notifica turno, sin consentimiento</small>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-semibold">Código de idioma</label>
|
||||
@@ -773,20 +797,101 @@ $tab = $_GET['tab'] ?? 'lugares';
|
||||
value="<?= htmlspecialchars($cfg['turnero_wa_lang'] ?? 'es_CO') ?>"
|
||||
placeholder="es_CO">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button class="btn btn-primary btn-sm w-100" onclick="guardarWhatsApp()">
|
||||
<i class="fas fa-save me-1"></i>Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end mt-2">
|
||||
<button class="btn btn-primary btn-sm" onclick="guardarWhatsApp()">
|
||||
<i class="fas fa-save me-1"></i>Guardar configuración
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-info py-2 px-3 mt-3" style="font-size:.82rem">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
La plantilla debe estar aprobada en Meta y contener una variable de URL
|
||||
(<code>{{1}}</code>) para el enlace de firma del consentimiento.
|
||||
La plantilla con consentimiento debe contener una variable de URL
|
||||
(<code>{{1}}</code>) para el enlace de firma.
|
||||
El sistema usará texto plano como fallback si la plantilla falla.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Perfil de WhatsApp Business -->
|
||||
<div class="mt-4">
|
||||
<p class="section-title"><i class="fab fa-whatsapp me-1" style="color:#25d366"></i>Perfil de WhatsApp Business</p>
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<!-- Foto de perfil -->
|
||||
<div style="position:relative;flex-shrink:0">
|
||||
<img id="wa-perfil-foto" src="" alt="Foto perfil"
|
||||
style="width:80px;height:80px;border-radius:50%;object-fit:cover;
|
||||
border:2px solid #dee2e6;background:#f1f5f9;display:none">
|
||||
<div id="wa-perfil-foto-placeholder"
|
||||
style="width:80px;height:80px;border-radius:50%;background:#e2e8f0;
|
||||
display:flex;align-items:center;justify-content:center;font-size:2rem">
|
||||
<i class="fab fa-whatsapp" style="color:#25d366"></i>
|
||||
</div>
|
||||
<label title="Cambiar foto" style="position:absolute;bottom:0;right:0;
|
||||
background:#25d366;color:#fff;border-radius:50%;width:26px;height:26px;
|
||||
display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:.8rem">
|
||||
<i class="fas fa-camera"></i>
|
||||
<input type="file" id="wa-foto-input" accept="image/jpeg,image/png"
|
||||
style="display:none" onchange="subirFotoPerfil(this)">
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-semibold" id="wa-perfil-nombre" style="font-size:.95rem">—</div>
|
||||
<div class="text-muted small" id="wa-perfil-about">—</div>
|
||||
<button class="btn btn-outline-secondary btn-sm mt-1" onclick="cargarPerfilWA()">
|
||||
<i class="fas fa-sync-alt me-1"></i>Cargar perfil actual
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Descripción corta (About) <small class="text-muted fw-normal">máx. 139 chars</small></label>
|
||||
<input type="text" id="wa-about" maxlength="139" class="form-control form-control-sm"
|
||||
placeholder="Ej: Laboratorio clínico · Lunes a Sábado 7am-5pm">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Descripción del negocio</label>
|
||||
<textarea id="wa-description" rows="2" class="form-control form-control-sm"
|
||||
placeholder="Descripción completa del laboratorio…"></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">Dirección</label>
|
||||
<input type="text" id="wa-address" class="form-control form-control-sm"
|
||||
placeholder="Calle 5 #12-34, Cúcuta">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">Email</label>
|
||||
<input type="email" id="wa-email" class="form-control form-control-sm"
|
||||
placeholder="contacto@laboratorio.com">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">Sitio web 1</label>
|
||||
<input type="url" id="wa-web1" class="form-control form-control-sm"
|
||||
placeholder="https://www.laboratorio.com">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">Sitio web 2 <small class="text-muted fw-normal">(opcional)</small></label>
|
||||
<input type="url" id="wa-web2" class="form-control form-control-sm"
|
||||
placeholder="https://instagram.com/laboratorio">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold">Categoría</label>
|
||||
<select id="wa-vertical" class="form-select form-select-sm">
|
||||
<option value="">— sin especificar —</option>
|
||||
<option value="HEALTH">Salud</option>
|
||||
<option value="MEDICAL_AND_HEALTH">Médico y Salud</option>
|
||||
<option value="BEAUTY">Belleza</option>
|
||||
<option value="EDUCATION">Educación</option>
|
||||
<option value="OTHER">Otro</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end mt-2">
|
||||
<button class="btn btn-success btn-sm" onclick="guardarPerfilWA()">
|
||||
<i class="fas fa-save me-1"></i>Guardar perfil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════
|
||||
TAB 5 — PANTALLA TV
|
||||
════════════════════════════════════════ -->
|
||||
@@ -898,7 +1003,10 @@ async function guardarLugar(tipoOrId = 'muestras', id = null) {
|
||||
});
|
||||
}
|
||||
|
||||
const body = { nombre, tipo, descripcion: desc, sort_order: orden, activo, formulario_ids: formularioIds };
|
||||
const formModo = esEdicion
|
||||
? (document.querySelector('input[name="edit-lu-form-modo"]:checked')?.value || 'link')
|
||||
: 'link';
|
||||
const body = { nombre, tipo, descripcion: desc, sort_order: orden, activo, formulario_ids: formularioIds, formulario_modo: formModo };
|
||||
if (id) body.id = id;
|
||||
|
||||
try {
|
||||
@@ -913,7 +1021,7 @@ async function guardarLugar(tipoOrId = 'muestras', id = null) {
|
||||
} catch (e) { toast('Error de conexión', 'error'); }
|
||||
}
|
||||
|
||||
function editarLugar(id, nombre, desc, activo, orden, tipo = 'muestras') {
|
||||
function editarLugar(id, nombre, desc, activo, orden, tipo = 'muestras', formModo = 'link') {
|
||||
document.getElementById('edit-lu-id').value = id;
|
||||
document.getElementById('edit-lu-tipo').value = tipo;
|
||||
document.getElementById('edit-lu-nombre').value = nombre;
|
||||
@@ -929,6 +1037,10 @@ function editarLugar(id, nombre, desc, activo, orden, tipo = 'muestras') {
|
||||
chk.checked = formIds.includes(parseInt(chk.value));
|
||||
});
|
||||
|
||||
// Modo de presentación
|
||||
const modoEl = document.querySelector(`input[name="edit-lu-form-modo"][value="${formModo}"]`);
|
||||
if (modoEl) modoEl.checked = true;
|
||||
|
||||
new bootstrap.Modal(document.getElementById('modalEditarLugar')).show();
|
||||
}
|
||||
|
||||
@@ -1172,17 +1284,92 @@ async function borrarTvVideo() {
|
||||
} catch(e) { toast('Error de conexión', 'error'); }
|
||||
}
|
||||
|
||||
async function cargarPerfilWA() {
|
||||
try {
|
||||
const res = await fetch(API + 'wa_profile.php');
|
||||
const json = await res.json();
|
||||
if (!json.ok) { toast(json.error || 'Error al cargar perfil', 'error'); return; }
|
||||
const p = json.perfil || {};
|
||||
if (p.profile_picture_url) {
|
||||
document.getElementById('wa-perfil-foto').src = p.profile_picture_url;
|
||||
document.getElementById('wa-perfil-foto').style.display = '';
|
||||
document.getElementById('wa-perfil-foto-placeholder').style.display = 'none';
|
||||
}
|
||||
document.getElementById('wa-perfil-about').textContent = p.about || '—';
|
||||
document.getElementById('wa-about').value = p.about || '';
|
||||
document.getElementById('wa-description').value = p.description || '';
|
||||
document.getElementById('wa-address').value = p.address || '';
|
||||
document.getElementById('wa-email').value = p.email || '';
|
||||
const webs = p.websites || [];
|
||||
document.getElementById('wa-web1').value = webs[0] || '';
|
||||
document.getElementById('wa-web2').value = webs[1] || '';
|
||||
if (p.vertical) document.getElementById('wa-vertical').value = p.vertical;
|
||||
toast('Perfil cargado');
|
||||
} catch (e) { toast('Error de conexión', 'error'); }
|
||||
}
|
||||
|
||||
async function guardarPerfilWA() {
|
||||
const body = {
|
||||
accion: 'guardar',
|
||||
about: document.getElementById('wa-about').value.trim(),
|
||||
description: document.getElementById('wa-description').value.trim(),
|
||||
address: document.getElementById('wa-address').value.trim(),
|
||||
email: document.getElementById('wa-email').value.trim(),
|
||||
vertical: document.getElementById('wa-vertical').value,
|
||||
websites: [document.getElementById('wa-web1').value.trim(),
|
||||
document.getElementById('wa-web2').value.trim()].filter(Boolean),
|
||||
};
|
||||
try {
|
||||
const res = await fetch(API + 'wa_profile.php', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
|
||||
toast('Perfil de WhatsApp actualizado');
|
||||
document.getElementById('wa-perfil-about').textContent = body.about || '—';
|
||||
} catch (e) { toast('Error de conexión', 'error'); }
|
||||
}
|
||||
|
||||
async function subirFotoPerfil(input) {
|
||||
if (!input.files[0]) return;
|
||||
const form = new FormData();
|
||||
form.append('accion', 'foto');
|
||||
form.append('foto', input.files[0]);
|
||||
const btn = input.closest('label');
|
||||
const orig = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
try {
|
||||
const res = await fetch(API + 'wa_profile.php', { method: 'POST', body: form });
|
||||
const json = await res.json();
|
||||
if (!json.ok) { toast(json.error || 'Error al subir foto', 'error'); return; }
|
||||
// Preview local
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
document.getElementById('wa-perfil-foto').src = e.target.result;
|
||||
document.getElementById('wa-perfil-foto').style.display = '';
|
||||
document.getElementById('wa-perfil-foto-placeholder').style.display = 'none';
|
||||
};
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
toast('Foto de perfil actualizada');
|
||||
} catch (e) { toast('Error al subir foto', 'error'); } finally {
|
||||
btn.innerHTML = orig;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function guardarWhatsApp() {
|
||||
const template = document.getElementById('wa-template')?.value.trim();
|
||||
const lang = document.getElementById('wa-lang')?.value.trim();
|
||||
const waKiosko = document.getElementById('chk-wa-kiosko')?.checked ? 1 : 0;
|
||||
const waConsent = document.getElementById('chk-wa-consent')?.checked ? 1 : 0;
|
||||
const template = document.getElementById('wa-template')?.value.trim();
|
||||
const templateMuestra = document.getElementById('wa-template-muestra')?.value.trim();
|
||||
const lang = document.getElementById('wa-lang')?.value.trim();
|
||||
const waKiosko = document.getElementById('chk-wa-kiosko')?.checked ? 1 : 0;
|
||||
const waConsent = document.getElementById('chk-wa-consent')?.checked ? 1 : 0;
|
||||
if (!template) { toast('El nombre de plantilla es requerido', 'error'); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch(API + 'sesion_turno.php', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ accion: 'config_wa', template, lang, wa_kiosko: waKiosko, wa_consent: waConsent })
|
||||
body: JSON.stringify({ accion: 'config_wa', template, template_muestra: templateMuestra, lang, wa_kiosko: waKiosko, wa_consent: waConsent })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
|
||||
|
||||
@@ -322,8 +322,8 @@ $_hasVideo = (bool)$_tvVideo;
|
||||
color: #fff; margin-top: .2rem;
|
||||
}
|
||||
.ann-pac {
|
||||
font-size: clamp(1.05rem, 1.8vw, 1.35rem);
|
||||
color: rgba(255,255,255,.45); margin-top: .3rem;
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
color: rgba(255,255,255,.85); margin-top: .3rem;
|
||||
}
|
||||
.ann-progress {
|
||||
width: 200px; height: 3px;
|
||||
@@ -616,8 +616,7 @@ function renderSnapshot(snap) {
|
||||
let anuncioNuevo = false;
|
||||
allTurnos.forEach(t => {
|
||||
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
|
||||
// Clave única por evento de llamada (id+llamado_at), independiente del escritorio
|
||||
const callKey = (t.id || t.codigo) + '|' + (t.llamado_at || '');
|
||||
const callKey = String(t.id || t.codigo);
|
||||
currentKeys.add(key);
|
||||
if (!lastSeenKeys.has(key)) {
|
||||
const c = t.prioridad_color || '<?= $_labColor ?>';
|
||||
@@ -627,7 +626,8 @@ function renderSnapshot(snap) {
|
||||
if (!_firstLoad) {
|
||||
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: nomVoz, color: c });
|
||||
}
|
||||
llamados.unshift({ codigo: t.codigo, destino: t.destino, paciente: t.paciente_nombre, color: c });
|
||||
llamados = llamados.filter(l => l.id !== t.id);
|
||||
llamados.unshift({ id: t.id, codigo: t.codigo, destino: t.destino, paciente: t.paciente_nombre, color: c });
|
||||
if (llamados.length > 30) llamados.pop();
|
||||
anuncioNuevo = true;
|
||||
}
|
||||
@@ -642,7 +642,7 @@ function renderSnapshot(snap) {
|
||||
const t = sorted[0];
|
||||
const c = t.prioridad_color || '<?= $_labColor ?>';
|
||||
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
|
||||
const callKey = (t.id || t.codigo) + '|' + (t.llamado_at || '');
|
||||
const callKey = String(t.id || t.codigo);
|
||||
const nomVoz = t.paciente_id ? t.paciente_nombre : null;
|
||||
announcedCalls.add(callKey);
|
||||
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: nomVoz, color: c });
|
||||
|
||||
@@ -464,10 +464,7 @@ unset($p);
|
||||
<div class="ticket-label">Su número de turno es</div>
|
||||
<div id="tick-codigo" class="ticket-codigo">—</div>
|
||||
<div id="tick-prio-badge"></div>
|
||||
<div class="ticket-pos">
|
||||
<div class="lbl">Posición en cola</div>
|
||||
<div class="num" id="tick-posicion">—</div>
|
||||
</div>
|
||||
<div id="tick-nombre" style="font-size:0.95rem;margin:4pt 0 2pt;font-weight:600;"></div>
|
||||
<div class="ticket-instruc">
|
||||
Por favor espere a ser llamado.<br>
|
||||
Recuerde traer su documento de identidad y la orden médica.
|
||||
@@ -606,7 +603,7 @@ unset($p);
|
||||
setSpinner(false);
|
||||
document.getElementById('tick-codigo').textContent = t.codigo;
|
||||
document.getElementById('tick-codigo').style.color = prioColor;
|
||||
document.getElementById('tick-posicion').textContent = '#' + t.posicion_cola;
|
||||
document.getElementById('tick-nombre').textContent = t.paciente_nombre || '';
|
||||
document.getElementById('tick-prio-badge').innerHTML =
|
||||
`<span class="ticket-prio-badge" style="background:${prioColor}">${prioCodigo} — ${prioNombre}</span>`;
|
||||
mostrar('screen-ticket');
|
||||
|
||||
+209
-41
@@ -17,19 +17,21 @@ if (!isUserLoggedIn()) {
|
||||
try {
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
$lugares = $pdo->query(
|
||||
"SELECT id, nombre, descripcion FROM turnero_lugares WHERE activo = 1 ORDER BY sort_order ASC"
|
||||
"SELECT id, nombre, descripcion, formulario_modo FROM turnero_lugares WHERE activo = 1 ORDER BY sort_order ASC"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (\Throwable) {
|
||||
$lugares = [];
|
||||
}
|
||||
|
||||
$lugarIdParam = isset($_GET['lugar_id']) ? (int)$_GET['lugar_id'] : 0;
|
||||
$lugarIdParam = isset($_GET['lugar_id']) ? (int)$_GET['lugar_id'] : 0;
|
||||
|
||||
// Resolver nombre del lugar para el título
|
||||
$lugarNombre = 'Estación de Servicio';
|
||||
// Resolver nombre y modo del lugar para el título
|
||||
$lugarNombre = 'Estación de Servicio';
|
||||
$lugarFormModo = 'link'; // 'link' | 'embebido'
|
||||
foreach ($lugares as $l) {
|
||||
if ((int)$l['id'] === $lugarIdParam) {
|
||||
$lugarNombre = $l['nombre'];
|
||||
$lugarNombre = $l['nombre'];
|
||||
$lugarFormModo = $l['formulario_modo'] ?? 'link';
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -54,8 +56,12 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.lugar-layout { grid-template-columns: 1fr; }
|
||||
.lugar-cola { max-height: 240px; }
|
||||
.lugar-layout { grid-template-columns: 1fr; position: relative; overflow: hidden; }
|
||||
.lugar-cola { transition: transform .2s ease; }
|
||||
.lugar-cola.mobile-oculta { transform: translateX(-100%); position: absolute; inset: 0; pointer-events: none; }
|
||||
.lugar-ficha { position: absolute; inset: 0; background: #fff; z-index: 10; transform: translateX(100%); transition: transform .2s ease; overflow-y: auto; }
|
||||
.lugar-ficha.mobile-visible { transform: translateX(0); }
|
||||
.btn-volver-cola { display: inline-flex !important; align-items: center; gap: 6px; }
|
||||
}
|
||||
|
||||
/* ── Columna cola ── */
|
||||
@@ -256,6 +262,11 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
|
||||
<!-- ══ Ficha del turno ════════════════════════════════ -->
|
||||
<div class="lugar-ficha" id="lugar-ficha">
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary btn-volver-cola mb-3"
|
||||
style="display:none" onclick="volverACola()">
|
||||
<i class="fas fa-arrow-left"></i> Cola
|
||||
</button>
|
||||
|
||||
<div class="ficha-placeholder" id="ficha-placeholder">
|
||||
<i class="fas fa-stethoscope fa-3x mb-3" style="color:#cbd5e1"></i>
|
||||
<p class="fw-semibold mb-1">Sin turno activo</p>
|
||||
@@ -285,6 +296,18 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
|
||||
<div class="pac-dato"><span class="lbl">Fecha nac.</span><span class="val" id="pac-fec">—</span></div>
|
||||
<div class="pac-dato"><span class="lbl">Celular</span><span class="val" id="pac-cel">—</span></div>
|
||||
</div>
|
||||
<div id="pac-embarazada" class="d-none mt-2">
|
||||
<span class="badge text-bg-danger">
|
||||
<i class="fas fa-baby me-1"></i>Paciente embarazada
|
||||
</span>
|
||||
</div>
|
||||
<div id="pac-medico" class="d-none mt-2">
|
||||
<div class="pac-dato">
|
||||
<span class="lbl">Médico</span>
|
||||
<span class="val" id="pac-medico-nombre" style="color:#4f46e5"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bloque-pac-sin" class="text-muted small">
|
||||
<i class="fas fa-info-circle me-1"></i>Sin paciente vinculado
|
||||
</div>
|
||||
@@ -298,7 +321,8 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Sección: Consentimientos ── -->
|
||||
|
||||
|
||||
<div class="ficha-sec" id="sec-consent">
|
||||
<h6><i class="fas fa-file-signature me-1"></i>Consentimientos informados</h6>
|
||||
|
||||
@@ -358,6 +382,38 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
|
||||
</div><!-- /lugar-layout -->
|
||||
</main>
|
||||
|
||||
<!-- ── Modal de consentimiento embebido ──────────────────────── -->
|
||||
<div id="modal-consentimiento" style="
|
||||
display:none;position:fixed;inset:0;z-index:9000;
|
||||
background:rgba(0,0,0,.55);align-items:center;justify-content:center;padding:16px">
|
||||
<div style="
|
||||
background:#fff;border-radius:14px;box-shadow:0 8px 40px rgba(0,0,0,.25);
|
||||
width:min(96vw,680px);max-height:92vh;
|
||||
display:flex;flex-direction:column;overflow:hidden">
|
||||
<!-- Header modal -->
|
||||
<div style="
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
padding:12px 18px;border-bottom:1px solid #e2e8f0;flex-shrink:0">
|
||||
<span style="font-weight:700;font-size:.95rem;color:#1e293b">
|
||||
<i class="fas fa-file-signature me-2 text-primary"></i>Formulario de consentimiento
|
||||
</span>
|
||||
<button onclick="cerrarModalConsentimiento()"
|
||||
style="background:none;border:none;font-size:1.2rem;color:#94a3b8;cursor:pointer;padding:2px 6px">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Loading -->
|
||||
<div id="modal-consent-loading" style="padding:32px;text-align:center;color:#64748b;flex-shrink:0">
|
||||
<i class="fas fa-spinner fa-spin fa-2x mb-2 d-block"></i>Cargando formulario…
|
||||
</div>
|
||||
<!-- Iframe -->
|
||||
<iframe id="modal-consent-iframe" src="" frameborder="0"
|
||||
style="flex:1;border:none;display:none;min-height:60vh"
|
||||
onload="document.getElementById('modal-consent-loading').style.display='none';this.style.display='block'">
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
// ── Estado global ─────────────────────────────────────────────
|
||||
@@ -368,9 +424,10 @@ let hayPendientes = false;
|
||||
let pollingColaId = null;
|
||||
let pollingConsentId = null;
|
||||
|
||||
const API = '<?= BASE_URL ?>modules/turnero/api/';
|
||||
const BASE_WA = '<?= BASE_URL ?>';
|
||||
const LUGAR_NOMBRE = document.getElementById('lbl-lugar-titulo')?.textContent || '<?= addslashes($lugarNombre) ?>';
|
||||
const API = '<?= BASE_URL ?>modules/turnero/api/';
|
||||
const BASE_WA = '<?= BASE_URL ?>';
|
||||
const LUGAR_NOMBRE = document.getElementById('lbl-lugar-titulo')?.textContent || '<?= addslashes($lugarNombre) ?>';
|
||||
const LUGAR_FORM_MODO = '<?= $lugarFormModo ?>';
|
||||
|
||||
|
||||
// ── Arranque ──────────────────────────────────────────────────
|
||||
@@ -448,23 +505,35 @@ function renderCola(snap) {
|
||||
lista.innerHTML = items.map(t => {
|
||||
const enServicio = t.estado === 'en_servicio';
|
||||
const esActivo = turnoActivo?.id == t.id;
|
||||
const onclick = enServicio
|
||||
? `seleccionarSinLlamar(${t.id})`
|
||||
: `seleccionarSinLlamar(${t.id})`;
|
||||
const indicador = enServicio
|
||||
? `<div class="ms-auto d-flex flex-column align-items-center" style="gap:1px">
|
||||
? `<div class="ms-auto d-flex flex-column align-items-center" style="gap:1px;flex-shrink:0">
|
||||
<i class="fas fa-stethoscope text-success" style="font-size:.85rem"></i>
|
||||
<span style="font-size:.58rem;color:#16a34a;font-weight:700">EN SERVICIO</span>
|
||||
</div>`
|
||||
: `<i class="fas fa-chevron-right text-muted ms-auto" style="font-size:.7rem"></i>`;
|
||||
: `<button onclick="event.stopPropagation();llamarTurnoEspecifico(${t.id})"
|
||||
title="Llamar turno ${escHtml(t.codigo)}"
|
||||
style="flex-shrink:0;background:#2563eb;border:none;color:#fff;
|
||||
border-radius:7px;padding:5px 9px;font-size:.78rem;cursor:pointer">
|
||||
<i class="fas fa-bell"></i>
|
||||
</button>`;
|
||||
const esMuestra = t.solo_muestras == 1;
|
||||
const muestraBadge = esMuestra
|
||||
? `<div style="font-size:.6rem;font-weight:700;color:#ea580c;letter-spacing:.04em;margin-top:1px">
|
||||
<i class="fas fa-plus-square me-1"></i>ENTREGA DE MUESTRAS
|
||||
</div>`
|
||||
: '';
|
||||
const cardStyle = esMuestra
|
||||
? 'cursor:pointer;border:2px solid #ea580c!important;background:#fff7ed!important'
|
||||
: 'cursor:pointer';
|
||||
return `
|
||||
<div class="cola-card ${enServicio ? 'en-servicio-card' : ''} ${esActivo ? 'activo' : ''}"
|
||||
style="cursor:pointer" onclick="${onclick}"
|
||||
style="${cardStyle}" onclick="seleccionarSinLlamar(${t.id})"
|
||||
title="${enServicio ? 'Ver turno en servicio' : `Ver turno ${escHtml(t.codigo)}`}">
|
||||
<div class="prio-dot" style="background:${t.prioridad_color}">${t.prioridad_codigo}</div>
|
||||
<div class="prio-dot" style="background:${esMuestra ? '#ea580c' : t.prioridad_color}">${esMuestra ? '<i class="fas fa-plus"></i>' : escHtml(t.prioridad_codigo)}</div>
|
||||
<div class="turno-info">
|
||||
<div class="cod">${escHtml(t.codigo)}</div>
|
||||
<div class="pac">${escHtml(t.paciente_nombre || 'Paciente')}</div>
|
||||
${muestraBadge}
|
||||
</div>
|
||||
${indicador}
|
||||
</div>`;
|
||||
@@ -490,6 +559,10 @@ async function seleccionarSinLlamar(turnoId) {
|
||||
await cargarFichaSolicitud(t.id);
|
||||
clearInterval(pollingConsentId);
|
||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId) {
|
||||
cargarFormEmbebido(t.id);
|
||||
}
|
||||
mostrarFichaMobile();
|
||||
}
|
||||
|
||||
async function rellamarDesdeCard(turnoId) {
|
||||
@@ -503,15 +576,13 @@ async function rellamarDesdeCard(turnoId) {
|
||||
await abrirFicha(json.turno);
|
||||
}
|
||||
|
||||
// ── Llamar siguiente: selecciona el primero en espera sin llamarlo ─
|
||||
// ── Llamar siguiente: llama al primero en espera ──────────────
|
||||
async function llamarSiguiente() {
|
||||
const primero = [..._colaMap.values()].find(t => t.estado === 'en_espera_lugar');
|
||||
if (!primero) { mostrarToast('Cola vacía — no hay turnos en espera.', 'warn', 3000); return; }
|
||||
await seleccionarSinLlamar(primero.id);
|
||||
await _llamar({});
|
||||
}
|
||||
|
||||
async function llamarTurnoEspecifico(turnoId) {
|
||||
await seleccionarSinLlamar(turnoId);
|
||||
await _llamar({ turno_id: turnoId });
|
||||
}
|
||||
|
||||
async function _llamar(extra) {
|
||||
@@ -564,6 +635,13 @@ async function abrirFicha(turno) {
|
||||
// Iniciar polling de consentimientos
|
||||
clearInterval(pollingConsentId);
|
||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||
|
||||
// Formulario embebido
|
||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId) {
|
||||
cargarFormEmbebido(turno.id);
|
||||
}
|
||||
|
||||
mostrarFichaMobile();
|
||||
}
|
||||
|
||||
// ── Cargar datos de la solicitud del turno ────────────────────
|
||||
@@ -579,6 +657,20 @@ async function cargarFichaSolicitud(turnoId) {
|
||||
let consts = json.consentimientos || [];
|
||||
|
||||
// Datos del paciente
|
||||
// Badge embarazada
|
||||
const badgeEmb = document.getElementById('pac-embarazada');
|
||||
if (badgeEmb) badgeEmb.classList.toggle('d-none', !(sol && sol.embarazada == 1));
|
||||
|
||||
const medDiv = document.getElementById('pac-medico');
|
||||
if (medDiv) {
|
||||
const tieneMedico = sol && sol.medico_nombre;
|
||||
medDiv.classList.toggle('d-none', !tieneMedico);
|
||||
if (tieneMedico) {
|
||||
const esp = sol.medico_especialidad ? ' · ' + sol.medico_especialidad : '';
|
||||
document.getElementById('pac-medico-nombre').textContent = sol.medico_nombre + esp;
|
||||
}
|
||||
}
|
||||
|
||||
if (pac) {
|
||||
document.getElementById('bloque-pac-info').style.display = '';
|
||||
document.getElementById('bloque-pac-sin').classList.add('d-none');
|
||||
@@ -654,29 +746,40 @@ function renderConsentimientos(lista) {
|
||||
const idJs = parseInt(c.id) || 0;
|
||||
const tId = parseInt(c.turno_id) || (turnoActivo?.id) || 0;
|
||||
|
||||
// Firmar aquí: solo si no completado y hay token
|
||||
const btnFirmar = (!ya && c.token)
|
||||
? `<button class="btn btn-outline-primary" title="Firmar aquí"
|
||||
onclick="abrirFirmaPresencial('${token}', ${idJs}, ${nomJs.replace(/"/g,'"')})">
|
||||
<i class="fas fa-signature"></i> Firmar
|
||||
</button>` : '';
|
||||
// Botón ver (firmado)
|
||||
const btnVer = (ya && c.token)
|
||||
? `<a href="${BASE_WA}ver_formulario_enviado.php?token=${token}" target="_blank"
|
||||
class="btn btn-outline-secondary" title="Ver firmado">
|
||||
<i class="fas fa-eye"></i> Ver
|
||||
</a>` : '';
|
||||
|
||||
// WA / Ver: según estado
|
||||
const btnWa = ya
|
||||
? (c.token ? `<a href="${BASE_WA}ver_formulario_enviado.php?token=${token}" target="_blank"
|
||||
class="btn btn-outline-secondary" title="Ver firmado">
|
||||
<i class="fas fa-eye"></i> Ver
|
||||
</a>` : '')
|
||||
: `<button class="btn btn-outline-success" title="Reenviar por WhatsApp"
|
||||
onclick="reenviarConsentimiento(${tId})">
|
||||
<i class="fab fa-whatsapp"></i> WA
|
||||
</button>`;
|
||||
// Acción principal según modo y estado
|
||||
let btnAccion = '';
|
||||
if (!ya && c.token) {
|
||||
if (LUGAR_FORM_MODO === 'embebido') {
|
||||
// Modo embebido: abrir modal directamente
|
||||
btnAccion = `<button class="btn btn-primary" title="Abrir formulario"
|
||||
onclick="abrirModalConsentimientoPorToken('${token}')">
|
||||
<i class="fas fa-pen me-1"></i>Firmar
|
||||
</button>`;
|
||||
} else {
|
||||
// Modo link: firmar presencial o reenviar WA
|
||||
btnAccion = `<button class="btn btn-outline-primary" title="Firmar aquí"
|
||||
onclick="abrirFirmaPresencial('${token}', ${idJs}, ${nomJs.replace(/"/g,'"')})">
|
||||
<i class="fas fa-signature"></i> Firmar
|
||||
</button>
|
||||
<button class="btn btn-outline-success" title="Reenviar por WhatsApp"
|
||||
onclick="reenviarConsentimiento(${tId})">
|
||||
<i class="fab fa-whatsapp"></i> WA
|
||||
</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<div class="consent-row ${c.estado}" data-consent-id="${c.id}">
|
||||
<i class="fas ${ico}"></i>
|
||||
<span class="nom-form">${escHtml(c.formulario_nombre || 'Consentimiento')}</span>
|
||||
<span class="c-badge">${label}</span>
|
||||
<div class="acciones-consent">${btnFirmar}${btnWa}</div>
|
||||
<div class="acciones-consent">${btnAccion}${btnVer}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -787,12 +890,64 @@ async function cambiarEstadoTurno(nuevoEstado) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reset ─────────────────────────────────────────────────────
|
||||
// ── Embebido como modal ───────────────────────────────────────
|
||||
let _consentTokenCache = null; // { turnoId, token }
|
||||
|
||||
// cargarFormEmbebido ya no necesita hacer nada (el token viene de la fila de consentimiento)
|
||||
async function cargarFormEmbebido(turnoId) { /* token llega vía renderConsentimientos */ }
|
||||
|
||||
function _abrirModalConToken(token) {
|
||||
const modal = document.getElementById('modal-consentimiento');
|
||||
const iframe = document.getElementById('modal-consent-iframe');
|
||||
const load = document.getElementById('modal-consent-loading');
|
||||
iframe.style.display = 'none';
|
||||
iframe.src = '';
|
||||
load.style.display = '';
|
||||
modal.style.display = 'flex';
|
||||
setTimeout(() => {
|
||||
iframe.src = `${BASE_WA}ver_formulario_enviado.php?token=${encodeURIComponent(token)}&embed=1`;
|
||||
}, 80);
|
||||
}
|
||||
|
||||
function abrirModalConsentimiento() {
|
||||
if (!_consentTokenCache?.token) { mostrarError('Token de consentimiento no disponible.'); return; }
|
||||
_abrirModalConToken(_consentTokenCache.token);
|
||||
}
|
||||
|
||||
function abrirModalConsentimientoPorToken(token) {
|
||||
_abrirModalConToken(token);
|
||||
}
|
||||
|
||||
function cerrarModalConsentimiento() {
|
||||
const modal = document.getElementById('modal-consentimiento');
|
||||
const iframe = document.getElementById('modal-consent-iframe');
|
||||
modal.style.display = 'none';
|
||||
iframe.src = '';
|
||||
iframe.style.display = 'none';
|
||||
document.getElementById('modal-consent-loading').style.display = '';
|
||||
// Refrescar estado del consentimiento
|
||||
if (turnoActivo) actualizarConsentimientos(turnoActivo.id);
|
||||
}
|
||||
|
||||
// Cerrar modal si se firma desde el iframe
|
||||
window.addEventListener('message', function(e) {
|
||||
if (e.data && e.data.type === 'turneroFirmado') {
|
||||
cerrarModalConsentimiento();
|
||||
mostrarToast('Consentimiento firmado ✓', 'success', 3000);
|
||||
if (turnoActivo) actualizarConsentimientos(turnoActivo.id);
|
||||
// Ocultar botón de firma
|
||||
const sec = document.getElementById('sec-form-embebido');
|
||||
if (sec) sec.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
function resetFicha() {
|
||||
clearInterval(pollingConsentId);
|
||||
turnoActivo = null;
|
||||
tieneConsent = false;
|
||||
hayPendientes = false;
|
||||
_consentTokenCache = null;
|
||||
cerrarModalConsentimiento();
|
||||
|
||||
document.getElementById('ficha-turno').classList.add('d-none');
|
||||
document.getElementById('ficha-placeholder').classList.remove('d-none');
|
||||
@@ -803,6 +958,19 @@ function resetFicha() {
|
||||
document.getElementById('btn-iniciar').disabled = false;
|
||||
document.getElementById('btn-finalizar').classList.add('d-none');
|
||||
document.getElementById('btn-regresar').classList.add('d-none');
|
||||
|
||||
volverACola();
|
||||
}
|
||||
|
||||
function esMobile() { return window.innerWidth <= 860; }
|
||||
function mostrarFichaMobile() {
|
||||
if (!esMobile()) return;
|
||||
document.getElementById('lugar-ficha').classList.add('mobile-visible');
|
||||
document.querySelector('.lugar-cola').classList.add('mobile-oculta');
|
||||
}
|
||||
function volverACola() {
|
||||
document.getElementById('lugar-ficha').classList.remove('mobile-visible');
|
||||
document.querySelector('.lugar-cola').classList.remove('mobile-oculta');
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
@@ -88,8 +88,12 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.rec-layout { grid-template-columns: 1fr; }
|
||||
.rec-cola { max-height: 260px; }
|
||||
.rec-layout { grid-template-columns: 1fr; position: relative; overflow: hidden; }
|
||||
.rec-cola { transition: transform .2s ease; }
|
||||
.rec-cola.mobile-oculta { transform: translateX(-100%); position: absolute; inset: 0; pointer-events: none; }
|
||||
.rec-ficha { position: absolute; inset: 0; background: #fff; z-index: 10; transform: translateX(100%); transition: transform .2s ease; overflow-y: auto; }
|
||||
.rec-ficha.mobile-visible { transform: translateX(0); }
|
||||
.btn-volver-cola { display: inline-flex !important; align-items: center; gap: 6px; }
|
||||
}
|
||||
|
||||
/* ── Columna cola ── */
|
||||
@@ -347,6 +351,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
<!-- ══ Columna derecha: ficha del turno ═══════════════ -->
|
||||
<div class="rec-ficha" id="rec-ficha">
|
||||
<button class="btn btn-sm btn-outline-secondary btn-volver-cola mb-3"
|
||||
style="display:none" onclick="volverACola()">
|
||||
<i class="fas fa-arrow-left"></i> Cola
|
||||
</button>
|
||||
|
||||
<div class="ficha-placeholder" id="ficha-placeholder">
|
||||
<i class="fas fa-ticket-alt fa-3x mb-3" style="color:#cbd5e1"></i>
|
||||
<p class="mb-0 fw-semibold">Sin turno activo</p>
|
||||
@@ -395,8 +404,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
<!-- Si el kiosko capturó un nombre -->
|
||||
<div id="bloque-nombre-kiosko" class="d-none mb-2">
|
||||
<small class="text-muted">Nombre del kiosko:</small>
|
||||
<small class="text-muted">Kiosko:</small>
|
||||
<div class="fw-semibold" id="lbl-nombre-kiosko"></div>
|
||||
<div id="bloque-pac-kiosko" class="d-none mt-1">
|
||||
<small class="text-muted">Paciente:</small>
|
||||
<div class="fw-semibold text-primary" id="lbl-pac-kiosko-nombre"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bloque-pac-no-vinculado">
|
||||
@@ -444,6 +457,37 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embarazo -->
|
||||
<div class="mt-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="chk-embarazada">
|
||||
<label class="form-check-label small fw-semibold text-danger" for="chk-embarazada">
|
||||
<i class="fas fa-baby me-1"></i>Paciente embarazada
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Médico tratante -->
|
||||
<div class="mt-2" id="bloque-medico">
|
||||
<div class="small fw-semibold text-secondary mb-1">
|
||||
<i class="fas fa-user-md me-1"></i>Médico tratante <span class="text-muted fw-normal">(opcional)</span>
|
||||
</div>
|
||||
<div id="medico-seleccionado" class="d-none mb-1">
|
||||
<span class="badge text-bg-light border" style="font-size:.8rem;padding:5px 9px" id="medico-badge-txt"></span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 ms-1 text-danger" onclick="quitarMedico()" title="Quitar médico"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="medico-buscador">
|
||||
<input type="text" id="inp-buscar-medico" class="form-control form-control-sm"
|
||||
placeholder="Buscar por nombre, código o doc…"
|
||||
autocomplete="off" oninput="buscarMedico()">
|
||||
<div id="medico-dropdown" class="position-relative">
|
||||
<ul id="medico-resultados" class="list-unstyled mb-0 border rounded bg-white shadow-sm position-absolute w-100 d-none"
|
||||
style="z-index:200;max-height:180px;overflow-y:auto;top:2px"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="inp-medico-id">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -461,6 +505,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
<!-- ── Sección 3: Exámenes ── -->
|
||||
<div class="ficha-section">
|
||||
<h6><i class="fas fa-vial me-1"></i>Exámenes solicitados</h6>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="chk-solo-muestras" onchange="toggleSoloMuestras()">
|
||||
<label class="form-check-label small fw-semibold text-warning" for="chk-solo-muestras">
|
||||
<i class="fas fa-vial me-1"></i>Solo entrega de muestras
|
||||
</label>
|
||||
</div>
|
||||
<div id="lista-examenes">
|
||||
<?php foreach ($examenesAgrupados as $cat => $items): ?>
|
||||
<div class="exam-group-title"><?= htmlspecialchars($cat) ?></div>
|
||||
@@ -491,16 +541,32 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
placeholder="Total $" step="100" min="0">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<select id="sel-pago" class="form-select form-select-sm">
|
||||
<select id="sel-pago" class="form-select form-select-sm" onchange="togglePagoCombinado()">
|
||||
<option value="">— Forma de pago —</option>
|
||||
<option value="efectivo">Efectivo</option>
|
||||
<option value="transferencia">Transferencia</option>
|
||||
<option value="tarjeta">Tarjeta</option>
|
||||
<option value="eps">EPS / Convenio</option>
|
||||
<option value="cortesia">Cortesía</option>
|
||||
<option value="combinado">Pago combinado</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Panel pago combinado -->
|
||||
<div id="panel-combinado" class="d-none mt-2 p-2 border rounded" style="background:#f8fafc">
|
||||
<div class="row g-2 align-items-center mb-1">
|
||||
<div class="col-5"><label class="form-label mb-0 small fw-semibold"><i class="fas fa-money-bill-wave me-1 text-success"></i>Efectivo</label></div>
|
||||
<div class="col-7"><input type="number" id="comb-efectivo" class="form-control form-control-sm" placeholder="$0" min="0" step="100" oninput="sumarCombinado()"></div>
|
||||
</div>
|
||||
<div class="row g-2 align-items-center mb-1">
|
||||
<div class="col-5"><label class="form-label mb-0 small fw-semibold"><i class="fas fa-university me-1 text-primary"></i>Transferencia</label></div>
|
||||
<div class="col-7"><input type="number" id="comb-transferencia" class="form-control form-control-sm" placeholder="$0" min="0" step="100" oninput="sumarCombinado()"></div>
|
||||
</div>
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-5"><label class="form-label mb-0 small fw-semibold"><i class="fas fa-credit-card me-1 text-warning"></i>Tarjeta</label></div>
|
||||
<div class="col-7"><input type="number" id="comb-tarjeta" class="form-control form-control-sm" placeholder="$0" min="0" step="100" oninput="sumarCombinado()"></div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="inp-obs" class="form-control form-control-sm mt-2"
|
||||
rows="2" placeholder="Observaciones…" maxlength="500"></textarea>
|
||||
</div>
|
||||
@@ -976,10 +1042,11 @@ function abrirFicha(turno) {
|
||||
document.getElementById('badge-turno-activo').classList.remove('d-none');
|
||||
document.getElementById('badge-codigo').textContent = turno.codigo;
|
||||
|
||||
// Nombre del kiosko
|
||||
// Nombre del kiosko (valor capturado en kiosko)
|
||||
if (turno.paciente_nombre) {
|
||||
document.getElementById('bloque-nombre-kiosko').classList.remove('d-none');
|
||||
document.getElementById('lbl-nombre-kiosko').textContent = turno.paciente_nombre;
|
||||
document.getElementById('bloque-pac-kiosko').classList.add('d-none');
|
||||
document.getElementById('inp-buscar-pac').value = turno.paciente_nombre;
|
||||
}
|
||||
|
||||
@@ -1006,6 +1073,7 @@ function abrirFicha(turno) {
|
||||
|
||||
// Cargar consentimientos del desk actual para este turno
|
||||
if (DESK_ID) cargarConsentimientosDesk(turno.id);
|
||||
mostrarFichaMobile();
|
||||
}
|
||||
|
||||
// ── Cargar consentimientos del desk para el turno ─────────────
|
||||
@@ -1091,13 +1159,18 @@ function seleccionarPaciente(pac) {
|
||||
}
|
||||
document.getElementById('bloque-pac-no-vinculado').classList.add('d-none');
|
||||
document.getElementById('bloque-pac-seleccionado').classList.remove('d-none');
|
||||
document.getElementById('lbl-pac-nombre').textContent =
|
||||
(pac.full_name || (pac.nombre||'') + ' ' + (pac.apellido||'')).trim();
|
||||
const pacNombre = (pac.full_name || pac.nombre_completo || (pac.nombre||'') + ' ' + (pac.apellido||'')).trim();
|
||||
document.getElementById('lbl-pac-nombre').textContent = pacNombre;
|
||||
document.getElementById('lbl-pac-doc').textContent =
|
||||
(pac.tipo_documento||'') + ' ' + (pac.documento||pac.numero_documento||'');
|
||||
document.getElementById('lbl-pac-cel').textContent =
|
||||
pac.telefono || pac.celular || '';
|
||||
document.getElementById('lista-pacientes-res').innerHTML = '';
|
||||
// Mostrar nombre del paciente vinculado debajo del valor del kiosko
|
||||
if (!document.getElementById('bloque-nombre-kiosko').classList.contains('d-none')) {
|
||||
document.getElementById('lbl-pac-kiosko-nombre').textContent = pacNombre;
|
||||
document.getElementById('bloque-pac-kiosko').classList.remove('d-none');
|
||||
}
|
||||
|
||||
// Mostrar historial del paciente
|
||||
const secHist = document.getElementById('sec-historial-pac');
|
||||
@@ -1110,17 +1183,40 @@ function seleccionarPaciente(pac) {
|
||||
_historialPacienteId = pac.id;
|
||||
}
|
||||
|
||||
function togglePagoCombinado() {
|
||||
const combinado = document.getElementById('sel-pago').value === 'combinado';
|
||||
document.getElementById('panel-combinado').classList.toggle('d-none', !combinado);
|
||||
const inp = document.getElementById('inp-total');
|
||||
inp.readOnly = combinado;
|
||||
inp.style.background = combinado ? '#e9ecef' : '';
|
||||
if (combinado) sumarCombinado();
|
||||
}
|
||||
function sumarCombinado() {
|
||||
const v = id => parseFloat(document.getElementById(id).value) || 0;
|
||||
const total = v('comb-efectivo') + v('comb-transferencia') + v('comb-tarjeta');
|
||||
document.getElementById('inp-total').value = total || '';
|
||||
}
|
||||
function resetPagoCombinado() {
|
||||
['comb-efectivo','comb-transferencia','comb-tarjeta'].forEach(id => document.getElementById(id).value = '');
|
||||
document.getElementById('panel-combinado').classList.add('d-none');
|
||||
const inp = document.getElementById('inp-total');
|
||||
inp.readOnly = false;
|
||||
inp.style.background = '';
|
||||
}
|
||||
|
||||
function desvincularPaciente() {
|
||||
pacienteActivo = null;
|
||||
_historialPacienteId = null;
|
||||
_historialPacienteCargado = false;
|
||||
document.getElementById('bloque-pac-no-vinculado').classList.remove('d-none');
|
||||
document.getElementById('bloque-pac-seleccionado').classList.add('d-none');
|
||||
document.getElementById('bloque-pac-kiosko').classList.add('d-none');
|
||||
document.getElementById('sec-historial-pac').classList.add('d-none');
|
||||
document.getElementById('hist-pac-body').classList.add('d-none');
|
||||
document.getElementById('btn-hist-toggle').classList.remove('open');
|
||||
document.getElementById('lista-pacientes-res').innerHTML = '';
|
||||
document.getElementById('inp-buscar-pac').value = '';
|
||||
document.getElementById('chk-embarazada').checked = false;
|
||||
}
|
||||
|
||||
function cambiarPaciente() {
|
||||
@@ -1148,7 +1244,7 @@ async function cargarHistorialPaciente(pacienteId) {
|
||||
const content = document.getElementById('hist-pac-content');
|
||||
content.innerHTML = '<div class="text-center text-muted py-2 small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando historial…</div>';
|
||||
try {
|
||||
const res = await fetch(`${API}get_historial.php?paciente_id=${pacienteId}&per_page=15&page=1`);
|
||||
const res = await fetch(`${API}get_historial.php?paciente_id=${pacienteId}&per_page=5&page=1`);
|
||||
const json = await res.json();
|
||||
if (!json.ok || !json.turnos || !json.turnos.length) {
|
||||
content.innerHTML = '<div class="text-center text-muted py-3 small"><i class="fas fa-inbox me-1"></i>Sin visitas anteriores registradas</div>';
|
||||
@@ -1229,15 +1325,18 @@ async function guardarSolicitud() {
|
||||
const lugarId = parseInt(document.getElementById('sel-lugar').value);
|
||||
if (!lugarId) { mostrarError('Seleccione el lugar destino.'); return; }
|
||||
|
||||
// ✅ VALIDACIÓN: verificar que los consentimientos del lugar estén firmados
|
||||
const consentPendientes = Array.from(document.querySelectorAll('.consent-item.pendiente, .consent-item.enviado, .consent-item.visto')).length;
|
||||
if (consentPendientes > 0) {
|
||||
mostrarError('⚠️ El paciente debe firmar los consentimientos del lugar antes de guardar la solicitud.');
|
||||
return;
|
||||
}
|
||||
const soloMuestras = document.getElementById('chk-solo-muestras').checked;
|
||||
|
||||
// ✅ VALIDACIÓN: verificar que los consentimientos del lugar estén firmados (omitir en solo muestras)
|
||||
if (!soloMuestras) {
|
||||
const consentPendientes = Array.from(document.querySelectorAll('.consent-item.pendiente, .consent-item.enviado, .consent-item.visto')).length;
|
||||
if (consentPendientes > 0) {
|
||||
mostrarError('⚠️ El paciente debe firmar los consentimientos del lugar antes de guardar la solicitud.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const examIds = Array.from(document.querySelectorAll('.exam-chk:checked')).map(c => parseInt(c.value));
|
||||
if (!examIds.length) { mostrarError('Seleccione al menos un examen.'); return; }
|
||||
if (!soloMuestras && !examIds.length) { mostrarError('Seleccione al menos un examen.'); return; }
|
||||
|
||||
const btn = document.getElementById('btn-guardar');
|
||||
btn.disabled = true;
|
||||
@@ -1253,9 +1352,17 @@ async function guardarSolicitud() {
|
||||
lugar_id: lugarId,
|
||||
exam_tipo_ids: examIds,
|
||||
numero_orden: document.getElementById('inp-consecutivo').value.trim() || null,
|
||||
total_cobrado: parseFloat(document.getElementById('inp-total').value) || null,
|
||||
metodo_pago: document.getElementById('sel-pago').value || null,
|
||||
observaciones: document.getElementById('inp-obs').value.trim() || null,
|
||||
total_cobrado: parseFloat(document.getElementById('inp-total').value) || null,
|
||||
metodo_pago: document.getElementById('sel-pago').value || null,
|
||||
pagos_detalle: document.getElementById('sel-pago').value === 'combinado' ? {
|
||||
efectivo: parseFloat(document.getElementById('comb-efectivo').value) || 0,
|
||||
transferencia: parseFloat(document.getElementById('comb-transferencia').value) || 0,
|
||||
tarjeta: parseFloat(document.getElementById('comb-tarjeta').value) || 0,
|
||||
} : null,
|
||||
observaciones: document.getElementById('inp-obs').value.trim() || null,
|
||||
embarazada: document.getElementById('chk-embarazada').checked ? 1 : 0,
|
||||
solo_muestras: soloMuestras ? 1 : 0,
|
||||
medico_id: parseInt(document.getElementById('inp-medico-id').value) || null,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
@@ -1334,9 +1441,9 @@ function renderConsentimientos(lista) {
|
||||
<i class="fab fa-whatsapp"></i> WhatsApp
|
||||
</button>`;
|
||||
|
||||
// Fila de firma del profesional (solo visible si el paciente ya firmó)
|
||||
// Fila de firma del profesional (solo si el formulario la requiere y el paciente ya firmó)
|
||||
let profRow = '';
|
||||
if (c.estado === 'firmado') {
|
||||
if (c.estado === 'firmado' && c.requiere_firma_profesional) {
|
||||
const tienePro = c.tiene_firma_profesional == 1 || c.tiene_firma_profesional === true;
|
||||
if (tienePro) {
|
||||
profRow = `<div class="consent-prof-row">
|
||||
@@ -1474,7 +1581,7 @@ async function refrescarConsentimientos(turnoId) {
|
||||
|
||||
// ── Enviar consentimientos (todos) ───────────────────────────
|
||||
async function enviarConsentimientosTodos() {
|
||||
if (!turnoActivo || !solicitudActiva) return;
|
||||
if (!turnoActivo) return;
|
||||
const btn = document.getElementById('btn-reenviar-consent');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Enviando…';
|
||||
@@ -1575,6 +1682,7 @@ async function marcarAusente() {
|
||||
document.getElementById('ficha-placeholder').classList.remove('d-none');
|
||||
document.getElementById('badge-turno-activo').classList.add('d-none');
|
||||
cargarCola();
|
||||
volverACola();
|
||||
}
|
||||
|
||||
// ── Soltar turno ──────────────────────────────────────────────
|
||||
@@ -1595,11 +1703,72 @@ async function soltarTurno() {
|
||||
document.getElementById('badge-turno-activo').classList.add('d-none');
|
||||
mostrarToast('Turno liberado', 'success');
|
||||
cargarCola();
|
||||
volverACola();
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
// ── Médico tratante ─────────────────────────────────────────────
|
||||
let _medicoTimer = null;
|
||||
const API_MEDICOS = '<?= BASE_URL ?>modules/medicos/api/list.php';
|
||||
|
||||
function buscarMedico() {
|
||||
clearTimeout(_medicoTimer);
|
||||
const q = document.getElementById('inp-buscar-medico').value.trim();
|
||||
const ul = document.getElementById('medico-resultados');
|
||||
if (q.length < 2) { ul.classList.add('d-none'); ul.innerHTML = ''; return; }
|
||||
_medicoTimer = setTimeout(async () => {
|
||||
const res = await fetch(API_MEDICOS + '?q=' + encodeURIComponent(q));
|
||||
const json = await res.json();
|
||||
const rows = (json.data || []).slice(0, 8);
|
||||
if (!rows.length) { ul.innerHTML = '<li class="px-3 py-2 text-muted small">Sin resultados</li>'; ul.classList.remove('d-none'); return; }
|
||||
ul.innerHTML = rows.map(m =>
|
||||
`<li class="px-3 py-2 small" style="cursor:pointer;border-bottom:1px solid #f1f5f9"
|
||||
onmousedown="seleccionarMedico(${m.id},'${escJs(m.codigo)}','${escJs(m.nombres)} ${escJs(m.apellidos)}','${escJs(m.cod_especialidad||'')}')">
|
||||
<span class="fw-semibold">${esc(m.nombres)} ${esc(m.apellidos)}</span>
|
||||
<span class="text-muted ms-1">[${esc(m.codigo)}]</span>
|
||||
${m.cod_especialidad ? `<span class="badge text-bg-light ms-1" style="font-size:.7rem">${esc(m.cod_especialidad)}</span>` : ''}
|
||||
</li>`
|
||||
).join('');
|
||||
ul.classList.remove('d-none');
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function seleccionarMedico(id, codigo, nombre, esp) {
|
||||
document.getElementById('inp-medico-id').value = id;
|
||||
document.getElementById('medico-badge-txt').textContent = nombre + (esp ? ' · ' + esp : '') + ' [' + codigo + ']';
|
||||
document.getElementById('medico-seleccionado').classList.remove('d-none');
|
||||
document.getElementById('medico-buscador').classList.add('d-none');
|
||||
document.getElementById('medico-resultados').classList.add('d-none');
|
||||
}
|
||||
|
||||
function quitarMedico() {
|
||||
document.getElementById('inp-medico-id').value = '';
|
||||
document.getElementById('medico-seleccionado').classList.add('d-none');
|
||||
document.getElementById('medico-buscador').classList.remove('d-none');
|
||||
document.getElementById('inp-buscar-medico').value = '';
|
||||
}
|
||||
|
||||
function resetMedico() {
|
||||
quitarMedico();
|
||||
}
|
||||
|
||||
function escJs(s) { return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); }
|
||||
|
||||
function toggleSoloMuestras() {
|
||||
const solo = document.getElementById('chk-solo-muestras').checked;
|
||||
document.querySelectorAll('.exam-chk').forEach(c => { c.checked = false; c.disabled = solo; });
|
||||
document.getElementById('lista-examenes').style.opacity = solo ? '0.4' : '';
|
||||
}
|
||||
|
||||
function resetCheckboxes() {
|
||||
document.querySelectorAll('.exam-chk').forEach(c => c.checked = false);
|
||||
document.getElementById('chk-solo-muestras').checked = false;
|
||||
document.querySelectorAll('.exam-chk').forEach(c => { c.checked = false; c.disabled = false; });
|
||||
document.getElementById('lista-examenes').style.opacity = '';
|
||||
document.getElementById('sel-pago').value = '';
|
||||
document.getElementById('inp-total').value = '';
|
||||
document.getElementById('inp-obs').value = '';
|
||||
resetPagoCombinado();
|
||||
resetMedico();
|
||||
}
|
||||
function toggleTodosExamenes(val) {
|
||||
document.querySelectorAll('.exam-chk').forEach(c => c.checked = val);
|
||||
@@ -1610,6 +1779,7 @@ function escHtml(str) {
|
||||
d.appendChild(document.createTextNode(String(str)));
|
||||
return d.innerHTML;
|
||||
}
|
||||
const esc = escHtml;
|
||||
|
||||
function resetPlaceholder() {
|
||||
const ph = document.getElementById('ficha-placeholder');
|
||||
@@ -1617,6 +1787,18 @@ function resetPlaceholder() {
|
||||
<i class="fas fa-ticket-alt fa-3x mb-3" style="color:#cbd5e1"></i>
|
||||
<p class="mb-0 fw-semibold">Sin turno activo</p>
|
||||
<small class="text-muted">Haga clic en "Llamar siguiente" o en <i class="fas fa-bell"></i> de un turno de la cola</small>`;
|
||||
volverACola();
|
||||
}
|
||||
|
||||
function esMobile() { return window.innerWidth <= 900; }
|
||||
function mostrarFichaMobile() {
|
||||
if (!esMobile()) return;
|
||||
document.getElementById('rec-ficha').classList.add('mobile-visible');
|
||||
document.querySelector('.rec-cola').classList.add('mobile-oculta');
|
||||
}
|
||||
function volverACola() {
|
||||
document.getElementById('rec-ficha').classList.remove('mobile-visible');
|
||||
document.querySelector('.rec-cola').classList.remove('mobile-oculta');
|
||||
}
|
||||
|
||||
function mostrarError(msg) {
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/views/verificar_paciente.php
|
||||
* Verificación de identidad del paciente por OTP — dentro del ERP.
|
||||
*/
|
||||
require_once APP_ROOT . '/config/config.php';
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
|
||||
Layout::open('Verificar Paciente', 'fas fa-id-card');
|
||||
?>
|
||||
<style>
|
||||
.vp-wrap {
|
||||
min-height: calc(100vh - 120px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px 16px;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.vp-card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.10);
|
||||
width: 100%; max-width: 420px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vp-header {
|
||||
background: linear-gradient(135deg, var(--brand-dark, #0d47a1), var(--brand, #1565c0));
|
||||
padding: 24px 28px 20px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
.vp-header::after {
|
||||
content: ''; position: absolute; bottom: -1px; left: 0; right: 0;
|
||||
height: 20px; background: #fff; border-radius: 20px 20px 0 0;
|
||||
}
|
||||
.vp-header h2 { color: #fff; font-size: 1.1rem; font-weight: 700; position: relative; z-index: 1; }
|
||||
.vp-header p { color: rgba(255,255,255,.75); font-size: .8rem; margin-top: 4px; position: relative; z-index: 1; }
|
||||
.vp-body { padding: 24px 28px 28px; }
|
||||
|
||||
.step { display: none; }
|
||||
.step.active { display: block; animation: fadeUp .25s ease; }
|
||||
@keyframes fadeUp { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
|
||||
|
||||
.step-icon { width:56px; height:56px; border-radius:50%; background:color-mix(in srgb,var(--brand,#1565c0) 12%,#fff); display:flex; align-items:center; justify-content:center; font-size:1.4rem; color:var(--brand,#1565c0); margin:0 auto 14px; }
|
||||
.step-title { font-size:1rem; font-weight:700; color:#1e293b; text-align:center; margin-bottom:6px; }
|
||||
.step-desc { font-size:.82rem; color:#64748b; text-align:center; line-height:1.55; margin-bottom:20px; }
|
||||
.highlight { color:var(--brand,#1565c0); font-weight:600; }
|
||||
|
||||
.steps-indicator { display:flex; align-items:center; justify-content:center; gap:8px; margin-bottom:20px; }
|
||||
.step-dot { width:8px; height:8px; border-radius:50%; background:#e2e8f0; transition:all .3s; }
|
||||
.step-dot.done,.step-dot.active { background:var(--brand,#1565c0); }
|
||||
.step-dot.active { transform:scale(1.4); }
|
||||
.step-line { flex:1; max-width:40px; height:2px; background:#e2e8f0; border-radius:1px; transition:background .3s; }
|
||||
.step-line.done { background:var(--brand,#1565c0); }
|
||||
|
||||
.input-wrap { position:relative; margin-bottom:14px; }
|
||||
.input-wrap i { position:absolute; left:13px; top:50%; transform:translateY(-50%); color:#94a3b8; font-size:.9rem; pointer-events:none; }
|
||||
.input-field { width:100%; padding:12px 12px 12px 38px; border:2px solid #e2e8f0; border-radius:10px; font-size:.93rem; color:#1e293b; background:#f8faff; outline:none; transition:border-color .2s, box-shadow .2s; }
|
||||
.input-field:focus { border-color:var(--brand,#1565c0); box-shadow:0 0 0 3px color-mix(in srgb,var(--brand,#1565c0) 15%,transparent); background:#fff; }
|
||||
|
||||
.otp-wrap { display:flex; gap:8px; justify-content:center; margin-bottom:14px; }
|
||||
.otp-digit { width:46px; height:54px; border:2px solid #e2e8f0; border-radius:10px; font-size:1.4rem; font-weight:700; text-align:center; color:var(--brand,#1565c0); background:#f8faff; outline:none; transition:border-color .2s, box-shadow .2s; }
|
||||
.otp-digit:focus { border-color:var(--brand,#1565c0); box-shadow:0 0 0 3px color-mix(in srgb,var(--brand,#1565c0) 15%,transparent); background:#fff; }
|
||||
|
||||
.btn-primary { width:100%; padding:12px; background:linear-gradient(135deg,var(--brand-dark,#0d47a1),var(--brand,#1565c0)); color:#fff; border:none; border-radius:10px; font-size:.93rem; font-weight:700; cursor:pointer; display:flex; align-items:center; justify-content:center; gap:8px; transition:opacity .2s, transform .15s; margin-top:4px; }
|
||||
.btn-primary:hover:not(:disabled) { opacity:.9; transform:translateY(-1px); }
|
||||
.btn-primary:disabled { opacity:.6; cursor:not-allowed; }
|
||||
.btn-link { background:none; border:none; color:var(--brand,#1565c0); font-size:.82rem; font-weight:600; cursor:pointer; text-decoration:underline; margin-top:10px; display:block; text-align:center; }
|
||||
|
||||
.vp-error { background:#fef2f2; border:1px solid #fca5a5; border-radius:8px; padding:10px 14px; font-size:.83rem; color:#dc2626; margin-bottom:12px; display:none; }
|
||||
.timer { font-size:.78rem; color:#94a3b8; text-align:center; margin-top:8px; }
|
||||
.success-icon { width:64px; height:64px; border-radius:50%; background:#dcfce7; display:flex; align-items:center; justify-content:center; font-size:1.8rem; color:#16a34a; margin:0 auto 14px; }
|
||||
</style>
|
||||
|
||||
<div class="vp-wrap">
|
||||
<div class="vp-card">
|
||||
<div class="vp-header">
|
||||
<h2><i class="fas fa-id-card me-2"></i>Verificar Paciente</h2>
|
||||
<p>Confirmación de identidad por código SMS</p>
|
||||
</div>
|
||||
<div class="vp-body">
|
||||
|
||||
<div class="steps-indicator">
|
||||
<div class="step-dot active" id="dot-1"></div>
|
||||
<div class="step-line" id="line-1"></div>
|
||||
<div class="step-dot" id="dot-2"></div>
|
||||
<div class="step-line" id="line-2"></div>
|
||||
<div class="step-dot" id="dot-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="vp-error" id="vp-error"></div>
|
||||
|
||||
<!-- PASO 1: Cédula -->
|
||||
<div class="step active" id="step-1">
|
||||
<div class="step-icon"><i class="fas fa-id-card"></i></div>
|
||||
<div class="step-title">Número de documento</div>
|
||||
<div class="step-desc">Ingresa el documento del paciente para enviarle un código de verificación.</div>
|
||||
<div class="input-wrap">
|
||||
<i class="fas fa-hashtag"></i>
|
||||
<input type="text" id="input-cedula" class="input-field" placeholder="Ej: 10234567"
|
||||
inputmode="numeric" pattern="\d*" maxlength="12"
|
||||
onkeydown="if(event.key==='Enter') enviarOtp()">
|
||||
</div>
|
||||
<button class="btn-primary" id="btn-enviar" onclick="enviarOtp()">
|
||||
<i class="fas fa-paper-plane"></i> Enviar código
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- PASO 2: OTP -->
|
||||
<div class="step" id="step-2">
|
||||
<div class="step-icon"><i class="fas fa-mobile-alt"></i></div>
|
||||
<div class="step-title">Código de verificación</div>
|
||||
<div class="step-desc" id="desc-otp">
|
||||
Ingresa el código de 6 dígitos enviado al número del paciente.
|
||||
</div>
|
||||
<div class="otp-wrap" id="otp-wrap">
|
||||
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
|
||||
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
|
||||
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
|
||||
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
|
||||
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
|
||||
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
|
||||
</div>
|
||||
<button class="btn-primary" id="btn-verificar" onclick="verificarOtp()">
|
||||
<i class="fas fa-check-circle"></i> Verificar
|
||||
</button>
|
||||
<div class="timer">Código válido por <span id="countdown">5:00</span></div>
|
||||
<button class="btn-link" id="btn-reenviar" onclick="reenviarOtp()" disabled>Reenviar código</button>
|
||||
</div>
|
||||
|
||||
<!-- PASO 3: Éxito -->
|
||||
<div class="step" id="step-3">
|
||||
<div class="success-icon"><i class="fas fa-check"></i></div>
|
||||
<div class="step-title">¡Verificación exitosa!</div>
|
||||
<div class="step-desc">
|
||||
Hola, <span class="highlight" id="nombre-paciente"></span>. Identidad verificada correctamente.
|
||||
</div>
|
||||
<button class="btn-primary" onclick="reiniciar()">
|
||||
<i class="fas fa-rotate-left"></i> Nueva verificación
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const BASE_URL = '<?= rtrim(BASE_URL, '/') ?>';
|
||||
let _cedula = '', _nombre = '', _timer = null, _reenvioTimer = null;
|
||||
|
||||
document.querySelectorAll('.otp-digit').forEach((inp, i, arr) => {
|
||||
inp.addEventListener('input', () => {
|
||||
const val = inp.value.replace(/\D/g, '');
|
||||
if (val.length > 1) {
|
||||
const digits = val.slice(0, 6);
|
||||
arr.forEach((d, j) => { d.value = digits[j] || ''; });
|
||||
const last = Math.min(digits.length - 1, arr.length - 1);
|
||||
arr[last].focus();
|
||||
if (otpCompleto()) verificarOtp();
|
||||
return;
|
||||
}
|
||||
inp.value = val;
|
||||
if (inp.value && i < arr.length - 1) arr[i + 1].focus();
|
||||
if (otpCompleto()) verificarOtp();
|
||||
});
|
||||
inp.addEventListener('keydown', e => {
|
||||
if (e.key === 'Backspace' && !inp.value && i > 0) arr[i - 1].focus();
|
||||
});
|
||||
inp.addEventListener('paste', e => {
|
||||
e.preventDefault();
|
||||
const txt = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g,'').slice(0,6);
|
||||
arr.forEach((d, j) => { d.value = txt[j] || ''; });
|
||||
const last = Math.min(txt.length - 1, arr.length - 1);
|
||||
if (last >= 0) arr[last].focus();
|
||||
if (txt.length === 6) verificarOtp();
|
||||
});
|
||||
});
|
||||
|
||||
function otpCompleto() { return [...document.querySelectorAll('.otp-digit')].every(d => d.value); }
|
||||
function getOtp() { return [...document.querySelectorAll('.otp-digit')].map(d => d.value).join(''); }
|
||||
function clearOtp() { document.querySelectorAll('.otp-digit').forEach(d => d.value = ''); document.querySelectorAll('.otp-digit')[0].focus(); }
|
||||
|
||||
function showError(msg) { const el = document.getElementById('vp-error'); el.textContent = msg; el.style.display = 'block'; }
|
||||
function hideError() { document.getElementById('vp-error').style.display = 'none'; }
|
||||
|
||||
function goStep(n) {
|
||||
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById('step-' + n).classList.add('active');
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
document.getElementById('dot-' + i).className = 'step-dot' + (i < n ? ' done' : i === n ? ' active' : '');
|
||||
}
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
document.getElementById('line-' + i).className = 'step-line' + (i < n ? ' done' : '');
|
||||
}
|
||||
hideError();
|
||||
}
|
||||
|
||||
async function enviarOtp() {
|
||||
const cedula = document.getElementById('input-cedula').value.trim();
|
||||
if (!cedula) { showError('Ingresa el número de documento.'); return; }
|
||||
hideError();
|
||||
const btn = document.getElementById('btn-enviar');
|
||||
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Enviando…';
|
||||
try {
|
||||
const r = await fetch(BASE_URL + '/api/public/send_otp.php', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cedula }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.ok) { showError(d.error || 'Error al enviar el código.'); btn.disabled = false; btn.innerHTML = '<i class="fas fa-paper-plane"></i> Enviar código'; return; }
|
||||
_cedula = cedula;
|
||||
_nombre = d.nombre || '';
|
||||
document.getElementById('desc-otp').innerHTML = `Código enviado al número <strong>${d.telefono || '***'}</strong> del paciente.`;
|
||||
goStep(2);
|
||||
iniciarContador(d.expira || 300);
|
||||
iniciarReenvio();
|
||||
setTimeout(() => document.querySelector('.otp-digit').focus(), 100);
|
||||
} catch(e) { showError('Error de conexión.'); }
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Enviar código';
|
||||
}
|
||||
|
||||
async function verificarOtp() {
|
||||
if (!otpCompleto()) return;
|
||||
hideError();
|
||||
const btn = document.getElementById('btn-verificar');
|
||||
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Verificando…';
|
||||
try {
|
||||
const r = await fetch(BASE_URL + '/api/public/verify_otp.php', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cedula: _cedula, codigo: getOtp() }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.ok) { showError(d.error || 'Código incorrecto.'); clearOtp(); btn.disabled = false; btn.innerHTML = '<i class="fas fa-check-circle"></i> Verificar'; return; }
|
||||
clearInterval(_timer); clearTimeout(_reenvioTimer);
|
||||
document.getElementById('nombre-paciente').textContent = d.nombre || _nombre;
|
||||
goStep(3);
|
||||
} catch(e) { showError('Error de conexión.'); }
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-check-circle"></i> Verificar';
|
||||
}
|
||||
|
||||
async function reenviarOtp() {
|
||||
hideError();
|
||||
const btn = document.getElementById('btn-reenviar');
|
||||
btn.disabled = true; btn.textContent = 'Enviando…';
|
||||
try {
|
||||
const r = await fetch(BASE_URL + '/api/public/send_otp.php', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cedula: _cedula }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.ok) { showError(d.error || 'Error al reenviar.'); btn.disabled = false; btn.textContent = 'Reenviar código'; return; }
|
||||
clearOtp();
|
||||
iniciarContador(d.expira || 300);
|
||||
iniciarReenvio();
|
||||
} catch(e) { showError('Error de conexión.'); }
|
||||
}
|
||||
|
||||
function iniciarContador(segundos) {
|
||||
clearInterval(_timer);
|
||||
const el = document.getElementById('countdown');
|
||||
let remaining = segundos;
|
||||
el.textContent = fmt(remaining);
|
||||
_timer = setInterval(() => {
|
||||
remaining--;
|
||||
el.textContent = fmt(remaining);
|
||||
if (remaining <= 0) { clearInterval(_timer); el.textContent = 'Expirado'; }
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function iniciarReenvio() {
|
||||
clearTimeout(_reenvioTimer);
|
||||
const btn = document.getElementById('btn-reenviar');
|
||||
btn.disabled = true; btn.textContent = 'Reenviar en 60s…';
|
||||
_reenvioTimer = setTimeout(() => { btn.disabled = false; btn.textContent = 'Reenviar código'; }, 60000);
|
||||
}
|
||||
|
||||
function fmt(s) { return Math.floor(s/60) + ':' + String(s%60).padStart(2,'0'); }
|
||||
|
||||
function reiniciar() {
|
||||
_cedula = ''; _nombre = '';
|
||||
clearInterval(_timer); clearTimeout(_reenvioTimer);
|
||||
document.getElementById('input-cedula').value = '';
|
||||
clearOtp();
|
||||
goStep(1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php Layout::close(); ?>
|
||||
@@ -22,14 +22,23 @@ class WhatsAppService
|
||||
private $db;
|
||||
private $rateLimitMonitor;
|
||||
|
||||
public function __construct()
|
||||
public function __construct($canal = null)
|
||||
{
|
||||
// Obtener configuración desde base de datos
|
||||
$config = getWhatsAppConfigFromDB();
|
||||
|
||||
$this->token = $config['token']; // Usar token de BD
|
||||
$this->phoneNumberId = $config['phone_number_id']; // Usar phone_number_id de BD
|
||||
$this->apiUrl = $config['api_url'] ?: 'https://graph.facebook.com/v22.0/'; // Usar api_url de BD
|
||||
|
||||
$this->token = $config['token'];
|
||||
$this->phoneNumberId = $config['phone_number_id'];
|
||||
$this->apiUrl = $config['api_url'] ?: 'https://graph.facebook.com/v22.0/';
|
||||
|
||||
// Si se especifica canal 'turnero', usar el phone ID del número turnero
|
||||
if ($canal === 'turnero') {
|
||||
$turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
|
||||
if ($turneroPhoneId) {
|
||||
$this->phoneNumberId = $turneroPhoneId;
|
||||
}
|
||||
}
|
||||
|
||||
$this->db = Database::getInstance();
|
||||
|
||||
// Inicializar monitor de rate limit
|
||||
@@ -698,39 +707,38 @@ class WhatsAppService
|
||||
* @param bool $skipAutoSave Omitir guardado automático
|
||||
* @param bool $isVoice Para audio: true = nota de voz con onda verde, false = audio normal
|
||||
*/
|
||||
public function sendMediaById($to, $mediaId, $mediaType, $caption = null, $filename = null, $skipAutoSave = false, $isVoice = false)
|
||||
public function sendMediaById($to, $mediaId, $mediaType, $caption = null, $filename = null, $skipAutoSave = false, $isVoice = false, $meta = null)
|
||||
{
|
||||
$data = [
|
||||
'messaging_product' => 'whatsapp',
|
||||
'to' => $this->formatPhoneNumber($to),
|
||||
'type' => $mediaType
|
||||
];
|
||||
|
||||
|
||||
$mediaData = ['id' => $mediaId];
|
||||
|
||||
|
||||
if ($caption && in_array($mediaType, ['image', 'video', 'document'])) {
|
||||
$mediaData['caption'] = $caption;
|
||||
}
|
||||
|
||||
|
||||
if ($filename && $mediaType === 'document') {
|
||||
$mediaData['filename'] = $filename;
|
||||
}
|
||||
|
||||
// NOTA: El parámetro 'ptt' (push-to-talk) para notas de voz NO está disponible
|
||||
// en WhatsApp Cloud API cuando se usa media_id. Solo funciona en WhatsApp Business API On-Premise.
|
||||
// Los mensajes de audio en formato .ogg con códec OPUS se mostrarán correctamente,
|
||||
// pero sin la onda verde característica de las notas de voz.
|
||||
|
||||
if ($mediaType === 'audio' && $isVoice) {
|
||||
error_log('sendMediaById: is_voice=true, pero ptt no soportado en Cloud API. Audio se enviará como archivo.');
|
||||
}
|
||||
|
||||
|
||||
$data[$mediaType] = $mediaData;
|
||||
|
||||
// Marcar para evitar guardado automático si se solicita
|
||||
|
||||
if (!empty($meta) && is_array($meta)) {
|
||||
$data['__app_meta'] = $meta;
|
||||
}
|
||||
|
||||
if ($skipAutoSave) {
|
||||
$data['__skip_auto_save'] = true;
|
||||
}
|
||||
|
||||
|
||||
return $this->sendMessage($data);
|
||||
}
|
||||
|
||||
@@ -890,6 +898,7 @@ class WhatsAppService
|
||||
try {
|
||||
$user = $this->getUserByPhone($data['to']);
|
||||
if ($user) {
|
||||
$appMeta = $data['__app_meta'] ?? [];
|
||||
$messageData = [
|
||||
'user_id' => $user['id'],
|
||||
'message_id' => $response['conversations'][0]['id'],
|
||||
@@ -897,7 +906,8 @@ class WhatsAppService
|
||||
'message_type' => $data['type'],
|
||||
'content' => $this->extractMessageContent($data),
|
||||
'status' => 'sent',
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'canal' => $appMeta['canal'] ?? 'bot',
|
||||
];
|
||||
|
||||
// Para mensajes multimedia, guardar el media_id si está disponible
|
||||
|
||||
@@ -188,7 +188,8 @@ if (class_exists('ModuleRegistry', false)) {
|
||||
$active = $_isActive($lnk['route']);
|
||||
?>
|
||||
<li><a href="<?= htmlspecialchars($href, ENT_QUOTES, 'UTF-8') ?>"
|
||||
class="nav-link<?= $active ?>">
|
||||
class="nav-link<?= $active ?>"
|
||||
<?= !empty($lnk['target']) ? 'target="' . htmlspecialchars($lnk['target'], ENT_QUOTES, 'UTF-8') . '"' : '' ?>>
|
||||
<i class="<?= htmlspecialchars($lnk['icon'], ENT_QUOTES, 'UTF-8') ?>"></i>
|
||||
<?= htmlspecialchars($lnk['name'], ENT_QUOTES, 'UTF-8') ?></a></li>
|
||||
<?php endforeach;
|
||||
@@ -363,7 +364,13 @@ if (class_exists('ModuleRegistry', false)) {
|
||||
.sidebar-section.collapsed .sb-chevron { transform: translateY(-50%) rotate(-90deg); }
|
||||
|
||||
/* Grupo colapsable */
|
||||
.sb-group { overflow: hidden; transition: max-height .28s ease, opacity .2s ease; }
|
||||
.sb-group {
|
||||
overflow: hidden; transition: max-height .28s ease, opacity .2s ease;
|
||||
background: color-mix(in srgb, var(--cat-color, transparent) 8%, transparent);
|
||||
border-left: 2px solid color-mix(in srgb, var(--cat-color, transparent) 40%, transparent);
|
||||
margin-left: 10px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
.sb-group > ul { list-style: none; padding: 0 0 4px; margin: 0; }
|
||||
.sb-group.collapsed { max-height: 0 !important; opacity: 0; pointer-events: none; }
|
||||
</style>
|
||||
@@ -495,6 +502,20 @@ if (class_exists('ModuleRegistry', false)) {
|
||||
var el = document.getElementById('_sb_notrans');
|
||||
if (el) el.remove();
|
||||
});
|
||||
|
||||
// Restaurar posición de scroll del sidebar entre páginas
|
||||
var nav = document.getElementById('erp-sidebar');
|
||||
if (nav) {
|
||||
var savedScroll = parseInt(localStorage.getItem('sb_scroll') || '0', 10);
|
||||
if (savedScroll) nav.scrollTop = savedScroll;
|
||||
|
||||
// Guardar posición al navegar
|
||||
document.querySelectorAll('#erp-sidebar .nav-link').forEach(function(link) {
|
||||
link.addEventListener('click', function() {
|
||||
localStorage.setItem('sb_scroll', nav.scrollTop);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
+163
-69
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
ini_set('display_errors', 1); error_reporting(E_ALL); // ponytail: quitar después de depurar
|
||||
/**
|
||||
* ver_formulario_enviado.php — Ver respuesta de un formulario (imprimible / PDF)
|
||||
* Acceso admin/enfermero: ver_formulario_enviado.php?id=ENVIO_ID (requiere sesión)
|
||||
@@ -188,6 +189,10 @@ $datosCliente = json_decode($envio['datos_cliente'] ?? '{}', true) ?? [];
|
||||
$datosPrefilled = json_decode($envio['datos_prefilled'] ?? '{}', true) ?? [];
|
||||
$todos = array_merge($datosPrefilled, $datosCliente);
|
||||
$modoEditar = $modoTurnero && $envio['estado'] !== 'firmado';
|
||||
$embebido = isset($_GET['embed']) && $_GET['embed'] === '1';
|
||||
// Pre-scan: formulario que solo requiere firma del profesional (sin firma paciente)
|
||||
$_soloFirmaPro = !empty(array_filter($esquema, fn($c) => ($c['tipo'] ?? '') === 'firma_profesional'))
|
||||
&& empty(array_filter($esquema, fn($c) => ($c['tipo'] ?? '') === 'firma'));
|
||||
|
||||
// Mapa id → label
|
||||
$labelMap = [];
|
||||
@@ -298,6 +303,31 @@ function esc2(mixed $v): string {
|
||||
.hash-seal-header { background: #000 !important; -webkit-print-color-adjust:exact; print-color-adjust:exact; }
|
||||
}
|
||||
|
||||
<?php if ($embebido): ?>
|
||||
/* ── Modo embebido (iframe) ── */
|
||||
body { background: #fff; font-size: 12px; }
|
||||
.action-bar { display: none !important; }
|
||||
.doc-wrap { margin: 0; border-radius: 0; box-shadow: none; max-width: 100%; }
|
||||
.doc-header { padding: 10px 14px 8px; }
|
||||
.doc-header-text h3 { font-size: 14px; }
|
||||
.doc-header-text h4 { font-size: 12px; }
|
||||
.doc-body { padding: 12px 14px; }
|
||||
.doc-footer { display: none; }
|
||||
.section-title { font-size: 10px; margin: 14px 0 8px; }
|
||||
.campo-label { font-size: 11px; }
|
||||
.campo-valor { font-size: 12px; }
|
||||
.campo-edit { margin-bottom: 8px; }
|
||||
.campo-edit label { font-size: 11px; margin-bottom: 2px; }
|
||||
.campo-edit .form-control,
|
||||
.campo-edit .form-select { font-size: 12px; padding: 3px 7px; }
|
||||
.campo-edit .form-check-label { font-size: 12px; }
|
||||
.campo-row { padding: 3px 0; }
|
||||
.turnero-cv { height: 110px !important; }
|
||||
.btn { font-size: 12px; padding: 3px 10px; }
|
||||
.hash-seal { display: none; }
|
||||
.alert { font-size: 12px; padding: 6px 10px; }
|
||||
<?php endif; ?>
|
||||
|
||||
/* ── Canvas firma profesional ───────────────────── */
|
||||
.firma-pro-widget { max-width: 520px; margin-top: 8px; }
|
||||
.fpw-canvas { border: 2px solid #198754; border-radius: 8px; background: #f8fff9;
|
||||
@@ -456,6 +486,7 @@ function esc2(mixed $v): string {
|
||||
$_renderedFirmaProfesional = false;
|
||||
$_renderedFirmaPaciente = false;
|
||||
$_esquemaTieneFirmaPaciente = false;
|
||||
$_esquemaTieneFirmaAlguna = false; // true si hay cualquier campo firma/firma_profesional
|
||||
$_firmaGlobalPacienteUsada = false; // la firma global solo va al primer campo firma
|
||||
|
||||
foreach ($esquema as $campo):
|
||||
@@ -470,6 +501,12 @@ function esc2(mixed $v): string {
|
||||
if (!$cid) continue;
|
||||
$isPro = ($tipo === 'firma_profesional');
|
||||
|
||||
// Marcar presencia de campos firma en el esquema
|
||||
$_esquemaTieneFirmaAlguna = true;
|
||||
if (!$isPro) {
|
||||
$_esquemaTieneFirmaPaciente = true;
|
||||
}
|
||||
|
||||
// La firma profesional global solo se muestra en el PRIMER campo firma_profesional.
|
||||
// Campos posteriores (ej. desistimiento) solo muestran su propia firma por campo.
|
||||
$fSvg = $isPro
|
||||
@@ -481,13 +518,11 @@ function esc2(mixed $v): string {
|
||||
$fLabel = htmlspecialchars($campo['label'] ?? ($isPro ? 'Firma profesional' : 'Firma paciente'));
|
||||
|
||||
// Campo paciente sin firma:
|
||||
// - En turnero pendiente: el PRIMER campo firma muestra el canvas, los demás se omiten.
|
||||
// - En turnero pendiente: todos los campos firma muestran canvas.
|
||||
// - En vista normal: omitir campos sin firma.
|
||||
if (!$fSvg && !$fFoto && !$isPro) {
|
||||
if ($modoTurnero && $modoEditar && !$_esquemaTieneFirmaPaciente) {
|
||||
$_esquemaTieneFirmaPaciente = true; // solo el primero muestra canvas
|
||||
} else {
|
||||
continue; // campos firma adicionales (desistimiento) sin firma → omitir
|
||||
if (!($modoTurnero && $modoEditar)) {
|
||||
continue; // no-turnero: omitir campos sin firma
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,30 +545,35 @@ function esc2(mixed $v): string {
|
||||
<img src="<?= htmlspecialchars($fFoto) ?>" alt="Foto - <?= $fLabel ?>">
|
||||
</div>
|
||||
<?php elseif (!$isPro && $modoTurnero && $modoEditar): ?>
|
||||
<div id="turneroFirmaWidget">
|
||||
<div class="turnero-firma-item no-print" data-cid="<?= esc2($cid) ?>">
|
||||
<p class="text-muted small mb-2"><i class="fas fa-pen me-1"></i>He leído el documento. Dibuje su firma:</p>
|
||||
<canvas id="turneroCv" width="500" height="160"
|
||||
<canvas class="turnero-cv" width="500" height="160"
|
||||
style="border:2px solid #1565c0;border-radius:8px;background:#f0f4ff;
|
||||
cursor:crosshair;display:block;max-width:100%;touch-action:none"></canvas>
|
||||
<div class="mt-2 d-flex gap-2 flex-wrap">
|
||||
<button id="turneroLimpiar" class="btn btn-outline-secondary btn-sm">
|
||||
<button class="btn btn-outline-secondary btn-sm turnero-limpiar">
|
||||
<i class="fas fa-eraser me-1"></i>Limpiar
|
||||
</button>
|
||||
<button id="turneroFirmar" class="btn btn-success fw-semibold">
|
||||
<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento
|
||||
<button class="btn btn-success fw-semibold turnero-firmar">
|
||||
<i class="fas fa-check-circle me-1"></i>Confirmar: <?= $fLabel ?>
|
||||
</button>
|
||||
</div>
|
||||
<div id="turneroMsg" class="mt-2 small"></div>
|
||||
<div class="turnero-msg mt-2 small"></div>
|
||||
</div>
|
||||
<?php elseif ($isPro): ?>
|
||||
<?php if (!$modoTurnero && !$modoPublico && !$yaHayCanvasPro): ?>
|
||||
<!-- Canvas del profesional: solo aparece una vez -->
|
||||
<?php
|
||||
// Mostrar canvas pro si: admin normal, O turnero+embebido+sesión activa+solo firma pro
|
||||
$_mostrarCanvasPro = !$yaHayCanvasPro && (
|
||||
(!$modoTurnero && !$modoPublico) ||
|
||||
($modoTurnero && $embebido && isUserLoggedIn() && $_soloFirmaPro && $modoEditar)
|
||||
);
|
||||
if ($_mostrarCanvasPro): ?>
|
||||
<!-- Canvas del profesional -->
|
||||
<div class="firma-pro-widget no-print" id="fpw-<?= htmlspecialchars($cid) ?>"
|
||||
data-envio="<?= (int)$envio['id'] ?>" data-campo="<?= htmlspecialchars($cid) ?>"
|
||||
<?php if ($modoTurnero): ?>
|
||||
data-turno="<?= (int)$tcRow['turno_id'] ?>"
|
||||
data-formulario="<?= (int)$tcRow['formulario_id'] ?>"
|
||||
<?php endif; ?>>
|
||||
data-solo-pro="<?= ($modoTurnero && $_soloFirmaPro) ? '1' : '0' ?>"
|
||||
data-turno="<?= isset($tcRow) ? (int)$tcRow['turno_id'] : '' ?>"
|
||||
data-formulario="<?= isset($tcRow) ? (int)$tcRow['formulario_id'] : '' ?>">
|
||||
<p class="text-muted small mb-2"><i class="fas fa-pen me-1"></i>Dibuje su firma en el recuadro:</p>
|
||||
<canvas class="fpw-canvas" width="500" height="150"></canvas>
|
||||
<div class="mt-2 d-flex gap-2">
|
||||
@@ -598,7 +638,7 @@ function esc2(mixed $v): string {
|
||||
</div>
|
||||
<?php continue; endif;
|
||||
if ($tipo === 'radio'):
|
||||
$opts = $campo['opciones'] ?? [];
|
||||
$opts = $campo['opciones'] ?? $campo['options'] ?? [];
|
||||
?>
|
||||
<div class="campo-edit">
|
||||
<label><?= $label ?></label>
|
||||
@@ -613,7 +653,7 @@ function esc2(mixed $v): string {
|
||||
</div>
|
||||
<?php continue; endif;
|
||||
if ($tipo === 'checkbox' || $tipo === 'lista_marcable'):
|
||||
$opts = $campo['opciones'] ?? [];
|
||||
$opts = $campo['opciones'] ?? $campo['options'] ?? $campo['items'] ?? [];
|
||||
$checkedArr = is_array($prefill) ? $prefill
|
||||
: (is_string($prefill) && $prefill !== '' ? (json_decode($prefill, true) ?: [$prefill]) : []);
|
||||
?>
|
||||
@@ -630,7 +670,7 @@ function esc2(mixed $v): string {
|
||||
</div>
|
||||
<?php continue; endif;
|
||||
if ($tipo === 'select'):
|
||||
$opts = $campo['opciones'] ?? [];
|
||||
$opts = $campo['opciones'] ?? $campo['options'] ?? [];
|
||||
?>
|
||||
<div class="campo-edit">
|
||||
<label><?= $label ?></label>
|
||||
@@ -642,15 +682,31 @@ function esc2(mixed $v): string {
|
||||
</select>
|
||||
</div>
|
||||
<?php continue; endif;
|
||||
// texto, numero, fecha, hora, y cualquier otro → input
|
||||
// Calculados: pre-llenar si no tienen valor aún
|
||||
if ($tipo === 'fecha_hoy' && $prefill === '') {
|
||||
$prefill = date('Y-m-d');
|
||||
}
|
||||
if ($tipo === 'edad' && $prefill === '') {
|
||||
$fnac = $paciente['fecha_nacimiento'] ?? '';
|
||||
if ($fnac) {
|
||||
$hoy = new DateTime();
|
||||
$bday = new DateTime($fnac);
|
||||
$prefill = (string)$hoy->diff($bday)->y;
|
||||
}
|
||||
}
|
||||
// texto, numero, fecha, hora, y calculados → input
|
||||
$inputType = match($tipo) {
|
||||
'numero' => 'number', 'fecha' => 'date', 'hora' => 'time', default => 'text'
|
||||
'numero', 'edad' => 'number',
|
||||
'fecha', 'fecha_hoy' => 'date',
|
||||
'hora' => 'time',
|
||||
default => 'text'
|
||||
};
|
||||
?>
|
||||
<div class="campo-edit">
|
||||
<label><?= $label ?></label>
|
||||
<input type="<?= $inputType ?>" class="form-control form-control-sm"
|
||||
name="<?= esc2($cid) ?>" value="<?= esc2($prefill) ?>"<?= $req ?>>
|
||||
name="<?= esc2($cid) ?>" value="<?= esc2($prefill) ?>"<?= $req ?>
|
||||
<?= in_array($tipo, ['edad']) ? 'min="0" max="120"' : '' ?>>
|
||||
</div>
|
||||
<?php continue;
|
||||
endif; // modoEditar
|
||||
@@ -659,6 +715,16 @@ function esc2(mixed $v): string {
|
||||
if ($tipo === 'linked') {
|
||||
$lk = $campo['linked_key'] ?? '';
|
||||
$valor = $paciente[$lk] ?? $todos[$cid] ?? null;
|
||||
} elseif ($tipo === 'fecha_hoy') {
|
||||
$valor = $todos[$cid] ?? date('Y-m-d');
|
||||
} elseif ($tipo === 'edad') {
|
||||
$valor = $todos[$cid] ?? '';
|
||||
if ($valor === '') {
|
||||
$fnac = $paciente['fecha_nacimiento'] ?? '';
|
||||
if ($fnac) {
|
||||
$valor = (string)(new DateTime())->diff(new DateTime($fnac))->y;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$valor = $todos[$cid] ?? null;
|
||||
}
|
||||
@@ -734,9 +800,9 @@ function esc2(mixed $v): string {
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- ── Widget firma turnero (fallback si el esquema no tiene campo firma) ── -->
|
||||
<?php if ($modoTurnero && $envio['estado'] !== 'firmado' && !$_esquemaTieneFirmaPaciente): ?>
|
||||
<div class="mt-4 no-print" id="turneroFirmaWidget">
|
||||
<!-- ── Widget firma turnero (fallback solo si el esquema no tiene NINGÚN campo firma) ── -->
|
||||
<?php if ($modoTurnero && $envio['estado'] !== 'firmado' && !$_esquemaTieneFirmaAlguna): ?>
|
||||
<div class="mt-4 no-print turnero-firma-item" data-cid="__firma_global">
|
||||
<div class="section-title" style="color:#1565c0">
|
||||
<i class="fas fa-pen me-1"></i>Firma del paciente / responsable
|
||||
</div>
|
||||
@@ -744,19 +810,19 @@ function esc2(mixed $v): string {
|
||||
He leído y comprendido el contenido de este documento.
|
||||
Por favor dibuje su firma en el recuadro:
|
||||
</p>
|
||||
<canvas id="turneroCv" width="500" height="160"
|
||||
<canvas class="turnero-cv" width="500" height="160"
|
||||
style="border:2px solid #1565c0;border-radius:8px;background:#f0f4ff;
|
||||
cursor:crosshair;display:block;max-width:100%;touch-action:none">
|
||||
</canvas>
|
||||
<div class="mt-2 d-flex gap-2 flex-wrap">
|
||||
<button id="turneroLimpiar" class="btn btn-outline-secondary btn-sm">
|
||||
<button class="btn btn-outline-secondary btn-sm turnero-limpiar">
|
||||
<i class="fas fa-eraser me-1"></i>Limpiar
|
||||
</button>
|
||||
<button id="turneroFirmar" class="btn btn-success fw-semibold">
|
||||
<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento
|
||||
<button class="btn btn-success fw-semibold turnero-firmar">
|
||||
<i class="fas fa-check-circle me-1"></i>Confirmar y firmar
|
||||
</button>
|
||||
</div>
|
||||
<div id="turneroMsg" class="mt-2 small"></div>
|
||||
<div class="turnero-msg mt-2 small"></div>
|
||||
</div>
|
||||
<?php elseif ($modoTurnero && $envio['estado'] === 'firmado'): ?>
|
||||
<div class="alert alert-success mt-4 d-flex align-items-center gap-2 no-print">
|
||||
@@ -834,7 +900,6 @@ function esc2(mixed $v): string {
|
||||
|
||||
btnSave.addEventListener('click', function() {
|
||||
const svg = canvas.toDataURL('image/png');
|
||||
// Verificar que no esté vacío (al menos 1000 bytes de data)
|
||||
if (svg.length < 1000) {
|
||||
msg.innerHTML = '<span class="text-danger"><i class="fas fa-exclamation-triangle me-1"></i>Por favor dibuje su firma antes de guardar.</span>';
|
||||
return;
|
||||
@@ -845,12 +910,32 @@ function esc2(mixed $v): string {
|
||||
|
||||
const turnoId = widget.dataset.turno;
|
||||
const formularioId = widget.dataset.formulario;
|
||||
const soloPro = widget.dataset.soloPro === '1';
|
||||
const isTurnero = !!(turnoId && formularioId);
|
||||
const saveUrl = isTurnero
|
||||
? 'modules/turnero/api/firmar_profesional_consentimiento.php'
|
||||
: 'api/lab/firmar_profesional.php';
|
||||
const savePayload = isTurnero
|
||||
? { turno_id: parseInt(turnoId), formulario_id: parseInt(formularioId), svg: svg }
|
||||
|
||||
// Recopilar campos del formulario cuando el pro es el firmante final
|
||||
var datosRespuestas = {};
|
||||
if (soloPro) {
|
||||
document.querySelectorAll('[name]').forEach(function(el) {
|
||||
var raw = el.name;
|
||||
var isArr = raw.slice(-2) === '[]';
|
||||
var name = isArr ? raw.slice(0, -2) : raw;
|
||||
if (el.type === 'checkbox') {
|
||||
if (el.checked) { if (!Array.isArray(datosRespuestas[name])) datosRespuestas[name] = []; datosRespuestas[name].push(el.value); }
|
||||
} else if (el.type === 'radio') {
|
||||
if (el.checked) datosRespuestas[name] = el.value;
|
||||
} else if (el.value !== '') {
|
||||
datosRespuestas[name] = el.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const savePayload = isTurnero
|
||||
? { turno_id: parseInt(turnoId), formulario_id: parseInt(formularioId), svg: svg,
|
||||
...(soloPro ? { solo_profesional: true, datos_respuestas: datosRespuestas } : {}) }
|
||||
: { envio_id: envioId, campo_id: campoId, svg: svg };
|
||||
|
||||
fetch(saveUrl, {
|
||||
@@ -861,18 +946,27 @@ function esc2(mixed $v): string {
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
// Reemplazar el canvas con la imagen firmada
|
||||
const img = document.createElement('img');
|
||||
img.src = svg;
|
||||
img.alt = 'Firma profesional';
|
||||
img.style.maxHeight = '140px';
|
||||
img.style.maxWidth = '340px';
|
||||
img.style.display = 'block';
|
||||
const box = document.createElement('div');
|
||||
box.className = 'firma-box';
|
||||
box.style.borderColor = '#198754';
|
||||
box.appendChild(img);
|
||||
widget.replaceWith(box);
|
||||
if (soloPro) {
|
||||
// Modo solo-profesional: mostrar éxito y notificar al padre
|
||||
document.querySelectorAll('.firma-pro-widget, .campo-edit, .section-title').forEach(function(el) {
|
||||
el.style.display = 'none';
|
||||
});
|
||||
var ok = document.createElement('div');
|
||||
ok.className = 'alert alert-success mt-4 d-flex align-items-center gap-2 no-print';
|
||||
ok.innerHTML = '<i class="fas fa-check-circle fs-4"></i><div><strong>Firmado correctamente.</strong><br>Puede cerrar esta ventana.</div>';
|
||||
widget.parentNode.insertBefore(ok, widget.nextSibling);
|
||||
widget.style.display = 'none';
|
||||
try { window.parent.postMessage({ type: 'turneroFirmado' }, '*'); } catch(e) {}
|
||||
} else {
|
||||
// Reemplazar canvas con imagen firmada
|
||||
var img = document.createElement('img');
|
||||
img.src = svg; img.alt = 'Firma profesional';
|
||||
img.style.maxHeight = '140px'; img.style.maxWidth = '340px'; img.style.display = 'block';
|
||||
var box = document.createElement('div');
|
||||
box.className = 'firma-box'; box.style.borderColor = '#198754';
|
||||
box.appendChild(img);
|
||||
widget.replaceWith(box);
|
||||
}
|
||||
} else {
|
||||
msg.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>' + (data.error||'Error al guardar') + '</span>';
|
||||
btnSave.disabled = false;
|
||||
@@ -888,16 +982,17 @@ function esc2(mixed $v): string {
|
||||
});
|
||||
})();
|
||||
|
||||
/* ── Firma de consentimiento turnero ────────────────────────────────── */
|
||||
(function () {
|
||||
var canvas = document.getElementById('turneroCv');
|
||||
var widget = document.getElementById('turneroFirmaWidget');
|
||||
/* ── Firma de consentimiento turnero (soporta múltiples campos firma) ── */
|
||||
document.querySelectorAll('.turnero-firma-item').forEach(function(widget) {
|
||||
var canvas = widget.querySelector('.turnero-cv');
|
||||
if (!canvas) return;
|
||||
var cid = widget.dataset.cid || '__firma';
|
||||
var ctx = canvas.getContext('2d');
|
||||
var btnLimp = document.getElementById('turneroLimpiar');
|
||||
var btnFirm = document.getElementById('turneroFirmar');
|
||||
var msgEl = document.getElementById('turneroMsg');
|
||||
var btnLimp = widget.querySelector('.turnero-limpiar');
|
||||
var btnFirm = widget.querySelector('.turnero-firmar');
|
||||
var msgEl = widget.querySelector('.turnero-msg');
|
||||
var drawing = false;
|
||||
var btnLabel = btnFirm ? btnFirm.innerHTML : '';
|
||||
|
||||
(function scaleCanvas() {
|
||||
var ratio = window.devicePixelRatio || 1;
|
||||
@@ -928,12 +1023,12 @@ function esc2(mixed $v): string {
|
||||
canvas.addEventListener('touchmove', function(e){ e.preventDefault(); if(!drawing) return; var p=getPos(e); ctx.lineTo(p.x,p.y); ctx.stroke(); }, {passive:false});
|
||||
canvas.addEventListener('touchend', function(){ drawing=false; });
|
||||
|
||||
btnLimp.addEventListener('click', function() {
|
||||
if (btnLimp) btnLimp.addEventListener('click', function() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
msgEl.textContent = '';
|
||||
});
|
||||
|
||||
btnFirm.addEventListener('click', function() {
|
||||
if (btnFirm) btnFirm.addEventListener('click', function() {
|
||||
var png = canvas.toDataURL('image/png');
|
||||
if (png.length < 1500) {
|
||||
msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-exclamation-triangle me-1"></i>Por favor dibuje su firma antes de confirmar.</span>';
|
||||
@@ -943,23 +1038,20 @@ function esc2(mixed $v): string {
|
||||
btnFirm.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando...';
|
||||
msgEl.textContent = '';
|
||||
|
||||
// Recopilar respuestas de campos interactivos
|
||||
var campos = {};
|
||||
document.querySelectorAll('[name]').forEach(function(el) {
|
||||
var rawName = el.name;
|
||||
var isArr = rawName.slice(-2) === '[]';
|
||||
var name = isArr ? rawName.slice(0, -2) : rawName;
|
||||
if (el.type === 'checkbox') {
|
||||
if (el.checked) {
|
||||
if (!Array.isArray(campos[name])) campos[name] = [];
|
||||
campos[name].push(el.value);
|
||||
}
|
||||
if (el.checked) { if (!Array.isArray(campos[name])) campos[name] = []; campos[name].push(el.value); }
|
||||
} else if (el.type === 'radio') {
|
||||
if (el.checked) campos[name] = el.value;
|
||||
} else if (el.value !== '') {
|
||||
campos[name] = el.value;
|
||||
}
|
||||
});
|
||||
campos[cid + '_svg'] = png; // identificar qué campo firmó
|
||||
|
||||
fetch(window.location.href, {
|
||||
method: 'POST',
|
||||
@@ -969,26 +1061,28 @@ function esc2(mixed $v): string {
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
widget.outerHTML =
|
||||
'<div class="alert alert-success mt-4 d-flex align-items-center gap-2 no-print">' +
|
||||
'<i class="fas fa-check-circle fs-4"></i>' +
|
||||
'<div><strong>Consentimiento firmado correctamente.</strong><br>' +
|
||||
'Puede cerrar esta ventana.</div></div>';
|
||||
// Notificar ventana padre si está en iframe/modal
|
||||
// Ocultar todos los demás widgets de firma (ya no se puede firmar dos veces)
|
||||
document.querySelectorAll('.turnero-firma-item').forEach(function(w) {
|
||||
w.style.display = 'none';
|
||||
});
|
||||
var ok = document.createElement('div');
|
||||
ok.className = 'alert alert-success mt-4 d-flex align-items-center gap-2 no-print';
|
||||
ok.innerHTML = '<i class="fas fa-check-circle fs-4"></i><div><strong>Firmado correctamente.</strong><br>Puede cerrar esta ventana.</div>';
|
||||
widget.parentNode.insertBefore(ok, widget.nextSibling);
|
||||
try { window.parent.postMessage({ type: 'turneroFirmado' }, '*'); } catch(e) {}
|
||||
} else {
|
||||
msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>' + (data.error || 'Error al guardar') + '</span>';
|
||||
btnFirm.disabled = false;
|
||||
btnFirm.innerHTML = '<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento';
|
||||
btnFirm.innerHTML = btnLabel;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>Error de conexión.</span>';
|
||||
btnFirm.disabled = false;
|
||||
btnFirm.innerHTML = '<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento';
|
||||
btnFirm.innerHTML = btnLabel;
|
||||
});
|
||||
});
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+18
-5
@@ -416,6 +416,7 @@ body::after { width: 400px; height: 400px; bottom: -100px; left: -100px; }
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const BASE_URL = '<?= rtrim(BASE_URL, '/') ?>';
|
||||
let _cedula = '';
|
||||
let _nombre = '';
|
||||
let _timer = null;
|
||||
@@ -424,7 +425,17 @@ let _reenvioTimer = null;
|
||||
// ── OTP input navegación automática ──────────────────────────────────
|
||||
document.querySelectorAll('.otp-digit').forEach((inp, i, arr) => {
|
||||
inp.addEventListener('input', () => {
|
||||
inp.value = inp.value.replace(/\D/g, '').slice(-1);
|
||||
const val = inp.value.replace(/\D/g, '');
|
||||
if (val.length > 1) {
|
||||
// Pega múltiples dígitos (mobile/autofill)
|
||||
const digits = val.slice(0, 6);
|
||||
arr.forEach((d, j) => { d.value = digits[j] || ''; });
|
||||
const last = Math.min(digits.length - 1, arr.length - 1);
|
||||
arr[last].focus();
|
||||
if (otpCompleto()) verificarOtp();
|
||||
return;
|
||||
}
|
||||
inp.value = val;
|
||||
if (inp.value && i < arr.length - 1) arr[i + 1].focus();
|
||||
if (otpCompleto()) verificarOtp();
|
||||
});
|
||||
@@ -435,7 +446,9 @@ document.querySelectorAll('.otp-digit').forEach((inp, i, arr) => {
|
||||
e.preventDefault();
|
||||
const txt = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g,'').slice(0,6);
|
||||
arr.forEach((d, j) => { d.value = txt[j] || ''; });
|
||||
if (txt.length === 6) { arr[5].focus(); verificarOtp(); }
|
||||
const last = Math.min(txt.length - 1, arr.length - 1);
|
||||
if (last >= 0) arr[last].focus();
|
||||
if (txt.length === 6) verificarOtp();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -480,7 +493,7 @@ async function enviarOtp() {
|
||||
hideError();
|
||||
|
||||
try {
|
||||
const r = await fetch('api/public/send_otp.php', {
|
||||
const r = await fetch(BASE_URL + '/api/public/send_otp.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cedula }),
|
||||
@@ -514,7 +527,7 @@ async function verificarOtp() {
|
||||
hideError();
|
||||
|
||||
try {
|
||||
const r = await fetch('api/public/verify_otp.php', {
|
||||
const r = await fetch(BASE_URL + '/api/public/verify_otp.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cedula: _cedula, codigo: getOtp() }),
|
||||
@@ -546,7 +559,7 @@ async function reenviarOtp() {
|
||||
clearOtp();
|
||||
|
||||
try {
|
||||
const r = await fetch('api/public/send_otp.php', {
|
||||
const r = await fetch(BASE_URL + '/api/public/send_otp.php', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cedula: _cedula }),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user