- Nueva tabla sync_wa_log (total, created, skipped, updated, errores, origen, modo) - Log guardado en /pacientes/sync-all, /terceros/send y /automation/run - Ruta GET /pacientes/historial devuelve últimos 100 registros - UI: tabla de historial con origen, modo, conteos y detalle de errores Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
278 lines
15 KiB
HTML
278 lines
15 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Pacientes — Sync WhatsApp{% endblock %}
|
|
{% block header %}Pacientes — Sincronizar con WhatsApp Lab{% endblock %}
|
|
{% block content %}
|
|
<div class="max-w-3xl space-y-6">
|
|
|
|
{% if not wa_configurado %}
|
|
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-4 flex items-start gap-3">
|
|
<i class="fas fa-exclamation-triangle text-yellow-500 mt-0.5"></i>
|
|
<div>
|
|
<p class="font-medium text-yellow-800">WhatsApp Lab no está configurado</p>
|
|
<p class="text-sm text-yellow-700 mt-1">
|
|
Ve a <a href="/config" class="underline">Configuración</a> y completa la
|
|
<strong>URL de WhatsApp Lab</strong> y la <strong>API Key</strong>.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Sync masiva -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
|
<div class="px-6 py-4 border-b border-gray-200">
|
|
<h3 class="font-semibold text-gray-800">
|
|
<i class="fas fa-sync-alt mr-2 text-green-500"></i>Sincronizar pacientes a WhatsApp Lab
|
|
</h3>
|
|
<p class="text-sm text-gray-500 mt-1">
|
|
Destino: <code class="bg-gray-100 px-1 rounded text-xs">{{ wa_url or "—" }}/api/lab/ingest_paciente.php</code>
|
|
</p>
|
|
</div>
|
|
<div class="p-6 space-y-5">
|
|
<form id="syncForm" class="space-y-4">
|
|
|
|
<div class="flex items-center gap-3">
|
|
<input type="checkbox" id="todosCheck" name="todos" value="1"
|
|
class="w-4 h-4 text-blue-600 rounded border-gray-300">
|
|
<label for="todosCheck" class="text-sm font-medium text-gray-700">
|
|
Sincronizar <strong>todos</strong> los pacientes (sin filtro de fecha)
|
|
</label>
|
|
</div>
|
|
|
|
<div id="fechaFields" class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Fecha inicio</label>
|
|
<input type="date" id="fecha_ini" name="fecha_ini"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Fecha fin</label>
|
|
<input type="date" id="fecha_fin" name="fecha_fin"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
|
|
</div>
|
|
</div>
|
|
|
|
<button type="button" onclick="runSync()"
|
|
id="btnSync"
|
|
{% if not wa_configurado %}disabled{% endif %}
|
|
class="px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors text-sm">
|
|
<i class="fas fa-upload mr-2"></i> Iniciar sincronización
|
|
</button>
|
|
</form>
|
|
|
|
<!-- Progreso -->
|
|
<div id="syncProgress" class="hidden">
|
|
<div class="flex items-center gap-3 text-sm text-gray-600">
|
|
<i class="fas fa-spinner fa-spin text-blue-500"></i>
|
|
<span>Sincronizando pacientes con WhatsApp Lab…</span>
|
|
</div>
|
|
<p class="text-xs text-gray-400 mt-1">Esto puede tomar varios segundos según la cantidad de pacientes.</p>
|
|
</div>
|
|
|
|
<!-- Resultado -->
|
|
<div id="syncResult" class="hidden"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Historial -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
|
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
|
<h3 class="font-semibold text-gray-800">
|
|
<i class="fas fa-history mr-2 text-gray-400"></i>Historial de sincronizaciones
|
|
</h3>
|
|
<button onclick="cargarHistorial()" class="text-sm text-blue-600 hover:underline">
|
|
<i class="fas fa-refresh mr-1"></i>Actualizar
|
|
</button>
|
|
</div>
|
|
<div id="historialContainer" class="p-4">
|
|
<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info sobre la API -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
|
<div class="px-6 py-4 border-b border-gray-200">
|
|
<h3 class="font-semibold text-gray-800">
|
|
<i class="fas fa-info-circle mr-2 text-blue-400"></i>Cómo funciona
|
|
</h3>
|
|
</div>
|
|
<div class="p-6 text-sm text-gray-600 space-y-3">
|
|
<div class="flex gap-3">
|
|
<span class="w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">1</span>
|
|
<p>Se consultan los pacientes en Firebird (<code class="bg-gray-100 px-1 rounded">PACIENTE</code>)
|
|
filtrados por rango de fecha de recepción (o todos si marcas la opción).</p>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<span class="w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">2</span>
|
|
<p>Cada paciente se envía a WhatsApp Lab via <code class="bg-gray-100 px-1 rounded">POST /api/lab/ingest_paciente.php</code>
|
|
con el header <code class="bg-gray-100 px-1 rounded">X-Lab-Key</code>.</p>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<span class="w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">3</span>
|
|
<p>El endpoint hace <strong>UPSERT</strong>: si el paciente ya existe por número de documento lo actualiza,
|
|
si no existe lo crea en <code class="bg-gray-100 px-1 rounded">lab_pacientes</code>.</p>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<span class="w-6 h-6 bg-green-100 text-green-700 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">✓</span>
|
|
<p>La sync también se dispara <strong>automáticamente</strong> al enviar un Tercero individual
|
|
o al ejecutar la Automatización RIPS.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
document.getElementById('todosCheck').addEventListener('change', function () {
|
|
document.getElementById('fechaFields').style.display = this.checked ? 'none' : '';
|
|
});
|
|
|
|
async function runSync() {
|
|
const btn = document.getElementById('btnSync');
|
|
const prog = document.getElementById('syncProgress');
|
|
const result = document.getElementById('syncResult');
|
|
const form = document.getElementById('syncForm');
|
|
|
|
btn.disabled = true;
|
|
prog.classList.remove('hidden');
|
|
result.classList.add('hidden');
|
|
result.innerHTML = '';
|
|
|
|
const data = new FormData(form);
|
|
|
|
try {
|
|
const resp = await fetch('/pacientes/sync-all', { method: 'POST', body: data });
|
|
const json = await resp.json();
|
|
|
|
prog.classList.add('hidden');
|
|
result.classList.remove('hidden');
|
|
|
|
if (json.success) {
|
|
const errColor = json.errores > 0 ? 'text-red-600' : 'text-gray-500';
|
|
result.innerHTML = `
|
|
<div class="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
|
|
<p class="font-medium text-green-800"><i class="fas fa-check-circle mr-2"></i>${json.message}</p>
|
|
<div class="grid grid-cols-4 gap-3 text-center text-sm">
|
|
<div class="bg-white rounded-lg p-3 border border-green-100">
|
|
<p class="text-2xl font-bold text-gray-800">${json.total}</p>
|
|
<p class="text-gray-500">Total</p>
|
|
</div>
|
|
<div class="bg-white rounded-lg p-3 border border-green-100">
|
|
<p class="text-2xl font-bold text-blue-600">${json.created}</p>
|
|
<p class="text-gray-500">Nuevos</p>
|
|
</div>
|
|
<div class="bg-white rounded-lg p-3 border border-green-100">
|
|
<p class="text-2xl font-bold text-gray-400">${json.skipped ?? 0}</p>
|
|
<p class="text-gray-500">Ya existían</p>
|
|
</div>
|
|
<div class="bg-white rounded-lg p-3 border border-green-100">
|
|
<p class="text-2xl font-bold text-red-500">${json.errores}</p>
|
|
<p class="text-gray-500">Errores</p>
|
|
</div>
|
|
</div>
|
|
${json.errores > 0 ? `<p class="text-sm text-red-600"><i class="fas fa-exclamation-triangle mr-1"></i>${json.errores} errores — revisa los detalles abajo.</p>` : ''}
|
|
${renderDetalle(json.detalle || [])}
|
|
</div>`;
|
|
} else {
|
|
result.innerHTML = `
|
|
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
<p class="font-medium text-red-800"><i class="fas fa-times-circle mr-2"></i>${json.message}</p>
|
|
</div>`;
|
|
}
|
|
} catch (e) {
|
|
prog.classList.add('hidden');
|
|
result.classList.remove('hidden');
|
|
result.innerHTML = `<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">Error de conexión: ${e.message}</div>`;
|
|
}
|
|
|
|
btn.disabled = false;
|
|
}
|
|
|
|
function renderDetalle(detalle) {
|
|
const errores = detalle.filter(d => !d.ok);
|
|
if (!errores.length) return '';
|
|
return `
|
|
<details class="mt-2">
|
|
<summary class="text-sm text-red-600 cursor-pointer">Ver ${errores.length} errores</summary>
|
|
<div class="mt-2 space-y-1 max-h-48 overflow-y-auto">
|
|
${errores.map(d => `
|
|
<div class="flex gap-2 text-xs text-red-700 bg-red-50 rounded px-2 py-1">
|
|
<span class="font-mono">${d.doc || '—'}</span>
|
|
<span class="text-gray-500">${d.nombre || ''}</span>
|
|
<span class="ml-auto">${d.message || ''}</span>
|
|
</div>`).join('')}
|
|
</div>
|
|
</details>`;
|
|
}
|
|
|
|
const _origenLabel = { manual: 'Manual', tercero: 'Tercero', automation: 'Automatización' };
|
|
const _modoLabel = { insertar: 'Solo nuevos', upsert: 'Upsert' };
|
|
|
|
async function cargarHistorial() {
|
|
const el = document.getElementById('historialContainer');
|
|
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>';
|
|
try {
|
|
const resp = await fetch('/pacientes/historial');
|
|
const rows = await resp.json();
|
|
if (!rows.length) {
|
|
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-6">Sin registros aún.</p>';
|
|
return;
|
|
}
|
|
el.innerHTML = `
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="text-left text-xs text-gray-500 border-b border-gray-100">
|
|
<th class="pb-2 pr-4 font-medium">Fecha</th>
|
|
<th class="pb-2 pr-4 font-medium">Origen</th>
|
|
<th class="pb-2 pr-4 font-medium">Modo</th>
|
|
<th class="pb-2 pr-3 font-medium text-right">Total</th>
|
|
<th class="pb-2 pr-3 font-medium text-right text-blue-600">Nuevos</th>
|
|
<th class="pb-2 pr-3 font-medium text-right text-gray-400">Omitidos</th>
|
|
<th class="pb-2 pr-3 font-medium text-right text-green-600">Actualizados</th>
|
|
<th class="pb-2 pr-3 font-medium text-right text-red-500">Errores</th>
|
|
<th class="pb-2 font-medium">Usuario</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-50">
|
|
${rows.map(r => {
|
|
const errDet = r.errores_det ? JSON.parse(r.errores_det) : [];
|
|
const fecha = r.created_at.replace('T', ' ').slice(0, 16);
|
|
return `
|
|
<tr class="hover:bg-gray-50">
|
|
<td class="py-2 pr-4 text-gray-500 whitespace-nowrap font-mono text-xs">${fecha}</td>
|
|
<td class="py-2 pr-4">
|
|
<span class="px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
r.origen === 'manual' ? 'bg-blue-50 text-blue-700' :
|
|
r.origen === 'tercero' ? 'bg-orange-50 text-orange-700' :
|
|
'bg-purple-50 text-purple-700'
|
|
}">${_origenLabel[r.origen] || r.origen}</span>
|
|
</td>
|
|
<td class="py-2 pr-4 text-gray-500 text-xs">${_modoLabel[r.modo] || r.modo}</td>
|
|
<td class="py-2 pr-3 text-right font-medium">${r.total}</td>
|
|
<td class="py-2 pr-3 text-right text-blue-600 font-medium">${r.created}</td>
|
|
<td class="py-2 pr-3 text-right text-gray-400">${r.skipped}</td>
|
|
<td class="py-2 pr-3 text-right text-green-600">${r.updated}</td>
|
|
<td class="py-2 pr-3 text-right ${r.errores > 0 ? 'text-red-500 font-medium' : 'text-gray-300'}">
|
|
${r.errores > 0 && errDet.length ? `
|
|
<details>
|
|
<summary class="cursor-pointer">${r.errores}</summary>
|
|
<div class="absolute z-10 bg-white border border-red-100 rounded-lg shadow-lg p-2 text-xs mt-1 max-w-xs">
|
|
${errDet.map(e => `<div class="text-red-600">${e.doc} — ${e.msg}</div>`).join('')}
|
|
</div>
|
|
</details>` : r.errores || '—'}
|
|
</td>
|
|
<td class="py-2 text-gray-400 text-xs">${r.username || '—'}</td>
|
|
</tr>`;
|
|
}).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>`;
|
|
} catch (e) {
|
|
el.innerHTML = `<p class="text-sm text-red-500 text-center py-4">Error: ${e.message}</p>`;
|
|
}
|
|
}
|
|
|
|
// Cargar historial al abrir la página
|
|
cargarHistorial();
|
|
</script>
|
|
{% endblock %}
|