353 lines
19 KiB
HTML
353 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Clientes - IntegraRepara</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://unpkg.com/lucide@latest"></script>
|
|
<style>
|
|
.fade-in { animation: fadeIn 0.2s ease-in-out; }
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
|
/* Ocultar scrollbar pero permitir scroll */
|
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
|
.selected-card { border-left: 4px solid #2563eb; background-color: #eff6ff; }
|
|
</style>
|
|
</head>
|
|
<body class="bg-gray-100 text-gray-800 font-sans h-screen overflow-hidden flex">
|
|
|
|
<div id="sidebar-container" class="h-full shrink-0"></div>
|
|
|
|
<div class="flex-1 flex flex-col h-full min-w-0">
|
|
<div id="header-container"></div>
|
|
|
|
<main class="flex-1 flex flex-row overflow-hidden relative">
|
|
|
|
<div class="w-full md:w-1/3 bg-white border-r border-gray-200 flex flex-col z-10 shadow-lg h-full">
|
|
|
|
<div class="p-4 border-b border-gray-100 bg-white">
|
|
<div class="flex justify-between items-center mb-3">
|
|
<h2 class="text-lg font-bold text-gray-800">Cartera de Clientes</h2>
|
|
</div>
|
|
<div class="relative">
|
|
<i data-lucide="search" class="w-4 h-4 absolute left-3 top-3 text-gray-400"></i>
|
|
<input type="text" id="searchInput" oninput="debounceSearch()" placeholder="Buscar por nombre, teléfono..." class="w-full pl-10 pr-4 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all">
|
|
</div>
|
|
</div>
|
|
|
|
<div id="clientsList" class="flex-1 overflow-y-auto no-scrollbar p-2 space-y-1">
|
|
<div class="text-center py-10 text-gray-400 text-sm">Cargando clientes...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="hidden md:flex flex-1 bg-gray-50 flex-col overflow-hidden relative" id="clientDetailPanel">
|
|
|
|
<div id="emptyState" class="absolute inset-0 flex flex-col items-center justify-center text-gray-400">
|
|
<div class="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-4">
|
|
<i data-lucide="users" class="w-10 h-10 text-gray-400"></i>
|
|
</div>
|
|
<p class="text-lg font-medium">Selecciona un cliente</p>
|
|
<p class="text-sm">o búscalo en el listado para ver su historial</p>
|
|
</div>
|
|
|
|
<div id="clientContent" class="hidden flex-col h-full fade-in">
|
|
|
|
<div class="bg-white p-6 border-b border-gray-200 shadow-sm shrink-0">
|
|
<div class="flex justify-between items-start">
|
|
<div class="flex items-center gap-4">
|
|
<div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center text-2xl font-black text-blue-600 uppercase shadow-inner border border-blue-200" id="detailAvatar">
|
|
C
|
|
</div>
|
|
<div>
|
|
<h1 class="text-2xl font-black text-gray-800 leading-tight uppercase" id="detailName">Nombre Cliente</h1>
|
|
<div class="flex items-center gap-3 mt-1 text-sm font-bold text-gray-500">
|
|
<span class="flex items-center gap-1"><i data-lucide="phone" class="w-3 h-3 text-blue-500"></i> <span id="detailPhone">...</span></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
<a id="btnCall" href="#" class="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition" title="Llamar"><i data-lucide="phone" class="w-5 h-5"></i></a>
|
|
<a id="btnWhatsapp" href="#" target="_blank" class="p-2 text-gray-500 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition" title="WhatsApp"><i data-lucide="message-circle" class="w-5 h-5"></i></a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
|
|
|
<div class="grid grid-cols-1 gap-6">
|
|
<div class="bg-white p-5 rounded-[1.5rem] shadow-sm border border-gray-100">
|
|
<h3 class="text-xs font-black text-gray-400 uppercase tracking-widest mb-4 flex items-center gap-2">
|
|
<i data-lucide="map-pin" class="w-4 h-4 text-rose-500"></i> Direcciones Encontradas
|
|
</h3>
|
|
<div id="addressList" class="space-y-3">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white rounded-[1.5rem] shadow-sm border border-gray-100 overflow-hidden">
|
|
<div class="p-5 border-b border-gray-100 bg-gray-50/50 flex justify-between items-center">
|
|
<h3 class="text-xs font-black text-gray-400 uppercase tracking-widest flex items-center gap-2">
|
|
<i data-lucide="folder-clock" class="w-4 h-4 text-blue-500"></i> Historial de Expedientes
|
|
</h3>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm text-left">
|
|
<thead class="text-[10px] font-black text-gray-400 uppercase tracking-widest bg-gray-50 border-b">
|
|
<tr>
|
|
<th class="px-6 py-4">Fecha Entrada</th>
|
|
<th class="px-6 py-4">Referencia</th>
|
|
<th class="px-6 py-4">Compañía</th>
|
|
<th class="px-6 py-4">Técnico</th>
|
|
<th class="px-6 py-4">Estado</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="servicesTableBody" class="text-gray-600 font-medium">
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</main>
|
|
</div>
|
|
|
|
<div id="toast" class="fixed bottom-5 right-5 bg-slate-800 text-white px-6 py-3 rounded-xl shadow-2xl translate-y-20 opacity-0 transition-all duration-300 z-[200] flex items-center gap-3 font-bold text-sm tracking-wide">
|
|
<i data-lucide="info" class="w-4 h-4"></i> <span id="toastMsg"></span>
|
|
</div>
|
|
|
|
<script src="js/layout.js"></script>
|
|
<script>
|
|
const API_URL = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
|
? 'http://localhost:3000'
|
|
: 'https://integrarepara-api.integrarepara.es';
|
|
|
|
let systemStatuses = [];
|
|
let searchTimeout = null;
|
|
|
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
if (!localStorage.getItem("token")) window.location.href = "index.html";
|
|
lucide.createIcons();
|
|
await loadStatuses();
|
|
// Iniciar búsqueda vacía para traer los últimos clientes
|
|
loadClients("");
|
|
});
|
|
|
|
async function loadStatuses() {
|
|
try {
|
|
const res = await fetch(`${API_URL}/statuses`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } });
|
|
const data = await res.json();
|
|
if (data.ok) systemStatuses = data.statuses;
|
|
} catch (e) { console.error("Error al cargar estados", e); }
|
|
}
|
|
|
|
// DEBOUNCE PARA NO SATURAR EL SERVIDOR AL ESCRIBIR
|
|
function debounceSearch() {
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => {
|
|
searchClients();
|
|
}, 300);
|
|
}
|
|
|
|
async function searchClients() {
|
|
const val = document.getElementById('searchInput').value.trim();
|
|
loadClients(val);
|
|
}
|
|
|
|
async function loadClients(search = "") {
|
|
const list = document.getElementById('clientsList');
|
|
list.innerHTML = `<div class="text-center py-10 text-blue-500 flex flex-col items-center gap-2"><i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i><span class="text-xs font-bold uppercase tracking-widest">Buscando...</span></div>`;
|
|
lucide.createIcons();
|
|
|
|
try {
|
|
// AQUÍ ES DONDE ESTÁ LA MAGIA. Buscamos directamente en scraped_services
|
|
const res = await fetch(`${API_URL}/services/active`, {
|
|
headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` }
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (!data.ok) throw new Error("Error en la carga");
|
|
|
|
// Agrupamos todos los servicios por teléfono para simular "Clientes"
|
|
let clientsMap = {};
|
|
|
|
data.services.forEach(s => {
|
|
const raw = s.raw_data || {};
|
|
let phone = raw['Teléfono'] || raw['TELEFONO'] || raw['TELEFONOS'] || "";
|
|
|
|
// Normalizar teléfono
|
|
const matchPhone = String(phone).match(/[6789]\d{8}/);
|
|
const cleanPhone = matchPhone ? matchPhone[0] : null;
|
|
|
|
if (cleanPhone) {
|
|
if (!clientsMap[cleanPhone]) {
|
|
clientsMap[cleanPhone] = {
|
|
id: cleanPhone, // Usamos el tfno como ID
|
|
full_name: raw['Nombre Cliente'] || raw['CLIENTE'] || "Cliente Sin Nombre",
|
|
phone: cleanPhone,
|
|
addresses: new Set(),
|
|
services: []
|
|
};
|
|
}
|
|
|
|
const addr = `${raw['Dirección'] || ''} ${raw['Población'] || ''}`.trim();
|
|
if (addr) clientsMap[cleanPhone].addresses.add(addr);
|
|
clientsMap[cleanPhone].services.push(s);
|
|
}
|
|
});
|
|
|
|
// Convertir mapa a Array y Filtrar si hay texto
|
|
let clientsArray = Object.values(clientsMap);
|
|
|
|
if (search !== "") {
|
|
const sLower = search.toLowerCase();
|
|
clientsArray = clientsArray.filter(c =>
|
|
c.full_name.toLowerCase().includes(sLower) ||
|
|
c.phone.includes(sLower)
|
|
);
|
|
}
|
|
|
|
// Ordenar por número de expedientes
|
|
clientsArray.sort((a, b) => b.services.length - a.services.length);
|
|
|
|
// Dibujar la lista
|
|
list.innerHTML = "";
|
|
if(clientsArray.length === 0) {
|
|
list.innerHTML = `<div class="text-center py-8 text-gray-400 text-xs font-bold uppercase tracking-widest border-2 border-dashed border-gray-200 rounded-xl m-2">Sin resultados</div>`;
|
|
return;
|
|
}
|
|
|
|
clientsArray.forEach(c => {
|
|
const el = document.createElement('div');
|
|
el.className = `p-3 mb-1 rounded-xl cursor-pointer hover:bg-blue-50 transition border border-transparent hover:border-blue-100 flex justify-between items-center group`;
|
|
el.id = `client-card-${c.id}`;
|
|
|
|
// Al hacer click, pintamos el detalle
|
|
el.onclick = () => renderClientDetails(c, el.id);
|
|
|
|
el.innerHTML = `
|
|
<div class="flex items-center gap-3 overflow-hidden">
|
|
<div class="w-10 h-10 rounded-full bg-slate-100 text-slate-500 flex items-center justify-center font-black text-sm shrink-0 border border-slate-200 group-hover:bg-blue-600 group-hover:text-white group-hover:border-blue-600 transition-colors uppercase">
|
|
${c.full_name.charAt(0)}
|
|
</div>
|
|
<div class="min-w-0">
|
|
<p class="font-black text-slate-700 text-sm truncate uppercase tracking-tight">${c.full_name}</p>
|
|
<p class="text-xs font-bold text-slate-400 truncate mt-0.5">${c.phone}</p>
|
|
</div>
|
|
</div>
|
|
<div class="text-right shrink-0">
|
|
<span class="bg-slate-100 text-slate-500 group-hover:bg-blue-100 group-hover:text-blue-700 transition-colors text-[10px] font-black px-2 py-1 rounded border border-slate-200 group-hover:border-blue-200">
|
|
${c.services.length} EXP
|
|
</span>
|
|
</div>
|
|
`;
|
|
list.appendChild(el);
|
|
});
|
|
|
|
} catch(e) {
|
|
console.error(e);
|
|
list.innerHTML = `<div class="text-center py-8 text-red-400 text-xs font-bold uppercase tracking-widest border-2 border-dashed border-red-200 rounded-xl m-2">Error de carga</div>`;
|
|
}
|
|
}
|
|
|
|
// 3. RENDERIZAR DETALLES
|
|
function renderClientDetails(clientData, cardId) {
|
|
// UI Toggle
|
|
document.getElementById('emptyState').classList.add('hidden');
|
|
document.getElementById('clientContent').classList.remove('hidden');
|
|
document.getElementById('clientContent').classList.add('flex');
|
|
|
|
// Resaltar seleccionado en la lista
|
|
document.querySelectorAll('#clientsList > div').forEach(d => d.classList.remove('selected-card'));
|
|
const card = document.getElementById(cardId);
|
|
if(card) card.classList.add('selected-card');
|
|
|
|
// Rellenar Cabecera
|
|
document.getElementById('detailAvatar').innerText = clientData.full_name.charAt(0).toUpperCase();
|
|
document.getElementById('detailName').innerText = clientData.full_name;
|
|
document.getElementById('detailPhone').innerText = clientData.phone;
|
|
|
|
// Botones Acción
|
|
document.getElementById('btnCall').href = `tel:+34${clientData.phone}`;
|
|
document.getElementById('btnWhatsapp').href = `https://wa.me/34${clientData.phone}`;
|
|
|
|
// Direcciones
|
|
const addrContainer = document.getElementById('addressList');
|
|
addrContainer.innerHTML = "";
|
|
const addresses = Array.from(clientData.addresses);
|
|
|
|
if(addresses.length === 0) {
|
|
addrContainer.innerHTML = `<p class="text-xs font-bold text-gray-400 uppercase tracking-widest">Sin dirección registrada.</p>`;
|
|
} else {
|
|
addresses.forEach(addr => {
|
|
const div = document.createElement('div');
|
|
div.className = "flex justify-between items-center text-xs font-bold text-slate-600 p-3 bg-slate-50 rounded-xl border border-slate-100 uppercase";
|
|
div.innerHTML = `
|
|
<span class="truncate pr-4"><i data-lucide="map-pin" class="w-3 h-3 inline mr-1 text-slate-400"></i> ${addr}</span>
|
|
<a href="https://maps.google.com/?q=${encodeURIComponent(addr)}" target="_blank" class="text-blue-500 hover:text-blue-700 bg-blue-50 p-2 rounded-lg border border-blue-100 transition-colors">
|
|
<i data-lucide="external-link" class="w-4 h-4"></i>
|
|
</a>
|
|
`;
|
|
addrContainer.appendChild(div);
|
|
});
|
|
}
|
|
|
|
// Tabla de Servicios
|
|
const tbody = document.getElementById('servicesTableBody');
|
|
tbody.innerHTML = "";
|
|
|
|
// Ordenar servicios del más nuevo al más antiguo
|
|
clientData.services.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
|
|
|
clientData.services.forEach(s => {
|
|
const tr = document.createElement('tr');
|
|
tr.className = "border-b border-gray-100 hover:bg-blue-50/30 transition-colors";
|
|
|
|
const raw = s.raw_data || {};
|
|
const date = new Date(s.created_at).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
|
|
|
// Buscar estado real
|
|
const dbStatusId = raw.status_operativo;
|
|
const statusObj = systemStatuses.find(st => String(st.id) === String(dbStatusId));
|
|
const stName = statusObj ? statusObj.name : (s.status_name || 'Pendiente');
|
|
|
|
// Color del estado (si es finalizado lo ponemos gris)
|
|
const isFinal = s.is_final || (statusObj && statusObj.is_final) || s.status === 'archived';
|
|
const badgeClass = isFinal
|
|
? "bg-slate-100 text-slate-500 border-slate-200"
|
|
: "bg-blue-50 text-blue-700 border-blue-200";
|
|
|
|
tr.innerHTML = `
|
|
<td class="px-6 py-4 font-mono text-xs font-bold text-slate-500">${date}</td>
|
|
<td class="px-6 py-4 font-black text-slate-800 text-xs">#${s.service_ref}</td>
|
|
<td class="px-6 py-4 text-xs font-bold text-slate-500 uppercase">${raw['Compañía'] || 'Particular'}</td>
|
|
<td class="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1.5 mt-1.5">
|
|
<i data-lucide="hard-hat" class="w-3 h-3"></i> ${s.assigned_name || 'Sin Asignar'}
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
<span class="${badgeClass} px-3 py-1.5 rounded-lg text-[9px] font-black uppercase tracking-widest border shadow-sm">
|
|
${stName}
|
|
</span>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function showToast(msg, isError = false) {
|
|
const t = document.getElementById('toast');
|
|
const m = document.getElementById('toastMsg');
|
|
t.className = `fixed bottom-5 right-5 px-6 py-4 rounded-xl shadow-2xl transition-all duration-300 z-[200] flex items-center gap-3 font-bold text-sm tracking-wide ${isError ? 'bg-red-600' : 'bg-slate-800'} text-white`;
|
|
m.innerText = msg;
|
|
t.classList.remove('translate-y-20', 'opacity-0');
|
|
setTimeout(() => t.classList.add('translate-y-20', 'opacity-0'), 3000);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html> |