Files
web/trazabilidad.html
2026-03-02 08:16:59 +00:00

311 lines
18 KiB
HTML

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Centro de Trazabilidad - IntegraRepara</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
.fade-in { animation: fadeIn 0.3s ease-out forwards; }
@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; }
/* Línea continua del timeline */
.timeline-line::before {
content: ''; position: absolute; top: 0; bottom: 0; left: 23px; width: 2px; background: #e2e8f0; z-index: 0;
}
.selected-card {
background-color: #eff6ff !important;
border-color: #bfdbfe !important;
ring: 2px;
ring-color: #3b82f6;
}
</style>
</head>
<body class="bg-gray-50 text-gray-800 font-sans antialiased overflow-hidden">
<div class="flex h-screen overflow-hidden text-left">
<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>
<div class="flex-1 flex overflow-hidden bg-white border-t border-slate-200">
<aside class="w-full md:w-1/3 lg:w-1/4 bg-slate-50/50 border-r border-slate-200 flex flex-col z-10">
<div class="p-4 border-b border-slate-200 bg-white shrink-0 flex items-center justify-between">
<div>
<h2 class="text-lg font-black text-slate-800 flex items-center gap-2">
<span class="bg-slate-800 p-1.5 rounded-lg text-white"><i data-lucide="history" class="w-4 h-4"></i></span>
Auditoría
</h2>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">Centro de Control</p>
</div>
</div>
<div class="p-4 border-b border-slate-200 bg-white space-y-3 shrink-0">
<div class="relative">
<i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"></i>
<input type="text" id="searchBox" oninput="renderServices()" placeholder="Buscar cliente, ref, tel..." class="w-full pl-9 pr-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm font-medium outline-none focus:bg-white focus:border-blue-500 focus:ring-2 focus:ring-blue-100 transition-all">
</div>
<div class="flex gap-2 items-center bg-slate-50 border border-slate-200 rounded-xl overflow-hidden shadow-sm">
<div class="px-3 text-slate-400 border-r border-slate-200"><i data-lucide="calendar-days" class="w-4 h-4"></i></div>
<input type="month" id="monthBox" onchange="renderServices()" class="w-full bg-transparent text-xs font-black px-2 py-2.5 outline-none text-slate-600 uppercase tracking-widest cursor-pointer">
<button onclick="document.getElementById('monthBox').value=''; renderServices()" class="p-2 text-slate-400 hover:text-red-500 transition-colors"><i data-lucide="x" class="w-4 h-4"></i></button>
</div>
</div>
<div id="servicesList" class="flex-1 overflow-y-auto no-scrollbar p-3 space-y-2">
<div class="py-10 text-center text-slate-400">
<i data-lucide="loader-2" class="w-6 h-6 animate-spin mx-auto mb-2"></i>
<p class="text-xs font-bold uppercase tracking-widest">Cargando...</p>
</div>
</div>
</aside>
<main class="flex-1 overflow-y-auto no-scrollbar bg-slate-50/50 relative">
<div id="emptyState" class="absolute inset-0 flex flex-col items-center justify-center text-center p-6 fade-in">
<div class="w-24 h-24 bg-white border border-slate-200 text-slate-300 rounded-full flex items-center justify-center mb-6 shadow-sm">
<i data-lucide="mouse-pointer-click" class="w-10 h-10"></i>
</div>
<h2 class="text-2xl font-black text-slate-700 tracking-tight">Selecciona un expediente</h2>
<p class="text-sm text-slate-500 font-medium mt-2 max-w-sm">Haz clic en un servicio de la izquierda para auditar sus movimientos.</p>
</div>
<div id="traceabilityView" class="hidden p-6 md:p-10 max-w-4xl mx-auto fade-in">
<div class="flex flex-col md:flex-row justify-between items-start md:items-end border-b-2 border-slate-200 pb-4 mb-6 gap-4">
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">Historial del expediente</p>
<h2 class="text-3xl font-black text-slate-800 flex items-center gap-3">
<span id="currentSvcRef">#---</span>
<span class="bg-blue-100 text-blue-700 px-3 py-1 rounded-lg font-black text-[10px] uppercase tracking-widest border border-blue-200" id="currentSvcStatus">ESTADO</span>
</h2>
</div>
<div class="bg-white px-4 py-2 rounded-xl shadow-sm border border-slate-200 flex items-center gap-2">
<i data-lucide="activity" class="w-4 h-4 text-emerald-500"></i>
<span class="text-xs font-bold text-slate-600" id="logCount">0 Registros</span>
</div>
</div>
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-200 mb-8 sticky top-0 z-10">
<div class="flex gap-4 items-start">
<div class="w-10 h-10 bg-amber-50 rounded-full flex items-center justify-center text-amber-500 shrink-0 border border-amber-100">
<i data-lucide="pen-tool" class="w-5 h-5"></i>
</div>
<div class="flex-1">
<textarea id="manualNoteInput" rows="2" placeholder="Escribe un apunte interno..." class="w-full bg-slate-50 border border-slate-200 px-4 py-3 rounded-xl text-sm font-medium outline-none focus:bg-white focus:border-amber-400 focus:ring-2 focus:ring-amber-100 transition-all resize-none"></textarea>
<div class="flex justify-end mt-2">
<button onclick="addManualNote(this)" class="bg-slate-800 hover:bg-slate-700 text-white text-xs font-black uppercase tracking-widest px-5 py-2.5 rounded-lg shadow transition-all flex items-center gap-2 active:scale-95">
Guardar Apunte <i data-lucide="send" class="w-3 h-3"></i>
</button>
</div>
</div>
</div>
</div>
<div class="relative timeline-line ml-2 md:ml-4 pb-20" id="timelineContainer"></div>
</div>
</main>
</div>
</div>
</div>
<div id="toast" class="fixed bottom-8 right-8 bg-slate-900 text-white px-6 py-3 rounded-2xl shadow-2xl hidden z-[200] font-bold text-sm flex items-center gap-2 transition-all"></div>
<script src="js/layout.js"></script>
<script>
// No declaramos API_URL porque layout.js ya lo hace.
let allServices = [];
let currentServiceId = null;
document.addEventListener("DOMContentLoaded", () => {
if (!localStorage.getItem("token")) window.location.href = "index.html";
lucide.createIcons();
// Ponemos el mes actual por defecto
const now = new Date();
const currentMonthStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
document.getElementById('monthBox').value = currentMonthStr;
loadAllServices();
});
async function loadAllServices() {
try {
const res = await fetch(`${API_URL}/providers/scraped`, {
headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` }
});
const data = await res.json();
if (data.ok && data.services) {
allServices = data.services;
renderServices();
const urlParams = new URLSearchParams(window.location.search);
const paramId = urlParams.get('id');
if (paramId) {
const sToSelect = allServices.find(s => s.id == paramId);
if(sToSelect) {
const stText = sToSelect.status === 'archived' ? 'Archivado' : 'Activo';
selectService(sToSelect.id, sToSelect.service_ref, stText);
}
}
} else {
document.getElementById('servicesList').innerHTML = `<p class="text-center text-red-500 text-sm font-bold mt-10">Error al cargar datos.</p>`;
}
} catch(e) {
document.getElementById('servicesList').innerHTML = `<p class="text-center text-red-500 text-sm font-bold mt-10">Fallo de conexión.</p>`;
}
}
function renderServices() {
const list = document.getElementById('servicesList');
const search = document.getElementById('searchBox').value.toLowerCase();
const month = document.getElementById('monthBox').value;
const filtered = allServices.filter(s => {
const raw = s.raw_data || {};
const textSearch = `${s.service_ref} ${raw["Nombre Cliente"] || raw["CLIENTE"]} ${raw["Teléfono"]} ${raw["Dirección"]}`.toLowerCase();
let matchesSearch = textSearch.includes(search);
let matchesMonth = true;
if (month) {
const svcDate = s.created_at ? s.created_at.substring(0, 7) : "";
matchesMonth = svcDate === month;
}
return matchesSearch && matchesMonth && s.provider !== 'SYSTEM_BLOCK';
});
list.innerHTML = "";
if (filtered.length === 0) {
list.innerHTML = `<div class="text-center p-8 mt-10"><i data-lucide="inbox" class="w-8 h-8 text-slate-300 mx-auto mb-3"></i><p class="text-slate-400 text-sm font-bold">Sin resultados.</p></div>`;
lucide.createIcons();
return;
}
filtered.forEach(s => {
const raw = s.raw_data || {};
const name = raw["Nombre Cliente"] || raw["CLIENTE"] || "S/N";
const date = new Date(s.created_at).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit' });
const isSelected = s.id === currentServiceId ? 'selected-card shadow-md' : 'bg-white hover:bg-slate-50 border-transparent';
const statusColor = s.status === 'archived' ? 'bg-slate-200 text-slate-600' : 'bg-emerald-100 text-emerald-700';
list.innerHTML += `
<div onclick="selectService(${s.id}, '${s.service_ref}', '${s.status === 'archived' ? 'Archivado' : 'Activo'}')"
class="cursor-pointer p-4 rounded-2xl border transition-all ${isSelected}">
<div class="flex justify-between items-start mb-1">
<span class="text-[10px] font-black text-slate-800 uppercase">#${s.service_ref}</span>
<span class="text-[8px] font-black uppercase px-2 py-0.5 rounded ${statusColor}">${s.status === 'archived' ? 'ARCH' : 'ACT'}</span>
</div>
<p class="text-sm font-black text-blue-600 truncate uppercase">${name}</p>
<div class="flex justify-between items-center mt-2">
<p class="text-[9px] font-bold text-slate-400">${date}</p>
<p class="text-[9px] font-bold text-slate-500 truncate pl-2">${raw["Población"] || ""}</p>
</div>
</div>
`;
});
lucide.createIcons();
}
function selectService(id, ref, statusText) {
currentServiceId = id;
renderServices();
const newUrl = new URL(window.location);
newUrl.searchParams.set('id', id);
window.history.pushState({}, '', newUrl);
document.getElementById('emptyState').classList.add('hidden');
document.getElementById('traceabilityView').classList.remove('hidden');
document.getElementById('currentSvcRef').innerText = `#${ref}`;
document.getElementById('currentSvcStatus').innerText = statusText;
document.getElementById('timelineContainer').innerHTML = `<div class="text-center py-10"><i data-lucide="loader-2" class="w-8 h-8 animate-spin mx-auto text-slate-300"></i></div>`;
lucide.createIcons();
loadLogsForService(id);
}
async function loadLogsForService(id) {
try {
const res = await fetch(`${API_URL}/services/${id}/logs`, {
headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` }
});
const data = await res.json();
const container = document.getElementById('timelineContainer');
document.getElementById('logCount').innerText = `${data.logs?.length || 0} Registros`;
container.innerHTML = "";
if (!data.logs || data.logs.length === 0) {
container.innerHTML = `<div class="bg-white p-6 rounded-2xl border text-center text-sm font-bold text-slate-400 italic">Sin actividad registrada.</div>`;
return;
}
data.logs.forEach((log, index) => {
const dateObj = new Date(log.created_at);
const fecha = dateObj.toLocaleDateString('es-ES', { day: '2-digit', month: 'short' });
const hora = dateObj.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
let icon = "activity", color = "bg-blue-50 text-blue-700 border-blue-200", dot = "bg-blue-500";
const txt = (log.action + " " + (log.details || "")).toLowerCase();
if (txt.includes('llamada') || txt.includes('contacto')) { icon="phone"; color="bg-teal-50 text-teal-700 border-teal-200"; dot="bg-teal-500"; }
else if (txt.includes('whatsapp')) { icon="message-circle"; color="bg-emerald-50 text-emerald-700 border-emerald-200"; dot="bg-emerald-500"; }
else if (txt.includes('nota')) { icon="sticky-note"; color="bg-amber-50 text-amber-700 border-amber-200"; dot="bg-amber-500"; }
else if (txt.includes('camino')) { icon="car"; color="bg-purple-50 text-purple-700 border-purple-200"; dot="bg-purple-500"; }
container.innerHTML += `
<div class="relative pl-12 pb-6 fade-in" style="animation-delay: ${index * 0.05}s">
<div class="absolute left-[15px] top-6 w-4 h-4 rounded-full border-2 border-white ring-4 ${dot} z-20"></div>
<div class="bg-white border border-slate-200 p-4 rounded-2xl shadow-sm">
<div class="flex justify-between mb-2">
<span class="px-2 py-1 rounded-lg text-[9px] font-black uppercase border ${color} flex items-center gap-1">
<i data-lucide="${icon}" class="w-3 h-3"></i> ${log.action}
</span>
<span class="text-[10px] font-bold text-slate-400">${fecha} | ${hora}</span>
</div>
<p class="text-sm text-slate-700 font-medium">${log.details || ""}</p>
<p class="mt-2 text-[9px] font-black text-slate-400 uppercase tracking-tighter">Por: ${log.user_name}</p>
</div>
</div>`;
});
lucide.createIcons();
} catch (e) { container.innerHTML = "Error de conexión."; }
}
async function addManualNote(btn) {
const input = document.getElementById('manualNoteInput');
const text = input.value.trim();
if (!text) return;
const old = btn.innerHTML;
btn.innerHTML = "..."; btn.disabled = true;
try {
const res = await fetch(`${API_URL}/services/${currentServiceId}/log`, {
method: 'POST',
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${localStorage.getItem("token")}` },
body: JSON.stringify({ action: "Nota Manual", details: text })
});
if(res.ok) { input.value = ""; loadLogsForService(currentServiceId); }
} catch (e) { showToast("Error al guardar"); }
finally { btn.innerHTML = old; btn.disabled = false; }
}
function showToast(msg) {
const t = document.getElementById('toast');
t.innerText = msg; t.classList.remove('hidden');
setTimeout(() => t.classList.add('hidden'), 3000);
}
</script>
</body>
</html>