Actualizar panel.html

This commit is contained in:
2026-04-04 17:21:48 +00:00
parent 2ae80234d0
commit 895e936040

View File

@@ -179,7 +179,7 @@
<h3 class="font-black text-sm text-slate-800 uppercase tracking-tight">Salud Global</h3> <h3 class="font-black text-sm text-slate-800 uppercase tracking-tight">Salud Global</h3>
</div> </div>
<div class="flex flex-col gap-3" id="healthSummary"> <div class="flex flex-col gap-3" id="healthSummary">
<p class="text-slate-400 text-xs font-medium animate-pulse">Analizando...</p> <p class="text-slate-400 text-xs font-medium animate-pulse">Analizando expedientes...</p>
</div> </div>
</div> </div>
@@ -191,6 +191,8 @@
</div> </div>
</div> </div>
<script src="js/layout.js"></script>
<script> <script>
const API_URL = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' const API_URL = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
? 'http://localhost:3000' ? 'http://localhost:3000'
@@ -198,23 +200,28 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if(!token) { if(!token) window.location.href = "index.html";
// Para pruebas sin login, comentar la siguiente linea:
window.location.href = "index.html";
}
// Pintar la fecha actual
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }; const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
document.getElementById('todayDate').innerText = new Date().toLocaleDateString('es-ES', options); document.getElementById('todayDate').innerText = new Date().toLocaleDateString('es-ES', options);
lucide.createIcons(); lucide.createIcons();
// Cargar datos
loadDashboardData(token); loadDashboardData(token);
// Refrescar automáticamente cada 5 minutos
setInterval(() => loadDashboardData(token), 300000);
}); });
async function loadDashboardData(token) { async function loadDashboardData(token) {
try { try {
// 1. Pedimos los expedientes vírgenes (Bandeja / Bolsa)
const resScraped = await fetch(`${API_URL}/providers/scraped`, { headers: { "Authorization": `Bearer ${token}` } }); const resScraped = await fetch(`${API_URL}/providers/scraped`, { headers: { "Authorization": `Bearer ${token}` } });
const dataScraped = await resScraped.json(); const dataScraped = await resScraped.json();
// 2. Pedimos los expedientes en curso (Panel Operativo)
const resActive = await fetch(`${API_URL}/services/active`, { headers: { "Authorization": `Bearer ${token}` } }); const resActive = await fetch(`${API_URL}/services/active`, { headers: { "Authorization": `Bearer ${token}` } });
const dataActive = await resActive.json(); const dataActive = await resActive.json();
@@ -223,56 +230,56 @@
} }
} catch (e) { } catch (e) {
console.error("Error cargando dashboard:", e); console.error("Error cargando dashboard:", e);
document.getElementById('todaySchedule').innerHTML = '<p class="text-red-500 text-sm font-bold">Error de conexión al cargar la agenda.</p>'; document.getElementById('todaySchedule').innerHTML = '<p class="text-red-500 text-sm font-bold">Error de conexión al cargar los datos.</p>';
} }
} }
// --- SISTEMA DE SALUD DEL SERVICIO --- // --- SISTEMA DE SALUD DEL SERVICIO ---
// Evalúa cada expediente y devuelve si está verde, ámbar o rojo
function calculateHealth(s) { function calculateHealth(s) {
const raw = s.raw_data || {}; const raw = s.raw_data || {};
const status = raw.status_operativo || ''; const status = raw.status_operativo || '';
const desc = (s.description || '').toLowerCase(); const desc = (s.description || '').toLowerCase();
let score = 'green';
let reason = 'Todo en orden';
// 1. ROJO: Problemas graves (Incidencia abierta, Reclamación, cita vencida grave) // 1. ROJO: Problemas graves (Incidencia abierta, Reclamación, quejas)
if (status === 'incidencia' || desc.includes('reclamacion') || desc.includes('enfadado') || desc.includes('queja')) { if (status === 'incidencia' || desc.includes('reclamacion') || desc.includes('enfadado') || desc.includes('queja') || desc.includes('urgente')) {
return { color: 'red', hex: 'bg-red-500', shadow: 'shadow-red-500/40', text: 'text-red-600', reason: 'Incidencia o queja activa' }; return { color: 'red', hex: 'bg-red-500', shadow: 'shadow-red-500/40', text: 'text-red-600', pulse: true, reason: 'Incidencia o queja activa' };
} }
// 2. AMBAR: Riesgos (Lleva días sin tocarse, o cita para hoy y es tarde) // 2. ÁMBAR: Riesgos (Lleva días sin tocarse)
if (s.created_at) { if (s.created_at) {
const daysIdle = (new Date() - new Date(s.created_at)) / (1000 * 3600 * 24); const daysIdle = (new Date() - new Date(s.created_at)) / (1000 * 3600 * 24);
if (daysIdle > 5 && !['finalizado', 'terminado', 'anulado'].includes(status)) { if (daysIdle > 5 && !['finalizado', 'terminado', 'anulado'].includes(status)) {
return { color: 'amber', hex: 'bg-amber-500', shadow: 'shadow-amber-500/40', text: 'text-amber-600', reason: `Inactivo > 5 días` }; return { color: 'amber', hex: 'bg-amber-500', shadow: 'shadow-amber-500/40', text: 'text-amber-600', pulse: false, reason: `Inactivo > 5 días` };
} }
} }
// 2.5 ÁMBAR: Cita para hoy y el técnico va con retraso
if (raw.scheduled_date === new Date().toISOString().split('T')[0] && raw.scheduled_time) { if (raw.scheduled_date === new Date().toISOString().split('T')[0] && raw.scheduled_time) {
const [h, m] = raw.scheduled_time.split(':'); const [h, m] = raw.scheduled_time.split(':');
const sched = new Date(); const sched = new Date();
sched.setHours(h, m, 0); sched.setHours(h, m, 0);
// Si ha pasado 1h de la cita y no está terminado // Si ha pasado 1 hora de la cita y no está terminado
if (new Date() > new Date(sched.getTime() + 60*60*1000) && status !== 'terminado') { if (new Date() > new Date(sched.getTime() + 60*60*1000) && status !== 'terminado') {
return { color: 'amber', hex: 'bg-amber-500', shadow: 'shadow-amber-500/40', text: 'text-amber-600', reason: 'Retraso en cita hoy' }; return { color: 'amber', hex: 'bg-amber-500', shadow: 'shadow-amber-500/40', text: 'text-amber-600', pulse: false, reason: 'Retraso en cita hoy' };
} }
} }
// 3. VERDE: Normal // 3. VERDE: Normal
return { color: 'green', hex: 'bg-emerald-500', shadow: 'shadow-emerald-500/40', text: 'text-emerald-600', reason: 'En tiempo' }; return { color: 'green', hex: 'bg-emerald-500', shadow: 'shadow-emerald-500/40', text: 'text-emerald-600', pulse: false, reason: 'En tiempo y forma' };
} }
// --- SUPERVISOR IA --- // --- SUPERVISOR IA ---
function runAISupervisor(allServices, todayVisits, urgentVisits) { function runAISupervisor(allServices, todayVisits, urgentVisits) {
const container = document.getElementById('aiAlertsContainer'); const container = document.getElementById('aiAlertsContainer');
let alerts = []; let alerts = [];
const now = new Date();
// 1. Solapes de operarios hoy // 1. Detección de Solapes de operarios hoy
const operariosHoy = {}; const operariosHoy = {};
todayVisits.forEach(v => { todayVisits.forEach(v => {
const op = v.assigned_name || 'Sin asignar'; const op = v.assigned_name || 'Sin asignar';
if(op === 'Sin asignar' || !v.raw_data.scheduled_time) return; if(op === 'Sin asignar' || !v.raw_data.scheduled_time) return;
if(!operariosHoy[op]) operariosHoy[op] = []; if(!operariosHoy[op]) operariosHoy[op] = [];
operariosHoy[op].push(v); operariosHoy[op].push(v);
}); });
@@ -280,8 +287,8 @@
let overlaps = 0; let overlaps = 0;
for(const [op, visits] of Object.entries(operariosHoy)) { for(const [op, visits] of Object.entries(operariosHoy)) {
if(visits.length < 2) continue; if(visits.length < 2) continue;
// Simple comprobacion: si tienen la misma hora // Si tienen la misma hora
const times = visits.map(v => v.raw_data.scheduled_time.substring(0,2)); // Solo horas const times = visits.map(v => v.raw_data.scheduled_time.substring(0,2)); // Extrae la hora '10', '11'
const duplicates = times.filter((item, index) => times.indexOf(item) != index); const duplicates = times.filter((item, index) => times.indexOf(item) != index);
if (duplicates.length > 0) overlaps++; if (duplicates.length > 0) overlaps++;
} }
@@ -292,14 +299,14 @@
}); });
} }
// 2. Retrasos probables // 2. Detección de Retrasos probables
let retrasos = 0; let retrasos = 0;
const now = new Date();
todayVisits.forEach(v => { todayVisits.forEach(v => {
if(v.raw_data.scheduled_time && v.raw_data.status_operativo !== 'terminado') { if(v.raw_data.scheduled_time && v.raw_data.status_operativo !== 'terminado') {
const [h, m] = v.raw_data.scheduled_time.split(':'); const [h, m] = v.raw_data.scheduled_time.split(':');
const sched = new Date(); sched.setHours(h, m, 0); const sched = new Date(); sched.setHours(h, m, 0);
if (now > new Date(sched.getTime() + 30*60000)) retrasos++; // Más de 30 min tarde // Si ha pasado 30 min y no han cerrado el aviso
if (now > new Date(sched.getTime() + 30*60000)) retrasos++;
} }
}); });
if (retrasos > 0) { if (retrasos > 0) {
@@ -309,7 +316,16 @@
}); });
} }
// 3. Optimización de urgencias (Misma población) // 3. Expedientes paralizados (Riesgo Incidencia)
let paralizados = allServices.filter(s => calculateHealth(s).reason.includes('> 5 días')).length;
if(paralizados > 0) {
alerts.push({
icon: 'alert-circle', color: 'text-orange-400', bg: 'bg-orange-400/10', border: 'border-orange-400/20',
title: 'Riesgo de queja', desc: `${paralizados} expediente(s) llevan más de 5 días sin movimientos.`
});
}
// 4. Optimización de urgencias (Misma población)
let matchUrgencias = false; let matchUrgencias = false;
let matchText = ""; let matchText = "";
const popsHoy = [...new Set(todayVisits.map(v => v.raw_data['Población']))].filter(Boolean); const popsHoy = [...new Set(todayVisits.map(v => v.raw_data['Población']))].filter(Boolean);
@@ -317,7 +333,7 @@
for(let u of urgentVisits) { for(let u of urgentVisits) {
if (!u.assigned_name && popsHoy.includes(u.raw_data['Población'])) { if (!u.assigned_name && popsHoy.includes(u.raw_data['Población'])) {
matchUrgencias = true; matchUrgencias = true;
matchText = `Hay técnicos en ${u.raw_data['Población']} que podrían cubrir una urgencia sin asignar.`; matchText = `Hay técnicos hoy en ${u.raw_data['Población']} que podrían cubrir una urgencia sin asignar.`;
break; break;
} }
} }
@@ -328,26 +344,17 @@
}); });
} }
// 4. Expedientes paralizados (Riesgo Incidencia)
let paralizados = allServices.filter(s => calculateHealth(s).reason.includes('> 5 días')).length;
if(paralizados > 0) {
alerts.push({
icon: 'alert-circle', color: 'text-orange-400', bg: 'bg-orange-400/10', border: 'border-orange-400/20',
title: 'Riesgo de queja', desc: `${paralizados} expediente(s) llevan más de 5 días sin movimientos.`
});
}
// Fallback si la IA no encuentra nada // Fallback si la IA no encuentra nada
if(alerts.length === 0) { if(alerts.length === 0) {
alerts.push({ alerts.push({
icon: 'check-circle-2', color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20', icon: 'check-circle-2', color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20',
title: 'Rutas Optimizadas', desc: 'No detecto anomalías. La planificación de hoy es eficiente.' title: 'Rutas Optimizadas', desc: 'No detecto anomalías. La planificación actual parece eficiente y correcta.'
}); });
} }
// Renderizar Alertas // Renderizar las 3 alertas más importantes
container.innerHTML = alerts.slice(0,3).map(a => ` container.innerHTML = alerts.slice(0,3).map(a => `
<div class="${a.bg} border ${a.border} rounded-2xl p-4 transition-all hover:scale-[1.02]"> <div class="${a.bg} border ${a.border} rounded-2xl p-4 transition-all hover:scale-[1.02] shadow-sm">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<i data-lucide="${a.icon}" class="w-4 h-4 ${a.color}"></i> <i data-lucide="${a.icon}" class="w-4 h-4 ${a.color}"></i>
<h4 class="font-black text-sm text-white">${a.title}</h4> <h4 class="font-black text-sm text-white">${a.title}</h4>
@@ -359,11 +366,12 @@
lucide.createIcons(); lucide.createIcons();
} }
// --- PROCESAMIENTO PRINCIPAL --- // --- PROCESAMIENTO PRINCIPAL DE DATOS ---
function processDashboard(scrapedList, activeList) { function processDashboard(scrapedList, activeList) {
const todayStr = new Date().toISOString().split('T')[0]; const todayStr = new Date().toISOString().split('T')[0];
const allServicesMap = new Map();
// 1. FUSIÓN INTELIGENTE SIN DUPLICADOS
const allServicesMap = new Map();
scrapedList.forEach(s => { if (s.status !== 'archived') allServicesMap.set(s.id, s); }); scrapedList.forEach(s => { if (s.status !== 'archived') allServicesMap.set(s.id, s); });
activeList.forEach(s => { activeList.forEach(s => {
if (s.status !== 'archived') { if (s.status !== 'archived') {
@@ -371,10 +379,9 @@
else allServicesMap.set(s.id, s); else allServicesMap.set(s.id, s);
} }
}); });
const allServices = Array.from(allServicesMap.values()); const allServices = Array.from(allServicesMap.values());
// Cálculos KPIs // 2. CÁLCULO DE KPIs
const totalActive = allServices.length; const totalActive = allServices.length;
const unassignedCount = allServices.filter(s => !s.assigned_name && s.automation_status !== 'in_progress').length; const unassignedCount = allServices.filter(s => !s.assigned_name && s.automation_status !== 'in_progress').length;
const queueCount = allServices.filter(s => !s.assigned_name && s.automation_status === 'in_progress').length; const queueCount = allServices.filter(s => !s.assigned_name && s.automation_status === 'in_progress').length;
@@ -385,7 +392,7 @@
return raw['Urgente'] === 'Sí' || raw['Urgente'] === 'true' || raw['URGENTE'] === 'SI'; return raw['Urgente'] === 'Sí' || raw['Urgente'] === 'true' || raw['URGENTE'] === 'SI';
}); });
// Animaciones // Pintar los números animados
animateValue("kpiTotal", totalActive); animateValue("kpiTotal", totalActive);
animateValue("kpiUnassigned", unassignedCount); animateValue("kpiUnassigned", unassignedCount);
animateValue("kpiQueue", queueCount); animateValue("kpiQueue", queueCount);
@@ -393,7 +400,7 @@
animateValue("kpiToday", todayVisits.length); animateValue("kpiToday", todayVisits.length);
animateValue("kpiUrgent", urgentVisits.length); animateValue("kpiUrgent", urgentVisits.length);
// Resumen de Salud Global // 3. RESUMEN DE SALUD GLOBAL
let healthStats = { green: 0, amber: 0, red: 0 }; let healthStats = { green: 0, amber: 0, red: 0 };
allServices.forEach(s => healthStats[calculateHealth(s).color]++); allServices.forEach(s => healthStats[calculateHealth(s).color]++);
@@ -407,18 +414,19 @@
<span class="font-black text-amber-600">${healthStats.amber}</span> <span class="font-black text-amber-600">${healthStats.amber}</span>
</div> </div>
<div class="flex justify-between items-center bg-slate-50 p-2.5 rounded-xl border border-slate-100"> <div class="flex justify-between items-center bg-slate-50 p-2.5 rounded-xl border border-slate-100">
<span class="flex items-center gap-2 text-xs font-bold text-slate-600"><div class="w-3 h-3 rounded-full bg-red-500 shadow-sm animate-pulse"></div> Problemas</span> <span class="flex items-center gap-2 text-xs font-bold text-slate-600"><div class="w-3 h-3 rounded-full bg-red-500 shadow-sm ${healthStats.red > 0 ? 'animate-pulse' : ''}"></div> Problemas</span>
<span class="font-black text-red-600">${healthStats.red}</span> <span class="font-black text-red-600">${healthStats.red}</span>
</div> </div>
`; `;
// Ejecutar Supervisor y Renders // 4. RENDERIZAR MÓDULOS
runAISupervisor(allServices, todayVisits, urgentVisits); runAISupervisor(allServices, todayVisits, urgentVisits);
renderTodaySchedule(todayVisits); renderTodaySchedule(todayVisits);
renderUrgentSchedule(urgentVisits); renderUrgentSchedule(urgentVisits);
renderCompanyDistribution(allServices); renderCompanyDistribution(allServices);
} }
// WIDGET 1: Agenda Hoy con Semáforos
function renderTodaySchedule(visits) { function renderTodaySchedule(visits) {
const container = document.getElementById("todaySchedule"); const container = document.getElementById("todaySchedule");
if (visits.length === 0) { if (visits.length === 0) {
@@ -439,36 +447,43 @@
const name = raw['Nombre Cliente'] || raw['CLIENTE'] || "Asegurado"; const name = raw['Nombre Cliente'] || raw['CLIENTE'] || "Asegurado";
const pop = raw['Población'] || raw['POBLACION-PROVINCIA'] || "Dirección no especificada"; const pop = raw['Población'] || raw['POBLACION-PROVINCIA'] || "Dirección no especificada";
// Semáforo de salud const hInfo = calculateHealth(s); // Calculamos salud de este expediente
const health = calculateHealth(s);
let statusColor = "bg-blue-50 text-blue-600";
if(raw.status_operativo === 'trabajando') statusColor = "bg-amber-50 text-amber-600";
if(raw.status_operativo === 'incidencia') statusColor = "bg-red-50 text-red-600";
if(raw.status_operativo === 'terminado') statusColor = "bg-emerald-50 text-emerald-600";
return ` return `
<div class="flex items-center justify-between p-4 rounded-2xl bg-white border border-slate-100 shadow-sm hover:shadow-md transition-all group relative overflow-hidden"> <div class="flex items-center justify-between p-4 rounded-2xl bg-white border border-slate-100 shadow-sm hover:shadow-md transition-all group relative overflow-hidden">
<div class="absolute left-0 top-0 bottom-0 w-1.5 ${health.hex}"></div>
<div class="absolute left-0 top-0 bottom-0 w-1.5 ${hInfo.hex}"></div>
<div class="flex items-center gap-4 flex-1 min-w-0 pl-2"> <div class="flex items-center gap-4 flex-1 min-w-0 pl-2">
<div class="w-14 h-14 rounded-[1rem] bg-slate-50 border border-slate-100 flex flex-col items-center justify-center shrink-0"> <div class="w-14 h-14 rounded-xl ${statusColor} flex flex-col items-center justify-center shrink-0 border border-slate-100/50 shadow-inner">
<span class="font-black text-slate-800 text-sm leading-none">${time}</span> <span class="font-black text-slate-800 text-sm leading-none">${time}</span>
<span class="text-[8px] font-bold text-slate-400 mt-1 uppercase">Hoy</span> <span class="text-[8px] font-bold text-slate-400 mt-1 uppercase">Hoy</span>
</div> </div>
<div class="min-w-0 flex-1 text-left"> <div class="min-w-0 flex-1 text-left">
<div class="flex items-center gap-2 mb-0.5"> <div class="flex items-center gap-2 mb-0.5">
<h4 class="font-black text-slate-800 uppercase text-sm truncate">${name}</h4> <h4 class="font-black text-slate-800 uppercase text-sm truncate">${name}</h4>
<span class="w-2 h-2 rounded-full ${health.hex} shadow-lg ${health.shadow} ${health.color==='red'?'animate-pulse':''}" title="${health.reason}"></span> <span class="w-2 h-2 rounded-full ${hInfo.hex} shadow-sm ${hInfo.pulse ? 'animate-pulse' : ''}" title="${hInfo.reason}"></span>
<span class="text-[8px] bg-slate-50 border border-slate-200 text-slate-500 px-2 py-0.5 rounded-md font-black ml-2">#${s.service_ref}</span>
</div> </div>
<p class="text-[10px] font-bold text-slate-400 uppercase truncate mb-1"> <p class="text-[10px] font-bold text-slate-400 uppercase truncate mt-1">
<i data-lucide="map-pin" class="w-3 h-3 inline mr-0.5"></i>${pop} <i data-lucide="map-pin" class="w-3 h-3 inline mr-0.5"></i>${pop}
<span class="mx-1 text-slate-200">|</span> <span class="mx-1 text-slate-200">|</span>
<span class="${health.text}">${health.reason}</span> <span class="${hInfo.text}">${hInfo.reason}</span>
</p> </p>
<span class="text-[9px] bg-slate-100 border border-slate-200 text-slate-500 px-2 py-0.5 rounded-md font-black">#${s.service_ref}</span>
</div> </div>
</div> </div>
<div class="flex flex-col items-end shrink-0 pl-4 ml-4 hidden md:flex">
<div class="flex flex-col items-end shrink-0 pl-4 border-l border-slate-100 ml-4 hidden md:flex">
<span class="text-[9px] font-black uppercase text-slate-400 tracking-widest mb-1">Operario</span> <span class="text-[9px] font-black uppercase text-slate-400 tracking-widest mb-1">Operario</span>
<div class="flex items-center gap-1.5 bg-blue-50/50 px-3 py-1.5 rounded-lg border border-blue-100"> <div class="flex items-center gap-1.5 bg-slate-50 px-3 py-1.5 rounded-lg border border-slate-100">
<i data-lucide="hard-hat" class="w-3.5 h-3.5 text-blue-500"></i> <i data-lucide="hard-hat" class="w-3.5 h-3.5 text-slate-500"></i>
<span class="text-[10px] font-black text-blue-700 uppercase">${op}</span> <span class="text-[10px] font-black text-slate-700 uppercase">${op}</span>
</div> </div>
</div> </div>
</div>`; </div>`;
@@ -476,6 +491,7 @@
lucide.createIcons(); lucide.createIcons();
} }
// WIDGET 2: Urgencias
function renderUrgentSchedule(visits) { function renderUrgentSchedule(visits) {
const container = document.getElementById("urgentSchedule"); const container = document.getElementById("urgentSchedule");
if (visits.length === 0) { if (visits.length === 0) {
@@ -491,30 +507,19 @@
const raw = s.raw_data; const raw = s.raw_data;
const name = raw['Nombre Cliente'] || raw['CLIENTE'] || "Asegurado"; const name = raw['Nombre Cliente'] || raw['CLIENTE'] || "Asegurado";
const op = s.assigned_name || raw.assigned_to_name || "Buscando Operario..."; const op = s.assigned_name || raw.assigned_to_name || "Buscando Operario...";
const pop = raw['Población'] || raw['POBLACION-PROVINCIA'] || "";
const health = calculateHealth(s);
return ` return `
<div class="p-4 rounded-2xl bg-white border border-red-100 shadow-sm flex flex-col md:flex-row md:justify-between md:items-center gap-3 relative overflow-hidden"> <div class="p-4 rounded-2xl bg-red-50/50 border border-red-100 flex justify-between items-center">
<div class="absolute left-0 top-0 bottom-0 w-1.5 bg-red-500"></div> <div>
<div class="pl-2"> <h4 class="font-black text-slate-800 uppercase text-xs truncate">${name}</h4>
<div class="flex items-center gap-2"> <p class="text-[10px] font-bold text-red-500 uppercase mt-1"><i data-lucide="info" class="w-3 h-3 inline mr-1"></i>REF: #${s.service_ref}</p>
<h4 class="font-black text-slate-800 uppercase text-xs truncate">${name}</h4>
${health.color === 'red' ? '<i data-lucide="alert-triangle" class="w-3 h-3 text-red-500 animate-pulse"></i>' : ''}
</div>
<p class="text-[10px] font-bold text-slate-500 uppercase mt-1">
<i data-lucide="map-pin" class="w-3 h-3 inline mr-1 text-red-400"></i>${pop}
</p>
</div>
<div class="flex items-center justify-between md:justify-end gap-3 pl-2 md:pl-0">
<span class="text-[10px] font-black text-slate-400">#${s.service_ref}</span>
<span class="text-[9px] font-black uppercase bg-red-50 text-red-600 px-2 py-1 rounded-md border border-red-100">${op}</span>
</div> </div>
<span class="text-[9px] font-black uppercase bg-white text-slate-600 px-2 py-1 rounded-md border border-slate-200 shadow-sm">${op}</span>
</div>`; </div>`;
}).join(''); }).join('');
lucide.createIcons(); lucide.createIcons();
} }
// WIDGET 3: Distribución Compañías
function renderCompanyDistribution(allServices) { function renderCompanyDistribution(allServices) {
const container = document.getElementById("companyDistribution"); const container = document.getElementById("companyDistribution");
if (allServices.length === 0) { container.innerHTML = "<p class='text-xs text-slate-400'>Sin datos</p>"; return; } if (allServices.length === 0) { container.innerHTML = "<p class='text-xs text-slate-400'>Sin datos</p>"; return; }
@@ -533,18 +538,19 @@
container.innerHTML = sorted.map(([comp, count]) => { container.innerHTML = sorted.map(([comp, count]) => {
const percent = Math.round((count / max) * 100); const percent = Math.round((count / max) * 100);
return ` return `
<div class="space-y-1.5"> <div class="space-y-1">
<div class="flex justify-between text-[10px] font-black uppercase tracking-wide"> <div class="flex justify-between text-xs font-black uppercase">
<span class="text-slate-600 truncate pr-2">${comp}</span> <span class="text-slate-600">${comp}</span>
<span class="text-indigo-600 shrink-0">${count} exp.</span> <span class="text-indigo-600">${count} expedientes</span>
</div> </div>
<div class="w-full bg-slate-100 rounded-full h-1.5"> <div class="w-full bg-slate-100 rounded-full h-2">
<div class="bg-indigo-500 h-1.5 rounded-full" style="width: ${percent}%"></div> <div class="bg-indigo-500 h-2 rounded-full" style="width: ${percent}%"></div>
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');
} }
// Función Auxiliar: Animación de números
function animateValue(id, end) { function animateValue(id, end) {
const obj = document.getElementById(id); const obj = document.getElementById(id);
if(!obj) return; if(!obj) return;