575 lines
34 KiB
HTML
575 lines
34 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Panel Inteligente - IntegraRepara</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://unpkg.com/lucide@latest"></script>
|
|
<style>
|
|
:root { --app-bg: #f8fafc; }
|
|
body { background-color: var(--app-bg); -webkit-tap-highlight-color: transparent; }
|
|
|
|
/* Animaciones fluidas */
|
|
.fade-in { animation: fadeIn 0.5s ease-in-out forwards; }
|
|
.fade-in-delay-1 { animation-delay: 0.1s; opacity: 0; }
|
|
.fade-in-delay-2 { animation-delay: 0.2s; opacity: 0; }
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
|
|
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
|
|
|
.stat-card { transition: all 0.2s; border: 1px solid #f1f5f9; }
|
|
.stat-card:hover { transform: translateY(-3px); box-shadow: 0 10px 25px -5px rgba(0,0,0,0.05); }
|
|
|
|
/* Efecto latido para el semáforo rojo/ia */
|
|
.animate-pulse-fast { animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
|
|
</style>
|
|
</head>
|
|
<body class="bg-slate-50 text-slate-800 font-sans antialiased text-left">
|
|
|
|
<div class="flex h-screen overflow-hidden">
|
|
|
|
<div id="sidebar-container" class="h-full shrink-0"></div>
|
|
|
|
<div class="flex-1 flex flex-col overflow-hidden relative">
|
|
|
|
<div id="header-container"></div>
|
|
|
|
<main class="flex-1 overflow-x-hidden overflow-y-auto bg-slate-50 p-8">
|
|
<div class="fade-in max-w-[1600px] mx-auto space-y-8 text-left">
|
|
|
|
<div class="flex justify-between items-end border-b border-slate-200 pb-6">
|
|
<div>
|
|
<h2 class="text-3xl font-black text-slate-800 tracking-tight flex items-center gap-3">
|
|
<span class="bg-blue-600 p-2.5 rounded-2xl text-white shadow-lg shadow-blue-200"><i data-lucide="cpu" class="w-6 h-6"></i></span>
|
|
DASHBOARD INTELIGENTE
|
|
</h2>
|
|
<p class="text-sm text-slate-500 mt-2 font-medium">Control de salud de expedientes y Supervisor IA en tiempo real.</p>
|
|
</div>
|
|
<div class="text-right hidden md:block">
|
|
<p class="text-xs font-bold text-blue-600 uppercase tracking-widest bg-blue-50 px-4 py-2 rounded-xl border border-blue-100" id="todayDate">Cargando fecha...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-slate-900 rounded-[2.5rem] shadow-xl p-8 border border-slate-800 relative overflow-hidden fade-in-delay-1">
|
|
<div class="absolute top-0 right-0 w-64 h-64 bg-blue-500 rounded-full mix-blend-multiply filter blur-[80px] opacity-20 animate-pulse-fast"></div>
|
|
<div class="absolute bottom-0 left-0 w-64 h-64 bg-purple-500 rounded-full mix-blend-multiply filter blur-[80px] opacity-20"></div>
|
|
|
|
<div class="relative z-10 flex flex-col md:flex-row gap-8 items-start">
|
|
<div class="shrink-0 md:w-64">
|
|
<div class="flex items-center gap-3 mb-2">
|
|
<div class="bg-white/10 p-2 rounded-xl text-blue-400 backdrop-blur-sm border border-white/10"><i data-lucide="bot" class="w-6 h-6"></i></div>
|
|
<h3 class="font-black text-xl text-white tracking-tight">Supervisor IA</h3>
|
|
</div>
|
|
<p class="text-slate-400 text-xs font-medium leading-relaxed">Analizando rutas, tiempos, solapes y salud de los expedientes en tiempo real...</p>
|
|
</div>
|
|
|
|
<div class="flex-1 w-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="aiAlertsContainer">
|
|
<div class="bg-white/5 border border-white/10 rounded-2xl p-4 animate-pulse">
|
|
<div class="h-4 bg-white/20 rounded w-1/2 mb-3"></div>
|
|
<div class="h-3 bg-white/10 rounded w-full mb-2"></div>
|
|
<div class="h-3 bg-white/10 rounded w-3/4"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-5 text-left fade-in-delay-2">
|
|
|
|
<div class="stat-card bg-white p-5 rounded-[2rem] shadow-sm flex flex-col justify-between">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">Activos Totales</p>
|
|
<div class="bg-blue-50 text-blue-600 p-2 rounded-xl"><i data-lucide="folder-open" class="w-4 h-4"></i></div>
|
|
</div>
|
|
<h3 class="text-3xl font-black text-slate-800" id="kpiTotal">-</h3>
|
|
<p class="text-[10px] text-slate-500 font-bold uppercase mt-2">Expedientes en marcha</p>
|
|
</div>
|
|
|
|
<div class="stat-card bg-white p-5 rounded-[2rem] shadow-sm flex flex-col justify-between border-b-4 border-b-rose-400">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<p class="text-[10px] font-black text-rose-500 uppercase tracking-widest">Sin Asignar</p>
|
|
<div class="bg-rose-50 text-rose-600 p-2 rounded-xl"><i data-lucide="inbox" class="w-4 h-4"></i></div>
|
|
</div>
|
|
<h3 class="text-3xl font-black text-slate-800" id="kpiUnassigned">-</h3>
|
|
<p class="text-[10px] text-slate-500 font-bold uppercase mt-2">Muertos en bandeja</p>
|
|
</div>
|
|
|
|
<div class="stat-card bg-white p-5 rounded-[2rem] shadow-sm flex flex-col justify-between">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<p class="text-[10px] font-black text-amber-500 uppercase tracking-widest">En Rueda</p>
|
|
<div class="bg-amber-50 text-amber-600 p-2 rounded-xl"><i data-lucide="zap" class="w-4 h-4"></i></div>
|
|
</div>
|
|
<h3 class="text-3xl font-black text-slate-800" id="kpiQueue">-</h3>
|
|
<p class="text-[10px] text-slate-500 font-bold uppercase mt-2">Buscando Operario (WA)</p>
|
|
</div>
|
|
|
|
<div class="stat-card bg-white p-5 rounded-[2rem] shadow-sm flex flex-col justify-between">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">Sin Agendar</p>
|
|
<div class="bg-slate-100 text-slate-500 p-2 rounded-xl"><i data-lucide="clock" class="w-4 h-4"></i></div>
|
|
</div>
|
|
<h3 class="text-3xl font-black text-slate-800" id="kpiPending">-</h3>
|
|
<p class="text-[10px] text-slate-500 font-bold uppercase mt-2">Asignados sin cita</p>
|
|
</div>
|
|
|
|
<div class="stat-card bg-white p-5 rounded-[2rem] shadow-sm flex flex-col justify-between border-l-4 border-l-blue-500">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<p class="text-[10px] font-black text-blue-600 uppercase tracking-widest">Citas Hoy</p>
|
|
<div class="bg-blue-600 text-white p-2 rounded-xl shadow-md shadow-blue-200"><i data-lucide="calendar-check" class="w-4 h-4"></i></div>
|
|
</div>
|
|
<h3 class="text-3xl font-black text-slate-800" id="kpiToday">-</h3>
|
|
<p class="text-[10px] text-slate-500 font-bold uppercase mt-2">Programadas para hoy</p>
|
|
</div>
|
|
|
|
<div class="stat-card bg-red-50 p-5 rounded-[2rem] shadow-sm flex flex-col justify-between border border-red-100">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<p class="text-[10px] font-black text-red-600 uppercase tracking-widest">Urgencias</p>
|
|
<div class="bg-red-600 text-white p-2 rounded-xl shadow-md shadow-red-200 animate-pulse"><i data-lucide="siren" class="w-4 h-4"></i></div>
|
|
</div>
|
|
<h3 class="text-3xl font-black text-red-600" id="kpiUrgent">-</h3>
|
|
<p class="text-[10px] text-red-500 font-bold uppercase mt-2">Atención prioritaria</p>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 xl:grid-cols-3 gap-8 pb-10">
|
|
|
|
<div class="xl:col-span-2 space-y-8">
|
|
|
|
<div class="bg-white rounded-[2.5rem] shadow-sm border border-slate-100 p-8 text-left relative">
|
|
<div class="flex items-center gap-3 mb-6 pb-4 border-b border-slate-50">
|
|
<div class="bg-blue-50 p-2 rounded-xl text-blue-600"><i data-lucide="calendar" class="w-5 h-5"></i></div>
|
|
<h3 class="font-black text-lg text-slate-800 uppercase tracking-tight">Agenda de Visitas para Hoy</h3>
|
|
</div>
|
|
<div id="todaySchedule" class="space-y-3">
|
|
<p class="text-slate-400 text-sm font-medium animate-pulse">Cargando agenda...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white rounded-[2.5rem] shadow-sm border border-red-100 p-8 text-left relative overflow-hidden">
|
|
<div class="absolute top-0 right-0 p-8 opacity-5 pointer-events-none">
|
|
<i data-lucide="siren" class="w-40 h-40 text-red-600"></i>
|
|
</div>
|
|
<div class="flex items-center gap-3 mb-6 pb-4 border-b border-slate-50 relative z-10">
|
|
<div class="bg-red-50 p-2 rounded-xl text-red-600"><i data-lucide="alert-triangle" class="w-5 h-5"></i></div>
|
|
<h3 class="font-black text-lg text-slate-800 uppercase tracking-tight">Avisos Urgentes Activos</h3>
|
|
</div>
|
|
<div id="urgentSchedule" class="space-y-3 relative z-10">
|
|
<p class="text-slate-400 text-sm font-medium animate-pulse">Buscando urgencias...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-8">
|
|
|
|
<div class="bg-white rounded-[2.5rem] shadow-sm border border-slate-100 p-6 text-left">
|
|
<div class="flex items-center gap-3 mb-6 pb-4 border-b border-slate-50">
|
|
<div class="bg-indigo-50 p-2 rounded-xl text-indigo-600"><i data-lucide="pie-chart" class="w-5 h-5"></i></div>
|
|
<h3 class="font-black text-sm text-slate-800 uppercase tracking-tight">Volumen por Aseguradora</h3>
|
|
</div>
|
|
<div id="companyDistribution" class="space-y-4">
|
|
<p class="text-slate-400 text-xs font-medium animate-pulse">Calculando...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white rounded-[2.5rem] shadow-sm border border-slate-100 p-6 text-left">
|
|
<div class="flex items-center gap-3 mb-6 pb-4 border-b border-slate-50">
|
|
<div class="bg-emerald-50 p-2 rounded-xl text-emerald-600"><i data-lucide="heart-pulse" class="w-5 h-5"></i></div>
|
|
<h3 class="font-black text-sm text-slate-800 uppercase tracking-tight">Salud Global</h3>
|
|
</div>
|
|
<div class="flex flex-col gap-3" id="healthSummary">
|
|
<p class="text-slate-400 text-xs font-medium animate-pulse">Analizando expedientes...</p>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="js/layout.js"></script>
|
|
|
|
<script>
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const token = localStorage.getItem("token");
|
|
if(!token) window.location.href = "index.html";
|
|
|
|
// Pintar la fecha actual
|
|
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
|
document.getElementById('todayDate').innerText = new Date().toLocaleDateString('es-ES', options);
|
|
|
|
lucide.createIcons();
|
|
|
|
// Cargar datos
|
|
loadDashboardData(token);
|
|
|
|
// Refrescar automáticamente cada 5 minutos
|
|
setInterval(() => loadDashboardData(token), 300000);
|
|
});
|
|
|
|
async function loadDashboardData(token) {
|
|
try {
|
|
// 1. Pedimos los expedientes vírgenes (Bandeja / Bolsa)
|
|
const resScraped = await fetch(`${API_URL}/providers/scraped`, { headers: { "Authorization": `Bearer ${token}` } });
|
|
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 dataActive = await resActive.json();
|
|
|
|
if (dataScraped.ok && dataActive.ok) {
|
|
processDashboard(dataScraped.services, dataActive.services);
|
|
}
|
|
} catch (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 los datos.</p>';
|
|
}
|
|
}
|
|
|
|
// --- SISTEMA DE SALUD DEL SERVICIO ---
|
|
// Evalúa cada expediente y devuelve si está verde, ámbar o rojo
|
|
function calculateHealth(s) {
|
|
const raw = s.raw_data || {};
|
|
const status = raw.status_operativo || '';
|
|
const desc = (s.description || '').toLowerCase();
|
|
|
|
// 1. ROJO: Problemas graves (Incidencia abierta, Reclamación, quejas)
|
|
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', pulse: true, reason: 'Incidencia o queja activa' };
|
|
}
|
|
|
|
// 2. ÁMBAR: Riesgos (Lleva días sin tocarse)
|
|
if (s.created_at) {
|
|
const daysIdle = (new Date() - new Date(s.created_at)) / (1000 * 3600 * 24);
|
|
if (daysIdle > 5 && !['finalizado', 'terminado', 'anulado'].includes(status)) {
|
|
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) {
|
|
const [h, m] = raw.scheduled_time.split(':');
|
|
const sched = new Date();
|
|
sched.setHours(h, m, 0);
|
|
// Si ha pasado 1 hora de la cita y no está 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', pulse: false, reason: 'Retraso en cita hoy' };
|
|
}
|
|
}
|
|
|
|
// 3. VERDE: Normal
|
|
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 ---
|
|
function runAISupervisor(allServices, todayVisits, urgentVisits) {
|
|
const container = document.getElementById('aiAlertsContainer');
|
|
let alerts = [];
|
|
const now = new Date();
|
|
|
|
// 1. Detección de Solapes de operarios hoy
|
|
const operariosHoy = {};
|
|
todayVisits.forEach(v => {
|
|
const op = v.assigned_name || 'Sin asignar';
|
|
if(op === 'Sin asignar' || !v.raw_data.scheduled_time) return;
|
|
if(!operariosHoy[op]) operariosHoy[op] = [];
|
|
operariosHoy[op].push(v);
|
|
});
|
|
|
|
let overlaps = 0;
|
|
for(const [op, visits] of Object.entries(operariosHoy)) {
|
|
if(visits.length < 2) continue;
|
|
// Si tienen la misma hora
|
|
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);
|
|
if (duplicates.length > 0) overlaps++;
|
|
}
|
|
if (overlaps > 0) {
|
|
alerts.push({
|
|
icon: 'calendar-off', color: 'text-rose-400', bg: 'bg-rose-400/10', border: 'border-rose-400/20',
|
|
title: 'Riesgo de Solape', desc: `Se han detectado ${overlaps} posibles solapes en la agenda de los operarios hoy.`
|
|
});
|
|
}
|
|
|
|
// 2. Detección de Retrasos probables
|
|
let retrasos = 0;
|
|
todayVisits.forEach(v => {
|
|
if(v.raw_data.scheduled_time && v.raw_data.status_operativo !== 'terminado') {
|
|
const [h, m] = v.raw_data.scheduled_time.split(':');
|
|
const sched = new Date(); sched.setHours(h, m, 0);
|
|
// Si ha pasado 30 min y no han cerrado el aviso
|
|
if (now > new Date(sched.getTime() + 30*60000)) retrasos++;
|
|
}
|
|
});
|
|
if (retrasos > 0) {
|
|
alerts.push({
|
|
icon: 'clock-4', color: 'text-amber-400', bg: 'bg-amber-400/10', border: 'border-amber-400/20',
|
|
title: 'Retrasos en ruta', desc: `Hay ${retrasos} cita(s) con un retraso probable superior a 30 minutos.`
|
|
});
|
|
}
|
|
|
|
// 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 matchText = "";
|
|
const popsHoy = [...new Set(todayVisits.map(v => v.raw_data['Población']))].filter(Boolean);
|
|
|
|
for(let u of urgentVisits) {
|
|
if (!u.assigned_name && popsHoy.includes(u.raw_data['Población'])) {
|
|
matchUrgencias = true;
|
|
matchText = `Hay técnicos hoy en ${u.raw_data['Población']} que podrían cubrir una urgencia sin asignar.`;
|
|
break;
|
|
}
|
|
}
|
|
if(matchUrgencias) {
|
|
alerts.push({
|
|
icon: 'map', color: 'text-emerald-400', bg: 'bg-emerald-400/10', border: 'border-emerald-400/20',
|
|
title: 'Optimización de Ruta', desc: matchText
|
|
});
|
|
}
|
|
|
|
// Fallback si la IA no encuentra nada
|
|
if(alerts.length === 0) {
|
|
alerts.push({
|
|
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 actual parece eficiente y correcta.'
|
|
});
|
|
}
|
|
|
|
// Renderizar las 3 alertas más importantes
|
|
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] shadow-sm">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<i data-lucide="${a.icon}" class="w-4 h-4 ${a.color}"></i>
|
|
<h4 class="font-black text-sm text-white">${a.title}</h4>
|
|
</div>
|
|
<p class="text-slate-300 text-xs leading-relaxed">${a.desc}</p>
|
|
</div>
|
|
`).join('');
|
|
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// --- PROCESAMIENTO PRINCIPAL DE DATOS ---
|
|
function processDashboard(scrapedList, activeList) {
|
|
const todayStr = new Date().toISOString().split('T')[0];
|
|
|
|
// 1. FUSIÓN INTELIGENTE SIN DUPLICADOS
|
|
const allServicesMap = new Map();
|
|
scrapedList.forEach(s => { if (s.status !== 'archived') allServicesMap.set(s.id, s); });
|
|
activeList.forEach(s => {
|
|
if (s.status !== 'archived') {
|
|
if (allServicesMap.has(s.id)) allServicesMap.set(s.id, { ...allServicesMap.get(s.id), ...s });
|
|
else allServicesMap.set(s.id, s);
|
|
}
|
|
});
|
|
const allServices = Array.from(allServicesMap.values());
|
|
|
|
// 2. CÁLCULO DE KPIs
|
|
const totalActive = allServices.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 pendingCount = allServices.filter(s => s.assigned_name && (!s.raw_data.scheduled_date || s.raw_data.scheduled_date === "")).length;
|
|
const todayVisits = allServices.filter(s => s.raw_data && s.raw_data.scheduled_date === todayStr);
|
|
const urgentVisits = allServices.filter(s => {
|
|
const raw = s.raw_data || {};
|
|
return raw['Urgente'] === 'Sí' || raw['Urgente'] === 'true' || raw['URGENTE'] === 'SI';
|
|
});
|
|
|
|
// Pintar los números animados
|
|
animateValue("kpiTotal", totalActive);
|
|
animateValue("kpiUnassigned", unassignedCount);
|
|
animateValue("kpiQueue", queueCount);
|
|
animateValue("kpiPending", pendingCount);
|
|
animateValue("kpiToday", todayVisits.length);
|
|
animateValue("kpiUrgent", urgentVisits.length);
|
|
|
|
// 3. RESUMEN DE SALUD GLOBAL
|
|
let healthStats = { green: 0, amber: 0, red: 0 };
|
|
allServices.forEach(s => healthStats[calculateHealth(s).color]++);
|
|
|
|
document.getElementById('healthSummary').innerHTML = `
|
|
<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-emerald-500 shadow-sm"></div> Normales</span>
|
|
<span class="font-black text-emerald-600">${healthStats.green}</span>
|
|
</div>
|
|
<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-amber-500 shadow-sm"></div> En Riesgo</span>
|
|
<span class="font-black text-amber-600">${healthStats.amber}</span>
|
|
</div>
|
|
<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 ${healthStats.red > 0 ? 'animate-pulse' : ''}"></div> Problemas</span>
|
|
<span class="font-black text-red-600">${healthStats.red}</span>
|
|
</div>
|
|
`;
|
|
|
|
// 4. RENDERIZAR MÓDULOS
|
|
runAISupervisor(allServices, todayVisits, urgentVisits);
|
|
renderTodaySchedule(todayVisits);
|
|
renderUrgentSchedule(urgentVisits);
|
|
renderCompanyDistribution(allServices);
|
|
}
|
|
|
|
// WIDGET 1: Agenda Hoy con Semáforos
|
|
function renderTodaySchedule(visits) {
|
|
const container = document.getElementById("todaySchedule");
|
|
if (visits.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="text-center py-10 border-2 border-dashed border-slate-100 rounded-[2rem]">
|
|
<i data-lucide="coffee" class="w-10 h-10 text-slate-300 mx-auto mb-3"></i>
|
|
<p class="text-slate-500 text-sm font-bold uppercase tracking-widest">No hay visitas programadas para hoy</p>
|
|
</div>`;
|
|
lucide.createIcons(); return;
|
|
}
|
|
|
|
visits.sort((a, b) => (a.raw_data.scheduled_time || "23:59").localeCompare(b.raw_data.scheduled_time || "23:59"));
|
|
|
|
container.innerHTML = visits.map(s => {
|
|
const raw = s.raw_data;
|
|
const time = raw.scheduled_time ? raw.scheduled_time.substring(0,5) : "--:--";
|
|
const op = s.assigned_name || raw.assigned_to_name || "Sin Asignar";
|
|
const name = raw['Nombre Cliente'] || raw['CLIENTE'] || "Asegurado";
|
|
const pop = raw['Población'] || raw['POBLACION-PROVINCIA'] || "Dirección no especificada";
|
|
|
|
const hInfo = calculateHealth(s); // Calculamos salud de este expediente
|
|
|
|
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 `
|
|
<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 ${hInfo.hex}"></div>
|
|
|
|
<div class="flex items-center gap-4 flex-1 min-w-0 pl-2">
|
|
<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="text-[8px] font-bold text-slate-400 mt-1 uppercase">Hoy</span>
|
|
</div>
|
|
|
|
<div class="min-w-0 flex-1 text-left">
|
|
<div class="flex items-center gap-2 mb-0.5">
|
|
<h4 class="font-black text-slate-800 uppercase text-sm truncate">${name}</h4>
|
|
<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>
|
|
<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}
|
|
<span class="mx-1 text-slate-200">|</span>
|
|
<span class="${hInfo.text}">${hInfo.reason}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<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-slate-500"></i>
|
|
<span class="text-[10px] font-black text-slate-700 uppercase">${op}</span>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// WIDGET 2: Urgencias
|
|
function renderUrgentSchedule(visits) {
|
|
const container = document.getElementById("urgentSchedule");
|
|
if (visits.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="text-center py-6 bg-emerald-50/50 rounded-2xl border border-emerald-100">
|
|
<i data-lucide="shield-check" class="w-8 h-8 text-emerald-400 mx-auto mb-2"></i>
|
|
<p class="text-emerald-600 text-xs font-bold uppercase tracking-widest">Todo bajo control. Sin urgencias.</p>
|
|
</div>`;
|
|
lucide.createIcons(); return;
|
|
}
|
|
|
|
container.innerHTML = visits.map(s => {
|
|
const raw = s.raw_data;
|
|
const name = raw['Nombre Cliente'] || raw['CLIENTE'] || "Asegurado";
|
|
const op = s.assigned_name || raw.assigned_to_name || "Buscando Operario...";
|
|
return `
|
|
<div class="p-4 rounded-2xl bg-red-50/50 border border-red-100 flex justify-between items-center">
|
|
<div>
|
|
<h4 class="font-black text-slate-800 uppercase text-xs truncate">${name}</h4>
|
|
<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>
|
|
</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>`;
|
|
}).join('');
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// WIDGET 3: Distribución Compañías
|
|
function renderCompanyDistribution(allServices) {
|
|
const container = document.getElementById("companyDistribution");
|
|
if (allServices.length === 0) { container.innerHTML = "<p class='text-xs text-slate-400'>Sin datos</p>"; return; }
|
|
|
|
const counts = {};
|
|
allServices.forEach(s => {
|
|
const raw = s.raw_data || {};
|
|
let comp = (raw['Compañía'] || raw['COMPAÑIA'] || raw['Procedencia'] || (s.provider === 'MANUAL' ? 'PARTICULAR' : 'OTRA')).toUpperCase().trim();
|
|
if(comp.includes("HOMESERVE")) comp = "HOMESERVE";
|
|
counts[comp] = (counts[comp] || 0) + 1;
|
|
});
|
|
|
|
const sorted = Object.entries(counts).sort((a,b) => b[1] - a[1]);
|
|
const max = sorted[0][1];
|
|
|
|
container.innerHTML = sorted.map(([comp, count]) => {
|
|
const percent = Math.round((count / max) * 100);
|
|
return `
|
|
<div class="space-y-1">
|
|
<div class="flex justify-between text-xs font-black uppercase">
|
|
<span class="text-slate-600">${comp}</span>
|
|
<span class="text-indigo-600">${count} expedientes</span>
|
|
</div>
|
|
<div class="w-full bg-slate-100 rounded-full h-2">
|
|
<div class="bg-indigo-500 h-2 rounded-full" style="width: ${percent}%"></div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
// Función Auxiliar: Animación de números
|
|
function animateValue(id, end) {
|
|
const obj = document.getElementById(id);
|
|
if(!obj) return;
|
|
let start = 0;
|
|
const duration = 800;
|
|
const range = end - start;
|
|
if(range === 0) { obj.innerText = end; return; }
|
|
const minTimer = 50;
|
|
let stepTime = Math.abs(Math.floor(duration / range));
|
|
stepTime = Math.max(stepTime, minTimer);
|
|
const startTime = new Date().getTime();
|
|
const endTime = startTime + duration;
|
|
let timer;
|
|
function run() {
|
|
const now = new Date().getTime();
|
|
const remaining = Math.max((endTime - now) / duration, 0);
|
|
const value = Math.round(end - (remaining * range));
|
|
obj.innerText = value;
|
|
if (value == end) clearInterval(timer);
|
|
}
|
|
timer = setInterval(run, stepTime);
|
|
run();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html> |