Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
465 lines
27 KiB
HTML
465 lines
27 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}ERP Lab{% endblock %}
|
|
{% block header %}<i class="fas fa-flask mr-2 text-green-400"></i> ERP Lab · Ximena Caicedo{% endblock %}
|
|
{% block content %}
|
|
<div class="-m-8 flex flex-col" style="height: calc(100vh - 64px)">
|
|
|
|
<!-- Barra de sub-tabs -->
|
|
<div class="bg-white border-b border-gray-200 px-4 flex-shrink-0">
|
|
<div class="flex">
|
|
<button id="btn-pacientes" onclick="switchTab('pacientes')"
|
|
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
|
|
<i class="fas fa-users text-xs"></i> Pacientes
|
|
</button>
|
|
<button id="btn-scheduler" onclick="switchTab('scheduler')"
|
|
class="tab-btn flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap">
|
|
<i class="fas fa-sync-alt text-xs"></i> Sync Automático
|
|
<span class="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded-full font-semibold">20 seg</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Paneles -->
|
|
<div class="flex-1 relative overflow-hidden bg-gray-50">
|
|
|
|
<!-- Tab: Pacientes (iframe) -->
|
|
<div id="panel-pacientes" class="tab-panel absolute inset-0 hidden">
|
|
<iframe id="iframe-pacientes" class="w-full h-full border-0"></iframe>
|
|
</div>
|
|
|
|
<!-- Tab: Sync Automático (inline) -->
|
|
<div id="panel-scheduler" class="tab-panel absolute inset-0 hidden overflow-y-auto">
|
|
<div class="p-6 space-y-5 max-w-5xl mx-auto">
|
|
|
|
<!-- Estado del scheduler -->
|
|
<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-robot mr-2 text-green-500"></i>Estado del Scheduler
|
|
</h3>
|
|
<span id="sched-badge" class="flex items-center gap-2 text-sm font-medium text-gray-400">
|
|
<span id="sched-dot" class="w-2 h-2 rounded-full bg-gray-300"></span>
|
|
<span id="sched-label">Verificando…</span>
|
|
</span>
|
|
</div>
|
|
<div id="sched-live" class="px-5 pt-3 pb-2 text-xs text-gray-500 flex flex-wrap gap-x-6 gap-y-1 border-b border-gray-100"></div>
|
|
<div class="p-5 grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-gray-600">
|
|
<div class="flex items-start gap-3">
|
|
<i class="fas fa-database text-blue-400 mt-0.5"></i>
|
|
<div>
|
|
<p class="font-medium text-gray-700">Fuente</p>
|
|
<p class="text-xs text-gray-500">Firebird · DBLAB_XIMENA_FB25</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<i class="fas fa-filter text-purple-400 mt-0.5"></i>
|
|
<div>
|
|
<p class="font-medium text-gray-700">Ventana de captura</p>
|
|
<p class="text-xs text-gray-500">Recepciones con HORAINICIORECEPCION en los últimos 2 minutos</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<i class="fas fa-paper-plane text-green-400 mt-0.5"></i>
|
|
<div>
|
|
<p class="font-medium text-gray-700">Destino</p>
|
|
<p class="text-xs text-gray-500">WhatsApp Lab · <code>ingest_paciente.php</code></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="px-5 pb-4 border-t border-gray-100 pt-3">
|
|
<p class="text-xs text-gray-500 font-medium mb-2">Datos sincronizados por recepción:</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<span class="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded">Paciente (upsert)</span>
|
|
<span class="text-xs bg-purple-50 text-purple-700 px-2 py-1 rounded">Exámenes + CUPS</span>
|
|
<span class="text-xs bg-orange-50 text-orange-700 px-2 py-1 rounded">Diagnóstico CIE-10</span>
|
|
<span class="text-xs bg-green-50 text-green-700 px-2 py-1 rounded">Médico ordenante</span>
|
|
<span class="text-xs bg-teal-50 text-teal-700 px-2 py-1 rounded">Empresa / EPS</span>
|
|
<span class="text-xs bg-yellow-50 text-yellow-700 px-2 py-1 rounded">Valor total</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Migración diagnósticos CIE-10 -->
|
|
<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-stethoscope mr-2 text-orange-400"></i>Migración CIE-10
|
|
</h3>
|
|
<span class="text-xs text-gray-400">12.422 diagnósticos de Firebird → WhatsApp</span>
|
|
</div>
|
|
<div class="p-5 flex flex-wrap items-center gap-4">
|
|
<p class="text-sm text-gray-500 flex-1">Pobla la tabla <code class="bg-gray-100 px-1 rounded">cie10_diagnosticos</code> en WhatsApp. Idempotente — se puede re-ejecutar. Si corta a la mitad, usa "Reanudar" para continuar desde donde quedó.</p>
|
|
<button id="btn-sync-diag" onclick="ejecutarSyncDiag()"
|
|
class="flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors whitespace-nowrap">
|
|
<i class="fas fa-upload text-xs"></i> Migrar diagnósticos
|
|
</button>
|
|
</div>
|
|
<div id="sync-diag-result" class="hidden px-5 pb-4">
|
|
<div id="sync-diag-inner"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Prueba manual -->
|
|
<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-play-circle mr-2 text-blue-500"></i>Prueba manual
|
|
</h3>
|
|
</div>
|
|
<div class="p-5 flex flex-wrap items-center gap-4">
|
|
<select id="sel-ventana"
|
|
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300">
|
|
<option value="2">Últimos 2 minutos (igual al scheduler)</option>
|
|
<option value="30" selected>Últimos 30 minutos</option>
|
|
<option value="120">Últimas 2 horas</option>
|
|
<option value="1440">Hoy completo</option>
|
|
</select>
|
|
<button id="btn-sync-now" onclick="ejecutarSyncNow()"
|
|
class="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
|
<i class="fas fa-bolt text-xs"></i> Ejecutar ahora
|
|
</button>
|
|
</div>
|
|
<div id="sync-now-result" class="hidden px-5 pb-5">
|
|
<div id="sync-now-inner"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Historial de sync automático -->
|
|
<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 sync automático
|
|
</h3>
|
|
<button onclick="cargarHistorialScheduler()"
|
|
class="text-sm text-blue-600 hover:underline flex items-center gap-1">
|
|
<i class="fas fa-refresh text-xs"></i> Actualizar
|
|
</button>
|
|
</div>
|
|
<div id="scheduler-hist" class="p-4">
|
|
<p class="text-sm text-gray-400 text-center py-4">Cargando…</p>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const _TAB_ACTIVE = 'border-green-500 text-green-700 bg-green-50';
|
|
const _TAB_INACTIVE = 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300';
|
|
|
|
let _schedulerLoaded = false;
|
|
let _histRefreshTimer = null;
|
|
|
|
function switchTab(name) {
|
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
|
|
document.querySelectorAll('.tab-btn').forEach(b => {
|
|
b.className = b.className.replace(_TAB_ACTIVE, '').replace(_TAB_INACTIVE, '').trim();
|
|
b.classList.add(..._TAB_INACTIVE.split(' '));
|
|
});
|
|
|
|
document.getElementById('panel-' + name).classList.remove('hidden');
|
|
const btn = document.getElementById('btn-' + name);
|
|
btn.className = btn.className.replace(_TAB_INACTIVE, '').trim();
|
|
btn.classList.add(..._TAB_ACTIVE.split(' '));
|
|
|
|
if (name === 'pacientes') {
|
|
const iframe = document.getElementById('iframe-pacientes');
|
|
if (!iframe.src) iframe.src = '/pacientes?embed=1';
|
|
}
|
|
if (name === 'scheduler') {
|
|
if (!_schedulerLoaded) {
|
|
_schedulerLoaded = true;
|
|
cargarHistorialScheduler();
|
|
pollSchedulerStatus();
|
|
}
|
|
// Auto-refresh historial cada 60s mientras el tab está activo
|
|
clearInterval(_histRefreshTimer);
|
|
_histRefreshTimer = setInterval(cargarHistorialScheduler, 60000);
|
|
} else {
|
|
clearInterval(_histRefreshTimer);
|
|
}
|
|
|
|
sessionStorage.setItem('erp-tab', name);
|
|
}
|
|
|
|
let _schedPollTimer = null;
|
|
async function pollSchedulerStatus() {
|
|
try {
|
|
const r = await fetch('/api/scheduler/status');
|
|
const d = await r.json();
|
|
const dot = document.getElementById('sched-dot');
|
|
const label = document.getElementById('sched-label');
|
|
const live = document.getElementById('sched-live');
|
|
if (d.running && d.job_exists) {
|
|
dot.className = 'w-2 h-2 rounded-full bg-green-400 animate-pulse';
|
|
label.textContent = 'Activo — cada 20 segundos';
|
|
label.className = 'text-green-600';
|
|
} else {
|
|
dot.className = 'w-2 h-2 rounded-full bg-red-400';
|
|
label.textContent = d.running ? 'Sin tarea registrada' : 'DETENIDO';
|
|
label.className = 'text-red-600';
|
|
}
|
|
const parts = [];
|
|
if (d.next_run) {
|
|
parts.push(`<span><i class="fas fa-clock mr-1 text-blue-400"></i>Próxima ejecución: <strong>${d.next_run}</strong></span>`);
|
|
}
|
|
if (d.ultimo_log) {
|
|
const ul = d.ultimo_log;
|
|
parts.push(`<span><i class="fas fa-history mr-1 text-purple-400"></i>Último sync scheduler: <strong>${ul.hora}</strong> · ${ul.total ?? 0} procesados, ${ul.errores ?? 0} errores</span>`);
|
|
}
|
|
live.innerHTML = parts.join('');
|
|
} catch(_) {}
|
|
_schedPollTimer = setTimeout(pollSchedulerStatus, 15000);
|
|
}
|
|
|
|
const _origenLabel = { manual: 'Manual', tercero: 'Tercero', scheduler: 'Scheduler', automation: 'Automatización' };
|
|
const _modoLabel = { insertar: 'Solo nuevos', upsert: 'Upsert' };
|
|
|
|
async function ejecutarSyncDiag(startFrom = 0) {
|
|
const btn = document.getElementById('btn-sync-diag');
|
|
const resBox = document.getElementById('sync-diag-result');
|
|
const inner = document.getElementById('sync-diag-inner');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i> Migrando…';
|
|
resBox.classList.remove('hidden');
|
|
inner.innerHTML = `<p class="text-sm text-gray-400">Enviando diagnósticos en lotes de 200${startFrom ? ` (desde #${startFrom.toLocaleString()})` : ''}…</p>`;
|
|
try {
|
|
const url = `/envios/erp/sync-diagnosticos${startFrom ? `?start_from=${startFrom}` : ''}`;
|
|
const resp = await fetch(url, { method: 'POST' });
|
|
const d = await resp.json();
|
|
const errHTML = d.errores?.length
|
|
? `<ul class="mt-2 ml-4 list-disc text-xs space-y-0.5">${d.errores.map(e => `<li>${e}</li>`).join('')}</ul>`
|
|
: '';
|
|
if (d.ok) {
|
|
inner.innerHTML = `<div class="text-sm text-green-700 bg-green-50 border border-green-200 rounded-lg px-4 py-3">
|
|
<i class="fas fa-check-circle mr-1"></i>
|
|
<strong>${d.insertados.toLocaleString()}</strong> / <strong>${d.total.toLocaleString()}</strong> diagnósticos migrados.
|
|
</div>`;
|
|
} else {
|
|
const firstErr = d.errores?.[0] || '';
|
|
const resumeMatch = firstErr.match(/Lote (\d+)-/);
|
|
const resumeFrom = resumeMatch ? parseInt(resumeMatch[1]) : null;
|
|
inner.innerHTML = `<div class="text-sm text-orange-700 bg-orange-50 border border-orange-200 rounded-lg px-4 py-3">
|
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
<strong>${d.insertados.toLocaleString()}</strong> / <strong>${d.total.toLocaleString()}</strong> migrados — ${d.errores?.length || 0} lote(s) fallaron.
|
|
${errHTML}
|
|
${resumeFrom != null ? `<button onclick="ejecutarSyncDiag(${resumeFrom})" class="mt-3 flex items-center gap-1 bg-orange-500 hover:bg-orange-600 text-white text-xs font-medium px-3 py-1.5 rounded-lg">
|
|
<i class="fas fa-play text-xs"></i> Reanudar desde #${resumeFrom.toLocaleString()}
|
|
</button>` : ''}
|
|
</div>`;
|
|
}
|
|
} catch(e) {
|
|
inner.innerHTML = `<p class="text-sm text-red-500">Error de red: ${e.message}</p>`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-upload text-xs"></i> Migrar diagnósticos';
|
|
}
|
|
}
|
|
|
|
function toggleDet(id) {
|
|
const row = document.getElementById(id);
|
|
if (row) row.classList.toggle('hidden');
|
|
}
|
|
|
|
async function ejecutarSyncNow() {
|
|
const btn = document.getElementById('btn-sync-now');
|
|
const ventana = document.getElementById('sel-ventana').value;
|
|
const resBox = document.getElementById('sync-now-result');
|
|
const inner = document.getElementById('sync-now-inner');
|
|
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs"></i> Sincronizando…';
|
|
resBox.classList.remove('hidden');
|
|
inner.innerHTML = '<p class="text-sm text-gray-400">Ejecutando…</p>';
|
|
|
|
try {
|
|
const resp = await fetch(`/envios/erp/sync-now?ventana_min=${ventana}`, { method: 'POST' });
|
|
const d = await resp.json();
|
|
|
|
if (!d.ok) {
|
|
inner.innerHTML = `
|
|
<div class="flex items-center gap-2 text-red-600 bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-sm">
|
|
<i class="fas fa-exclamation-circle"></i>
|
|
<span>${d.error || 'Error desconocido'}</span>
|
|
</div>`;
|
|
} else if (d.total === 0) {
|
|
inner.innerHTML = `
|
|
<div class="flex items-center gap-2 text-gray-500 bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-sm">
|
|
<i class="fas fa-info-circle text-blue-400"></i>
|
|
<span>${d.mensaje || 'Sin recepciones en la ventana seleccionada.'} (Hoy: <strong>${d.total_hoy}</strong> recepción/es registradas)</span>
|
|
</div>`;
|
|
} else {
|
|
const detalles = (d.detalle || []).map(p => {
|
|
const a = p.action || (p.ok ? 'updated' : 'error');
|
|
const badge = a === 'created' ? '<span class="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">Nuevo</span>'
|
|
: a === 'updated' ? '<span class="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded">Actualizado</span>'
|
|
: a === 'skipped' ? '<span class="text-xs bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded">Omitido</span>'
|
|
: `<span class="text-xs bg-red-100 text-red-600 px-1.5 py-0.5 rounded" title="${p.message || ''}">${a}</span>`;
|
|
return `<div class="flex items-center justify-between py-1 border-b border-gray-50 text-xs">
|
|
<span class="text-gray-700">${p.nombre || p.doc || '—'}</span>
|
|
${badge}
|
|
</div>`;
|
|
}).join('');
|
|
inner.innerHTML = `
|
|
<div class="bg-green-50 border border-green-200 rounded-lg px-4 py-3 mb-3">
|
|
<div class="flex flex-wrap gap-4 text-sm">
|
|
<span class="text-green-700 font-semibold"><i class="fas fa-check-circle mr-1"></i>${d.total} procesados</span>
|
|
<span class="text-blue-600">${d.created} nuevos</span>
|
|
<span class="text-green-600">${d.updated} actualizados</span>
|
|
<span class="text-gray-400">${d.skipped} omitidos</span>
|
|
${d.errores > 0 ? `<span class="text-red-500 font-medium">${d.errores} errores</span>` : ''}
|
|
<span class="text-gray-400 ml-auto text-xs">Hoy: ${d.total_hoy} recepciones</span>
|
|
</div>
|
|
</div>
|
|
${detalles ? `<div class="max-h-48 overflow-y-auto pr-1">${detalles}</div>` : ''}`;
|
|
}
|
|
} catch (e) {
|
|
inner.innerHTML = `<p class="text-sm text-red-500">Error de red: ${e.message}</p>`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-bolt text-xs"></i> Ejecutar ahora';
|
|
}
|
|
}
|
|
|
|
async function cargarHistorialScheduler() {
|
|
const el = document.getElementById('scheduler-hist');
|
|
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();
|
|
const sched = rows.filter(r => r.origen === 'scheduler');
|
|
if (!sched.length) {
|
|
el.innerHTML = '<p class="text-sm text-gray-400 text-center py-6">Sin ejecuciones automáticas registradas 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-3 font-medium">Usuario</th>
|
|
<th class="pb-2 pr-3 font-medium text-right">Procesados</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-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 text-right">Detalle</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-50">
|
|
${sched.map((r, i) => {
|
|
const dt = new Date(r.created_at.replace(' ', 'T') + 'Z');
|
|
const fecha = dt.toLocaleString('es-CO', {
|
|
timeZone: 'America/Bogota',
|
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
hour: '2-digit', minute: '2-digit', hour12: false
|
|
});
|
|
const errDet = r.errores_det ? JSON.parse(r.errores_det) : [];
|
|
const pacientes = r.detalle_json ? JSON.parse(r.detalle_json) : [];
|
|
const rowId = 'hist-det-' + i;
|
|
|
|
const detHtml = pacientes.map((p, pi) => {
|
|
const badge = p.action === 'created' ? '<span class="bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">Nuevo</span>'
|
|
: p.action === 'updated' ? '<span class="bg-green-100 text-green-700 px-1.5 py-0.5 rounded">Actualizado</span>'
|
|
: p.action === 'skipped' ? '<span class="bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded">Omitido</span>'
|
|
: `<span class="bg-red-100 text-red-600 px-1.5 py-0.5 rounded">${p.action}</span>`;
|
|
|
|
const diag = p.diagnostico_cod
|
|
? `<span class="text-orange-600">${p.diagnostico_cod}</span>${p.diagnostico_nombre ? ' · ' + p.diagnostico_nombre : ''}`
|
|
: '<span class="text-gray-300">Sin diagnóstico</span>';
|
|
|
|
const empresa = (p.nom_empresa || p.nit_empresa)
|
|
? `<span class="text-teal-600">${p.nom_empresa || p.nit_empresa}</span>`
|
|
: '<span class="text-gray-300">Sin empresa</span>';
|
|
|
|
const valor = p.valor_total > 0
|
|
? `<span class="text-yellow-700">$${Number(p.valor_total).toLocaleString('es-CO')}</span>`
|
|
: '';
|
|
|
|
const exams = (p.examenes_det || []).map(e =>
|
|
`<tr class="border-b border-gray-50">
|
|
<td class="py-0.5 pr-3 font-mono text-purple-600">${e.cups || '—'}</td>
|
|
<td class="py-0.5 pr-3 text-gray-600">${e.nombre || '—'}</td>
|
|
<td class="py-0.5 text-right text-gray-500">${e.precio != null ? '$' + Number(e.precio).toLocaleString('es-CO') : '—'}</td>
|
|
</tr>`
|
|
).join('');
|
|
|
|
return `<div class="mb-3 border border-gray-100 rounded-lg overflow-hidden">
|
|
<div class="flex items-center gap-3 px-3 py-2 bg-gray-50 border-b border-gray-100">
|
|
<span class="text-gray-400 font-mono text-xs">${p.doc}</span>
|
|
<span class="font-medium text-gray-700 flex-1">${p.nombre}</span>
|
|
${badge}
|
|
</div>
|
|
<div class="px-3 py-1.5 flex flex-wrap gap-x-4 gap-y-0.5 text-xs border-b border-gray-50">
|
|
<span>Dx: ${diag}</span>
|
|
<span>EPS: ${empresa}</span>
|
|
${valor ? `<span>Total: ${valor}</span>` : ''}
|
|
</div>
|
|
${exams ? `<table class="w-full text-xs px-3">
|
|
<thead><tr class="text-gray-400 border-b border-gray-100">
|
|
<th class="py-1 pr-3 text-left font-medium px-3">CUPS</th>
|
|
<th class="py-1 pr-3 text-left font-medium">Examen</th>
|
|
<th class="py-1 text-right font-medium pr-3">Precio</th>
|
|
</tr></thead>
|
|
<tbody class="px-3">${exams}</tbody>
|
|
</table>` : '<p class="text-xs text-gray-300 px-3 py-1">Sin exámenes registrados</p>'}
|
|
</div>`;
|
|
}).join('');
|
|
|
|
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-3 text-xs text-gray-400">${r.username || '—'}</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-green-600">${r.updated}</td>
|
|
<td class="py-2 pr-3 text-right">
|
|
${r.errores > 0
|
|
? `<button onclick="toggleDet('${rowId}')" class="text-red-500 font-medium hover:underline">${r.errores}</button>`
|
|
: '<span class="text-gray-300">—</span>'}
|
|
</td>
|
|
<td class="py-2 text-right">
|
|
${pacientes.length ? `
|
|
<button onclick="toggleDet('${rowId}')"
|
|
class="text-xs text-blue-500 hover:underline">Ver ${pacientes.length}</button>` : '—'}
|
|
</td>
|
|
</tr>
|
|
${(pacientes.length || errDet.length) ? `
|
|
<tr id="${rowId}" class="hidden">
|
|
<td colspan="7" class="px-3 pb-4 pt-2">
|
|
${errDet.length ? `
|
|
<div class="mb-3 bg-red-50 border border-red-200 rounded-lg px-3 py-2 text-xs text-red-700">
|
|
<p class="font-medium mb-1"><i class="fas fa-exclamation-circle mr-1"></i>${errDet.length} error(es):</p>
|
|
<ul class="list-disc ml-4 space-y-0.5">${errDet.map(e =>
|
|
typeof e === 'object'
|
|
? `<li><span class="font-mono text-red-400">${e.doc || ''}</span> ${e.nombre || ''} — ${e.msg || JSON.stringify(e)}</li>`
|
|
: `<li>${e}</li>`
|
|
).join('')}</ul>
|
|
</div>` : ''}
|
|
<div class="max-h-96 overflow-y-auto space-y-2 text-xs">
|
|
${detHtml}
|
|
</div>
|
|
</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>`;
|
|
}
|
|
}
|
|
|
|
(function init() {
|
|
document.querySelectorAll('.tab-btn').forEach(b => {
|
|
b.classList.add(..._TAB_INACTIVE.split(' '), 'border-b-2');
|
|
});
|
|
const saved = sessionStorage.getItem('erp-tab') || 'pacientes';
|
|
switchTab(saved);
|
|
})();
|
|
</script>
|
|
{% endblock %}
|