Añadir mapa en vivo (De Camino) y línea de tiempo visual del estado

This commit is contained in:
2026-08-23 21:25:38 +00:00
parent 2db3988517
commit c3d81de6e8

View File

@@ -14,6 +14,8 @@
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
:root { --app-bg: #f8fafc; }
@@ -1111,6 +1113,115 @@ lucide.createIcons();
} catch(e) { return dateStr; }
}
// --- LÍNEA DE TIEMPO VISUAL DEL ESTADO ---
function renderStatusTimeline(ctx) {
const { stNameLower, hasDate, hasWorker, isFinalized, isLate } = ctx;
// Un servicio anulado no encaja en una línea de progreso: mostramos un aviso aparte
if (stNameLower.includes('anulado')) {
return `
<div class="mt-4 flex items-center gap-2 bg-slate-50 border border-slate-200 rounded-xl px-3 py-2.5">
<i data-lucide="x-circle" class="w-4 h-4 text-slate-400 shrink-0"></i>
<p class="text-[10px] font-black uppercase tracking-widest text-slate-400">Servicio anulado</p>
</div>
`;
}
const steps = [
{ label: 'Solicitado', icon: 'file-text' },
{ label: 'Asignado', icon: 'user-check' },
{ label: 'Citado', icon: 'calendar-check' },
{ label: 'En camino', icon: 'truck' },
{ label: 'Trabajando', icon: 'hammer' },
{ label: 'Finalizado', icon: 'flag' },
];
let currentIdx = 0;
let branchLabel = null;
let branchColor = 'indigo';
if (isFinalized) {
currentIdx = 5;
} else if (stNameLower.includes('camino')) {
currentIdx = 3;
} else if (stNameLower.includes('trabajando') || stNameLower.includes('reparación')) {
currentIdx = 4;
} else if (stNameLower.includes('incidencia')) {
currentIdx = hasDate ? 3 : 2;
branchLabel = 'Incidencia en gestión';
branchColor = 'rose';
} else if (stNameLower.includes('compañ') || stNameLower.includes('perito')) {
currentIdx = 1;
branchLabel = 'Esperando autorización de tu aseguradora';
branchColor = 'slate';
} else if (stNameLower.includes('desasignado')) {
currentIdx = 0;
branchLabel = 'Buscando un nuevo técnico';
branchColor = 'slate';
} else if (hasDate) {
currentIdx = 2;
if (isLate) { branchLabel = 'Retraso sobre la hora prevista'; branchColor = 'amber'; }
} else if (stNameLower.includes('esperando') || stNameLower.includes('asignado') || (hasWorker && !hasDate)) {
currentIdx = 1;
} else {
currentIdx = 0;
}
const colorMap = {
indigo: { text: 'text-indigo-600', ring: 'ring-indigo-300', icon: 'info' },
rose: { text: 'text-rose-600', ring: 'ring-rose-300', icon: 'alert-triangle' },
amber: { text: 'text-amber-600', ring: 'ring-amber-300', icon: 'clock-4' },
slate: { text: 'text-slate-500', ring: 'ring-slate-300', icon: 'info' },
};
const activeColor = colorMap[branchLabel ? branchColor : 'indigo'];
const stepsHtml = steps.map((step, i) => {
const isDone = i < currentIdx;
const isActive = i === currentIdx;
const beforeColored = i <= currentIdx;
const afterColored = i < currentIdx;
let circleClasses, labelClasses;
if (isDone) {
circleClasses = 'bg-indigo-600 text-white';
labelClasses = 'text-slate-400';
} else if (isActive) {
circleClasses = `${branchLabel ? (branchColor === 'rose' ? 'bg-rose-500' : branchColor === 'amber' ? 'bg-amber-500' : 'bg-slate-400') : 'bg-indigo-600'} text-white ring-4 ring-offset-1 ${activeColor.ring}`;
labelClasses = `${activeColor.text} font-black`;
} else {
circleClasses = 'bg-slate-100 text-slate-300';
labelClasses = 'text-slate-300';
}
return `
<div class="flex-1 flex flex-col items-center relative">
<div class="flex items-center w-full">
<div class="flex-1 h-0.5 ${i === 0 ? 'bg-transparent' : (beforeColored ? 'bg-indigo-600' : 'bg-slate-200')}"></div>
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 ${circleClasses}">
<i data-lucide="${isDone ? 'check' : step.icon}" class="w-3 h-3"></i>
</div>
<div class="flex-1 h-0.5 ${i === steps.length - 1 ? 'bg-transparent' : (afterColored ? 'bg-indigo-600' : 'bg-slate-200')}"></div>
</div>
<p class="text-[7px] font-black uppercase tracking-tight mt-1.5 text-center leading-tight ${labelClasses}">${step.label}</p>
</div>
`;
}).join('');
const branchHtml = branchLabel ? `
<div class="mt-3 flex items-center gap-1.5 justify-center">
<i data-lucide="${activeColor.icon}" class="w-3 h-3 ${activeColor.text}"></i>
<p class="text-[9px] font-bold ${activeColor.text}">${branchLabel}</p>
</div>
` : '';
return `
<div class="mt-4 pt-4 border-t border-slate-100">
<div class="flex items-start">${stepsHtml}</div>
${branchHtml}
</div>
`;
}
// --- RENDER PRINCIPAL DE AVISOS ---
function renderPortal(client, company, allServices) {
@@ -1175,6 +1286,19 @@ let cName = client && client.name ? client.name : "Cliente";
let hasWorker = (srv.assigned_worker && srv.assigned_worker !== 'Pendiente' && srv.assigned_worker !== 'Sin asignar');
let isUrgent = (srv.title && srv.title.includes('URGENTE')) || srv.is_urgent === true || (raw['Urgente'] && (raw['Urgente'].toLowerCase() === 'sí' || raw['Urgente'].toLowerCase() === 'si' || raw['Urgente'].toLowerCase() === 'true'));
// 🛑 NUEVO: Calculado aquí arriba para poder reutilizarlo tanto en la tarjeta de estado como en la línea de tiempo visual
let isLate = false;
if (hasDate) {
let endTChk = addOneHour(srv.scheduled_time);
let nowChk = new Date();
let schedPartsChk = srv.scheduled_date.split('-');
let endTimePartsChk = endTChk.split(':');
if (schedPartsChk.length === 3 && endTimePartsChk.length === 2) {
let limitDateChk = new Date(schedPartsChk[0], schedPartsChk[1] - 1, schedPartsChk[2], endTimePartsChk[0], endTimePartsChk[1], 0);
if (nowChk > limitDateChk) isLate = true;
}
}
// Recopilar dirección limpia para mostrar
let cAddr = raw["Dirección"] || raw["DOMICILIO"] || "Dirección no especificada";
let cCP = raw["Código Postal"] || "";
@@ -1209,7 +1333,7 @@ let cName = client && client.name ? client.name : "Cliente";
else if (stNameLower.includes('camino')) {
headerColor = "bg-indigo-100 text-indigo-600"; icon = "truck"; tagTitle = "Desplazamiento"; mainTitle = "¡En camino!"; subDesc = "El técnico se dirige a tu domicilio.";
etasToInit.push({ id: srv.id, address: fullAddress });
extras = `<div id="eta-container-${srv.id}" class="mt-3 bg-indigo-50 rounded-xl p-3 border border-indigo-100"><p class="text-[10px] font-bold text-indigo-500 flex items-center gap-1.5"><i data-lucide="loader-2" class="w-3 h-3 animate-spin"></i> Calculando ruta...</p></div>`;
extras = `<div id="map-container-${srv.id}" class="mt-3 rounded-xl overflow-hidden border border-indigo-100" style="height:190px; width:100%;"></div><div id="eta-container-${srv.id}" class="mt-3 bg-indigo-50 rounded-xl p-3 border border-indigo-100"><p class="text-[10px] font-bold text-indigo-500 flex items-center gap-1.5"><i data-lucide="loader-2" class="w-3 h-3 animate-spin"></i> Calculando ruta...</p></div>`;
}
else if (stNameLower.includes('trabajando') || stNameLower.includes('reparación')) {
headerColor = "bg-orange-100 text-orange-600"; icon = "hammer"; tagTitle = "En Domicilio"; mainTitle = "Trabajando"; subDesc = "El técnico está realizando la reparación.";
@@ -1225,15 +1349,6 @@ let cName = client && client.name ? client.name : "Cliente";
}
else if (hasDate && !stNameLower.includes('anulado') && !stNameLower.includes('desasignado')) {
let endT = addOneHour(srv.scheduled_time);
let now = new Date();
let schedParts = srv.scheduled_date.split('-');
let endTimeParts = endT.split(':');
let isLate = false;
if (schedParts.length === 3 && endTimeParts.length === 2) {
let limitDate = new Date(schedParts[0], schedParts[1] - 1, schedParts[2], endTimeParts[0], endTimeParts[1], 0);
if (now > limitDate) isLate = true;
}
if (isLate) {
headerColor = "bg-amber-100 text-amber-600"; icon = "clock-4"; tagTitle = "Demora"; mainTitle = "Técnico Retrasado"; subDesc = `La cita estaba prevista hasta las ${endT}. Llegará lo antes posible.`;
@@ -1297,6 +1412,9 @@ let cName = client && client.name ? client.name : "Cliente";
? `<div class="absolute -top-3 right-6 bg-emerald-500 text-white text-[9px] font-black px-3 py-1.5 rounded-full shadow-md uppercase tracking-widest flex items-center gap-1 border-2 border-white z-10"><i data-lucide="badge-check" class="w-3 h-3"></i> Pagado Online</div>`
: '';
// 🛑 NUEVO: Línea de tiempo visual del proceso
let timelineHtml = renderStatusTimeline({ stNameLower, hasDate, hasWorker, isFinalized, isLate });
// Generación de la tarjeta Blanca
let cardHtml = `
<div id="service-card-${srv.id}" class="bg-white rounded-[2rem] p-6 shadow-sm border border-slate-100 relative text-left transition-all duration-500 mt-3">
@@ -1325,6 +1443,8 @@ let cName = client && client.name ? client.name : "Cliente";
</div>
${extras}
${timelineHtml}
<hr class="border-slate-100 my-5">
<p class="text-[10px] font-black text-slate-300 uppercase tracking-widest mb-3">Detalle de Avería</p>
@@ -1375,23 +1495,51 @@ let cName = client && client.name ? client.name : "Cliente";
}, 300);
}
// --- SISTEMA GPS ---
// --- MAPA EN VIVO (LEAFLET / OPENSTREETMAP) ---
window.portalMaps = window.portalMaps || {};
function updateServiceMap(serviceId, workerLat, workerLng, clientLat, clientLng) {
const mapDiv = document.getElementById(`map-container-${serviceId}`);
if (!mapDiv || typeof L === 'undefined') return;
if (!window.portalMaps[serviceId]) {
const map = L.map(mapDiv, { zoomControl: false, attributionControl: false, dragging: true, scrollWheelZoom: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
const truckIcon = L.divIcon({
className: '',
html: '<div style="background:#4f46e5;width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 10px rgba(79,70,229,0.45);border:2px solid white;"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 17h4V5H2v12h3"/><path d="M20 17h2v-3.34a4 4 0 0 0-1.17-2.83L19 9h-5v8h1"/><circle cx="7.5" cy="17.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/></svg></div>',
iconSize: [34, 34], iconAnchor: [17, 17]
});
const homeIcon = L.divIcon({
className: '',
html: '<div style="background:#0f172a;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.35);border:2px solid white;"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></div>',
iconSize: [26, 26], iconAnchor: [13, 13]
});
const workerMarker = L.marker([workerLat, workerLng], { icon: truckIcon }).addTo(map);
const homeMarker = L.marker([clientLat, clientLng], { icon: homeIcon }).addTo(map);
const line = L.polyline([[workerLat, workerLng], [clientLat, clientLng]], { color: '#818cf8', weight: 3, dashArray: '6,7', opacity: 0.85 }).addTo(map);
map.fitBounds(line.getBounds(), { padding: [36, 36] });
window.portalMaps[serviceId] = { map, workerMarker, homeMarker, line };
} else {
const inst = window.portalMaps[serviceId];
inst.workerMarker.setLatLng([workerLat, workerLng]);
inst.line.setLatLngs([[workerLat, workerLng], [clientLat, clientLng]]);
inst.map.fitBounds(inst.line.getBounds(), { padding: [36, 36] });
}
}
// --- SISTEMA GPS (ETA + MAPA EN VIVO) ---
async function calculateClientETA(serviceId, destAddress) {
const container = document.getElementById(`eta-container-${serviceId}`);
if (!container) return;
// Geocodificamos el domicilio del cliente UNA sola vez (no cambia entre refrescos)
let cLat = null, cLon = null;
try {
const res = await fetch(`${API_URL}/public/portal/${urlToken}/location/${serviceId}`);
const data = await res.json();
if (!data.ok || !data.location) {
container.innerHTML = `<p class="text-xs font-bold text-indigo-600 leading-tight">El técnico está en camino hacia tu domicilio.</p>`;
return;
}
const wLat = parseFloat(data.location.lat);
const wLon = parseFloat(data.location.lng);
let geoRes = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(destAddress + ', España')}`);
let geoData = await geoRes.json();
@@ -1403,61 +1551,69 @@ let cName = client && client.name ? client.name : "Cliente";
}
if (geoData && geoData.length > 0) {
const cLat = parseFloat(geoData[0].lat);
const cLon = parseFloat(geoData[0].lon);
cLat = parseFloat(geoData[0].lat);
cLon = parseFloat(geoData[0].lon);
}
} catch (e) { /* seguimos sin coordenadas de destino */ }
let totalMins = null;
async function tick() {
try {
const res = await fetch(`${API_URL}/public/portal/${urlToken}/location/${serviceId}`);
const data = await res.json();
if (!data.ok || !data.location) {
container.innerHTML = `<p class="text-xs font-bold text-indigo-600 leading-tight">El técnico está en camino hacia tu domicilio.</p>`;
return;
}
const wLat = parseFloat(data.location.lat);
const wLon = parseFloat(data.location.lng);
if (cLat === null || cLon === null) {
container.innerHTML = `<p class="text-xs font-bold text-indigo-600 leading-tight">El técnico está de camino.</p>`;
return;
}
const R = 6371;
const dLat = (cLat - wLat) * Math.PI / 180;
const dLon = (cLon - wLon) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(wLat * Math.PI / 180) * Math.cos(cLat * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(wLat * Math.PI / 180) * Math.cos(cLat * Math.PI / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const km = R * c;
const totalMins = Math.round((km/35)*60) + 5;
const remainingMins = Math.max(1, Math.round((km / 35) * 60) + 5);
if (totalMins === null) totalMins = remainingMins;
let startedAt = new Date().getTime();
if (data.location.updated_at) {
const parsed = new Date(data.location.updated_at).getTime();
if (!isNaN(parsed)) startedAt = parsed;
}
let progressPercent = totalMins ? ((totalMins - remainingMins) / totalMins) * 100 : 5;
if (progressPercent > 95) progressPercent = 95;
if (progressPercent < 5) progressPercent = 5;
function renderETA() {
const now = new Date().getTime();
const diffMins = Math.floor((now - startedAt) / 60000);
let remainingMins = totalMins - diffMins;
if (remainingMins < 1) remainingMins = 1;
let progressPercent = (diffMins / totalMins) * 100;
if (progressPercent > 95) progressPercent = 95;
if (progressPercent < 5) progressPercent = 5;
container.innerHTML = `
<p class="text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-2 flex items-center gap-1.5">
<i data-lucide="clock" class="w-3.5 h-3.5"></i> Llegada en aprox. <span class="text-indigo-700 text-sm ml-0.5">${remainingMins} min</span>
</p>
<div class="w-full bg-white rounded-full h-2 overflow-hidden shadow-inner border border-indigo-100">
<div class="bg-indigo-500 h-full rounded-full transition-all duration-1000 relative" style="width: ${progressPercent}%">
<div class="absolute right-0 top-0 bottom-0 w-4 bg-white/40 animate-pulse"></div>
</div>
container.innerHTML = `
<p class="text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-2 flex items-center gap-1.5">
<i data-lucide="clock" class="w-3.5 h-3.5"></i> Llegada en aprox. <span class="text-indigo-700 text-sm ml-0.5">${remainingMins} min</span>
</p>
<div class="w-full bg-white rounded-full h-2 overflow-hidden shadow-inner border border-indigo-100">
<div class="bg-indigo-500 h-full rounded-full transition-all duration-1000 relative" style="width: ${progressPercent}%">
<div class="absolute right-0 top-0 bottom-0 w-4 bg-white/40 animate-pulse"></div>
</div>
<div class="flex justify-between items-center mt-1.5 px-1">
<p class="text-[8px] font-black uppercase text-indigo-300">Saliendo</p>
<p class="text-[8px] font-black uppercase text-indigo-400">A ${km.toFixed(1)} km</p>
</div>
`;
lucide.createIcons();
}
</div>
<div class="flex justify-between items-center mt-1.5 px-1">
<p class="text-[8px] font-black uppercase text-indigo-300">Saliendo</p>
<p class="text-[8px] font-black uppercase text-indigo-400">A ${km.toFixed(1)} km</p>
</div>
`;
lucide.createIcons();
renderETA();
setInterval(renderETA, 60000);
} else {
container.innerHTML = `<p class="text-xs font-bold text-indigo-600 leading-tight">El técnico está de camino.</p>`;
updateServiceMap(serviceId, wLat, wLon, cLat, cLon);
} catch (e) {
if (container) container.innerHTML = `<p class="text-xs font-bold text-indigo-600 leading-tight">El técnico está de camino.</p>`;
}
} catch(e) {
if (container) container.innerHTML = `<p class="text-xs font-bold text-indigo-600 leading-tight">El técnico está de camino.</p>`;
}
tick();
setInterval(tick, 30000);
}
</script>
</body>