Files
Portal/index.html
2026-04-04 13:42:17 +00:00

1443 lines
84 KiB
HTML

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Portal del Cliente - IntegraRepara</title>
<script src="https://cdn.tailwindcss.com"></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://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"></script>
<style>
:root { --app-bg: #f8fafc; }
body { background-color: var(--app-bg); -webkit-tap-highlight-color: transparent; }
/* Animaciones fluidas */
.fade-in { animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; opacity: 0; }
.fade-in-delay-1 { animation-delay: 0.1s; }
.fade-in-delay-2 { animation-delay: 0.2s; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(15px); } to { opacity: 1; transform: translateY(0); } }
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* Efecto Glassmorphism */
.glass-nav { background: rgba(255, 255, 255, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
/* Estilos de pestañas */
.tab-content { display: none; }
.tab-content.active { display: block; animation: fadeIn 0.3s ease forwards; }
.nav-btn { color: #94a3b8; transition: all 0.2s; }
.nav-btn.active { color: #2563eb; }
.nav-btn.active i { transform: translateY(-2px); }
</style>
</head>
<body class="text-slate-800 font-sans antialiased min-h-screen flex flex-col relative overflow-x-hidden pb-24">
<div id="loader" class="fixed inset-0 bg-white z-[100] flex flex-col items-center justify-center transition-opacity duration-500">
<div class="relative w-16 h-16 flex items-center justify-center mb-4">
<div class="absolute inset-0 border-4 border-slate-100 border-t-blue-600 rounded-full animate-spin"></div>
<i data-lucide="home" class="w-6 h-6 text-blue-600 animate-pulse"></i>
</div>
<p class="text-xs font-black tracking-widest uppercase text-slate-400">Preparando tu portal...</p>
</div>
<div id="errorScreen" class="hidden w-full max-w-md mx-auto p-6 flex-col items-center justify-center min-h-screen text-center z-10 relative">
<div class="w-20 h-20 bg-rose-50 text-rose-500 rounded-[2rem] flex items-center justify-center mb-6 shadow-sm border border-rose-100 rotate-12">
<i data-lucide="shield-alert" class="w-10 h-10 -rotate-12"></i>
</div>
<h2 class="text-2xl font-black text-slate-800 mb-2 tracking-tight">Acceso Caducado</h2>
<p class="text-sm text-slate-500 font-medium leading-relaxed px-4">Por seguridad, los enlaces tienen un tiempo límite. Por favor, solicita uno nuevo a tu técnico o gestor.</p>
</div>
<main id="mainContent" class="hidden w-full max-w-lg mx-auto flex flex-col relative z-10">
<header class="pt-10 pb-8 px-6 bg-white rounded-b-[2.5rem] shadow-[0_10px_40px_rgba(0,0,0,0.03)] border-b border-slate-100 fade-in relative z-20">
<div class="flex justify-between items-center mb-6 gap-4">
<div class="flex-1 min-w-0">
<p class="text-[10px] font-black text-blue-600 uppercase tracking-widest mb-1">Bienvenido/a</p>
<h1 id="clientName" class="text-2xl font-black tracking-tight text-slate-900 leading-tight">Cliente</h1>
</div>
<div id="companyLogoContainer" class="hidden shrink-0 w-20 h-20 bg-white rounded-[1.5rem] shadow-sm border border-slate-100 p-2 overflow-hidden">
<img id="companyLogo" src="" class="w-full h-full object-contain">
</div>
</div>
<div class="grid grid-cols-4 gap-2">
<div class="bg-amber-50 rounded-2xl p-2 border border-amber-100 flex flex-col items-center justify-center shadow-sm">
<h3 class="text-xl font-black text-amber-600 leading-none" id="countPendientes">0</h3>
<p class="text-[7px] font-black text-amber-600 uppercase tracking-tighter mt-1">Pte. Citar</p>
</div>
<div class="bg-rose-50 rounded-2xl p-2 border border-rose-100 flex flex-col items-center justify-center shadow-sm">
<h3 class="text-xl font-black text-rose-600 leading-none" id="countIncidencias">0</h3>
<p class="text-[7px] font-black text-rose-600 uppercase tracking-tighter mt-1">Incidencias</p>
</div>
<div class="bg-slate-50 rounded-2xl p-2 border border-slate-100 flex flex-col items-center justify-center">
<h3 class="text-xl font-black text-slate-800 leading-none" id="countActive">0</h3>
<p class="text-[7px] font-black text-slate-500 uppercase tracking-tighter mt-1">En Proceso</p>
</div>
<div class="bg-slate-50 rounded-2xl p-2 border border-slate-100 flex flex-col items-center justify-center">
<h3 class="text-xl font-black text-slate-800 leading-none" id="countHistory">0</h3>
<p class="text-[7px] font-black text-slate-500 uppercase tracking-tighter mt-1">Finalizados</p>
</div>
</div>
</header>
<div id="promo-banner" class="hidden mt-4 mb-2 mx-5 fade-in relative">
<div onclick="openProtectionPlan()" class="relative cursor-pointer rounded-[2rem] overflow-hidden shadow-[0_12px_30px_rgba(15,23,42,0.18)] active:scale-[0.98] transition-all duration-300">
<img
src="img/banner-proteccion.jpg"
alt="Plan Protección Eléctrica"
class="w-full h-auto block"
>
<button
onclick="event.stopPropagation(); closeProtectionBanner()"
class="absolute top-3 right-3 w-8 h-8 rounded-full bg-black/35 hover:bg-black/50 text-white flex items-center justify-center transition-all z-10"
>
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
</div>
<div class="w-full relative z-10">
<div id="tabAvisos" class="tab-content active px-5 pt-6 pb-6">
<h2 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4 ml-2 fade-in fade-in-delay-1">Tus Reparaciones</h2>
<div id="activeServicesContainer" class="space-y-5 fade-in fade-in-delay-1"></div>
<div id="noActiveServices" class="hidden text-center p-8 bg-white rounded-[2.5rem] shadow-sm border border-slate-100 mt-2">
<div class="w-16 h-16 bg-emerald-50 text-emerald-500 rounded-full flex items-center justify-center mx-auto mb-4"><i data-lucide="check-circle-2" class="w-8 h-8"></i></div>
<h3 class="text-lg font-black text-slate-800 tracking-tight">Todo perfecto</h3>
<p class="text-xs text-slate-400 font-medium mt-1">No tienes reparaciones activas en este momento.</p>
</div>
<div id="historyContainerWrapper" class="hidden mt-8 fade-in fade-in-delay-2">
<button onclick="toggleHistory()" class="w-full bg-white border border-slate-200 p-5 rounded-[2rem] flex justify-between items-center shadow-sm active:scale-95 transition-all">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-slate-50 text-slate-400 rounded-xl flex items-center justify-center border border-slate-100"><i data-lucide="archive" class="w-5 h-5"></i></div>
<div class="text-left">
<span class="block text-xs font-black text-slate-700 uppercase tracking-widest">Finalizados</span>
<span class="text-[10px] font-bold text-slate-400" id="labelHistoryCount">0 servicios finalizados</span>
</div>
</div>
<i data-lucide="chevron-down" id="historyChevron" class="w-5 h-5 text-slate-300 transition-transform duration-300"></i>
</button>
<div id="historyServicesContainer" class="hidden space-y-4 mt-4 pb-4 transition-all"></div>
</div>
</div>
<div id="tabPresupuestos" class="tab-content px-5 pt-6 pb-6">
<div class="flex justify-between items-center mb-4 ml-2 mr-2">
<h2 class="text-xs font-black text-slate-400 uppercase tracking-widest">Tus Presupuestos</h2>
<span id="badgeQuotesCount" class="text-[9px] font-black bg-blue-100 text-blue-600 px-2 py-0.5 rounded-md hidden">0 NUEVOS</span>
</div>
<div id="quotesContainer" class="space-y-4">
</div>
</div>
</div>
<div id="quoteNudge" class="fixed bottom-24 right-16 z-[60] hidden pointer-events-none fade-in">
<div class="bg-blue-600 text-white px-4 py-3 rounded-2xl shadow-2xl flex items-center gap-3 animate-bounce relative border-2 border-white/20">
<div class="flex flex-col">
<span class="text-[8px] font-black uppercase tracking-[0.2em] opacity-80 leading-none mb-1">Acción Pendiente</span>
<span class="text-[11px] font-black uppercase tracking-wider whitespace-nowrap">Revisar Presupuesto</span>
</div>
<i data-lucide="arrow-big-down-dash" class="w-5 h-5"></i>
<div class="absolute -bottom-1.5 right-6 w-3 h-3 bg-blue-600 rotate-45"></div>
</div>
</div>
</main>
<nav class="fixed bottom-0 w-full glass-nav border-t border-slate-200 pb-safe pt-2 z-50 transition-transform duration-300 translate-y-full" id="bottomNav">
<div class="flex justify-around items-center max-w-lg mx-auto pb-3 pt-1">
<button id="btnNavAvisos" onclick="switchTab('Avisos')" class="nav-btn active flex flex-col items-center gap-1 w-24 relative">
<i data-lucide="wrench" class="w-6 h-6"></i>
<span class="text-[10px] font-black uppercase tracking-widest">Avisos</span>
</button>
<button id="btnNavPresupuestos" onclick="switchTab('Presupuestos')" class="nav-btn flex flex-col items-center gap-1 w-24 relative">
<div class="relative">
<i data-lucide="file-text" class="w-6 h-6"></i>
<span id="badgeNavPresupuestos" class="absolute -top-1 -right-1.5 w-3 h-3 bg-red-500 border-2 border-white rounded-full hidden animate-pulse"></span>
</div>
<span class="text-[10px] font-black uppercase tracking-widest">Presup.</span>
</button>
</div>
</nav>
<div id="quoteModal" class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[100] hidden flex-col justify-end">
<div class="bg-white w-full rounded-t-[2.5rem] p-6 pt-8 pb-10 transition-transform transform translate-y-full duration-300 max-h-[90vh] overflow-y-auto no-scrollbar" id="quoteModalSheet">
<div class="flex justify-between items-start mb-6">
<div>
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest bg-slate-100 px-2 py-1 rounded-md" id="qmRef">REF</span>
<h2 class="text-xl font-black text-slate-800 mt-2 leading-tight" id="qmTitle">Título</h2>
</div>
<button onclick="closeQuoteModal()" class="bg-slate-100 p-2.5 rounded-full text-slate-600 active:scale-90 transition-transform"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<div class="bg-slate-50 border border-slate-100 rounded-2xl p-5 mb-6">
<div class="flex justify-between items-center mb-3">
<span class="text-xs font-bold text-slate-500">Fecha de emisión</span>
<span class="text-xs font-black text-slate-800" id="qmDate">--</span>
</div>
<div class="border-t border-slate-200 pt-3 mt-3 mb-3">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2">Desglose de Conceptos</p>
<ul id="qmItemsList" class="space-y-2 text-xs font-medium text-slate-600">
</ul>
</div>
<div class="flex justify-between items-center border-t border-slate-200 pt-3">
<span class="text-sm font-black text-slate-500 uppercase tracking-widest">Total Presupuestado</span>
<span class="text-3xl font-black text-blue-600" id="qmAmount">0.00€</span>
</div>
</div>
<div id="qmActionsContainer" class="mb-4">
<div id="qmDecisionButtons" class="flex flex-col gap-3">
<button id="btnStripePay" onclick="payWithStripe()" class="hidden w-full bg-indigo-600 text-white border border-indigo-700 font-black py-4 rounded-2xl flex items-center justify-center gap-2 uppercase tracking-widest text-[11px] active:scale-95 transition-all hover:bg-indigo-700 shadow-xl shadow-indigo-500/30">
<i data-lucide="credit-card" class="w-5 h-5"></i> Pagar Presupuesto
</button>
<div id="qmAcceptRejectGroup" class="flex gap-3">
<button onclick="openSignatureArea()" class="flex-1 bg-emerald-50 text-emerald-600 border border-emerald-200 font-black py-4 rounded-2xl flex flex-col items-center justify-center gap-1 uppercase tracking-widest text-[10px] active:scale-95 transition-all hover:bg-emerald-500 hover:text-white">
<i data-lucide="pen-tool" class="w-5 h-5"></i> Firmar y Aceptar
</button>
<button onclick="rejectBudget()" class="flex-1 bg-rose-50 text-rose-600 border border-rose-200 font-black py-4 rounded-2xl flex flex-col items-center justify-center gap-1 uppercase tracking-widest text-[10px] active:scale-95 transition-all hover:bg-rose-500 hover:text-white">
<i data-lucide="thumbs-down" class="w-5 h-5"></i> Rechazar
</button>
</div>
</div>
<div id="qmStatusMessage" class="hidden text-center p-4 rounded-xl font-black text-xs uppercase tracking-widest"></div>
</div>
<div id="qmSignatureArea" class="hidden mb-6 bg-slate-50 p-4 rounded-2xl border border-slate-200">
<p class="text-[10px] font-black text-emerald-600 uppercase tracking-widest mb-2 text-center"><i data-lucide="pen-tool" class="w-3 h-3 inline"></i> Dibuja tu firma para confirmar</p>
<div class="border-2 border-dashed border-slate-300 bg-white rounded-xl overflow-hidden mb-3">
<canvas id="signatureCanvas" class="w-full h-32 touch-none"></canvas>
</div>
<div class="flex gap-2">
<button onclick="clearSignature()" class="px-3 py-3 bg-slate-200 text-slate-600 rounded-xl text-xs font-bold uppercase active:scale-95"><i data-lucide="eraser" class="w-4 h-4"></i></button>
<button onclick="confirmAcceptBudget()" class="flex-1 bg-emerald-500 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">
Confirmar y Enviar
</button>
</div>
<button onclick="closeSignatureArea()" class="w-full mt-2 text-[10px] font-bold text-slate-400 p-2 uppercase tracking-widest">Cancelar</button>
</div>
<button id="btnDownloadPdf" class="w-full bg-slate-800 text-white font-black py-4 rounded-2xl shadow-xl flex items-center justify-center gap-2 uppercase tracking-widest text-xs active:scale-95 transition-transform">
<i data-lucide="download" class="w-4 h-4"></i> Descargar Copia en PDF
</button>
</div>
</div>
<div id="toast" class="fixed bottom-24 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-6 py-3 rounded-full shadow-2xl hidden z-[200] font-bold text-xs uppercase tracking-widest text-center transition-all whitespace-nowrap"></div>
<div id="pdf-wrapper" class="hidden">
<div id="pdf-content" style="background-color: white; color: #1e293b; font-family: Arial, Helvetica, sans-serif; width: 800px; min-height: 1122px; display: flex; flex-direction: column; padding: 30px 40px 40px 40px; box-sizing: border-box;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #e2e8f0; padding-bottom: 15px; margin-bottom: 20px;">
<div style="max-width: 250px; max-height: 80px;">
<img id="pdf-company-logo" src="" alt="Logo Empresa" style="max-width: 100%; max-height: 80px; object-fit: contain; display: none;">
<h2 id="pdf-company-name-fallback" style="margin:0; color:#2563eb; font-size: 22px; font-weight: 900; display: none;">IntegraRepara</h2>
</div>
<div style="text-align: right; font-size: 11px; color: #64748b; line-height: 1.4;">
<div id="pdf-company-name" style="font-size: 14px; font-weight: 900; color: #0f172a; margin-bottom: 2px;">Empresa S.L.</div>
<div id="pdf-company-dni">CIF: B12345678</div>
<div id="pdf-company-address">Calle Falsa 123</div>
<div id="pdf-company-location">28000 Madrid (Madrid)</div>
</div>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 20px;">
<div>
<h1 style="font-size: 24px; font-weight: 900; color: #2563eb; margin: 0 0 5px 0; letter-spacing: -1px;">PRESUPUESTO</h1>
<div style="font-size: 12px; color: #64748b; line-height: 1.4;">
<strong>Referencia:</strong> <span id="pdf-budget-id">#PRE-000</span><br>
<strong>Fecha:</strong> <span id="pdf-date">01/01/2026</span><br>
<strong>Validez:</strong> 30 días<br>
</div>
</div>
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; padding: 12px 15px; border-radius: 8px; width: 280px;">
<h3 style="margin: 0 0 5px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: #94a3b8; font-weight: 800;">Presupuestado a:</h3>
<p id="pdf-client-name" style="font-size: 14px; font-weight: 800; color: #0f172a; margin: 0 0 4px 0;">Nombre Cliente</p>
<p style="font-size: 12px; margin: 0; color: #475569; line-height: 1.4;">
<span id="pdf-client-phone">Teléfono</span><br>
<span id="pdf-client-address">Dirección</span>
</p>
</div>
</div>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<thead>
<tr>
<th style="background: #f1f5f9; color: #475569; font-weight: 800; text-transform: uppercase; font-size: 10px; padding: 8px 10px; text-align: left; border-bottom: 2px solid #cbd5e1; border-radius: 6px 0 0 0;">Concepto</th>
<th style="background: #f1f5f9; color: #475569; font-weight: 800; text-transform: uppercase; font-size: 10px; padding: 8px 10px; text-align: center; border-bottom: 2px solid #cbd5e1;">Cant.</th>
<th style="background: #f1f5f9; color: #475569; font-weight: 800; text-transform: uppercase; font-size: 10px; padding: 8px 10px; text-align: right; border-bottom: 2px solid #cbd5e1;">Precio Ud.</th>
<th style="background: #f1f5f9; color: #475569; font-weight: 800; text-transform: uppercase; font-size: 10px; padding: 8px 10px; text-align: right; border-bottom: 2px solid #cbd5e1; border-radius: 0 6px 0 0;">Subtotal</th>
</tr>
</thead>
<tbody id="pdf-items">
</tbody>
</table>
<div style="width: 250px; margin-left: auto; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; padding: 8px 15px; font-size: 11px; border-bottom: 1px solid #f1f5f9; color: #475569;">
<span style="font-weight: 600; text-transform: uppercase;">Base Imponible</span>
<span id="pdf-subtotal" style="font-weight: 800; color: #0f172a;">0.00 €</span>
</div>
<div style="display: flex; justify-content: space-between; padding: 8px 15px; font-size: 11px; border-bottom: 1px solid #f1f5f9; color: #475569;">
<span style="font-weight: 600; text-transform: uppercase;">IVA (21%)</span>
<span id="pdf-tax" style="font-weight: 800; color: #0f172a;">0.00 €</span>
</div>
<div style="display: flex; justify-content: space-between; padding: 10px 15px; background: #2563eb; color: white;">
<span style="font-weight: 900; font-size: 13px;">TOTAL A PAGAR</span>
<span id="pdf-total" style="font-weight: 900; font-size: 16px;">0.00 €</span>
</div>
</div>
<div style="margin-top: auto; padding-top: 20px;">
<div id="pdf-bank-info" style="margin-bottom: 10px; padding: 12px 15px; background-color: #f0fdf4; border-left: 4px solid #22c55e; border-radius: 0 6px 6px 0; font-size: 11px; color: #166534; display: none;">
<strong>INFORMACIÓN DE PAGO</strong><br>
Para confirmar el presupuesto, por favor realice una transferencia a la siguiente cuenta:<br>
<span id="pdf-bank-account" style="font-size: 14px; font-weight: 900; letter-spacing: 1px; margin-top: 4px; display: inline-block;">ES00 0000 0000 0000 0000</span>
</div>
<div id="pdf-obs-info" style="margin-bottom: 15px; padding: 12px 15px; background-color: #fefce8; border-left: 4px solid #f59e0b; border-radius: 0 6px 6px 0; font-size: 10px; color: #713f12; display: none;">
<strong>OBSERVACIONES Y CONDICIONES</strong><br>
<span id="pdf-obs-text"></span>
</div>
<div style="text-align: center; font-size: 9px; color: #94a3b8; border-top: 1px solid #e2e8f0; padding-top: 10px;">
Documento generado por IntegraRepara - Sistema de Gestión de Asistencias
</div>
</div>
</div>
</div>
<script>
const API_URL = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
? 'http://localhost:3000'
: 'https://integrarepara-api.integrarepara.es';
let urlToken = "";
let etasToInit = [];
let currentQuotes = [];
let globalCompanyData = null; // 🛑 NUEVO: Guarda los datos para el PDF
// 🛑 NUEVO: Diccionario de logos de compañías
const companyLogos = {
'REPSOL': 'https://cdn.sanity.io/images/rn4tswnp/production/1bc5be0207b732bd18dd0fc38e063d5701267068-1000x832.png?rect=6,0,994,832&h=320&auto=format&dpr=2',
'MUTUA': 'https://www.google.com/s2/favicons?domain=mutua.es&sz=128',
'ALLIANZ': 'https://www.google.com/s2/favicons?domain=allianz.es&sz=128',
'CASER': 'https://www.google.com/s2/favicons?domain=caser.es&sz=128',
'SEGURCAIXA': 'https://unijepol.eu/wp-content/uploads/2021/01/Segur-Caixa-Adeslas.jpg',
'LA CAIXA': 'https://www.google.com/s2/favicons?domain=segurcaixaadeslas.es&sz=128',
'AXA': 'https://www.google.com/s2/favicons?domain=axa.es&sz=128',
'LDA': 'https://www.google.com/s2/favicons?domain=lineadirecta.com&sz=128',
'LINEA DIRECTA': 'https://www.google.com/s2/favicons?domain=lineadirecta.com&sz=128',
'HOMESERVE': 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTeO7TsnpOYqLGgJm0puUyUMLi657od4mGWKQ&s',
'RGA': 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRemEVB4iYmTGoZL0nrBQJkZ1vNlDNtKfGEug&s',
'RURAL': 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRemEVB4iYmTGoZL0nrBQJkZ1vNlDNtKfGEug&s',
'SANTANDER': 'https://www.google.com/s2/favicons?domain=santander.com&sz=128',
'BANSABADELL': 'https://www.google.com/s2/favicons?domain=bancsabadell.com&sz=128',
'GENERALI': 'https://cdn.worldvectorlogo.com/logos/generali-logo.svg',
'MAPFRE': 'https://www.google.com/s2/favicons?domain=mapfre.es&sz=128',
'DEFAULT': 'https://cdn-icons-png.flaticon.com/512/2875/2875438.png'
};
document.addEventListener("DOMContentLoaded", async () => {
lucide.createIcons();
const urlParams = new URLSearchParams(window.location.search);
urlToken = urlParams.get('token');
const serviceParam = urlParams.get('service');
if (!urlToken) { showError(); return; }
try {
// Obtenemos SIEMPRE todos los servicios de ese cliente, aunque haya '?service=XX' en la URL
let fetchUrl = `${API_URL}/public/portal/${urlToken}`;
const res = await fetch(fetchUrl);
const data = await res.json();
if (!data.ok) throw new Error("Token inválido");
const servicesList = data.services || [];
currentQuotes = data.quotes || [];
renderPortal(data.client, data.company, servicesList);
initProtectionBanner(data.subscription);
renderQuotes();
// Si viene un service en la URL, podríamos hacer scroll hacia él aquí
if (serviceParam) {
setTimeout(() => {
const targetCard = document.getElementById(`service-card-${serviceParam}`);
if (targetCard) {
targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetCard.classList.add('ring-2', 'ring-blue-400', 'ring-offset-4');
setTimeout(() => targetCard.classList.remove('ring-2', 'ring-blue-400', 'ring-offset-4'), 3000);
}
}, 500);
}
} catch (e) {
console.error("Error cargando portal:", e);
showError();
}
});
function showError() {
document.getElementById('loader').classList.add('opacity-0', 'pointer-events-none');
setTimeout(() => {
document.getElementById('loader').classList.add('hidden');
document.getElementById('errorScreen').classList.remove('hidden');
document.getElementById('errorScreen').classList.add('flex');
}, 300);
}
function openProtectionPlan() {
window.location.href = `plan-tranquilidad.html?token=${urlToken}`;
}
function closeProtectionBanner() {
localStorage.setItem(`promo_closed_${urlToken}`, 'true');
const banner = document.getElementById('promo-banner');
if (!banner) return;
banner.classList.add('opacity-0', 'translate-y-2', 'pointer-events-none');
setTimeout(() => {
banner.classList.add('hidden');
}, 250);
}
function initProtectionBanner(subscription) {
const banner = document.getElementById('promo-banner');
if (!banner) return;
const wasClosed = localStorage.getItem(`promo_closed_${urlToken}`) === 'true';
const isPlanPage = window.location.pathname.includes('plan-tranquilidad');
if (!subscription && !wasClosed && !isPlanPage) {
banner.classList.remove('hidden');
} else {
banner.classList.add('hidden');
}
lucide.createIcons();
}
// --- SISTEMA DE PESTAÑAS ---
function toggleHistory() {
const container = document.getElementById('historyServicesContainer');
const chevron = document.getElementById('historyChevron');
if (container.classList.contains('hidden')) {
container.classList.remove('hidden');
container.classList.add('fade-in');
chevron.classList.add('rotate-180');
} else {
container.classList.add('hidden');
container.classList.remove('fade-in');
chevron.classList.remove('rotate-180');
}
}
function switchTab(tabName) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(el => el.classList.remove('active'));
document.getElementById(`tab${tabName}`).classList.add('active');
document.getElementById(`btnNav${tabName}`).classList.add('active');
// 🚨 Ocultar el señalador si entramos en presupuestos
const nudge = document.getElementById('quoteNudge');
// Comprobar si hay algún presupuesto 'pending' QUE NO HAYA SIDO VISTO
const hasUnseenPending = currentQuotes.some(q =>
(!q.status || q.status === 'pending') &&
localStorage.getItem(`quote_viewed_${q.id}`) !== 'true'
);
const isAvisosTab = document.getElementById('tabAvisos').classList.contains('active');
// Solo mostrar si es nuevo, está pendiente y estamos en la pestaña principal
if (hasUnseenPending && isAvisosTab) {
nudge.classList.remove('hidden');
} else {
nudge.classList.add('hidden');
}
} // <--- ESTA ES LA LLAVE QUE FALTA
// --- GESTIÓN DE PRESUPUESTOS ---
function renderQuotes() {
const container = document.getElementById('quotesContainer');
let unseenCount = 0;
if (currentQuotes.length === 0) {
container.innerHTML = `
<div class="text-center p-10 bg-white rounded-[2rem] border border-slate-100 shadow-sm">
<div class="w-16 h-16 bg-slate-50 text-slate-300 rounded-full flex items-center justify-center mx-auto mb-3"><i data-lucide="file-x" class="w-8 h-8"></i></div>
<h3 class="text-base font-black text-slate-800">Sin Presupuestos</h3>
<p class="text-xs text-slate-400 font-medium mt-1">No tienes presupuestos pendientes de revisar.</p>
</div>`;
updateQuotesBadges(0);
return;
}
let html = '';
currentQuotes.forEach(q => {
const isViewed = localStorage.getItem(`quote_viewed_${q.id}`) === 'true';
if (!isViewed) unseenCount++;
// Formateamos las variables reales de la BD
let refStr = q.quote_ref || q.ref || q.id || "S/N";
let titleStr = q.title || "Presupuesto de Reparación";
let amountStr = parseFloat(q.total || q.amount || 0).toFixed(2);
let dateStr = q.created_at || q.date || "";
if(dateStr && dateStr.includes('T')) dateStr = dateStr.split('T')[0].split('-').reverse().join('/');
// 🛑 LÓGICA STRIPE: Creamos el botón rápido si está firmado, la empresa tiene Stripe y NO está pagado
let bSet = globalCompanyData?.billing_settings || {};
if (typeof bSet === 'string') { try { bSet = JSON.parse(bSet); } catch(e) {} }
const stripeEnabled = bSet.stripe_enabled && bSet.stripe_pk;
const isSigned = (q.status === 'accepted' || q.status === 'converted');
let quickPayButton = '';
if (stripeEnabled && isSigned && q.status !== 'paid') {
quickPayButton = `
<button onclick="event.stopPropagation(); quickPayStripe(${q.id})" class="mt-4 w-full bg-indigo-600 text-white font-black py-3 rounded-xl flex items-center justify-center gap-2 uppercase tracking-widest text-[10px] shadow-md shadow-indigo-500/20 active:scale-95 transition-transform hover:bg-indigo-700">
<i data-lucide="credit-card" class="w-4 h-4"></i> Pagar Ahora
</button>
`;
}
// 🛑 FIX: Calcar los colores y estados del escritorio
let statusBadge = "";
if (!q.status || q.status === 'pending') {
statusBadge = `<span class="bg-amber-100 text-amber-700 px-2 py-1 rounded-md text-[8px] font-black uppercase tracking-widest flex items-center gap-1 w-fit mt-1.5"><i data-lucide="clock" class="w-3 h-3"></i> Pte. Resolver</span>`;
} else if (q.status === 'accepted' || q.status === 'converted') {
statusBadge = `<span class="bg-blue-100 text-blue-600 px-2 py-1 rounded-md text-[8px] font-black uppercase tracking-widest flex items-center gap-1 w-fit mt-1.5 border border-blue-200"><i data-lucide="clock" class="w-3 h-3"></i> Aceptado (Pte. Pago)</span>`;
} else if (q.status === 'paid') {
statusBadge = `<span class="bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md text-[8px] font-black uppercase tracking-widest flex items-center gap-1 w-fit mt-1.5 shadow-sm border border-emerald-200"><i data-lucide="badge-check" class="w-3 h-3"></i> Pagado Online</span>`;
} else if (q.status === 'rejected') {
statusBadge = `<span class="bg-rose-100 text-rose-700 px-2 py-1 rounded-md text-[8px] font-black uppercase tracking-widest flex items-center gap-1 w-fit mt-1.5"><i data-lucide="x" class="w-3 h-3"></i> Rechazado</span>`;
}
html += `
<div onclick="openQuoteModal(${q.id})" class="bg-white p-5 rounded-[2rem] shadow-sm border ${isViewed ? 'border-slate-100' : 'border-blue-400 ring-2 ring-blue-50'} relative cursor-pointer active:scale-95 transition-all text-left">
${!isViewed ? '<div class="absolute top-5 right-5 w-3 h-3 bg-red-500 rounded-full animate-pulse shadow-sm border-2 border-white"></div>' : ''}
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 rounded-xl ${isViewed ? 'bg-slate-50 text-slate-400' : 'bg-blue-50 text-blue-600'} flex items-center justify-center shrink-0">
<i data-lucide="file-text" class="w-5 h-5"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest leading-none mb-1">REF #${refStr}</p>
<h3 class="font-black text-slate-800 text-sm leading-tight pr-6 truncate">${titleStr}</h3>
${statusBadge}
</div>
</div>
<div class="flex justify-between items-end border-t border-slate-100 pt-3 mt-1">
<span class="text-[10px] font-bold text-slate-500 flex items-center gap-1"><i data-lucide="calendar" class="w-3 h-3"></i> ${dateStr}</span>
<span class="text-lg font-black text-slate-800">${amountStr}€</span>
</div>
${quickPayButton}
</div>`;
});
container.innerHTML = html;
updateQuotesBadges(unseenCount);
// 🚨 LÓGICA DEL SEÑALADOR (NUDGE)
const nudge = document.getElementById('quoteNudge');
// Comprobar si hay algún presupuesto que sea 'pending'
const hasPending = currentQuotes.some(q => !q.status || q.status === 'pending');
// Solo lo mostramos si hay pendientes Y estamos en la pestaña de Avisos
const isAvisosTab = document.getElementById('tabAvisos').classList.contains('active');
if (hasPending && isAvisosTab) {
nudge.classList.remove('hidden');
} else {
nudge.classList.add('hidden');
}
lucide.createIcons();
}
function updateQuotesBadges(count) {
const badgeNav = document.getElementById('badgeNavPresupuestos');
const badgeTab = document.getElementById('badgeQuotesCount');
if (count > 0) {
badgeNav.classList.remove('hidden');
badgeTab.classList.remove('hidden');
badgeTab.innerText = `${count} NUEVO${count > 1 ? 'S' : ''}`;
} else {
badgeNav.classList.add('hidden');
badgeTab.classList.add('hidden');
}
}
let signaturePad = null;
let currentBudgetIdForSignature = null;
function openQuoteModal(id) {
const q = currentQuotes.find(x => x.id === id);
if (!q) return;
currentBudgetIdForSignature = id;
localStorage.setItem(`quote_viewed_${id}`, 'true');
renderQuotes();
document.getElementById('qmRef').innerText = `REF #${q.quote_ref || q.ref || q.id || "S/N"}`;
document.getElementById('qmTitle').innerText = q.title || "Presupuesto de Reparación";
let fDate = q.created_at || q.date || "";
if(fDate && fDate.includes('T')) fDate = fDate.split('T')[0].split('-').reverse().join('/');
document.getElementById('qmDate').innerText = fDate;
document.getElementById('qmAmount').innerText = parseFloat(q.total || q.amount || 0).toFixed(2) + "€";
// PINTAR LÍNEAS DE CONCEPTOS
let itemsArr = [];
if (typeof q.items === 'string') {
try { itemsArr = JSON.parse(q.items); } catch(e) { itemsArr = []; }
} else if (Array.isArray(q.items)) {
itemsArr = q.items;
}
let itemsHtml = '';
if(itemsArr.length > 0) {
itemsArr.forEach(i => {
itemsHtml += `<li class="flex justify-between border-b border-slate-100 pb-2 mb-2"><span class="w-2/3 pr-2 leading-tight">${i.qty}x ${i.concept}</span> <span class="font-black text-slate-800 shrink-0 text-right">${(i.qty * i.price).toFixed(2)}€</span></li>`;
});
} else {
itemsHtml = `<li class="text-slate-400 italic">Desglose general aplicado en el importe total.</li>`;
}
document.getElementById('qmItemsList').innerHTML = itemsHtml;
// CONTROL DE ESTADOS (ACEPTADO / RECHAZADO / PAGADO)
const btnContainer = document.getElementById('qmDecisionButtons');
const msgContainer = document.getElementById('qmStatusMessage');
const sigArea = document.getElementById('qmSignatureArea');
const btnStripe = document.getElementById('btnStripePay');
const acceptRejectGroup = document.getElementById('qmAcceptRejectGroup');
sigArea.classList.add('hidden');
// 1. Lógica Stripe: Mostrar SOLO si está configurado, YA ESTÁ FIRMADO y NO está pagado.
let bSet = globalCompanyData?.billing_settings || {};
if (typeof bSet === 'string') { try { bSet = JSON.parse(bSet); } catch(e) {} }
// Comprobamos si el cliente ya ha plasmado su firma
const isSigned = (q.status === 'accepted' || q.status === 'converted');
if (bSet.stripe_enabled && bSet.stripe_pk && isSigned && q.status !== 'paid') {
// Si ya firmó, enseñamos el botón morado de Stripe
btnStripe.classList.remove('hidden');
btnStripe.classList.add('flex');
} else {
// Si no ha firmado todavía (o ya pagó), lo ocultamos
btnStripe.classList.add('hidden');
btnStripe.classList.remove('flex');
}
// 2. Control de qué botones y mensajes se ven
if (!q.status || q.status === 'pending') {
btnContainer.classList.remove('hidden');
if(acceptRejectGroup) { acceptRejectGroup.classList.remove('hidden'); acceptRejectGroup.classList.add('flex'); }
msgContainer.classList.add('hidden');
}
else if (q.status === 'accepted' || q.status === 'converted') {
// Ya firmó, ocultamos botones de firma, pero dejamos el contenedor para que se vea el botón de Stripe
btnContainer.classList.remove('hidden');
if(acceptRejectGroup) { acceptRejectGroup.classList.add('hidden'); acceptRejectGroup.classList.remove('flex'); }
msgContainer.classList.remove('hidden');
msgContainer.className = "text-center p-4 rounded-xl font-black text-xs uppercase tracking-widest bg-blue-50 text-blue-600 border border-blue-100";
msgContainer.innerHTML = '<i data-lucide="info" class="w-5 h-5 inline mb-1"></i><br>Presupuesto Firmado (Pendiente de pago)';
}
else if (q.status === 'paid') {
// Pagado: Ocultamos todo tipo de botón
btnContainer.classList.add('hidden');
msgContainer.classList.remove('hidden');
msgContainer.className = "text-center p-4 rounded-xl font-black text-xs uppercase tracking-widest bg-emerald-50 text-emerald-600 border border-emerald-100";
msgContainer.innerHTML = '<i data-lucide="check-circle-2" class="w-5 h-5 inline mb-1"></i><br>Abonado Correctamente';
}
else if (q.status === 'rejected') {
btnContainer.classList.add('hidden');
msgContainer.classList.remove('hidden');
msgContainer.className = "text-center p-4 rounded-xl font-black text-xs uppercase tracking-widest bg-rose-50 text-rose-600 border border-rose-100";
msgContainer.innerHTML = '<i data-lucide="x-circle" class="w-5 h-5 inline mb-1"></i><br>Presupuesto Rechazado';
}
document.getElementById('btnDownloadPdf').setAttribute('onclick', `generatePDF(${q.id})`);
const modal = document.getElementById('quoteModal');
const sheet = document.getElementById('quoteModalSheet');
modal.classList.remove('hidden');
modal.classList.add('flex');
lucide.createIcons();
setTimeout(() => sheet.classList.remove('translate-y-full'), 10);
}
function closeQuoteModal() {
const modal = document.getElementById('quoteModal');
const sheet = document.getElementById('quoteModalSheet');
sheet.classList.add('translate-y-full');
setTimeout(() => {
modal.classList.add('hidden');
modal.classList.remove('flex');
}, 300);
}
// --- SISTEMA DE FIRMA Y RESPUESTA ---
function openSignatureArea() {
document.getElementById('qmDecisionButtons').classList.add('hidden');
document.getElementById('qmSignatureArea').classList.remove('hidden');
const canvas = document.getElementById('signatureCanvas');
if(!signaturePad) {
signaturePad = new SignaturePad(canvas, { backgroundColor: 'rgb(255, 255, 255)' });
}
signaturePad.clear();
const ratio = Math.max(window.devicePixelRatio || 1, 1);
canvas.width = canvas.offsetWidth * ratio;
canvas.height = canvas.offsetHeight * ratio;
canvas.getContext("2d").scale(ratio, ratio);
}
function closeSignatureArea() {
document.getElementById('qmSignatureArea').classList.add('hidden');
document.getElementById('qmDecisionButtons').classList.remove('hidden');
}
function clearSignature() {
if(signaturePad) signaturePad.clear();
}
async function confirmAcceptBudget() {
if(signaturePad.isEmpty()) return showToast("⚠️ Por favor, dibuja tu firma para aceptar.");
const signatureBase64 = signaturePad.toDataURL("image/png");
showToast("⏳ Guardando aceptación...");
try {
const res = await fetch(`${API_URL}/public/portal/${urlToken}/budget/${currentBudgetIdForSignature}/respond`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'accept', signature: signatureBase64 })
});
const data = await res.json();
if(data.ok) {
const b = currentQuotes.find(x => x.id === currentBudgetIdForSignature);
if(b) b.status = 'accepted';
showToast("✅ ¡Presupuesto Aceptado! La oficina ha sido notificada.");
closeQuoteModal();
renderQuotes();
}
} catch(e) { showToast("❌ Error de conexión al guardar."); }
}
async function rejectBudget() {
if(!confirm("¿Estás seguro de que deseas rechazar este presupuesto?")) return;
showToast("⏳ Registrando...");
try {
const res = await fetch(`${API_URL}/public/portal/${urlToken}/budget/${currentBudgetIdForSignature}/respond`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'reject' })
});
const data = await res.json();
if(data.ok) {
const b = currentQuotes.find(x => x.id === currentBudgetIdForSignature);
if(b) b.status = 'rejected';
showToast("❌ Presupuesto rechazado. Gracias por avisarnos.");
closeQuoteModal();
renderQuotes();
}
} catch(e) { showToast("❌ Error de conexión al guardar."); }
}
// --- LÓGICA DE PDF AÑADIDA ---
let allServicesGlobalBackup = []; // <--- Variable para no perder los datos del cliente
// 💳 LANZAR PAGO CON STRIPE
async function payWithStripe() {
showToast("⏳ Redirigiendo a pasarela segura...");
try {
const res = await fetch(`${API_URL}/public/portal/${urlToken}/budget/${currentBudgetIdForSignature}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (data.ok && data.checkout_url) {
window.location.href = data.checkout_url; // Redirigimos a la ventana de pago de Stripe
} else {
showToast(data.error || "❌ Error al iniciar el pago.", true);
}
} catch (e) {
showToast("❌ Error de conexión con el banco.", true);
}
}
// 💳 LANZAR PAGO RÁPIDO DESDE LA TARJETA EXTERNA
async function quickPayStripe(id) {
showToast("⏳ Redirigiendo a pasarela segura...");
try {
const res = await fetch(`${API_URL}/public/portal/${urlToken}/budget/${id}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (data.ok && data.checkout_url) {
window.location.href = data.checkout_url;
} else {
showToast(data.error || "❌ Error al iniciar el pago.");
}
} catch (e) {
showToast("❌ Error de conexión con el banco.");
}
}
async function generatePDF(id) {
const budget = currentQuotes.find(b => b.id === id);
if(!budget) return showToast("❌ Error: Presupuesto no encontrado");
showToast("⏳ Generando PDF, espera unos segundos...");
// 1. FIX DE PARSEO: A veces PostgreSQL manda el JSON como String. Lo forzamos a Objeto.
let bSet = globalCompanyData?.billing_settings || {};
if (typeof bSet === 'string') {
try { bSet = JSON.parse(bSet); } catch(e) { bSet = {}; }
}
let empNombre = globalCompanyData?.name || "Empresa Reparadora";
let empLogo = globalCompanyData?.logo || null;
let empDni = bSet.dni ? `CIF/NIF: ${bSet.dni}` : "";
let empAddress = bSet.address || "";
let empCity = bSet.city || "";
let empState = bSet.state || "";
let empZip = bSet.zip || "";
let empIban = bSet.iban || null;
let empObs = bSet.obs || null;
document.getElementById('pdf-company-name').innerText = empNombre;
document.getElementById('pdf-company-dni').innerText = empDni;
document.getElementById('pdf-company-address').innerText = empAddress;
const locText = [empZip, empCity, empState ? `(${empState})` : ''].filter(Boolean).join(' ');
document.getElementById('pdf-company-location').innerText = locText;
const logoImg = document.getElementById('pdf-company-logo');
const logoTxt = document.getElementById('pdf-company-name-fallback');
if (empLogo) {
logoImg.src = empLogo;
logoImg.style.display = 'block';
logoTxt.style.display = 'none';
} else {
logoImg.style.display = 'none';
logoTxt.innerText = empNombre;
logoTxt.style.display = 'block';
}
document.getElementById('pdf-budget-id').innerText = `#${budget.quote_ref || budget.ref || budget.id}`;
let fDate = budget.created_at || budget.date || "";
if(fDate && fDate.includes('T')) fDate = fDate.split('T')[0].split('-').reverse().join('/');
document.getElementById('pdf-date').innerText = fDate;
// 2. FIX DE CLIENTE: Si el cliente viene vacío o como la palabra "null", tira del expediente base
let fallbackClient = {};
if (allServicesGlobalBackup && allServicesGlobalBackup.length > 0) {
fallbackClient = allServicesGlobalBackup[0]?.raw_data || {};
}
let finalName = String(budget.client_name || "");
if(finalName.trim() === "" || finalName === "null") {
finalName = fallbackClient["Nombre Cliente"] || fallbackClient["CLIENTE"] || document.getElementById('clientName').innerText || "Cliente";
}
let finalPhone = String(budget.client_phone || "");
if(finalPhone.trim() === "" || finalPhone === "null") {
finalPhone = fallbackClient["Teléfono"] || fallbackClient["TELEFONO"] || fallbackClient["TELEFONOS"] || "-";
}
let finalAddress = String(budget.client_address || "");
if(finalAddress.trim() === "" || finalAddress === "null") {
let fallbackAddr = fallbackClient["Dirección"] || fallbackClient["DOMICILIO"] || "";
let fallbackCp = fallbackClient["Código Postal"] || fallbackClient["C.P."] || "";
let fallbackPob = fallbackClient["Población"] || fallbackClient["POBLACION-PROVINCIA"] || "";
finalAddress = `${fallbackAddr}, ${fallbackCp} ${fallbackPob}`.trim();
if (finalAddress === "," || finalAddress === "") finalAddress = "Dirección no especificada";
}
document.getElementById('pdf-client-name').innerText = finalName;
document.getElementById('pdf-client-phone').innerText = finalPhone;
document.getElementById('pdf-client-address').innerText = finalAddress;
// 3. FIX DE ITEMS: Forzamos parseo por si viene como String de BD
let itemsArr = budget.items || [];
if (typeof itemsArr === 'string') {
try { itemsArr = JSON.parse(itemsArr); } catch(e) { itemsArr = []; }
}
if (!Array.isArray(itemsArr)) itemsArr = [];
let itemsHtml = '';
if (itemsArr.length > 0) {
itemsHtml = itemsArr.map(item => `
<tr>
<td style="padding: 8px 10px; border-bottom: 1px solid #e2e8f0; font-size: 12px; color: #334155;">${item.concept}</td>
<td style="padding: 8px 10px; border-bottom: 1px solid #e2e8f0; font-size: 12px; color: #334155; text-align: center;">${item.qty}</td>
<td style="padding: 8px 10px; border-bottom: 1px solid #e2e8f0; font-size: 12px; color: #334155; text-align: right;">${parseFloat(item.price).toFixed(2)} €</td>
<td style="padding: 8px 10px; border-bottom: 1px solid #e2e8f0; font-size: 12px; font-weight: 700; color: #0f172a; text-align: right;">${(item.qty * item.price).toFixed(2)} €</td>
</tr>
`).join('');
} else {
itemsHtml = `<tr>
<td style="padding: 8px 10px; border-bottom: 1px solid #e2e8f0; font-size: 12px; color: #334155;">${budget.title || "Presupuesto General de Reparación"}</td>
<td style="padding: 8px 10px; border-bottom: 1px solid #e2e8f0; font-size: 12px; color: #334155; text-align: center;">1</td>
<td style="padding: 8px 10px; border-bottom: 1px solid #e2e8f0; font-size: 12px; color: #334155; text-align: right;">${parseFloat(budget.total || budget.amount || 0).toFixed(2)} €</td>
<td style="padding: 8px 10px; border-bottom: 1px solid #e2e8f0; font-size: 12px; font-weight: 700; color: #0f172a; text-align: right;">${parseFloat(budget.total || budget.amount || 0).toFixed(2)} €</td>
</tr>`;
}
document.getElementById('pdf-items').innerHTML = itemsHtml;
let tot = parseFloat(budget.total || budget.amount || 0);
let sub = budget.subtotal ? parseFloat(budget.subtotal) : tot / 1.21;
let tx = budget.tax ? parseFloat(budget.tax) : tot - sub;
document.getElementById('pdf-subtotal').innerText = sub.toFixed(2) + " €";
document.getElementById('pdf-tax').innerText = tx.toFixed(2) + " €";
document.getElementById('pdf-total').innerText = tot.toFixed(2) + " €";
// 4. MOSTRAR BLOQUES OCULTOS (IBAN / OBS)
const bankContainer = document.getElementById('pdf-bank-info');
if (empIban && empIban.trim() !== "") {
document.getElementById('pdf-bank-account').innerText = empIban;
bankContainer.style.display = 'block';
} else {
bankContainer.style.display = 'none';
}
const obsContainer = document.getElementById('pdf-obs-info');
if (empObs && empObs.trim() !== "") {
document.getElementById('pdf-obs-text').innerHTML = String(empObs).replace(/\n/g, '<br>');
obsContainer.style.display = 'block';
} else {
obsContainer.style.display = 'none';
}
// REVELAR, DIBUJAR Y ESCONDER
const wrapper = document.getElementById('pdf-wrapper');
wrapper.classList.remove('hidden');
wrapper.style.position = 'absolute';
wrapper.style.left = '-9999px';
wrapper.style.top = '0'; // 🛑 FIX: Evita que herede margen superior si el usuario hizo scroll
const element = document.getElementById('pdf-content');
const opt = {
margin: 0,
filename: `Presupuesto_${budget.quote_ref || budget.id}.pdf`,
image: { type: 'jpeg', quality: 1 },
// 🛑 FIX: Añadimos scrollY: 0 y windowY: 0 para que haga la "foto" clavada arriba
html2canvas: { scale: 2, useCORS: true, logging: false, scrollY: 0, windowY: 0 },
jsPDF: { unit: 'in', format: 'a4', orientation: 'portrait' }
};
try {
await new Promise(r => setTimeout(r, 500));
await html2pdf().set(opt).from(element).save();
showToast("✅ PDF Descargado con éxito");
} catch (error) {
showToast("❌ Error al generar el PDF");
} finally {
wrapper.classList.add('hidden');
wrapper.style.position = '';
wrapper.style.left = '';
}
}
function showToast(msg) {
const t = document.getElementById('toast');
t.innerHTML = msg;
t.classList.remove('hidden');
setTimeout(() => t.classList.add('hidden'), 3000);
}
function showToast(msg) {
const t = document.getElementById('toast');
t.innerHTML = msg;
t.classList.remove('hidden');
setTimeout(() => t.classList.add('hidden'), 3000);
}
// --- FUNCIONES DE TEXTO Y FECHAS ---
function summarizeDescription(rawText) {
if (!rawText) return "Revisión técnica en el domicilio.";
let text = String(rawText);
// 1. Limpieza inicial: Quitar fechas y coletillas exactas que ensucian el corte
text = text.replace(/\(?\d{2}\/\d{2}\/\d{4}.{0,12}\d{2}:\d{2}\)?\s*-?/g, " "); // (23/02/2026 - 11:42)
text = text.replace(/\d{2}\/\d{2}\/\d{4}\s*-\s*/g, " "); // 17/02/2026 -
text = text.replace(/Descripción de la Averia/gi, " ");
text = text.replace(/Suceso:/gi, " ");
text = text.replace(/Texto manual/gi, " ");
text = text.replace(/DAñO_[0-9]:/gi, " ");
text = text.replace(/\(posible cobertura\)/gi, " ");
// 2. Separar por saltos de línea reales O por dobles barras (//) típicas de Multiasistencia
let lines = text.split(/[\n]|(?:\/\/?)/);
let validLines = [];
// 3. LISTA NEGRA: Si la línea tiene alguna de estas palabras, es basura interna y se borra entera
const junkKeywords = [
"cobro banco", "cobro contado", "servicio asignado", "sms", "enviado sms",
"cambio de estado", "este servicio requiere", "atención: el importe",
"el tipo de cobro", "siniestro comunicado", "cita confirmada", "visita al cliente",
"introducción ptto", "aplazamiento pte", "fecha y hora", "para el ofrecimiento",
"enviar factura", "declarante", "aviso nocita", "límite máximo", "importante",
"conectese via", "servicio con prof", "servicio sin prof", "servicio con la fecha",
"servicio desasignado", "comentario de desasignacion", "webservice", "alta servicio",
"alta avería", "alta reparación", "alta daños", "alta sin luz", "alta cambio",
"contacto con cliente", "fin previsto", "entrega presupuesto", "reparación con expediente",
"abierta reclamación", "llamada autom", "voicebot", "cliente informado por whatsapp",
"a espera de cita", "llamada entrante", "llamada saliente", "se ha introducido un importe",
"intento de llamada", "llamada de asegurado", "llama asegurad", "llamo a",
"servicio generado por proceso automático", "cubre", "cobertura", "daños propios",
"tipo de averia", "franja horaria", "agente", "oficina bbva", "repasar",
"revisado por perito", "perito tasa", "cita deseada", "rango horario"
];
for (let line of lines) {
let cleanLine = line.trim();
if (cleanLine.length < 12) continue; // Ignorar códigos o números sueltos
let isJunk = junkKeywords.some(keyword => cleanLine.toLowerCase().includes(keyword));
if (!isJunk) {
// Limpiar asteriscos y basura suelta
cleanLine = cleanLine.replace(/[\*\_]+/g, "").trim();
if(cleanLine.length > 5) validLines.push(cleanLine);
}
}
// Si después de la escabechina nos quedamos sin nada, ponemos el texto base
if (validLines.length === 0) return "Revisión técnica en el domicilio.";
// 4. EL MAGO: Nos quedamos única y exclusivamente con la primera frase válida
let finalSummary = validLines[0];
// Limpieza estética final
finalSummary = finalSummary.replace(/[\/]+/g, ", ")
.replace(/[-_]{2,}/g, " ")
.replace(/\s{2,}/g, " ")
.trim();
// Transformar MAYÚSCULAS en texto normal para que no parezca que gritamos
finalSummary = finalSummary.toLowerCase();
finalSummary = finalSummary.charAt(0).toUpperCase() + finalSummary.slice(1);
// Cortar si es escandalosamente largo (más de 150 caracteres)
if (finalSummary.length > 150) {
finalSummary = finalSummary.substring(0, 150).trim() + "...";
}
return finalSummary.endsWith('.') || finalSummary.endsWith('...') ? finalSummary : finalSummary + ".";
}
function addOneHour(timeStr) {
if(!timeStr) return "";
let [h, m] = timeStr.split(':').map(Number);
let tm = h * 60 + m + 60;
return `${String(Math.floor(tm / 60)).padStart(2,'0')}:${String(tm % 60).padStart(2,'0')}`;
}
function formatDate(dateStr) {
if (!dateStr) return "";
try {
const parts = dateStr.split('-');
if(parts.length !== 3) return dateStr;
const d = new Date(parts[0], parts[1]-1, parts[2]);
return d.toLocaleDateString('es-ES', { weekday: 'long', day: 'numeric', month: 'short' });
} catch(e) { return dateStr; }
}
// --- RENDER PRINCIPAL DE AVISOS ---
function renderPortal(client, company, allServices) {
globalCompanyData = company; // 🛑 NUEVO: Guardamos la empresa
allServicesGlobalBackup = allServices; // 🛑 NUEVO: Guardamos una copia de los avisos para el PDF
if (company.name) document.title = `Portal - ${company.name}`;
if (company.logo) {
document.getElementById('companyLogo').src = company.logo;
document.getElementById('companyLogoContainer').classList.remove('hidden');
}
// Esto tomará el nombre completo tal cual viene de la base de datos
let cName = client && client.name ? client.name : "Cliente";
document.getElementById('clientName').innerText = cName;
const activeContainer = document.getElementById('activeServicesContainer');
const historyContainerWrapper = document.getElementById('historyContainerWrapper');
const historyContainer = document.getElementById('historyServicesContainer');
activeContainer.innerHTML = '';
historyContainer.innerHTML = '';
// Contadores
let countAct = 0;
let countHist = 0;
let countInc = 0;
let countPend = 0;
// 🛑 NUEVO: Ordenar por prioridad para que "Elige tu cita" salga siempre arriba
allServices.sort((a, b) => {
const getPriority = (s) => {
let stName = (s.status_name || "").toLowerCase();
let raw = s.raw_data || {};
let hasDate = (s.scheduled_date && s.scheduled_time);
let hasWorker = (s.assigned_worker && s.assigned_worker !== 'Pendiente' && s.assigned_worker !== 'Sin asignar');
// 1. MÁXIMA PRIORIDAD: El cliente tiene que coger cita
if (stName.includes('esperando') || stName.includes('asignado') || (hasWorker && !hasDate)) return 1;
// 2. PRIORIDAD ALTA: El técnico está en camino
if (stName.includes('camino')) return 2;
// 3. PRIORIDAD MEDIA: Hay una incidencia
if (stName.includes('incidencia')) return 3;
// 4. RESTO NORMAL: Trabajando, Confirmada, etc.
return 4;
};
return getPriority(a) - getPriority(b);
});
allServices.forEach(srv => {
let raw = srv.raw_data || {};
let stNameLower = (srv.status_name || "").toLowerCase();
// 🛑 FIX: Ampliamos el radar para cazar todos los archivados y anulados
let isFinalized = srv.is_final === true || srv.status === 'archived' || stNameLower.includes('finalizado') || stNameLower.includes('anulado');
let descLimpia = summarizeDescription(srv.description);
let hasDate = (srv.scheduled_date && srv.scheduled_time);
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'));
// Recopilar dirección limpia para mostrar
let cAddr = raw["Dirección"] || raw["DOMICILIO"] || "Dirección no especificada";
let cCP = raw["Código Postal"] || "";
let cPop = raw["Población"] || raw["POBLACION-PROVINCIA"] || "";
let fullAddress = `${cAddr}, ${cCP} ${cPop}`.trim();
if(fullAddress === ",") fullAddress = "No especificada";
// DIRECCIÓN HTML (Restaurado a petición)
let addressHtml = `
<div class="mt-4 bg-slate-50/70 rounded-2xl p-4 border border-slate-100 flex items-start gap-3">
<div class="bg-white p-2 rounded-xl border border-slate-200 shadow-sm shrink-0 text-slate-400">
<i data-lucide="map-pin" class="w-4 h-4"></i>
</div>
<div>
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Lugar de reparación</p>
<p class="text-xs font-bold text-slate-700 leading-snug">${fullAddress}</p>
</div>
</div>
`;
// DISEÑO DE ESTADOS EN TARJETA BLANCA
let headerColor = "bg-slate-50 text-slate-500";
let icon = "clock";
let tagTitle = "Estado";
let mainTitle = srv.status_name || "En Proceso";
let subDesc = "Buscando información...";
let extras = "";
if (isFinalized || stNameLower.includes('finalizado') || stNameLower.includes('anulado')) {
headerColor = "bg-slate-100 text-slate-400"; icon = "archive"; tagTitle = "Terminado"; mainTitle = "Archivo"; subDesc = "Este servicio ya ha sido concluido o archivado.";
}
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>`;
}
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.";
}
else if (stNameLower.includes('incidencia')) {
headerColor = "bg-rose-100 text-rose-600"; icon = "alert-triangle"; tagTitle = "Atención"; mainTitle = "En Incidencia"; subDesc = "Ha surgido un contratiempo técnico que estamos gestionando.";
}
else if (stNameLower.includes('compañ') || stNameLower.includes('perito')) {
headerColor = "bg-slate-100 text-slate-600"; icon = "building"; tagTitle = "Trámite"; mainTitle = "Espera de Compañía"; subDesc = "A la espera de autorización por parte de tu aseguradora.";
}
else if (raw.appointment_status === 'pending' && raw.requested_date) {
headerColor = "bg-purple-100 text-purple-600"; icon = "hourglass"; tagTitle = "Revisión"; mainTitle = "Cita Solicitada"; subDesc = `Has solicitado cita para el <b>${formatDate(raw.requested_date)}</b>. Esperando confirmación.`;
}
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.`;
extras = `<a href="cita.html?token=${urlToken}&service=${srv.id}" class="mt-3 block text-center w-full bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 font-black py-2.5 rounded-xl text-[10px] uppercase tracking-widest shadow-sm transition-colors">Modificar Cita</a>`;
} else {
headerColor = "bg-emerald-100 text-emerald-600"; icon = "calendar-check"; tagTitle = "Confirmada"; mainTitle = `${formatDate(srv.scheduled_date)}`; subDesc = `El técnico llegará entre las <b>${srv.scheduled_time}</b> y las <b>${endT}</b>.`;
extras = `<a href="cita.html?token=${urlToken}&service=${srv.id}" class="mt-3 block text-center w-full bg-slate-50 border border-slate-200 text-slate-600 hover:bg-slate-100 font-black py-2.5 rounded-xl text-[10px] uppercase tracking-widest shadow-sm transition-colors">Gestionar Cita</a>`;
}
}
else if (isUrgent) {
headerColor = "bg-red-100 text-red-600"; icon = "flame"; tagTitle = "Urgencia"; mainTitle = "Prioridad Máxima"; subDesc = hasWorker ? "Técnico asignado de urgencia." : "Buscando técnico de emergencia.";
}
else if (stNameLower.includes('esperando') || stNameLower.includes('asignado') || (hasWorker && !hasDate)) {
headerColor = "bg-amber-100 text-amber-600"; icon = "calendar-plus"; tagTitle = "Acción Requerida"; mainTitle = "Elige tu cita"; subDesc = "Técnico asignado. Selecciona cuándo quieres que vayamos.";
extras = `<a href="cita.html?token=${urlToken}&service=${srv.id}" class="mt-3 block text-center w-full bg-blue-600 text-white font-black py-3 rounded-xl text-xs uppercase tracking-widest shadow-md">Agendar Cita Ahora</a>`;
}
else if (stNameLower.includes('desasignado')) {
headerColor = "bg-slate-100 text-slate-500"; icon = "user-x"; tagTitle = "Reorganizando"; mainTitle = "Sin Técnico"; subDesc = "Buscando un nuevo técnico disponible para tu zona.";
}
// Contadores para el Dashboard superior
if (isFinalized) {
countHist++;
} else {
countAct++;
if (stNameLower.includes('incidencia')) countInc++;
if (stNameLower.includes('esperando') || stNameLower.includes('asignado') || (hasWorker && !hasDate)) countPend++;
}
// 🛑 NUEVO: Extraer la compañía y el logotipo
let compName = (raw["Compañía"] || raw["COMPAÑIA"] || raw["Aseguradora"] || raw["Cliente"] || "").toUpperCase();
let compLogo = companyLogos['DEFAULT'];
let isPrivate = false;
// Si no hay compañía o es particular
if (!compName || compName === "PARTICULAR" || compName === "PRIVADO") {
isPrivate = true;
compName = "PARTICULAR";
} else {
// Buscar coincidencia en el diccionario
for (const [key, url] of Object.entries(companyLogos)) {
if (compName.includes(key)) {
compLogo = url;
break;
}
}
}
// Si es particular, hereda el logo de tu empresa (si existe)
if (isPrivate && globalCompanyData?.logo) {
compLogo = globalCompanyData.logo;
}
// 🛑 FIX: Buscador inteligente de referencias (HomeServe, Multi, Manuales, etc.)
let numRef = raw["SERVICIO"] || raw["Expediente"] || raw["Referencia"] || raw["service_ref"];
if (!numRef && srv.title && srv.title.includes('#')) numRef = srv.title.split('#')[1].trim();
if (!numRef) numRef = srv.id; // Si falla todo, ponemos el ID interno
// 🟢 MEDALLA DE PAGO ONLINE
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>`
: '';
// 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">
${paidBadgeHtml}
<div class="flex justify-between items-center mb-5 pb-4 border-b border-slate-100">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-lg bg-slate-50 border border-slate-100 flex items-center justify-center p-1 shrink-0 overflow-hidden">
<img src="${compLogo}" class="w-full h-full object-contain">
</div>
<span class="text-xs font-black text-slate-700 uppercase tracking-widest truncate max-w-[130px]" title="${compName}">${compName}</span>
</div>
<span class="text-[10px] font-bold text-slate-400 bg-slate-50 px-2.5 py-1.5 rounded-lg border border-slate-100 tracking-widest shrink-0">REF #${numRef}</span>
</div>
<div class="flex items-start gap-4 mb-5">
<div class="w-12 h-12 rounded-2xl flex items-center justify-center shrink-0 ${headerColor}">
<i data-lucide="${icon}" class="w-6 h-6"></i>
</div>
<div class="flex-1 min-w-0 pt-0.5">
<p class="text-[9px] font-black uppercase tracking-widest text-slate-400 mb-0.5">${tagTitle}</p>
<h4 class="font-black text-slate-800 text-lg leading-tight truncate">${mainTitle}</h4>
<p class="text-xs font-medium text-slate-500 mt-1 leading-snug">${subDesc}</p>
</div>
</div>
${extras}
<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-sm font-bold text-slate-700 leading-relaxed">${descLimpia}</p>
${addressHtml}
${hasWorker && !isFinalized ? `
<div class="flex gap-2 mt-5">
${srv.worker_phone ? `<a href="tel:+${srv.worker_phone.replace('+','')}" class="flex-1 bg-slate-50 border border-slate-200 text-slate-600 font-black py-2.5 rounded-xl flex items-center justify-center gap-1.5 text-[10px] uppercase tracking-widest"><i data-lucide="phone" class="w-3.5 h-3.5"></i> Llamar</a>
<a href="https://wa.me/${srv.worker_phone.replace('+','')}" target="_blank" class="flex-1 bg-emerald-50 border border-emerald-200 text-emerald-600 font-black py-2.5 rounded-xl flex items-center justify-center gap-1.5 text-[10px] uppercase tracking-widest"><i data-lucide="message-circle" class="w-3.5 h-3.5"></i> WhatsApp</a>` : ''}
</div>
` : ''}
</div>
`;
if (isFinalized) historyContainer.innerHTML += cardHtml;
else activeContainer.innerHTML += cardHtml;
});
// Actualizar contadores cabecera
document.getElementById('countActive').innerText = countAct;
document.getElementById('countHistory').innerText = countHist;
document.getElementById('countIncidencias').innerText = countInc;
document.getElementById('countPendientes').innerText = countPend;
// 🛑 FIX: Le pasamos el dato real al botón del acordeón
if (document.getElementById('labelHistoryCount')) {
document.getElementById('labelHistoryCount').innerText = `${countHist} servicios finalizados`;
}
if (countAct === 0) document.getElementById('noActiveServices').classList.remove('hidden');
else document.getElementById('noActiveServices').classList.add('hidden');
if (countHist > 0) document.getElementById('historyContainerWrapper').classList.remove('hidden');
else document.getElementById('historyContainerWrapper').classList.add('hidden');
lucide.createIcons();
// Quitar Loader, mostrar App y Menú inferior
document.getElementById('loader').classList.add('opacity-0', 'pointer-events-none');
setTimeout(() => {
document.getElementById('loader').classList.add('hidden');
document.getElementById('mainContent').classList.remove('hidden');
document.getElementById('bottomNav').classList.remove('translate-y-full');
etasToInit.forEach(item => calculateClientETA(item.id, item.address));
}, 300);
}
// --- SISTEMA GPS ---
async function calculateClientETA(serviceId, destAddress) {
const container = document.getElementById(`eta-container-${serviceId}`);
if (!container) return;
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();
if (!geoData || geoData.length === 0) {
const parts = destAddress.split(',');
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')}`);
geoData = await geoRes.json();
}
if (geoData && geoData.length > 0) {
const cLat = parseFloat(geoData[0].lat);
const cLon = parseFloat(geoData[0].lon);
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 km = R * c;
const totalMins = Math.round((km/35)*60) + 5;
let startedAt = new Date().getTime();
if (data.location.updated_at) {
const parsed = new Date(data.location.updated_at).getTime();
if (!isNaN(parsed)) startedAt = parsed;
}
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>
</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>`;
}
} catch(e) {
if (container) container.innerHTML = `<p class="text-xs font-bold text-indigo-600 leading-tight">El técnico está de camino.</p>`;
}
}
</script>
</body>
</html>