Actualizar servicios.html

This commit is contained in:
2026-02-20 16:48:13 +00:00
parent 5c02d5f313
commit 67b6526072

View File

@@ -9,10 +9,33 @@
<style> <style>
.fade-in { animation: fadeIn 0.3s ease-in-out; } .fade-in { animation: fadeIn 0.3s ease-in-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.no-scrollbar::-webkit-scrollbar { display: none; }
.card-hover:hover { transform: translateY(-2px); transition: all 0.2s; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05); }
/* ANIMACIÓN DE TEMBLOR (SHAKE) PARA TARJETAS BLOQUEADAS */ /* Ocultar barra de scroll pero mantener funcionalidad */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* Scroll horizontal bonito para el Kanban */
.kanban-board {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
gap: 1.5rem;
padding-bottom: 1rem;
}
.kanban-board::-webkit-scrollbar { height: 8px; }
.kanban-board::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 10px; }
.kanban-board::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
.kanban-board::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
.kanban-column {
min-width: 320px;
max-width: 320px;
scroll-snap-align: start;
}
.card-hover { transition: all 0.2s ease; cursor: pointer; }
.card-hover:hover { transform: translateY(-3px); box-shadow: 0 12px 25px -5px rgba(0, 0, 0, 0.1); border-color: #93c5fd; }
.shake { animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both; } .shake { animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both; }
@keyframes shake { @keyframes shake {
10%, 90% { transform: translate3d(-2px, 0, 0); } 10%, 90% { transform: translate3d(-2px, 0, 0); }
@@ -20,6 +43,8 @@
30%, 50%, 70% { transform: translate3d(-6px, 0, 0); } 30%, 50%, 70% { transform: translate3d(-6px, 0, 0); }
40%, 60% { transform: translate3d(6px, 0, 0); } 40%, 60% { transform: translate3d(6px, 0, 0); }
} }
.pulse-slow { animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
</style> </style>
</head> </head>
<body class="bg-slate-50 text-slate-800 font-sans antialiased text-left"> <body class="bg-slate-50 text-slate-800 font-sans antialiased text-left">
@@ -30,63 +55,45 @@
<div class="flex-1 flex flex-col overflow-hidden relative"> <div class="flex-1 flex flex-col overflow-hidden relative">
<div id="header-container"></div> <div id="header-container"></div>
<main class="flex-1 overflow-x-hidden overflow-y-auto bg-slate-50 p-6 no-scrollbar text-left relative"> <main class="flex-1 overflow-hidden flex flex-col bg-slate-50 p-6 relative">
<div class="fade-in max-w-full mx-auto space-y-6">
<div class="flex justify-between items-center bg-white p-6 rounded-[2rem] shadow-sm border border-slate-100"> <div class="flex justify-between items-center bg-white p-6 rounded-[2rem] shadow-sm border border-slate-100 shrink-0 mb-6">
<div> <div>
<h2 class="text-2xl font-black text-slate-800 tracking-tight flex items-center gap-3"> <h2 class="text-2xl font-black text-slate-800 tracking-tight flex items-center gap-3">
<span class="bg-blue-600 p-2.5 rounded-xl text-white shadow-lg shadow-blue-200"><i data-lucide="kanban"></i></span> <span class="bg-blue-600 p-2.5 rounded-xl text-white shadow-lg shadow-blue-200"><i data-lucide="kanban"></i></span>
PANEL OPERATIVO PANEL OPERATIVO
</h2> </h2>
<p class="text-sm text-slate-500 mt-1 font-medium">Tablero Kanban de gestión de expedientes.</p> <p class="text-sm text-slate-500 mt-1 font-medium">Tablero Kanban de gestión de expedientes activos.</p>
</div> </div>
<button onclick="openCreateModal()" class="bg-slate-900 hover:bg-blue-600 text-white px-6 py-3.5 rounded-2xl shadow-xl flex items-center gap-3 font-black text-xs uppercase tracking-widest transition-all active:scale-95"> <button onclick="openCreateModal()" class="bg-slate-900 hover:bg-blue-600 text-white px-6 py-3.5 rounded-2xl shadow-xl flex items-center gap-3 font-black text-xs uppercase tracking-widest transition-all active:scale-95">
<i data-lucide="plus-circle" class="w-5 h-5"></i> Nuevo Servicio <i data-lucide="plus-circle" class="w-5 h-5"></i> Nuevo Servicio
</button> </button>
</div> </div>
<div class="flex flex-wrap gap-4 items-center bg-white p-4 rounded-[1.5rem] shadow-sm border border-slate-100 mb-6"> <div class="flex flex-wrap gap-4 items-center bg-white p-4 rounded-[1.5rem] shadow-sm border border-slate-100 mb-6 shrink-0">
<div class="relative flex-1 min-w-[250px]"> <div class="relative flex-1 min-w-[250px]">
<i data-lucide="search" class="w-4 h-4 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400"></i> <i data-lucide="search" class="w-4 h-4 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400"></i>
<input type="text" id="searchFilter" oninput="renderLists()" placeholder="Buscar por cliente, REF, población, teléfono..." class="w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-blue-500 outline-none transition-all"> <input type="text" id="searchFilter" oninput="renderBoard()" placeholder="Buscar por cliente, REF, población, teléfono, compañía..." class="w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-blue-500 outline-none transition-all">
</div> </div>
<div class="relative w-full md:w-64"> <div class="flex gap-3 w-full md:w-auto">
<select id="opFilter" onchange="renderLists()" class="w-full bg-slate-50 border border-slate-200 text-xs font-black px-4 py-3 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 uppercase tracking-widest text-slate-600 appearance-none pr-10 cursor-pointer"> <div class="relative min-w-[200px]">
<select id="opFilter" onchange="renderBoard()" class="w-full bg-slate-50 border border-slate-200 text-xs font-black px-4 py-3 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 uppercase tracking-widest text-slate-600 appearance-none pr-10 cursor-pointer">
<option value="ALL">TODOS LOS OPERARIOS</option> <option value="ALL">TODOS LOS OPERARIOS</option>
</select> </select>
<i data-lucide="chevron-down" class="w-4 h-4 absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none"></i> <i data-lucide="chevron-down" class="w-4 h-4 absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none"></i>
</div> </div>
<div class="relative min-w-[200px]">
<select id="statusFilter" onchange="renderBoard()" class="w-full bg-slate-50 border border-slate-200 text-xs font-black px-4 py-3 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 uppercase tracking-widest text-slate-600 appearance-none pr-10 cursor-pointer">
<option value="ALL">TODOS LOS ESTADOS</option>
</select>
<i data-lucide="chevron-down" class="w-4 h-4 absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none"></i>
</div>
</div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6"> <div id="kanbanBoard" class="kanban-board flex-1">
<div class="space-y-4 bg-slate-100/50 p-4 rounded-[2rem] border border-slate-200/60">
<div class="flex items-center gap-2 px-2 pb-2 border-b border-slate-200">
<div class="w-2.5 h-2.5 rounded-full bg-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.5)]"></div>
<h3 class="font-black text-slate-600 uppercase text-[10px] tracking-widest">Sin Asignar / En Pausa</h3>
</div>
<div id="unassigned-list" class="space-y-3"></div>
</div> </div>
<div class="space-y-4 bg-slate-100/50 p-4 rounded-[2rem] border border-slate-200/60">
<div class="flex items-center gap-2 px-2 pb-2 border-b border-slate-200">
<div class="w-2.5 h-2.5 rounded-full bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.5)]"></div>
<h3 class="font-black text-slate-600 uppercase text-[10px] tracking-widest">Asignados (Falta Fecha)</h3>
</div>
<div id="pending-list" class="space-y-3"></div>
</div>
<div class="space-y-4 bg-slate-100/50 p-4 rounded-[2rem] border border-slate-200/60">
<div class="flex items-center gap-2 px-2 pb-2 border-b border-slate-200">
<div class="w-2.5 h-2.5 rounded-full bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.5)]"></div>
<h3 class="font-black text-slate-600 uppercase text-[10px] tracking-widest">Citados / En Curso</h3>
</div>
<div id="assigned-list" class="space-y-3"></div>
</div>
</div>
</div>
</main> </main>
</div> </div>
</div> </div>
@@ -142,7 +149,6 @@
<h3 class="font-black text-slate-800 text-xl leading-none tracking-tight">#<span id="detRef"></span></h3> <h3 class="font-black text-slate-800 text-xl leading-none tracking-tight">#<span id="detRef"></span></h3>
</div> </div>
</div> </div>
<div class="flex flex-col items-end text-right"> <div class="flex flex-col items-end text-right">
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">Compañía Aseguradora</p> <p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">Compañía Aseguradora</p>
<span id="detCompany" class="bg-white border border-slate-200 px-3 py-1 rounded-lg text-xs font-black text-slate-700 mt-1 uppercase shadow-sm truncate max-w-[250px]"></span> <span id="detCompany" class="bg-white border border-slate-200 px-3 py-1 rounded-lg text-xs font-black text-slate-700 mt-1 uppercase shadow-sm truncate max-w-[250px]"></span>
@@ -164,8 +170,7 @@
<i data-lucide="phone" class="w-3.5 h-3.5"></i> <span id="detPhone"></span> <i data-lucide="phone" class="w-3.5 h-3.5"></i> <span id="detPhone"></span>
</a> </a>
<button onclick="copyClientPortalLink()" id="btnPortalLink" class="text-[10px] font-black bg-blue-50 border border-blue-100 hover:bg-blue-600 hover:text-white text-blue-600 px-3 py-1.5 rounded-lg flex items-center gap-1.5 transition-all shadow-sm active:scale-95"> <button onclick="copyClientPortalLink()" id="btnPortalLink" class="text-[10px] font-black bg-blue-50 border border-blue-100 hover:bg-blue-600 hover:text-white text-blue-600 px-3 py-1.5 rounded-lg flex items-center gap-1.5 transition-all shadow-sm active:scale-95">
<i data-lucide="link" class="w-3 h-3"></i> <i data-lucide="link" class="w-3 h-3"></i> Copiar Portal Cliente
Copiar Portal Cliente
</button> </button>
</div> </div>
</div> </div>
@@ -178,7 +183,6 @@
</p> </p>
</div> </div>
</div> </div>
<div> <div>
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-2 ml-2">Descripción de la Avería</p> <p class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-2 ml-2">Descripción de la Avería</p>
<div class="bg-amber-50/60 border border-amber-100 p-5 rounded-[1.5rem] text-sm font-medium text-slate-700 min-h-[120px] max-h-48 overflow-y-auto no-scrollbar shadow-inner leading-relaxed" id="detDesc"></div> <div class="bg-amber-50/60 border border-amber-100 p-5 rounded-[1.5rem] text-sm font-medium text-slate-700 min-h-[120px] max-h-48 overflow-y-auto no-scrollbar shadow-inner leading-relaxed" id="detDesc"></div>
@@ -207,12 +211,6 @@
<p class="text-[10px] font-black text-slate-800 uppercase ml-1 flex items-center gap-1.5"><i data-lucide="arrow-right-left" class="w-4 h-4 text-blue-500"></i> Cambio de Estado</p> <p class="text-[10px] font-black text-slate-800 uppercase ml-1 flex items-center gap-1.5"><i data-lucide="arrow-right-left" class="w-4 h-4 text-blue-500"></i> Cambio de Estado</p>
<div class="relative"> <div class="relative">
<select id="detStatusMap" class="w-full bg-slate-800 text-white border-none p-4 rounded-xl text-xs font-bold shadow-lg outline-none cursor-pointer appearance-none pr-10"> <select id="detStatusMap" class="w-full bg-slate-800 text-white border-none p-4 rounded-xl text-xs font-bold shadow-lg outline-none cursor-pointer appearance-none pr-10">
<option value="sin_asignar">🔴 SIN ASIGNAR / EN PAUSA</option>
<option value="citado">📅 CITADO (Visita programada)</option>
<option value="de_camino">🚗 DE CAMINO AL DOMICILIO</option>
<option value="trabajando">🛠️ TRABAJANDO EN EL LUGAR</option>
<option value="incidencia">⚠️ CON INCIDENCIA / PAUSADO</option>
<option value="terminado">✅ TRABAJO TERMINADO</option>
</select> </select>
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400 absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none"></i> <i data-lucide="chevron-down" class="w-4 h-4 text-slate-400 absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none"></i>
</div> </div>
@@ -264,15 +262,48 @@
<script src="js/layout.js"></script> <script src="js/layout.js"></script>
<script> <script>
let localData = []; let localData = [];
let systemStatuses = []; // Guardará todos los estados de la DB
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
if (!localStorage.getItem("token")) window.location.href = "index.html"; if (!localStorage.getItem("token")) window.location.href = "index.html";
refreshPanel(); loadInitialData();
loadGuilds(); setInterval(refreshPanelOnly, 20000);
setInterval(refreshPanel, 20000);
}); });
async function refreshPanel() { async function loadInitialData() {
await loadStatuses();
await loadGuilds();
await refreshPanelOnly();
}
// NUEVO: Cargar los estados reales desde la DB
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;
// Rellenar filtro superior
const filterSelect = document.getElementById('statusFilter');
filterSelect.innerHTML = '<option value="ALL">TODOS LOS ESTADOS</option>';
systemStatuses.forEach(st => {
// Omitimos los finales (Terminado/Anulado) por defecto en el panel operativo para no saturarlo,
// pero los dejamos en el filtro para que se puedan buscar
filterSelect.innerHTML += `<option value="${st.id}">${st.name}</option>`;
});
// Rellenar selector del modal de detalles
const modalSelect = document.getElementById('detStatusMap');
modalSelect.innerHTML = '';
systemStatuses.forEach(st => {
modalSelect.innerHTML += `<option value="${st.id}">👉 ${st.name}</option>`;
});
}
} catch (e) { console.error("Error cargando estados:", e); }
}
async function refreshPanelOnly() {
try { try {
const res = await fetch(`${API_URL}/services/active`, { const res = await fetch(`${API_URL}/services/active`, {
headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` }
@@ -281,7 +312,7 @@
if (data.ok) { if (data.ok) {
localData = data.services; localData = data.services;
updateOperatorFilter(); updateOperatorFilter();
renderLists(); renderBoard();
} }
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
@@ -296,63 +327,107 @@
if (html.includes(`value="${currentValue}"`)) opSelect.value = currentValue; if (html.includes(`value="${currentValue}"`)) opSelect.value = currentValue;
} }
function renderLists() { // ==========================================
// 🚀 NUEVO RENDERIZADOR DE KANBAN DINÁMICO
// ==========================================
function renderBoard() {
if(systemStatuses.length === 0) return; // Si no hay estados cargados, no hace nada
const searchTerm = document.getElementById('searchFilter').value.toLowerCase(); const searchTerm = document.getElementById('searchFilter').value.toLowerCase();
const selectedOp = document.getElementById('opFilter').value; const selectedOp = document.getElementById('opFilter').value;
const selectedStatus = document.getElementById('statusFilter').value;
// Filtro Global
const filteredData = localData.filter(s => { const filteredData = localData.filter(s => {
const raw = s.raw_data; const raw = s.raw_data;
const name = (raw["Nombre Cliente"] || raw["CLIENTE"] || "").toLowerCase(); const name = (raw["Nombre Cliente"] || raw["CLIENTE"] || "").toLowerCase();
const addr = (raw["Dirección"] || raw["DOMICILIO"] || "").toLowerCase(); const addr = (raw["Dirección"] || raw["DOMICILIO"] || "").toLowerCase();
const comp = (raw["Compañía"] || raw["COMPAÑIA"] || raw["Procedencia"] || "").toLowerCase();
const pop = (raw["Población"] || raw["POBLACION-PROVINCIA"] || "").toLowerCase(); const pop = (raw["Población"] || raw["POBLACION-PROVINCIA"] || "").toLowerCase();
const phone = (raw["Teléfono"] || raw["TELEFONO"] || "").toLowerCase(); const phone = (raw["Teléfono"] || raw["TELEFONO"] || "").toLowerCase();
const ref = (s.service_ref || "").toLowerCase(); const ref = (s.service_ref || "").toLowerCase();
const assigned = s.assigned_name || ""; const assigned = s.assigned_name || "";
const matchesSearch = searchTerm === "" || name.includes(searchTerm) || ref.includes(searchTerm) || addr.includes(searchTerm) || pop.includes(searchTerm) || phone.includes(searchTerm); // Mapeo inverso: El backend antiguo de "scraped" guarda textos como 'citado'.
const matchesOp = selectedOp === "ALL" || assigned === selectedOp; // Ahora debemos casarlos con los IDs reales de systemStatuses.
// Para no romper la DB existente, buscamos por ID o por coincidencia de nombre.
let currentStatusId = "ALL";
const dbStatusText = s.raw_data.status_operativo;
return matchesSearch && matchesOp; if (!s.assigned_name || dbStatusText === 'sin_asignar') {
currentStatusId = systemStatuses.find(st => st.name.toLowerCase().includes('pendiente'))?.id || "ALL";
} else if (dbStatusText === 'asignado_operario') {
currentStatusId = systemStatuses.find(st => st.name.toLowerCase() === 'asignado')?.id || "ALL";
} else if (dbStatusText === 'citado' || (!dbStatusText && s.raw_data.scheduled_date)) {
currentStatusId = systemStatuses.find(st => st.name.toLowerCase().includes('citado'))?.id || "ALL";
} else {
// Si el status_operativo de la DB ya es un ID numérico (del nuevo selector)
const foundStatus = systemStatuses.find(st => String(st.id) === String(dbStatusText));
if (foundStatus) currentStatusId = foundStatus.id;
}
// Guardamos el ID calculado en el objeto para poder agruparlo luego fácilmente
s._calculated_status_id = String(currentStatusId);
const matchesSearch = searchTerm === "" || name.includes(searchTerm) || ref.includes(searchTerm) || addr.includes(searchTerm) || pop.includes(searchTerm) || phone.includes(searchTerm) || comp.includes(searchTerm);
const matchesOp = selectedOp === "ALL" || assigned === selectedOp;
const matchesStatus = selectedStatus === "ALL" || String(currentStatusId) === String(selectedStatus);
return matchesSearch && matchesOp && matchesStatus;
}); });
const unassigned = filteredData.filter(s => !s.assigned_name || s.raw_data.status_operativo === 'sin_asignar'); const board = document.getElementById('kanbanBoard');
const pending = filteredData.filter(s => s.assigned_name && (!s.raw_data.scheduled_date || s.raw_data.scheduled_date === "") && s.raw_data.status_operativo !== 'sin_asignar'); board.innerHTML = ""; // Limpiamos el tablero
const assigned = filteredData.filter(s => s.raw_data.scheduled_date && s.raw_data.scheduled_date !== "" && s.raw_data.status_operativo !== 'sin_asignar');
document.getElementById('unassigned-list').innerHTML = unassigned.length > 0 // Mapeo de colores de Tailwind para los estados dinámicos
? unassigned.map(s => { const colorMap = {
let color = 'rose'; 'gray': { bg: 'bg-slate-100/50', border: 'border-slate-200/60', dot: 'bg-slate-500', shadow: 'shadow-slate-500/50' },
let label = s.assigned_name ? 'Pausado' : 'Sin Asignar'; 'blue': { bg: 'bg-blue-50/50', border: 'border-blue-200/60', dot: 'bg-blue-500', shadow: 'shadow-blue-500/50' },
'emerald': { bg: 'bg-emerald-50/50', border: 'border-emerald-200/60', dot: 'bg-emerald-500', shadow: 'shadow-emerald-500/50' },
'rose': { bg: 'bg-rose-50/50', border: 'border-rose-200/60', dot: 'bg-rose-500', shadow: 'shadow-rose-500/50' },
'amber': { bg: 'bg-amber-50/50', border: 'border-amber-200/60', dot: 'bg-amber-500', shadow: 'shadow-amber-500/50' },
'indigo': { bg: 'bg-indigo-50/50', border: 'border-indigo-200/60', dot: 'bg-indigo-500', shadow: 'shadow-indigo-500/50' },
'purple': { bg: 'bg-purple-50/50', border: 'border-purple-200/60', dot: 'bg-purple-500', shadow: 'shadow-purple-500/50' },
'red': { bg: 'bg-red-50/50', border: 'border-red-200/60', dot: 'bg-red-500', shadow: 'shadow-red-500/50' }
};
// COLORES INTELIGENTES PARA LA BOLSA DE TRABAJO // Creamos una columna para cada estado (Ocultamos los finales a menos que se filtren explícitamente)
if (!s.assigned_name) { systemStatuses.forEach(st => {
if (s.automation_status === 'in_progress') { color = 'amber'; label = 'Buscando (WA)'; } if (st.is_final && selectedStatus !== String(st.id) && searchTerm === "") return; // No pintar Finalizados en vista general salvo que se busque
if (s.automation_status === 'failed') { color = 'orange'; label = 'En Bolsa'; }
}
return cardTemplate(s, color, label);
}).join('')
: '<p class="text-center py-10 text-slate-300 text-xs font-bold uppercase border-2 border-dashed border-slate-200/50 rounded-[1.5rem]">Vacío</p>';
document.getElementById('pending-list').innerHTML = pending.length > 0 const servicesInThisStatus = filteredData.filter(s => s._calculated_status_id === String(st.id));
? pending.map(s => cardTemplate(s, 'blue', 'Falta Fecha')).join('') const colors = colorMap[st.color] || colorMap['gray'];
: '<p class="text-center py-10 text-slate-300 text-xs font-bold uppercase border-2 border-dashed border-slate-200/50 rounded-[1.5rem]">Vacío</p>';
document.getElementById('assigned-list').innerHTML = assigned.length > 0 const column = document.createElement('div');
? assigned.map(s => { column.className = `kanban-column space-y-4 p-4 rounded-[2rem] border ${colors.bg} ${colors.border}`;
let color = 'emerald';
let label = 'Citado'; let cardsHtml = servicesInThisStatus.length > 0
if(s.raw_data.status_operativo === 'de_camino') { color = 'blue'; label = 'De Camino'; } ? servicesInThisStatus.map(s => cardTemplate(s, st.color, st.name)).join('')
if(s.raw_data.status_operativo === 'trabajando') { color = 'amber'; label = 'Trabajando'; } : '<p class="text-center py-10 text-slate-400 text-xs font-bold uppercase border-2 border-dashed border-slate-200/50 rounded-[1.5rem] bg-white/30">Vacío</p>';
if(s.raw_data.status_operativo === 'incidencia') { color = 'red'; label = 'Incidencia'; }
if(s.raw_data.status_operativo === 'terminado') { color = 'purple'; label = 'Terminado'; } column.innerHTML = `
return cardTemplate(s, color, label); <div class="flex items-center justify-between px-2 pb-2 border-b ${colors.border}">
}).join('') <div class="flex items-center gap-2">
: '<p class="text-center py-10 text-slate-300 text-xs font-bold uppercase border-2 border-dashed border-slate-200/50 rounded-[1.5rem]">Vacío</p>'; <div class="w-2.5 h-2.5 rounded-full ${colors.dot} shadow-[0_0_10px_currentColor]"></div>
<h3 class="font-black text-slate-700 uppercase text-[10px] tracking-widest">${st.name}</h3>
</div>
<span class="text-[10px] font-black text-slate-400 bg-white px-2 py-0.5 rounded-lg shadow-sm">${servicesInThisStatus.length}</span>
</div>
<div class="space-y-3 overflow-y-auto max-h-[calc(100vh-280px)] no-scrollbar pb-10">
${cardsHtml}
</div>
`;
board.appendChild(column);
});
lucide.createIcons(); lucide.createIcons();
} }
function cardTemplate(s, color, label) { // ==========================================
// RESTO DE FUNCIONES INTACTAS
// ==========================================
function cardTemplate(s, colorName, label) {
const raw = s.raw_data || {}; const raw = s.raw_data || {};
const name = raw["Nombre Cliente"] || raw["CLIENTE"] || "Asegurado Sin Nombre"; const name = raw["Nombre Cliente"] || raw["CLIENTE"] || "Asegurado Sin Nombre";
const addr = raw["Dirección"] || raw["DOMICILIO"] || "---"; const addr = raw["Dirección"] || raw["DOMICILIO"] || "---";
@@ -362,11 +437,12 @@
const cita = raw.scheduled_date ? `${raw.scheduled_date} | ${raw.scheduled_time}` : 'Pendiente Cita'; const cita = raw.scheduled_date ? `${raw.scheduled_date} | ${raw.scheduled_time}` : 'Pendiente Cita';
const companyName = raw['Compañía'] || raw['COMPAÑIA'] || raw['Procedencia'] || (s.provider === 'MANUAL' ? 'PARTICULAR' : 'ASEGURADORA'); const companyName = raw['Compañía'] || raw['COMPAÑIA'] || raw['Procedencia'] || (s.provider === 'MANUAL' ? 'PARTICULAR' : 'ASEGURADORA');
let iconEstado = 'calendar'; let iconEstado = 'tag';
if(raw.status_operativo === 'de_camino') iconEstado = 'car'; if(raw.scheduled_date) iconEstado = 'calendar';
if(raw.status_operativo === 'trabajando') iconEstado = 'wrench'; if(label.toLowerCase().includes('camino')) iconEstado = 'car';
if(raw.status_operativo === 'incidencia') iconEstado = 'alert-triangle'; if(label.toLowerCase().includes('reparaci') || label.toLowerCase().includes('trabaja')) iconEstado = 'wrench';
if(raw.status_operativo === 'terminado') iconEstado = 'check-circle'; if(label.toLowerCase().includes('incidencia')) iconEstado = 'alert-triangle';
if(label.toLowerCase().includes('terminad') || label.toLowerCase().includes('finaliz')) iconEstado = 'check-circle';
const colorClasses = { const colorClasses = {
'rose': 'bg-rose-100 text-rose-600', 'rose': 'bg-rose-100 text-rose-600',
@@ -375,9 +451,11 @@
'amber': 'bg-amber-100 text-amber-600', 'amber': 'bg-amber-100 text-amber-600',
'orange': 'bg-orange-100 text-orange-600', 'orange': 'bg-orange-100 text-orange-600',
'red': 'bg-red-100 text-red-600', 'red': 'bg-red-100 text-red-600',
'purple': 'bg-purple-100 text-purple-600' 'purple': 'bg-purple-100 text-purple-600',
'indigo': 'bg-indigo-100 text-indigo-600',
'gray': 'bg-slate-200 text-slate-600'
}; };
const badgeClass = colorClasses[color] || 'bg-slate-100 text-slate-600'; const badgeClass = colorClasses[colorName] || colorClasses['gray'];
const isBlocked = !s.assigned_name && (s.automation_status === 'in_progress' || s.automation_status === 'failed'); const isBlocked = !s.assigned_name && (s.automation_status === 'in_progress' || s.automation_status === 'failed');
const clickAction = isBlocked ? `shakeCard(this, '${s.automation_status}'); event.stopPropagation();` : `openDetail(${s.id})`; const clickAction = isBlocked ? `shakeCard(this, '${s.automation_status}'); event.stopPropagation();` : `openDetail(${s.id})`;
@@ -386,14 +464,14 @@
return ` return `
<div class="bg-white p-4 rounded-3xl border border-slate-200 shadow-sm card-hover text-left flex flex-col gap-3 relative ${cursorStyle}" onclick="${clickAction}"> <div class="bg-white p-4 rounded-3xl border border-slate-200 shadow-sm card-hover text-left flex flex-col gap-3 relative ${cursorStyle}" onclick="${clickAction}">
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<span class="text-[9px] font-black ${badgeClass} px-2.5 py-1 rounded-lg uppercase tracking-wider shadow-sm flex items-center gap-1.5"> <span class="text-[9px] font-black ${badgeClass} px-2.5 py-1 rounded-lg uppercase tracking-wider shadow-sm flex items-center gap-1.5 truncate max-w-[60%]">
${isBlocked ? '<span class="w-1.5 h-1.5 bg-current rounded-full pulse-slow opacity-70"></span>' : ''} ${isBlocked ? '<span class="w-1.5 h-1.5 bg-current rounded-full pulse-slow opacity-70 shrink-0"></span>' : ''}
${label} <span class="truncate">${label}</span>
</span> </span>
<span class="text-[9px] text-slate-400 font-bold uppercase bg-slate-50 border border-slate-100 px-2 py-1 rounded-lg">#${s.service_ref}</span> <span class="text-[9px] text-slate-400 font-bold uppercase bg-slate-50 border border-slate-100 px-2 py-1 rounded-lg shrink-0">#${s.service_ref}</span>
</div> </div>
<div> <div>
<p class="text-[9px] font-black text-blue-500 uppercase tracking-widest mb-0.5">${companyName}</p> <p class="text-[9px] font-black text-blue-500 uppercase tracking-widest mb-0.5 truncate">${companyName}</p>
<h4 class="font-black text-slate-800 uppercase text-sm leading-tight line-clamp-1" title="${name}">${name}</h4> <h4 class="font-black text-slate-800 uppercase text-sm leading-tight line-clamp-1" title="${name}">${name}</h4>
</div> </div>
<div class="bg-slate-50/50 p-2.5 rounded-xl border border-slate-100"> <div class="bg-slate-50/50 p-2.5 rounded-xl border border-slate-100">
@@ -415,7 +493,6 @@
</div>`; </div>`;
} }
// FUNCIÓN QUE HACE TEMBLAR LA TARJETA
function shakeCard(element, status) { function shakeCard(element, status) {
element.classList.add('shake'); element.classList.add('shake');
setTimeout(() => element.classList.remove('shake'), 400); setTimeout(() => element.classList.remove('shake'), 400);
@@ -456,7 +533,6 @@
document.getElementById('detCompany').innerText = companyName; document.getElementById('detCompany').innerText = companyName;
document.getElementById('detName').innerText = raw["Nombre Cliente"] || raw["CLIENTE"] || "Asegurado Sin Nombre"; document.getElementById('detName').innerText = raw["Nombre Cliente"] || raw["CLIENTE"] || "Asegurado Sin Nombre";
// --- EXTRACCIÓN INTELIGENTE DE UN SOLO TELÉFONO ---
const rawPhone = raw["Teléfono"] || raw["TELEFONOS"] || raw["TELEFONO"] || ""; const rawPhone = raw["Teléfono"] || raw["TELEFONOS"] || raw["TELEFONO"] || "";
const matchPhone = rawPhone.toString().match(/[6789]\d{8}/); const matchPhone = rawPhone.toString().match(/[6789]\d{8}/);
const singlePhone = matchPhone ? matchPhone[0] : ""; const singlePhone = matchPhone ? matchPhone[0] : "";
@@ -472,19 +548,20 @@
document.getElementById('detPhoneLink').classList.remove('text-blue-600'); document.getElementById('detPhoneLink').classList.remove('text-blue-600');
document.getElementById('detPhoneLink').classList.add('text-slate-400', 'pointer-events-none'); document.getElementById('detPhoneLink').classList.add('text-slate-400', 'pointer-events-none');
} }
// --------------------------------------------------
document.getElementById('detAddrText').innerText = `${raw["Dirección"] || "Dirección no especificada"} ${raw["Población"] || ""}`; document.getElementById('detAddrText').innerText = `${raw["Dirección"] || "Dirección no especificada"} ${raw["Población"] || ""}`;
document.getElementById('detDesc').innerHTML = (raw["Descripción"] || raw["DESCRIPCION"] || "Sin notas.").replace(/\n/g, '<br>'); document.getElementById('detDesc').innerHTML = (raw["Descripción"] || raw["DESCRIPCION"] || "Sin notas.").replace(/\n/g, '<br>');
if (s.assigned_name && raw.status_operativo !== 'sin_asignar') { if (s.assigned_name && s._calculated_status_id !== 'sin_asignar') {
document.getElementById('panelAsignado').classList.remove('hidden'); document.getElementById('panelAsignado').classList.remove('hidden');
document.getElementById('panelSinAsignar').classList.add('hidden'); document.getElementById('panelSinAsignar').classList.add('hidden');
document.getElementById('detWorker').innerText = s.assigned_name; document.getElementById('detWorker').innerText = s.assigned_name;
document.getElementById('dateInput').value = raw.scheduled_date || ""; document.getElementById('dateInput').value = raw.scheduled_date || "";
document.getElementById('timeInput').value = raw.scheduled_time || ""; document.getElementById('timeInput').value = raw.scheduled_time || "";
document.getElementById('detStatusMap').value = raw.status_operativo || 'citado';
// Mapeo del estado calculado al selector
document.getElementById('detStatusMap').value = s._calculated_status_id;
} else { } else {
document.getElementById('panelAsignado').classList.add('hidden'); document.getElementById('panelAsignado').classList.add('hidden');
document.getElementById('panelSinAsignar').classList.remove('hidden'); document.getElementById('panelSinAsignar').classList.remove('hidden');
@@ -508,8 +585,11 @@
const time = document.getElementById('timeInput').value; const time = document.getElementById('timeInput').value;
const statusMap = document.getElementById('detStatusMap').value; const statusMap = document.getElementById('detStatusMap').value;
if (statusMap !== 'sin_asignar' && !date && statusMap !== 'incidencia' && statusMap !== 'terminado') { // Busca el estado seleccionado en el array real de la base de datos
if(!confirm("No has asignado Fecha. ¿Deseas continuar?")) return; const selectedSt = systemStatuses.find(st => String(st.id) === String(statusMap));
if (selectedSt && !selectedSt.is_final && !date && !selectedSt.name.toLowerCase().includes('pausa')) {
if(!confirm("No has asignado Fecha para este estado. ¿Deseas continuar?")) return;
} }
const btn = document.getElementById('btnSaveAppt'); const btn = document.getElementById('btnSaveAppt');
@@ -518,20 +598,14 @@
btn.disabled = true; btn.disabled = true;
try { try {
if(statusMap === 'sin_asignar') { // Guarda directamente el ID del estado en status_operativo
await fetch(`${API_URL}/providers/scraped/${id}`, {
method: 'PUT',
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${localStorage.getItem("token")}` },
body: JSON.stringify({ status_operativo: statusMap, assigned_to: null, assigned_to_name: null })
});
} else {
await fetch(`${API_URL}/services/set-appointment/${id}`, { await fetch(`${API_URL}/services/set-appointment/${id}`, {
method: 'PUT', method: 'PUT',
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${localStorage.getItem("token")}` }, headers: { "Content-Type": "application/json", "Authorization": `Bearer ${localStorage.getItem("token")}` },
body: JSON.stringify({ date, time, status_operativo: statusMap }) body: JSON.stringify({ date, time, status_operativo: statusMap })
}); });
}
closeDetailModal(); showToast("Estado actualizado"); refreshPanel(); closeDetailModal(); showToast("Estado actualizado"); refreshPanelOnly();
} catch (e) { alert("Error"); } } catch (e) { alert("Error"); }
finally { btn.innerHTML = originalContent; btn.disabled = false; } finally { btn.innerHTML = originalContent; btn.disabled = false; }
} }
@@ -556,7 +630,7 @@
body: JSON.stringify({ guild_id, cp, useDelay }) body: JSON.stringify({ guild_id, cp, useDelay })
}); });
const data = await res.json(); const data = await res.json();
if(data.ok) { closeDetailModal(); showToast("Enviado a la rueda de WhatsApp"); refreshPanel(); } if(data.ok) { closeDetailModal(); showToast("Enviado a la rueda de WhatsApp"); refreshPanelOnly(); }
else { alert(data.error || "No hay operarios en esa zona para ese gremio"); btn.innerHTML = '<div><p class="font-black uppercase text-xs">Mandar a la Cola</p><p class="text-[9px] text-blue-200">Rueda automática (WA)</p></div><i data-lucide="zap" class="w-5 h-5"></i>'; lucide.createIcons(); } else { alert(data.error || "No hay operarios en esa zona para ese gremio"); btn.innerHTML = '<div><p class="font-black uppercase text-xs">Mandar a la Cola</p><p class="text-[9px] text-blue-200">Rueda automática (WA)</p></div><i data-lucide="zap" class="w-5 h-5"></i>'; lucide.createIcons(); }
} catch (e) { alert("Error"); } } catch (e) { alert("Error"); }
} }
@@ -583,7 +657,7 @@
status_operativo: 'asignado_operario' status_operativo: 'asignado_operario'
}) })
}); });
closeDetailModal(); showToast("Asignado correctamente"); refreshPanel(); closeDetailModal(); showToast("Asignado correctamente"); refreshPanelOnly();
} catch (e) { alert("Error"); } } catch (e) { alert("Error"); }
} }
@@ -600,7 +674,7 @@
method: 'POST', headers: { "Content-Type": "application/json", "Authorization": `Bearer ${localStorage.getItem("token")}` }, method: 'POST', headers: { "Content-Type": "application/json", "Authorization": `Bearer ${localStorage.getItem("token")}` },
body: JSON.stringify({ ...data, mode: action }) body: JSON.stringify({ ...data, mode: action })
}); });
if (res.ok) { closeCreateModal(); refreshPanel(); } if (res.ok) { closeCreateModal(); refreshPanelOnly(); }
} catch(e) { alert("Error al guardar"); } } catch(e) { alert("Error al guardar"); }
} }
@@ -626,7 +700,6 @@
if(data.ok) sel.innerHTML = '<option value="">Seleccionar operario...</option>' + data.operators.map(o => `<option value="${o.id}">${o.full_name}</option>`).join(''); if(data.ok) sel.innerHTML = '<option value="">Seleccionar operario...</option>' + data.operators.map(o => `<option value="${o.id}">${o.full_name}</option>`).join('');
} }
// ====== AQUÍ ESTÁ LA NUEVA FUNCIÓN CORREGIDA ======
async function copyClientPortalLink() { async function copyClientPortalLink() {
const phone = document.getElementById('detPhone').innerText; const phone = document.getElementById('detPhone').innerText;
const name = document.getElementById('detName').innerText; const name = document.getElementById('detName').innerText;
@@ -644,7 +717,6 @@
lucide.createIcons(); lucide.createIcons();
try { try {
// Ahora usamos /ensure en lugar de /search, y enviamos los datos por POST
const res = await fetch(`${API_URL}/clients/ensure`, { const res = await fetch(`${API_URL}/clients/ensure`, {
method: 'POST', method: 'POST',
headers: { headers: {