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://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://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> <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> <style>
:root { --app-bg: #f8fafc; } :root { --app-bg: #f8fafc; }
@@ -1111,6 +1113,115 @@ lucide.createIcons();
} catch(e) { return dateStr; } } 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 --- // --- RENDER PRINCIPAL DE AVISOS ---
function renderPortal(client, company, allServices) { function renderPortal(client, company, allServices) {
@@ -1174,6 +1285,19 @@ let cName = client && client.name ? client.name : "Cliente";
let hasDate = (srv.scheduled_date && srv.scheduled_time); let hasDate = (srv.scheduled_date && srv.scheduled_time);
let hasWorker = (srv.assigned_worker && srv.assigned_worker !== 'Pendiente' && srv.assigned_worker !== 'Sin asignar'); 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')); 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 // Recopilar dirección limpia para mostrar
let cAddr = raw["Dirección"] || raw["DOMICILIO"] || "Dirección no especificada"; let cAddr = raw["Dirección"] || raw["DOMICILIO"] || "Dirección no especificada";
@@ -1209,7 +1333,7 @@ let cName = client && client.name ? client.name : "Cliente";
else if (stNameLower.includes('camino')) { 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."; 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 }); 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')) { 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."; 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')) { else if (hasDate && !stNameLower.includes('anulado') && !stNameLower.includes('desasignado')) {
let endT = addOneHour(srv.scheduled_time); 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) { 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.`; 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.`;
@@ -1293,10 +1408,13 @@ let cName = client && client.name ? client.name : "Cliente";
if (!numRef) numRef = srv.id; // Si falla todo, ponemos el ID interno if (!numRef) numRef = srv.id; // Si falla todo, ponemos el ID interno
// 🟢 MEDALLA DE PAGO ONLINE // 🟢 MEDALLA DE PAGO ONLINE
let paidBadgeHtml = raw.is_paid let paidBadgeHtml = raw.is_paid
? `<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>` ? `<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 // Generación de la tarjeta Blanca
let cardHtml = ` 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"> <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> </div>
${extras} ${extras}
${timelineHtml}
<hr class="border-slate-100 my-5"> <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> <p class="text-[10px] font-black text-slate-300 uppercase tracking-widest mb-3">Detalle de Avería</p>
@@ -1375,89 +1495,125 @@ let cName = client && client.name ? client.name : "Cliente";
}, 300); }, 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) { async function calculateClientETA(serviceId, destAddress) {
const container = document.getElementById(`eta-container-${serviceId}`); const container = document.getElementById(`eta-container-${serviceId}`);
if (!container) return; if (!container) return;
// Geocodificamos el domicilio del cliente UNA sola vez (no cambia entre refrescos)
let cLat = null, cLon = null;
try { 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 geoRes = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(destAddress + ', España')}`);
let geoData = await geoRes.json(); let geoData = await geoRes.json();
if (!geoData || geoData.length === 0) { if (!geoData || geoData.length === 0) {
const parts = destAddress.split(','); const parts = destAddress.split(',');
const fallbackDest = parts.length > 1 ? parts[parts.length - 1].trim() : destAddress; const fallbackDest = parts.length > 1 ? parts[parts.length - 1].trim() : destAddress;
geoRes = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(fallbackDest + ', España')}`); geoRes = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(fallbackDest + ', España')}`);
geoData = await geoRes.json(); geoData = await geoRes.json();
} }
if (geoData && geoData.length > 0) { if (geoData && geoData.length > 0) {
const cLat = parseFloat(geoData[0].lat); cLat = parseFloat(geoData[0].lat);
const cLon = parseFloat(geoData[0].lon); cLon = parseFloat(geoData[0].lon);
}
const R = 6371; } catch (e) { /* seguimos sin coordenadas de destino */ }
const dLat = (cLat - wLat) * Math.PI / 180;
const dLon = (cLon - wLon) * Math.PI / 180; let totalMins = null;
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)); async function tick() {
const km = R * c; try {
const res = await fetch(`${API_URL}/public/portal/${urlToken}/location/${serviceId}`);
const totalMins = Math.round((km/35)*60) + 5; const data = await res.json();
let startedAt = new Date().getTime(); if (!data.ok || !data.location) {
if (data.location.updated_at) { container.innerHTML = `<p class="text-xs font-bold text-indigo-600 leading-tight">El técnico está en camino hacia tu domicilio.</p>`;
const parsed = new Date(data.location.updated_at).getTime(); return;
if (!isNaN(parsed)) startedAt = parsed; }
}
const wLat = parseFloat(data.location.lat);
function renderETA() { const wLon = parseFloat(data.location.lng);
const now = new Date().getTime();
const diffMins = Math.floor((now - startedAt) / 60000); 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>`;
let remainingMins = totalMins - diffMins; return;
if (remainingMins < 1) remainingMins = 1; }
let progressPercent = (diffMins / totalMins) * 100; const R = 6371;
if (progressPercent > 95) progressPercent = 95; const dLat = (cLat - wLat) * Math.PI / 180;
if (progressPercent < 5) progressPercent = 5; 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);
container.innerHTML = ` const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
<p class="text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-2 flex items-center gap-1.5"> const km = R * c;
<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> const remainingMins = Math.max(1, Math.round((km / 35) * 60) + 5);
<div class="w-full bg-white rounded-full h-2 overflow-hidden shadow-inner border border-indigo-100"> if (totalMins === null) totalMins = remainingMins;
<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> let progressPercent = totalMins ? ((totalMins - remainingMins) / totalMins) * 100 : 5;
</div> if (progressPercent > 95) progressPercent = 95;
</div> if (progressPercent < 5) progressPercent = 5;
<div class="flex justify-between items-center mt-1.5 px-1">
<p class="text-[8px] font-black uppercase text-indigo-300">Saliendo</p> container.innerHTML = `
<p class="text-[8px] font-black uppercase text-indigo-400">A ${km.toFixed(1)} km</p> <p class="text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-2 flex items-center gap-1.5">
</div> <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>
lucide.createIcons(); <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>
renderETA(); </div>
setInterval(renderETA, 60000); </div>
<div class="flex justify-between items-center mt-1.5 px-1">
} else { <p class="text-[8px] font-black uppercase text-indigo-300">Saliendo</p>
container.innerHTML = `<p class="text-xs font-bold text-indigo-600 leading-tight">El técnico está de camino.</p>`; <p class="text-[8px] font-black uppercase text-indigo-400">A ${km.toFixed(1)} km</p>
</div>
`;
lucide.createIcons();
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> </script>
</body> </body>