559 lines
33 KiB
HTML
559 lines
33 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); }
|
|
|
|
.tech-card { transition: all 0.2s; border: 1px solid #f1f5f9; }
|
|
.tech-card:hover { transform: translateY(-2px); box-shadow: 0 8px 20px -6px rgba(0,0,0,0.06); }
|
|
|
|
/* Efecto latido para urgencias */
|
|
.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">
|
|
|
|
<!-- HEADER -->
|
|
<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="layout-dashboard" class="w-6 h-6"></i></span>
|
|
PANEL OPERATIVO
|
|
</h2>
|
|
<p class="text-sm text-slate-500 mt-2 font-medium">Qué necesita tu atención ahora: servicios sin asignar, agenda de hoy y estado de tus técnicos.</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>
|
|
|
|
<!-- TECNICOS AHORA MISMO -->
|
|
<div>
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<h3 class="text-[13px] font-extrabold uppercase tracking-wider text-slate-500">Técnicos ahora mismo</h3>
|
|
</div>
|
|
<div id="technicianStrip" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-3">
|
|
<p class="text-sm text-slate-400 font-medium">Cargando técnicos...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- KPIs -->
|
|
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
|
|
|
|
<div class="stat-card bg-rose-50 border-rose-100 p-4 rounded-[1.75rem] shadow-sm flex flex-col justify-between min-h-[128px]">
|
|
<div class="flex justify-between items-start">
|
|
<p class="text-[10px] font-black text-rose-600 uppercase tracking-widest">Sin Asignar</p>
|
|
<div class="bg-white text-rose-600 p-1.5 rounded-xl"><i data-lucide="folder-open" class="w-4 h-4"></i></div>
|
|
</div>
|
|
<h3 class="text-3xl font-black text-rose-600 mt-1" id="kpiUnassigned">0</h3>
|
|
<p class="text-[10px] font-bold text-rose-500 uppercase mt-1">Requieren técnico ya</p>
|
|
</div>
|
|
|
|
<div class="stat-card bg-red-50 border-red-100 p-4 rounded-[1.75rem] shadow-sm flex flex-col justify-between min-h-[128px]">
|
|
<div class="flex justify-between items-start">
|
|
<p class="text-[10px] font-black text-red-600 uppercase tracking-widest">Urgencias</p>
|
|
<div class="bg-red-600 text-white p-1.5 rounded-xl shadow-lg shadow-red-200 animate-pulse-fast" id="urgentIconBadge"><i data-lucide="alert-triangle" class="w-4 h-4"></i></div>
|
|
</div>
|
|
<h3 class="text-3xl font-black text-red-600 mt-1" id="kpiUrgent">0</h3>
|
|
<p class="text-[10px] font-bold text-red-500 uppercase mt-1">Atención prioritaria</p>
|
|
</div>
|
|
|
|
<div class="stat-card bg-white p-4 rounded-[1.75rem] shadow-sm flex flex-col justify-between min-h-[128px]">
|
|
<div class="flex justify-between items-start">
|
|
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">Citas Hoy</p>
|
|
<div class="bg-blue-50 text-blue-600 p-1.5 rounded-xl"><i data-lucide="calendar-check" class="w-4 h-4"></i></div>
|
|
</div>
|
|
<h3 class="text-3xl font-black text-slate-800 mt-1" id="kpiToday">0</h3>
|
|
<p class="text-[10px] font-bold text-slate-500 uppercase mt-1">Programadas para hoy</p>
|
|
</div>
|
|
|
|
<div class="stat-card bg-emerald-50 border-emerald-100 p-4 rounded-[1.75rem] shadow-sm flex flex-col justify-between min-h-[128px]">
|
|
<div class="flex justify-between items-start">
|
|
<p class="text-[10px] font-black text-emerald-600 uppercase tracking-widest">Técnicos Libres</p>
|
|
<div class="bg-white text-emerald-600 p-1.5 rounded-xl"><i data-lucide="check-circle" class="w-4 h-4"></i></div>
|
|
</div>
|
|
<h3 class="text-3xl font-black text-emerald-600 mt-1"><span id="kpiFree">0</span><span class="text-sm font-bold text-slate-400" id="kpiFreeTotal"> de 0</span></h3>
|
|
<p class="text-[10px] font-bold text-emerald-500 uppercase mt-1">Disponibles ahora</p>
|
|
</div>
|
|
|
|
<div class="stat-card bg-amber-50 border-amber-100 p-4 rounded-[1.75rem] shadow-sm flex flex-col justify-between min-h-[128px]">
|
|
<div class="flex justify-between items-start">
|
|
<p class="text-[10px] font-black text-amber-600 uppercase tracking-widest">En Servicio</p>
|
|
<div class="bg-white text-amber-600 p-1.5 rounded-xl"><i data-lucide="wrench" class="w-4 h-4"></i></div>
|
|
</div>
|
|
<h3 class="text-3xl font-black text-amber-600 mt-1" id="kpiWorking">0</h3>
|
|
<p class="text-[10px] font-bold text-amber-500 uppercase mt-1">Trabajando ahora</p>
|
|
</div>
|
|
|
|
<div class="stat-card bg-white p-4 rounded-[1.75rem] shadow-sm flex flex-col justify-between min-h-[128px]">
|
|
<div class="flex justify-between items-start">
|
|
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">Activos Totales</p>
|
|
<div class="bg-slate-100 text-slate-500 p-1.5 rounded-xl"><i data-lucide="briefcase" class="w-4 h-4"></i></div>
|
|
</div>
|
|
<h3 class="text-3xl font-black text-slate-800 mt-1" id="kpiTotal">0</h3>
|
|
<p class="text-[10px] font-bold text-slate-500 uppercase mt-1">Expedientes en marcha</p>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- MAIN GRID -->
|
|
<div class="grid grid-cols-1 xl:grid-cols-[1.65fr_1fr] gap-5">
|
|
|
|
<!-- LEFT COLUMN -->
|
|
<div class="flex flex-col gap-5">
|
|
|
|
<!-- AGENDA DE HOY -->
|
|
<div class="bg-white rounded-[1.75rem] border border-slate-100 shadow-sm p-6">
|
|
<div class="flex items-center gap-3 pb-4 mb-3 border-b border-slate-100">
|
|
<span class="bg-blue-50 text-blue-600 rounded-xl p-2"><i data-lucide="calendar-check" class="w-[18px] h-[18px]"></i></span>
|
|
<h3 class="text-[15px] font-extrabold text-slate-800 uppercase tracking-wide">Agenda de Hoy</h3>
|
|
<span class="ml-auto text-xs font-bold text-slate-400" id="todayScheduleCount">0 citas</span>
|
|
</div>
|
|
<div id="todaySchedule" class="flex flex-col">
|
|
<p class="text-sm text-slate-400 font-medium py-4">Cargando agenda...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- AVISOS URGENTES -->
|
|
<div class="bg-white rounded-[1.75rem] border border-slate-100 shadow-sm p-6">
|
|
<div class="flex items-center gap-3 pb-4 mb-3 border-b border-slate-100">
|
|
<span class="bg-red-50 text-red-600 rounded-xl p-2"><i data-lucide="alert-triangle" class="w-[18px] h-[18px]"></i></span>
|
|
<h3 class="text-[15px] font-extrabold text-slate-800 uppercase tracking-wide">Avisos Urgentes</h3>
|
|
<span class="ml-auto text-xs font-bold text-red-500" id="urgentAlertsCount">0 activos</span>
|
|
</div>
|
|
<div id="urgentAlerts" class="flex flex-col gap-2.5">
|
|
<p class="text-sm text-slate-400 font-medium py-4">Cargando avisos...</p>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- RIGHT COLUMN -->
|
|
<div class="flex flex-col gap-5">
|
|
|
|
<!-- CARGA DE TECNICOS -->
|
|
<div class="bg-white rounded-[1.75rem] border border-slate-100 shadow-sm p-5">
|
|
<div class="flex items-center gap-3 pb-3.5 mb-3.5 border-b border-slate-100">
|
|
<span class="bg-indigo-50 text-indigo-600 rounded-xl p-2"><i data-lucide="users" class="w-[17px] h-[17px]"></i></span>
|
|
<h3 class="text-[13.5px] font-extrabold text-slate-800 uppercase tracking-wide">Carga de Técnicos</h3>
|
|
</div>
|
|
<div id="technicianLoad" class="flex flex-col gap-3">
|
|
<p class="text-sm text-slate-400 font-medium">Cargando...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- VOLUMEN POR ASEGURADORA -->
|
|
<div class="bg-white rounded-[1.75rem] border border-slate-100 shadow-sm p-5">
|
|
<div class="flex items-center gap-3 pb-3.5 mb-3.5 border-b border-slate-100">
|
|
<span class="bg-indigo-50 text-indigo-600 rounded-xl p-2"><i data-lucide="pie-chart" class="w-[17px] h-[17px]"></i></span>
|
|
<h3 class="text-[13.5px] font-extrabold text-slate-800 uppercase tracking-wide">Volumen por Aseguradora</h3>
|
|
</div>
|
|
<div id="companyDistribution" class="flex flex-col gap-2.5">
|
|
<p class="text-sm text-slate-400 font-medium">Cargando...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SUPERVISOR IA (condensado) -->
|
|
<div class="bg-slate-900 rounded-[1.5rem] p-5 relative overflow-hidden">
|
|
<div class="absolute -top-8 -right-8 w-36 h-36 bg-blue-500 opacity-20 rounded-full blur-3xl"></div>
|
|
<div class="relative flex items-center gap-2.5 mb-3.5">
|
|
<span class="bg-white/10 text-blue-400 rounded-xl p-2"><i data-lucide="cpu" class="w-[17px] h-[17px]"></i></span>
|
|
<h3 class="text-[13.5px] font-extrabold text-white uppercase tracking-wide">Supervisor IA</h3>
|
|
</div>
|
|
<div id="aiSupervisorSummary" class="relative flex flex-col gap-2.5">
|
|
<p class="text-xs text-slate-400 font-medium">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";
|
|
|
|
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
|
document.getElementById('todayDate').innerText = new Date().toLocaleDateString('es-ES', options);
|
|
|
|
lucide.createIcons();
|
|
|
|
loadDashboardData(token);
|
|
setInterval(() => loadDashboardData(token), 300000);
|
|
});
|
|
|
|
function escapeHtml(str) {
|
|
if (str === null || str === undefined) return '';
|
|
return String(str).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
}
|
|
|
|
async function loadDashboardData(token) {
|
|
try {
|
|
const headers = { "Authorization": `Bearer ${token}` };
|
|
const [resScraped, resActive, resUsers, resBudgets] = await Promise.all([
|
|
fetch(`${API_URL}/providers/scraped`, { headers }),
|
|
fetch(`${API_URL}/services/active`, { headers }),
|
|
fetch(`${API_URL}/admin/users`, { headers }),
|
|
fetch(`${API_URL}/budgets`, { headers })
|
|
]);
|
|
const dataScraped = await resScraped.json();
|
|
const dataActive = await resActive.json();
|
|
const dataUsers = await resUsers.json().catch(() => ({ ok: false }));
|
|
const dataBudgets = await resBudgets.json().catch(() => ({ ok: false }));
|
|
|
|
if (dataScraped.ok && dataActive.ok) {
|
|
processDashboard(
|
|
dataScraped.services,
|
|
dataActive.services,
|
|
dataUsers.ok ? dataUsers.users : [],
|
|
dataBudgets.ok ? dataBudgets.budgets : []
|
|
);
|
|
}
|
|
} 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>';
|
|
}
|
|
}
|
|
|
|
function processDashboard(scrapedList, activeList, users, budgets) {
|
|
const todayStr = new Date().toISOString().split('T')[0];
|
|
|
|
// 1. FUSION SIN DUPLICADOS (misma lógica que el panel anterior)
|
|
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. KPIs GLOBALES
|
|
const totalActive = allServices.length;
|
|
const unassignedCount = allServices.filter(s => !s.assigned_name && s.automation_status !== 'in_progress').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'] === 'Si' || raw['Urgente'] === 'true' || raw['URGENTE'] === 'SI';
|
|
});
|
|
const workingNowVisits = todayVisits.filter(s => (s.raw_data || {}).status_operativo === 'trabajando');
|
|
|
|
animateValue("kpiUnassigned", unassignedCount);
|
|
animateValue("kpiUrgent", urgentVisits.length);
|
|
animateValue("kpiToday", todayVisits.length);
|
|
animateValue("kpiWorking", workingNowVisits.length);
|
|
animateValue("kpiTotal", totalActive);
|
|
|
|
// 3. TECNICOS: estado y carga de hoy
|
|
const tecnicos = (users || []).filter(u => u.role === 'operario' && u.status === 'active');
|
|
const tecnicoInfo = tecnicos.map(t => {
|
|
const misHoy = todayVisits.filter(s => s.assigned_to === t.id);
|
|
const total = misHoy.length;
|
|
const terminados = misHoy.filter(s => (s.raw_data || {}).status_operativo === 'terminado').length;
|
|
const trabajando = misHoy.some(s => (s.raw_data || {}).status_operativo === 'trabajando');
|
|
const incidencia = misHoy.some(s => (s.raw_data || {}).status_operativo === 'incidencia');
|
|
let badge;
|
|
if (incidencia) badge = { label: 'Incidencia', color: 'red' };
|
|
else if (trabajando) badge = { label: 'En servicio', color: 'amber' };
|
|
else if (total === 0 || terminados === total) badge = { label: 'Libre ahora', color: 'emerald' };
|
|
else badge = { label: `${terminados}/${total} hoy`, color: 'blue' };
|
|
return { ...t, total, terminados, badge };
|
|
});
|
|
const tecnicosLibres = tecnicoInfo.filter(t => t.badge.label === 'Libre ahora').length;
|
|
document.getElementById('kpiFree').innerText = tecnicosLibres;
|
|
document.getElementById('kpiFreeTotal').innerText = ` de ${tecnicoInfo.length}`;
|
|
|
|
renderTechnicianStrip(tecnicoInfo);
|
|
renderTechnicianLoad(tecnicoInfo);
|
|
|
|
// 4. RENDERIZAR MODULOS
|
|
renderTodaySchedule(todayVisits);
|
|
renderUrgentAlerts(allServices, budgets, todayStr);
|
|
renderCompanyDistribution(allServices);
|
|
renderAISupervisor(unassignedCount, urgentVisits.length, tecnicosLibres, tecnicoInfo.length);
|
|
}
|
|
|
|
const BADGE_COLOR_CLASSES = {
|
|
emerald: 'bg-emerald-500', amber: 'bg-amber-500', red: 'bg-red-500', blue: 'bg-blue-500', slate: 'bg-slate-400'
|
|
};
|
|
const BADGE_TEXT_CLASSES = {
|
|
emerald: 'text-emerald-600', amber: 'text-amber-600', red: 'text-red-600', blue: 'text-blue-600', slate: 'text-slate-500'
|
|
};
|
|
|
|
function renderTechnicianStrip(tecnicoInfo) {
|
|
const container = document.getElementById('technicianStrip');
|
|
if (tecnicoInfo.length === 0) {
|
|
container.innerHTML = '<p class="text-sm text-slate-400 font-medium">No hay técnicos activos configurados.</p>';
|
|
return;
|
|
}
|
|
container.innerHTML = tecnicoInfo.map(t => `
|
|
<div class="tech-card bg-white rounded-2xl p-3.5 flex flex-col gap-2 shadow-sm">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm font-extrabold text-slate-800 truncate">${escapeHtml(t.full_name)}</span>
|
|
<span class="w-2 h-2 rounded-full shrink-0 ${BADGE_COLOR_CLASSES[t.badge.color]}"></span>
|
|
</div>
|
|
<span class="text-[10px] font-black uppercase tracking-wide ${BADGE_TEXT_CLASSES[t.badge.color]}">${t.badge.label}</span>
|
|
<span class="text-xs text-slate-500 font-semibold">${t.total} cita${t.total === 1 ? '' : 's'} hoy${t.total > 0 ? ` · ${t.terminados}/${t.total} hechas` : ''}</span>
|
|
</div>
|
|
`).join('');
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function renderTechnicianLoad(tecnicoInfo) {
|
|
const container = document.getElementById('technicianLoad');
|
|
if (tecnicoInfo.length === 0) {
|
|
container.innerHTML = '<p class="text-sm text-slate-400 font-medium">Sin técnicos activos.</p>';
|
|
return;
|
|
}
|
|
const maxLoad = Math.max(1, ...tecnicoInfo.map(t => t.total));
|
|
const sorted = [...tecnicoInfo].sort((a, b) => b.total - a.total);
|
|
container.innerHTML = sorted.map(t => {
|
|
const pct = t.total === 0 ? 3 : Math.round((t.total / maxLoad) * 100);
|
|
const barColor = t.badge.color === 'red' ? 'bg-red-500' : t.badge.color === 'amber' ? 'bg-amber-500' : t.badge.color === 'blue' ? 'bg-blue-500' : 'bg-emerald-500';
|
|
return `
|
|
<div>
|
|
<div class="flex justify-between text-xs mb-1">
|
|
<span class="font-bold text-slate-800">${escapeHtml(t.full_name)}</span>
|
|
<span class="font-bold text-slate-500">${t.terminados}/${t.total}</span>
|
|
</div>
|
|
<div class="h-[7px] bg-slate-100 rounded-full overflow-hidden">
|
|
<div class="h-full rounded-full ${barColor}" style="width:${pct}%"></div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderTodaySchedule(visits) {
|
|
const container = document.getElementById('todaySchedule');
|
|
document.getElementById('todayScheduleCount').innerText = `${visits.length} cita${visits.length === 1 ? '' : 's'}`;
|
|
if (visits.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="text-center py-10 border-2 border-dashed border-slate-100 rounded-2xl">
|
|
<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 name = raw['Nombre Cliente'] || raw['CLIENTE'] || "Asegurado";
|
|
const address = raw['Población'] || raw['POBLACION-PROVINCIA'] || "Dirección no especificada";
|
|
const company = raw['Compañía'] || raw['Compania'] || raw['COMPANIA'] || "Particular";
|
|
|
|
let statusBadge, rowClasses = "flex items-center gap-3.5 py-3 border-b border-slate-50";
|
|
if (!s.assigned_name) {
|
|
statusBadge = `<span class="text-[11px] font-extrabold text-white bg-rose-500 px-2.5 py-1 rounded-full shrink-0">Asignar ya</span>`;
|
|
rowClasses = "flex items-center gap-3.5 p-3.5 my-0.5 -mx-3.5 rounded-2xl bg-rose-50";
|
|
} else if (raw.status_operativo === 'incidencia') {
|
|
statusBadge = `<span class="text-[11px] font-extrabold text-red-600 bg-red-50 px-2.5 py-1 rounded-full shrink-0">Incidencia</span>`;
|
|
} else if (raw.status_operativo === 'trabajando') {
|
|
statusBadge = `<span class="text-[11px] font-extrabold text-blue-600 bg-blue-50 px-2.5 py-1 rounded-full shrink-0">En servicio</span>`;
|
|
} else if (raw.status_operativo === 'terminado') {
|
|
statusBadge = `<span class="text-[11px] font-extrabold text-slate-500 bg-slate-100 px-2.5 py-1 rounded-full shrink-0">Finalizada</span>`;
|
|
} else {
|
|
statusBadge = `<span class="text-[11px] font-extrabold text-emerald-600 bg-emerald-50 px-2.5 py-1 rounded-full shrink-0">Asignada</span>`;
|
|
}
|
|
|
|
return `
|
|
<div class="${rowClasses}">
|
|
<span class="w-[52px] text-[13px] font-extrabold text-slate-800 shrink-0">${time}</span>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-[13.5px] font-bold text-slate-800 truncate">${escapeHtml(name)}</p>
|
|
<p class="text-xs text-slate-500 truncate">${escapeHtml(address)}</p>
|
|
</div>
|
|
<span class="text-[11px] font-bold text-indigo-600 bg-indigo-50 px-2.5 py-1 rounded-full shrink-0 hidden sm:inline-block">${escapeHtml(company)}</span>
|
|
<span class="text-xs font-bold text-slate-600 w-24 shrink-0 truncate hidden md:inline-block">${escapeHtml(s.assigned_name || "Sin técnico")}</span>
|
|
${statusBadge}
|
|
</div>`;
|
|
}).join('');
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function hoursSince(dateStr) {
|
|
const then = new Date(dateStr).getTime();
|
|
if (isNaN(then)) return 0;
|
|
return Math.floor((Date.now() - then) / 3600000);
|
|
}
|
|
|
|
function renderUrgentAlerts(allServices, budgets, todayStr) {
|
|
const container = document.getElementById('urgentAlerts');
|
|
const alerts = [];
|
|
|
|
// Servicios sin técnico, ordenados por antigüedad
|
|
allServices
|
|
.filter(s => !s.assigned_name && s.automation_status !== 'in_progress')
|
|
.forEach(s => {
|
|
const hrs = hoursSince(s.created_at);
|
|
if (hrs >= 12) {
|
|
const raw = s.raw_data || {};
|
|
const company = raw['Compañía'] || raw['Compania'] || raw['COMPANIA'] || "Particular";
|
|
alerts.push({
|
|
severity: 3,
|
|
hrs,
|
|
html: `
|
|
<div class="flex items-start gap-3 p-3.5 bg-red-50 rounded-2xl border border-red-100">
|
|
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5 shrink-0 animate-pulse-fast"></span>
|
|
<div class="flex-1">
|
|
<p class="text-[13.5px] font-bold text-slate-800">Servicio ${escapeHtml(s.service_ref || ('#' + s.id))} sin técnico desde hace ${hrs} h</p>
|
|
<p class="text-xs text-slate-500 mt-0.5">${escapeHtml(raw['Población'] || raw['POBLACION-PROVINCIA'] || 'Sin dirección')} · ${escapeHtml(company)}</p>
|
|
</div>
|
|
<span class="text-[11px] font-extrabold text-red-600 whitespace-nowrap">Asignar →</span>
|
|
</div>`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Presupuestos pendientes de respuesta hace tiempo
|
|
(budgets || [])
|
|
.filter(b => !['converted', 'paid', 'rejected', 'cancelled', 'rechazado', 'anulado'].includes((b.status || '').toLowerCase()))
|
|
.forEach(b => {
|
|
const days = Math.floor(hoursSince(b.created_at) / 24);
|
|
if (days >= 2) {
|
|
alerts.push({
|
|
severity: 1,
|
|
hrs: days * 24,
|
|
html: `
|
|
<div class="flex items-start gap-3 p-3.5 bg-slate-50 rounded-2xl">
|
|
<span class="w-2 h-2 rounded-full bg-slate-400 mt-1.5 shrink-0"></span>
|
|
<div class="flex-1">
|
|
<p class="text-[13.5px] font-bold text-slate-800">Presupuesto PRE-${b.id} pendiente de aprobación</p>
|
|
<p class="text-xs text-slate-500 mt-0.5">Cliente sin responder desde hace ${days} día${days === 1 ? '' : 's'}</p>
|
|
</div>
|
|
<span class="text-[11px] font-extrabold text-slate-500 whitespace-nowrap">Ver →</span>
|
|
</div>`
|
|
});
|
|
}
|
|
});
|
|
|
|
if (alerts.length === 0) {
|
|
document.getElementById('urgentAlertsCount').innerText = "0 activos";
|
|
container.innerHTML = `
|
|
<div class="text-center py-8">
|
|
<i data-lucide="check-circle" class="w-8 h-8 text-emerald-400 mx-auto mb-2"></i>
|
|
<p class="text-slate-500 text-sm font-bold">Todo bajo control. Sin avisos urgentes.</p>
|
|
</div>`;
|
|
lucide.createIcons();
|
|
return;
|
|
}
|
|
|
|
alerts.sort((a, b) => (b.severity - a.severity) || (b.hrs - a.hrs));
|
|
document.getElementById('urgentAlertsCount').innerText = `${alerts.length} activo${alerts.length === 1 ? '' : 's'}`;
|
|
container.innerHTML = alerts.slice(0, 8).map(a => a.html).join('');
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function renderCompanyDistribution(allServices) {
|
|
const container = document.getElementById('companyDistribution');
|
|
const counts = {};
|
|
allServices.forEach(s => {
|
|
const raw = s.raw_data || {};
|
|
const company = raw['Compañía'] || raw['Compania'] || raw['COMPANIA'] || "Particular";
|
|
counts[company] = (counts[company] || 0) + 1;
|
|
});
|
|
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
if (entries.length === 0) {
|
|
container.innerHTML = '<p class="text-sm text-slate-400 font-medium">Sin datos todavía.</p>';
|
|
return;
|
|
}
|
|
const max = Math.max(...entries.map(e => e[1]));
|
|
const palette = ['bg-indigo-500', 'bg-sky-500', 'bg-blue-400', 'bg-slate-400', 'bg-slate-300'];
|
|
container.innerHTML = entries.map(([name, count], i) => `
|
|
<div>
|
|
<div class="flex justify-between items-center mb-1">
|
|
<span class="text-[13px] font-bold text-slate-700">${escapeHtml(name)}</span>
|
|
<span class="text-[13px] font-extrabold text-slate-800">${count}</span>
|
|
</div>
|
|
<div class="h-[7px] bg-slate-100 rounded-full overflow-hidden">
|
|
<div class="h-full rounded-full ${palette[i] || 'bg-slate-300'}" style="width:${Math.round((count / max) * 100)}%"></div>
|
|
</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function renderAISupervisor(unassignedCount, urgentCount, tecnicosLibres, tecnicosTotal) {
|
|
const container = document.getElementById('aiSupervisorSummary');
|
|
const items = [];
|
|
items.push({ color: 'bg-emerald-500', text: 'IA de WhatsApp activa y respondiendo' });
|
|
if (urgentCount > 0) {
|
|
items.push({ color: 'bg-red-500', text: `${urgentCount} servicio${urgentCount === 1 ? '' : 's'} urgente${urgentCount === 1 ? '' : 's'} en curso` });
|
|
}
|
|
if (unassignedCount > 0) {
|
|
items.push({ color: 'bg-amber-500', text: `${unassignedCount} servicio${unassignedCount === 1 ? '' : 's'} sin asignar` });
|
|
} else {
|
|
items.push({ color: 'bg-emerald-500', text: 'Sin servicios pendientes de asignar' });
|
|
}
|
|
items.push({ color: 'bg-blue-500', text: `${tecnicosLibres} de ${tecnicosTotal} técnicos libres ahora` });
|
|
|
|
container.innerHTML = items.map(it => `
|
|
<div class="flex items-center gap-2.5">
|
|
<span class="w-2 h-2 rounded-full ${it.color} shrink-0"></span>
|
|
<span class="text-[12.5px] text-slate-300 font-medium">${it.text}</span>
|
|
</div>`).join('');
|
|
}
|
|
|
|
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>
|