1443 lines
84 KiB
HTML
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> |