This commit is contained in:
Lizandro Guarnizo
2026-03-16 18:36:11 -05:00
parent bbfe0417df
commit ded19cbb8d
6 changed files with 416 additions and 5 deletions
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* API: Historial de envíos masivos (broadcasts)
* GET ?page=1&per_page=20
*/
require_once '../config/config.php';
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Método no permitido']);
exit;
}
$db = Database::getInstance();
// Crear tabla si no existe todavía
$db->getConnection()->exec("
CREATE TABLE IF NOT EXISTS broadcast_history (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
message_type VARCHAR(20) NOT NULL DEFAULT 'text',
selection_type VARCHAR(20) NOT NULL DEFAULT 'filter',
filter_used VARCHAR(50) NULL,
template_name VARCHAR(120) NULL,
message_preview TEXT NULL,
total_users INT UNSIGNED NOT NULL DEFAULT 0,
sent_count INT UNSIGNED NOT NULL DEFAULT 0,
error_count INT UNSIGNED NOT NULL DEFAULT 0,
sent_by INT UNSIGNED NULL,
sent_by_name VARCHAR(120) NULL,
INDEX idx_sent_at (sent_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = min(100, max(5, (int)($_GET['per_page'] ?? 20)));
$offset = ($page - 1) * $perPage;
try {
$total = (int)$db->getConnection()
->query("SELECT COUNT(*) FROM broadcast_history")
->fetchColumn();
$rows = $db->fetchAll(
"SELECT * FROM broadcast_history ORDER BY sent_at DESC LIMIT ? OFFSET ?",
[$perPage, $offset]
);
echo json_encode([
'success' => true,
'data' => $rows,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'pages' => (int)ceil($total / $perPage),
]);
} catch (Exception $e) {
error_log('get_broadcast_history.php: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Error interno del servidor']);
}
+53
View File
@@ -9,6 +9,27 @@ require_once '../config/config.php';
// Verificar autenticación
requireAuthentication();
// Asegurar que la tabla de historial existe
try {
Database::getInstance()->getConnection()->exec("
CREATE TABLE IF NOT EXISTS broadcast_history (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
message_type VARCHAR(20) NOT NULL DEFAULT 'text',
selection_type VARCHAR(20) NOT NULL DEFAULT 'filter',
filter_used VARCHAR(50) NULL,
template_name VARCHAR(120) NULL,
message_preview TEXT NULL,
total_users INT UNSIGNED NOT NULL DEFAULT 0,
sent_count INT UNSIGNED NOT NULL DEFAULT 0,
error_count INT UNSIGNED NOT NULL DEFAULT 0,
sent_by INT UNSIGNED NULL,
sent_by_name VARCHAR(120) NULL,
INDEX idx_sent_at (sent_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
} catch (Exception $_e) { /* silencioso si ya existe */ }
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST');
@@ -271,6 +292,38 @@ try {
}
}
// --- Guardar historial de broadcast ---
try {
$adminId = (int)($_SESSION['admin_user']['id'] ?? $_SESSION['user_id'] ?? 0);
$adminName = $_SESSION['admin_user']['name'] ?? $_SESSION['username'] ?? 'Sistema';
$tplName = null;
$preview = '';
if ($messageType === 'template' && isset($template)) {
$tplName = $template['template_name'] ?? $template['name'] ?? null;
$preview = mb_substr($template['body'] ?? $tplName ?? '', 0, 200);
} else {
$preview = mb_substr($input['message'] ?? '', 0, 200);
}
$db->insert('broadcast_history', [
'sent_at' => date('Y-m-d H:i:s'),
'message_type' => $messageType,
'selection_type' => $selectionType,
'filter_used' => ($selectionType === 'filter') ? ($input['filter'] ?? 'all') : null,
'template_name' => $tplName,
'message_preview' => $preview,
'total_users' => count($users),
'sent_count' => $sentCount,
'error_count' => $errorCount,
'sent_by' => $adminId ?: null,
'sent_by_name' => $adminName ?: null,
]);
} catch (Exception $eHist) {
error_log('broadcast_history insert failed: ' . $eHist->getMessage());
}
// --- Fin historial ---
echo json_encode([
'success' => true,
'sent_count' => $sentCount,
+4
View File
@@ -1947,6 +1947,10 @@ class SimpleWhatsAppManager {
$('#broadcast-users').val(null).trigger('change');
document.getElementById('broadcast-template-variables').style.display = 'none';
document.getElementById('broadcast-template-preview').style.display = 'none';
// Refrescar historial automáticamente
if (typeof window.loadBroadcastHistory === 'function') {
setTimeout(() => window.loadBroadcastHistory(1), 800);
}
} else {
this.showError(result.error || 'Error enviando broadcast');
}
+1
View File
@@ -118,6 +118,7 @@ class Domicilio {
p.direccion AS paciente_direccion,
o.examenes_solicitados,
o.indicaciones AS indicaciones_orden,
o.local_file AS orden_local_file,
e.nombre_completo AS enfermera_nombre,
e.telefono AS enfermera_telefono,
a.id AS asignacion_id,
+120
View File
@@ -663,6 +663,44 @@ try {
</div>
</div>
</div>
<!-- Historial de Envíos Masivos -->
<div class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-history me-2 text-secondary"></i>Historial de Envíos Masivos</h5>
<button class="btn btn-sm btn-outline-secondary" onclick="loadBroadcastHistory(1)">
<i class="fas fa-sync-alt"></i> Actualizar
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>Fecha</th>
<th>Tipo</th>
<th>Mensaje / Plantilla</th>
<th class="text-center">Total</th>
<th class="text-center text-success">Enviados</th>
<th class="text-center text-danger">Errores</th>
<th>Enviado por</th>
</tr>
</thead>
<tbody id="broadcast-history-tbody">
<tr>
<td colspan="7" class="text-center text-muted py-4">
<i class="fas fa-spinner fa-spin me-1"></i> Cargando historial...
</td>
</tr>
</tbody>
</table>
</div>
<div id="bh-pagination-row" class="d-flex justify-content-between align-items-center px-3 py-2 border-top" style="display:none!important">
<small class="text-muted" id="bh-pagination-info"></small>
<div class="d-flex gap-1" id="bh-pagination-btns"></div>
</div>
</div>
</div>
</div>
<!-- WhatsApp Profile Tab -->
@@ -2191,6 +2229,88 @@ try {
})();
</script>
<!-- ── / T&C Aceptaciones JS ─────────────────────────────────────────── -->
<!-- ── Historial Envíos Masivos JS ──────────────────────────────────── -->
<script>
(function () {
let _currentPage = 1;
window.loadBroadcastHistory = function (page) {
page = page || 1;
_currentPage = page;
const tbody = document.getElementById('broadcast-history-tbody');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3"><i class="fas fa-spinner fa-spin me-1"></i> Cargando...</td></tr>';
fetch('api/get_broadcast_history.php?page=' + page + '&per_page=10')
.then(r => r.json())
.then(res => {
if (!res.success || !res.data) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Sin registros</td></tr>';
return;
}
if (res.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3"><i class="fas fa-inbox me-1"></i> Aún no hay envíos masivos registrados</td></tr>';
} else {
const typeLabels = { text: 'Texto', template: 'Plantilla', image: 'Imagen', document: 'Documento' };
const filterLabels = { all: 'Todos', active: 'Activos', recent: 'Recientes', null: '—' };
tbody.innerHTML = res.data.map(r => {
const fecha = r.sent_at ? r.sent_at.replace('T', ' ').substring(0, 16) : '—';
const tipo = typeLabels[r.message_type] || r.message_type || '—';
const msg = r.template_name
? '<span class="badge bg-success-subtle text-success border border-success-subtle me-1">Plantilla</span>' + htmlEsc(r.template_name)
: (r.message_preview ? htmlEsc(r.message_preview.substring(0, 60)) + (r.message_preview.length > 60 ? '…' : '') : '—');
const pct = r.total_users > 0 ? Math.round(r.sent_count / r.total_users * 100) : 0;
return `<tr>
<td class="small text-nowrap">${fecha}</td>
<td><span class="badge bg-secondary-subtle text-secondary border">${tipo}</span></td>
<td class="small">${msg}</td>
<td class="text-center fw-bold">${r.total_users}</td>
<td class="text-center text-success fw-bold">${r.sent_count} <small class="text-muted">(${pct}%)</small></td>
<td class="text-center ${r.error_count > 0 ? 'text-danger fw-bold' : 'text-muted'}">${r.error_count}</td>
<td class="small text-muted">${htmlEsc(r.sent_by_name || 'Sistema')}</td>
</tr>`;
}).join('');
}
// Paginación
const pagRow = document.getElementById('bh-pagination-row');
const pagInfo = document.getElementById('bh-pagination-info');
const pagBtns = document.getElementById('bh-pagination-btns');
if (pagRow && res.pages > 1) {
pagRow.style.display = '';
pagInfo.textContent = 'Página ' + res.page + ' de ' + res.pages + ' (' + res.total + ' registros)';
let btns = '';
for (let p = 1; p <= res.pages; p++) {
btns += `<button class="btn btn-sm ${p === res.page ? 'btn-secondary' : 'btn-outline-secondary'}" onclick="loadBroadcastHistory(${p})">${p}</button>`;
}
pagBtns.innerHTML = btns;
} else if (pagRow) {
pagRow.style.display = 'none';
}
})
.catch(err => {
if (tbody) tbody.innerHTML = '<tr><td colspan="7" class="text-center text-danger py-3">Error al cargar el historial</td></tr>';
console.error('loadBroadcastHistory error:', err);
});
};
function htmlEsc(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Cargar al activar el tab de broadcast
document.addEventListener('DOMContentLoaded', () => {
const broadcastLinks = document.querySelectorAll('.nav-link[data-tab="broadcast"], [data-tab="broadcast"]');
broadcastLinks.forEach(link => {
link.addEventListener('click', () => setTimeout(() => loadBroadcastHistory(1), 100));
});
});
})();
</script>
<!-- ── / Historial Envíos Masivos JS ─────────────────────────────────── -->
</body>
</html>
+173 -5
View File
@@ -148,7 +148,7 @@ $domIdParam = (int)($_GET['id'] ?? 0);
<div class="modal-body">
<form id="form-domicilio">
<input type="hidden" name="id" id="dom-id">
<input type="hidden" name="orden_id" id="dom-orden-id" value="<?= $ordenIdParam ?>">
<input type="hidden" name="orden_id_url" id="dom-orden-id" value="<?= $ordenIdParam ?>">
<!-- ── Paciente ── -->
<h6 class="text-muted text-uppercase small fw-semibold border-bottom pb-1 mb-3">
@@ -251,6 +251,44 @@ $domIdParam = (int)($_GET['id'] ?? 0);
</div>
</div>
<!-- ── Orden Médica ── -->
<h6 class="text-muted text-uppercase small fw-semibold border-bottom pb-1 mb-3">
<i class="fas fa-file-medical me-1"></i> Orden Médica Adjunta
</h6>
<div class="mb-3">
<input type="hidden" name="orden_id" id="dom-orden-id-form">
<!-- Preview archivo actual o pendiente -->
<div id="dom-orden-preview" class="d-flex align-items-center gap-3 p-2 rounded border bg-light mb-2 d-none">
<div id="dom-orden-thumb-wrap">
<img id="dom-orden-img" src="" alt="Orden" style="max-height:80px;max-width:100px;object-fit:contain;border-radius:4px;display:none">
<div id="dom-orden-icon" class="d-none">
<i class="fas fa-file-alt fa-3x text-secondary"></i>
</div>
</div>
<div class="flex-grow-1 overflow-hidden">
<div id="dom-orden-fname" class="small fw-semibold text-truncate"></div>
<div id="dom-orden-fsize" class="small text-muted"></div>
</div>
</div>
<div id="dom-orden-empty" class="small text-muted mb-2">
<i class="fas fa-paperclip me-1"></i> Sin orden adjunta
</div>
<div class="d-flex gap-2 flex-wrap align-items-center">
<label class="btn btn-sm btn-outline-primary mb-0" for="dom-orden-input">
<i class="fas fa-upload me-1"></i>
<span id="dom-orden-btn-lbl">Adjuntar orden</span>
</label>
<button type="button" class="btn btn-sm btn-outline-danger d-none" id="dom-orden-quitar"
onclick="domQuitarOrden()">
<i class="fas fa-times me-1"></i> Quitar
</button>
</div>
<input type="file" id="dom-orden-input" class="d-none"
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf,.pdf,.doc,.docx"
onchange="domSeleccionarOrdenFile(this)">
<small class="text-muted d-block mt-1">Imagen, PDF o DOC — máx. 10 MB</small>
</div>
<!-- ── Notas ── -->
<div class="mb-2">
<label class="form-label">Notas internas</label>
@@ -270,6 +308,93 @@ $domIdParam = (int)($_GET['id'] ?? 0);
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const modal = new bootstrap.Modal('#modalDomicilio');
// ── Estado orden en el modal ───────────────────────────────────────────────
let _domOrderFile = null; // File pendiente de subir
let _domCurrentLocalFile = null; // local_file actual de la orden
let _domRemoveOrder = false; // el usuario quiere desvincularse de la orden
function _domResetOrden() {
_domOrderFile = null;
_domCurrentLocalFile = null;
_domRemoveOrder = false;
document.getElementById('dom-orden-id-form').value = '';
document.getElementById('dom-orden-input').value = '';
_domRenderOrdenPreview();
}
function _domRenderOrdenPreview() {
const preview = document.getElementById('dom-orden-preview');
const empty = document.getElementById('dom-orden-empty');
const img = document.getElementById('dom-orden-img');
const icon = document.getElementById('dom-orden-icon');
const fname = document.getElementById('dom-orden-fname');
const fsize = document.getElementById('dom-orden-fsize');
const btnQuitar = document.getElementById('dom-orden-quitar');
const btnLbl = document.getElementById('dom-orden-btn-lbl');
if (_domOrderFile) {
// Archivo local seleccionado aún no subido
const isImg = _domOrderFile.type.startsWith('image/');
if (isImg) {
img.src = URL.createObjectURL(_domOrderFile);
img.style.display = '';
icon.classList.add('d-none');
} else {
img.style.display = 'none';
icon.classList.remove('d-none');
}
fname.textContent = _domOrderFile.name;
fsize.textContent = (_domOrderFile.size / 1024).toFixed(1) + ' KB — pendiente de guardar';
preview.classList.remove('d-none');
empty.classList.add('d-none');
btnQuitar.classList.remove('d-none');
btnLbl.textContent = 'Cambiar archivo';
} else if (_domCurrentLocalFile && !_domRemoveOrder) {
// Archivo ya guardado en servidor
const isImg = /\.(jpe?g|png|gif|webp)$/i.test(_domCurrentLocalFile);
if (isImg) {
img.src = 'uploads/media/' + encodeURIComponent(_domCurrentLocalFile);
img.style.display = '';
icon.classList.add('d-none');
} else {
img.style.display = 'none';
icon.classList.remove('d-none');
}
fname.textContent = _domCurrentLocalFile;
fsize.textContent = 'Archivo guardado';
preview.classList.remove('d-none');
empty.classList.add('d-none');
btnQuitar.classList.remove('d-none');
btnLbl.textContent = 'Cambiar archivo';
} else {
preview.classList.add('d-none');
empty.classList.remove('d-none');
btnQuitar.classList.add('d-none');
btnLbl.textContent = 'Adjuntar orden';
}
}
function domSeleccionarOrdenFile(input) {
const file = input.files[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
alert('El archivo supera los 10 MB permitidos');
input.value = '';
return;
}
_domOrderFile = file;
_domRemoveOrder = false;
_domRenderOrdenPreview();
}
function domQuitarOrden() {
_domOrderFile = null;
_domRemoveOrder = true;
document.getElementById('dom-orden-id-form').value = '';
document.getElementById('dom-orden-input').value = '';
_domRenderOrdenPreview();
}
const COLOR_DOM = { programado:'secondary', confirmado:'info', en_camino:'primary', en_domicilio:'warning', completado:'success', cancelado:'danger', reprogramado:'dark' };
let domSeleccionado = <?= $domIdParam ?: 'null' ?>;
let paginaActual = 1;
@@ -547,6 +672,7 @@ function abrirFormulario() {
document.getElementById('dom-fecha').value = document.getElementById('filtro-fecha').value || '<?= date('Y-m-d') ?>';
document.getElementById('dom-tipo-cliente').value = 'particular';
toggleSeguro('particular');
_domResetOrden();
document.getElementById('modal-titulo').textContent = 'Nuevo Domicilio';
modal.show();
@@ -585,6 +711,13 @@ async function editarDomicilio(id) {
document.getElementById('dom-valor-cop').value = dom.valor_copago || '';
document.getElementById('dom-copago-lab').value = dom.copago_laboratorio || '';
document.getElementById('dom-notas').value = dom.notas_admin || '';
// Orden médica adjunta
_domResetOrden();
document.getElementById('dom-orden-id-form').value = dom.orden_id || '';
_domCurrentLocalFile = dom.orden_local_file || null;
_domRenderOrdenPreview();
modal.show();
}
@@ -596,11 +729,45 @@ function toggleSeguro(val) {
async function guardarDomicilio() {
const form = document.getElementById('form-domicilio');
if (!form.checkValidity()) { form.reportValidity(); return; }
if (!document.getElementById('dom-paciente-id').value) {
alert('Debes seleccionar un paciente'); return;
}
const pacienteId = document.getElementById('dom-paciente-id').value;
if (!pacienteId) { alert('Debes seleccionar un paciente'); return; }
const datos = Object.fromEntries(new FormData(form).entries());
if (!datos.id) delete datos.id;
// Tomar orden_id del campo oculto real (no el parámetro URL)
datos.orden_id = document.getElementById('dom-orden-id-form').value || null;
delete datos.orden_id_url;
// ── Gestión del archivo de orden médica ──────────────────────────────
if (_domRemoveOrder) {
datos.orden_id = null;
} else if (_domOrderFile) {
// Subir el archivo primero
const fd = new FormData();
fd.append('file', _domOrderFile);
const upR = await fetch('api/lab/upload_orden.php', { method:'POST', body: fd });
const upD = await upR.json();
if (!upD.success) {
mostrarToast('Error subiendo el archivo: ' + (upD.error || 'desconocido'), 'danger');
return;
}
// Actualizar o crear la orden médica
const orId = document.getElementById('dom-orden-id-form').value;
const orPayload = orId
? { id: parseInt(orId), local_file: upD.local_file }
: { paciente_id: parseInt(pacienteId), local_file: upD.local_file };
const orR = await fetch('api/lab/save_orden.php', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(orPayload),
});
const orD = await orR.json();
if (!orD.success) {
mostrarToast('Error guardando la orden: ' + (orD.error || 'desconocido'), 'danger');
return;
}
datos.orden_id = orD.id ?? orD.data?.id ?? (parseInt(orId) || null);
}
// ── Guardar domicilio ────────────────────────────────────────────────
if (!datos.orden_id) delete datos.orden_id;
const r = await fetch('api/lab/save_domicilio.php', {
@@ -611,7 +778,8 @@ async function guardarDomicilio() {
if (d.success) {
modal.hide();
mostrarToast(d.message, 'success');
const editadoId = datos.id ? parseInt(datos.id) : null;
const editadoId = datos.id ? parseInt(datos.id) : (d.id || null);
_domResetOrden();
await cargarLista(paginaActual);
if (editadoId) verDomicilio(editadoId);
} else {