Buscador global robusto: todo el histórico de la empresa, filtrado por fechas
La pestaña "Mis Asignados" pasa a ser "Buscar (Toda la Empresa)": consulta /services/search con fecha (expediente o cita), texto, compañía y chips de estado, con "Cargar Más" para paginar. Ya no se limita a lo mío ni a los últimos 15 días: encuentra cualquier servicio, de cualquier técnico, en cualquier estado (citado, anulado, finalizado...). "Bolsa Libre" sigue igual que antes.
This commit is contained in:
242
buscar.html
242
buscar.html
@@ -60,7 +60,7 @@
|
||||
|
||||
<div class="flex gap-2 w-full pt-1" id="tabsContainer">
|
||||
<button onclick="setTab('mine')" id="tab-mine" class="flex-1 py-2.5 rounded-xl text-xs uppercase tracking-widest border transition-all tab-active">
|
||||
Mis Asignados
|
||||
Buscar (Toda la Empresa)
|
||||
</button>
|
||||
<button onclick="setTab('unassigned')" id="tab-unassigned" class="flex-1 py-2.5 rounded-xl text-xs uppercase tracking-widest border transition-all tab-inactive">
|
||||
Bolsa Libre
|
||||
@@ -69,13 +69,59 @@
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-y-auto no-scrollbar p-5 relative z-10" id="mainArea">
|
||||
<div id="loader" class="text-center py-10 opacity-50">
|
||||
|
||||
<!-- 🆕 Panel de filtros del buscador global: solo visible en la pestaña "Buscar" -->
|
||||
<div id="searchFiltersPanel" class="hidden mb-5 bg-white p-4 rounded-3xl border border-slate-200 shadow-sm space-y-3 fade-in">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="block text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1 ml-1">Filtrar por</label>
|
||||
<div class="relative">
|
||||
<select id="dateFieldSelect" class="w-full bg-slate-50 border border-slate-200 p-2.5 rounded-xl text-[11px] font-bold text-slate-700 outline-none appearance-none pr-8 cursor-pointer">
|
||||
<option value="created_at">Fecha del expediente</option>
|
||||
<option value="scheduled_date">Fecha de la cita</option>
|
||||
</select>
|
||||
<i data-lucide="chevron-down" class="w-4 h-4 absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1 ml-1">Compañía</label>
|
||||
<div class="relative">
|
||||
<select id="companyFilterSelect" class="w-full bg-slate-50 border border-slate-200 p-2.5 rounded-xl text-[11px] font-bold text-slate-700 outline-none appearance-none pr-8 cursor-pointer">
|
||||
<option value="">Todas</option>
|
||||
</select>
|
||||
<i data-lucide="chevron-down" class="w-4 h-4 absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="block text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1 ml-1">Desde</label>
|
||||
<input type="date" id="dateFromInput" class="w-full bg-slate-50 border border-slate-200 p-2.5 rounded-xl text-[11px] font-bold text-slate-700 outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1 ml-1">Hasta</label>
|
||||
<input type="date" id="dateToInput" class="w-full bg-slate-50 border border-slate-200 p-2.5 rounded-xl text-[11px] font-bold text-slate-700 outline-none">
|
||||
</div>
|
||||
</div>
|
||||
<div id="statusChipsRow" class="flex gap-1.5 overflow-x-auto no-scrollbar pb-0.5"></div>
|
||||
<button onclick="runSearch(true)" id="btnRunSearch" class="w-full bg-primary-dynamic text-white font-black py-3 rounded-xl shadow-md flex items-center justify-center gap-2 uppercase tracking-widest text-xs active:scale-95 transition-transform">
|
||||
<i data-lucide="search" class="w-4 h-4"></i> Buscar
|
||||
</button>
|
||||
<p class="text-center text-[9px] font-bold text-slate-400">Elige una fecha, o escribe algo arriba (nombre, teléfono, ref...) para buscar en todo el histórico.</p>
|
||||
</div>
|
||||
|
||||
<div id="loader" class="text-center py-10 opacity-50 hidden">
|
||||
<i data-lucide="loader-2" class="w-8 h-8 animate-spin mx-auto text-primary-dynamic mb-2"></i>
|
||||
<p class="text-xs font-bold uppercase tracking-widest text-slate-400">Recopilando datos...</p>
|
||||
</div>
|
||||
|
||||
<div id="servicesList" class="space-y-4 pb-24 hidden fade-in">
|
||||
<div id="servicesList" class="space-y-4 pb-4 hidden fade-in">
|
||||
</div>
|
||||
|
||||
<button onclick="runSearch(false)" id="btnLoadMore" class="hidden w-full bg-white border border-slate-200 text-slate-600 font-black py-3.5 rounded-2xl shadow-sm flex items-center justify-center gap-2 uppercase tracking-widest text-xs active:scale-95 transition-transform mb-24">
|
||||
<i data-lucide="chevron-down" class="w-4 h-4"></i> Cargar Más
|
||||
</button>
|
||||
<div class="pb-24"></div>
|
||||
</main>
|
||||
|
||||
<nav class="bg-white/95 backdrop-blur-md border-t border-slate-200 shrink-0 pb-safe z-20 absolute bottom-0 w-full shadow-[0_-10px_30px_rgba(0,0,0,0.05)] rounded-t-[2rem]">
|
||||
@@ -196,6 +242,13 @@
|
||||
let currentTab = 'mine';
|
||||
let isRestricted = false;
|
||||
|
||||
// 🆕 Estado del buscador global (pestaña "Buscar"): ahora es un histórico de TODA la empresa,
|
||||
// cualquier estado (citado/anulado/finalizado incluido), filtrado por fecha en vez de traerlo todo.
|
||||
let hasSearchedOnce = false;
|
||||
let searchOffset = 0;
|
||||
let searchHasMore = false;
|
||||
let statusChipSelected = 'all';
|
||||
|
||||
// Variables de Usuario para Asignación
|
||||
let myUserId = null;
|
||||
let myUserName = "";
|
||||
@@ -252,6 +305,8 @@
|
||||
|
||||
await loadStatuses();
|
||||
await loadGuilds();
|
||||
renderStatusChips();
|
||||
loadCompaniesFilter();
|
||||
loadAllData();
|
||||
});
|
||||
|
||||
@@ -285,6 +340,9 @@
|
||||
btnUnass.className = "flex-1 py-2.5 rounded-xl text-xs uppercase tracking-widest border transition-all tab-active";
|
||||
btnMine.className = "flex-1 py-2.5 rounded-xl text-xs uppercase tracking-widest border transition-all tab-inactive";
|
||||
}
|
||||
// 🆕 El panel de fecha/compañía/estado solo tiene sentido en la pestaña "Buscar"
|
||||
document.getElementById('searchFiltersPanel').classList.toggle('hidden', tabName !== 'mine');
|
||||
document.getElementById('btnLoadMore').classList.toggle('hidden', !(tabName === 'mine' && hasSearchedOnce && searchHasMore));
|
||||
renderServices();
|
||||
}
|
||||
|
||||
@@ -304,25 +362,19 @@
|
||||
} catch(e){}
|
||||
}
|
||||
|
||||
// 🆕 Ahora solo carga la Bolsa Libre (avisos sin asignar, en vivo). La pestaña "Buscar" ya
|
||||
// NO se carga automáticamente: es un histórico de toda la empresa y exige fecha o texto
|
||||
// (ver runSearch) para no volcar la base de datos entera de golpe.
|
||||
async function loadAllData() {
|
||||
document.getElementById('servicesList').classList.add('hidden');
|
||||
document.getElementById('loader').classList.remove('hidden');
|
||||
allServices = [];
|
||||
allServices = allServices.filter(s => s._scope === 'mine'); // conservamos lo ya buscado
|
||||
|
||||
try {
|
||||
const headers = { "Authorization": `Bearer ${localStorage.getItem("token")}` };
|
||||
|
||||
// 1. Cargar mis servicios asignados (SIEMPRE)
|
||||
const resMine = await fetch(`${API_URL}/services/active`, { headers });
|
||||
const dataMine = await resMine.json();
|
||||
|
||||
let myItems = [];
|
||||
if (dataMine.ok) {
|
||||
myItems = dataMine.services.map(s => ({...s, _scope: 'mine'}));
|
||||
}
|
||||
|
||||
let unassignedItems = [];
|
||||
// 2. Cargar bolsa libre SOLO SI NO ESTÁ RESTRINGIDO
|
||||
// Cargar bolsa libre SOLO SI NO ESTÁ RESTRINGIDO
|
||||
if (!isRestricted) {
|
||||
const resAll = await fetch(`${API_URL}/providers/scraped`, { headers });
|
||||
const dataAll = await resAll.json();
|
||||
@@ -333,7 +385,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
allServices = [...myItems, ...unassignedItems].filter(s => s.provider !== 'SYSTEM_BLOCK');
|
||||
allServices = [...allServices, ...unassignedItems].filter(s => s.provider !== 'SYSTEM_BLOCK');
|
||||
|
||||
// Si ya se había lanzado una búsqueda antes de recargar, la repetimos para refrescarla también.
|
||||
if (hasSearchedOnce) { await runSearch(true); return; }
|
||||
|
||||
renderServices();
|
||||
} catch (e) {
|
||||
alert("Error cargando los datos");
|
||||
@@ -343,6 +399,108 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 Lista de compañías para el filtro del buscador global
|
||||
async function loadCompaniesFilter() {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/companies`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } });
|
||||
const data = await res.json();
|
||||
if (!data.ok) return;
|
||||
const sel = document.getElementById('companyFilterSelect');
|
||||
sel.innerHTML = '<option value="">Todas</option>' + data.companies.map(c => `<option value="${c.name}">${c.name.toUpperCase()}</option>`).join('');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 🆕 Chips de estado del buscador global (Todos + cada estado configurado en el sistema)
|
||||
function renderStatusChips() {
|
||||
const row = document.getElementById('statusChipsRow');
|
||||
if (!row) return;
|
||||
const chips = [{ id: 'all', name: 'Todos' }, ...systemStatuses.map(st => ({ id: String(st.id), name: st.name }))];
|
||||
row.innerHTML = chips.map(c => {
|
||||
const active = statusChipSelected === c.id;
|
||||
return `<button type="button" onclick="selectStatusChip('${c.id}')" class="shrink-0 px-3 py-1.5 rounded-full border text-[10px] font-black uppercase tracking-widest transition-all ${active ? 'bg-primary-dynamic text-white border-primary-dynamic' : 'bg-white text-slate-500 border-slate-200'}">${c.name}</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function selectStatusChip(id) {
|
||||
statusChipSelected = id;
|
||||
renderStatusChips();
|
||||
renderServices();
|
||||
}
|
||||
|
||||
// 🆕 Buscador global: cualquier servicio de la empresa, cualquier técnico, cualquier estado.
|
||||
// reset=true → nueva búsqueda (sustituye lo que hubiera). reset=false → "Cargar Más" (añade la siguiente página).
|
||||
async function runSearch(reset) {
|
||||
const dateField = document.getElementById('dateFieldSelect').value;
|
||||
const dateFrom = document.getElementById('dateFromInput').value;
|
||||
const dateTo = document.getElementById('dateToInput').value;
|
||||
const company = document.getElementById('companyFilterSelect').value;
|
||||
const term = document.getElementById('searchInput').value.trim();
|
||||
|
||||
if (!dateFrom && !dateTo && term === "") {
|
||||
alert("Elige un rango de fechas, o escribe algo arriba (nombre, teléfono, referencia...) para buscar.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
searchOffset = 0;
|
||||
searchHasMore = false;
|
||||
allServices = allServices.filter(s => s._scope !== 'mine');
|
||||
}
|
||||
|
||||
const loader = document.getElementById('loader');
|
||||
const list = document.getElementById('servicesList');
|
||||
const btnMore = document.getElementById('btnLoadMore');
|
||||
|
||||
if (reset) {
|
||||
list.classList.add('hidden');
|
||||
loader.classList.remove('hidden');
|
||||
} else {
|
||||
btnMore.innerHTML = `<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i> Cargando...`;
|
||||
btnMore.disabled = true;
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (term) params.set('q', term);
|
||||
params.set('date_field', dateField);
|
||||
if (dateFrom) params.set('date_from', dateFrom);
|
||||
if (dateTo) params.set('date_to', dateTo);
|
||||
if (company) params.set('company', company);
|
||||
params.set('limit', '30');
|
||||
params.set('offset', String(searchOffset));
|
||||
|
||||
const res = await fetch(`${API_URL}/services/search?${params.toString()}`, {
|
||||
headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
hasSearchedOnce = true;
|
||||
|
||||
if (data.ok) {
|
||||
const existingIds = new Set(allServices.filter(s => s._scope === 'mine').map(s => s.id));
|
||||
const newItems = data.services
|
||||
.filter(s => s.provider !== 'SYSTEM_BLOCK' && !existingIds.has(s.id))
|
||||
.map(s => ({ ...s, _scope: 'mine' }));
|
||||
|
||||
allServices = [...allServices, ...newItems];
|
||||
searchHasMore = !!data.hasMore;
|
||||
searchOffset += data.services.length;
|
||||
} else {
|
||||
alert("Error al buscar.");
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Error de conexión");
|
||||
} finally {
|
||||
loader.classList.add('hidden');
|
||||
list.classList.remove('hidden');
|
||||
btnMore.innerHTML = `<i data-lucide="chevron-down" class="w-4 h-4"></i> Cargar Más`;
|
||||
btnMore.disabled = false;
|
||||
renderServices();
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
function getServiceStateInfo(s) {
|
||||
const raw = s.raw_data || {};
|
||||
const dbStat = raw.status_operativo;
|
||||
@@ -389,9 +547,31 @@
|
||||
if(term.length > 0) clearBtn.classList.remove('hidden');
|
||||
else clearBtn.classList.add('hidden');
|
||||
|
||||
// 🆕 En la pestaña "Buscar", mientras no se haya lanzado ninguna búsqueda todavía,
|
||||
// mostramos un aviso a pedir fecha/texto en vez de una lista vacía confusa.
|
||||
if (currentTab === 'mine' && !hasSearchedOnce) {
|
||||
document.getElementById('btnLoadMore').classList.add('hidden');
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-16 bg-white rounded-3xl border border-slate-100 shadow-sm mt-4">
|
||||
<div class="w-16 h-16 bg-primary-dynamic/10 text-primary-dynamic rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i data-lucide="filter" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<h3 class="font-black text-slate-800 text-lg uppercase tracking-tight">Elige fecha o busca algo</h3>
|
||||
<p class="text-xs text-slate-400 font-medium mt-1 px-6">Este buscador cubre TODO el histórico de la empresa. Pon un rango de fechas arriba, o escribe un nombre/teléfono/referencia, y pulsa "Buscar".</p>
|
||||
</div>`;
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredData = allServices.filter(s => {
|
||||
if (s._scope !== currentTab) return false;
|
||||
|
||||
// 🆕 Chip de estado (solo aplica en la pestaña "Buscar")
|
||||
if (currentTab === 'mine' && statusChipSelected !== 'all') {
|
||||
const info = getServiceStateInfo(s);
|
||||
if (String(info.id) !== String(statusChipSelected)) return false;
|
||||
}
|
||||
|
||||
const raw = s.raw_data || {};
|
||||
const name = (raw["Nombre Cliente"] || raw["CLIENTE"] || "").toLowerCase();
|
||||
const addr = (raw["Dirección"] || raw["DOMICILIO"] || "").toLowerCase();
|
||||
@@ -404,6 +584,10 @@
|
||||
return name.includes(term) || ref.includes(term) || addr.includes(term) || pop.includes(term) || phone.includes(term) || comp.includes(term);
|
||||
});
|
||||
|
||||
// 🆕 "Cargar Más" depende de si el servidor aún tiene más páginas, no de si el filtro
|
||||
// actual (chip/texto) tiene resultados: puede que haya más coincidencias en la página siguiente.
|
||||
document.getElementById('btnLoadMore').classList.toggle('hidden', !(currentTab === 'mine' && hasSearchedOnce && searchHasMore));
|
||||
|
||||
if (filteredData.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-16 bg-white rounded-3xl border border-slate-100 shadow-sm mt-4">
|
||||
@@ -433,6 +617,7 @@
|
||||
const colorData = colorDict[stateInfo.color] || colorDict['gray'];
|
||||
const isScheduled = !!raw.scheduled_date;
|
||||
const citaText = isScheduled ? `${raw.scheduled_date} | ${raw.scheduled_time}` : 'Sin Cita';
|
||||
const expedienteDate = s.created_at ? new Date(s.created_at).toLocaleDateString('es-ES') : '';
|
||||
|
||||
// Generación Dinámica de Botones según el Estado
|
||||
let actionButtons = `
|
||||
@@ -452,7 +637,20 @@
|
||||
</button>
|
||||
`;
|
||||
|
||||
// 🆕 En "Buscar" (histórico de toda la empresa) el servicio puede ser mío, de otro
|
||||
// técnico, o seguir sin asignar. Los botones de acción cambian según cada caso.
|
||||
const isMineItem = !!(myUserId && String(s.assigned_to) === String(myUserId));
|
||||
const isUnassignedItem = !s.assigned_to;
|
||||
let techBadge = '';
|
||||
|
||||
if (currentTab === 'mine') {
|
||||
if (isUnassignedItem) {
|
||||
actionButtons = `
|
||||
<button onclick="claimService(${s.id})" class="flex-1 bg-emerald-500 hover:bg-emerald-600 text-white font-black py-2.5 rounded-xl flex items-center justify-center gap-1.5 transition-colors text-[10px] uppercase tracking-widest shadow-md">
|
||||
<i data-lucide="hand" class="w-4 h-4"></i> ¡Asignármelo!
|
||||
</button>
|
||||
` + actionButtons + anularBtn;
|
||||
} else if (isMineItem) {
|
||||
if (!isScheduled) {
|
||||
actionButtons = `
|
||||
<button onclick="quickCopyPortalLink(${s.id}, '${safePhone}', '${name.replace(/'/g, "\\'")}', '${addr.replace(/'/g, "\\'")}', this)" class="flex-1 bg-primary-dynamic/10 hover:bg-primary-dynamic/20 border border-primary-dynamic/20 text-primary-dynamic font-black py-2.5 rounded-xl flex items-center justify-center gap-1.5 transition-colors text-[10px] uppercase tracking-widest relative overflow-hidden">
|
||||
@@ -460,7 +658,6 @@
|
||||
</button>
|
||||
` + actionButtons;
|
||||
} else {
|
||||
// GOAL 1: BOTÓN CAMBIAR CITA
|
||||
actionButtons = `
|
||||
<button onclick="openActionModal(${s.id})" class="flex-1 bg-amber-50 hover:bg-amber-100 border border-amber-200 text-amber-600 font-black py-2.5 rounded-xl flex items-center justify-center gap-1.5 transition-colors text-[10px] uppercase tracking-widest">
|
||||
<i data-lucide="calendar-clock" class="w-4 h-4"></i> Cambiar Cita
|
||||
@@ -468,6 +665,10 @@
|
||||
` + actionButtons;
|
||||
}
|
||||
actionButtons = actionButtons + anularBtn;
|
||||
} else {
|
||||
// Servicio de OTRO técnico: solo consulta (Llamar + Entrar), sin tocar su asignación.
|
||||
techBadge = `<span class="text-[9px] text-indigo-500 font-black uppercase bg-indigo-50 border border-indigo-100 px-2.5 py-1.5 rounded-lg flex items-center gap-1"><i data-lucide="user" class="w-3 h-3"></i> ${s.assigned_name || 'Otro técnico'}</span>`;
|
||||
}
|
||||
} else if (currentTab === 'unassigned') {
|
||||
// GOAL 2: BOTÓN ASIGNÁRMELO
|
||||
actionButtons = `
|
||||
@@ -481,14 +682,16 @@
|
||||
<div class="bg-white p-5 rounded-3xl border border-slate-200 shadow-sm relative overflow-hidden flex flex-col gap-4">
|
||||
${isUrgent ? '<div class="absolute top-0 right-0 bg-red-500 text-white text-[8px] font-black px-3 py-1 rounded-bl-xl uppercase tracking-widest shadow-sm z-10">Urgente</div>' : ''}
|
||||
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-[9px] font-black ${colorData.bg} ${colorData.text} px-2.5 py-1.5 rounded-lg uppercase tracking-wider flex items-center gap-1.5 border ${colorData.border}">
|
||||
<div class="w-2 h-2 rounded-full ${colorData.dot}"></div>
|
||||
${stateInfo.name}
|
||||
</span>
|
||||
<span class="text-[9px] text-slate-400 font-bold uppercase bg-slate-50 border border-slate-100 px-2.5 py-1.5 rounded-lg">#${s.service_ref}</span>
|
||||
${techBadge}
|
||||
</div>
|
||||
${expedienteDate && currentTab === 'mine' ? `<span class="text-[9px] text-slate-400 font-bold shrink-0">${expedienteDate}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -691,6 +894,9 @@
|
||||
function getOccupiedRanges(dateStr) {
|
||||
let ranges = [];
|
||||
allServices.forEach(s => {
|
||||
// 🆕 Con el buscador global "allServices" puede traer citas de OTROS técnicos:
|
||||
// para el aviso de solapamiento solo nos interesan las mías propias.
|
||||
if (myUserId && String(s.assigned_to) !== String(myUserId)) return;
|
||||
const raw = s.raw_data || {};
|
||||
if (raw.scheduled_date === dateStr && raw.scheduled_time) {
|
||||
const startMin = timeToMins(raw.scheduled_time);
|
||||
|
||||
Reference in New Issue
Block a user