Anade parte de trabajo con IA y firma

This commit is contained in:
2026-08-19 22:17:18 +00:00
parent 4656c6dc6e
commit 82c2f2adb4

View File

@@ -6,6 +6,7 @@
<title>Calendario - 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>
<style>
/* VARIABLES DINÁMICAS: Se actualizarán por JS desde la base de datos */
:root {
@@ -280,6 +281,13 @@
</div>
</div>
<div class="bg-white p-5 rounded-[2rem] shadow-sm border border-slate-200 text-left">
<p class="text-[10px] font-black text-primary-dynamic uppercase tracking-widest mb-3 flex items-center gap-1.5"><i data-lucide="clipboard-signature" class="w-4 h-4"></i> Parte de Trabajo</p>
<button onclick="openWorkReportModal()" class="w-full bg-slate-800 text-white font-black py-3.5 rounded-xl shadow-md flex items-center justify-center gap-2 uppercase text-xs tracking-widest active:scale-95 transition-all">
<i data-lucide="file-signature" class="w-4 h-4"></i> Crear Parte de Trabajo
</button>
</div>
<div class="pt-4 text-center">
<button onclick="askToFinish()" class="w-full bg-emerald-600 text-white font-black py-5 rounded-[2rem] shadow-xl flex items-center justify-center gap-3 uppercase text-sm tracking-widest active:scale-95 transition-all">
<i data-lucide="check-circle" class="w-6 h-6"></i> Finalizar Trabajo
@@ -289,6 +297,102 @@
</div>
</div>
<div id="workReportModal" class="translate-y-full transition-transform duration-300 fixed inset-0 z-[160] flex flex-col bg-slate-50" style="display:none;">
<div class="pt-safe bg-white border-b border-slate-200 flex justify-between items-center p-4 shrink-0 shadow-sm">
<button type="button" onclick="closeWorkReportModal()" class="bg-slate-100 p-2.5 rounded-full text-slate-600 active:scale-90 transition-transform"><i data-lucide="x" class="w-6 h-6"></i></button>
<h2 class="font-black text-slate-800 text-sm uppercase tracking-widest text-center">Parte de Trabajo</h2>
<div class="w-10"></div>
</div>
<div class="flex-1 overflow-y-auto p-5 space-y-5 no-scrollbar pb-40">
<div class="bg-white p-5 rounded-3xl shadow-sm border border-slate-200 space-y-3 text-left">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1.5"><i data-lucide="pencil-line" class="w-4 h-4 text-blue-500"></i> Descripción del trabajo realizado</p>
<textarea id="wrText" rows="6" placeholder="Escribe aquí, con tus palabras, lo que has hecho (diagnóstico, piezas, reparación...)" class="input-modern w-full resize-none leading-relaxed"></textarea>
<button type="button" id="btnImproveAI" onclick="improveReportWithAI()" class="w-full bg-indigo-50 text-indigo-700 hover:bg-indigo-100 font-black py-3 rounded-xl border border-indigo-200 flex items-center justify-center gap-2 text-[10px] uppercase tracking-widest active:scale-95 transition-all">
<i data-lucide="sparkles" class="w-4 h-4"></i> Mejorar con IA
</button>
<p class="text-[9px] text-slate-400 font-medium leading-relaxed">La IA redacta el texto de forma profesional. Puedes seguir editándolo después a mano.</p>
</div>
<div class="bg-white p-5 rounded-3xl shadow-sm border border-slate-200 space-y-3 text-left">
<div class="flex justify-between items-center">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1.5"><i data-lucide="signature" class="w-4 h-4 text-blue-500"></i> Firma del cliente</p>
<button type="button" onclick="clearSignature()" class="text-[9px] font-black text-slate-400 uppercase tracking-widest active:scale-95">Borrar</button>
</div>
<canvas id="signatureCanvas" class="w-full h-44 bg-slate-50 rounded-2xl border-2 border-dashed border-slate-300" style="touch-action:none;"></canvas>
<p class="text-[9px] text-slate-400 font-medium">Pásale el móvil al cliente para que firme aquí antes de enviar el parte.</p>
</div>
<div class="bg-white p-5 rounded-3xl shadow-sm border border-slate-200 space-y-3 text-left">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1.5"><i data-lucide="phone-forwarded" class="w-4 h-4 text-blue-500"></i> Enviar también a otro número (opcional)</p>
<input type="tel" id="wrExtraPhone" placeholder="Ej: 600123456" class="input-modern w-full">
<p class="text-[9px] text-slate-400 font-medium">El parte se envía siempre al teléfono del cliente. Si quieres, aquí puedes añadir un número extra (p.ej. la aseguradora).</p>
</div>
<button type="button" id="btnGenerateReport" onclick="generateAndSendWorkReport()" class="w-full bg-primary-dynamic text-white font-black py-4 rounded-2xl shadow-xl flex items-center justify-center gap-2 uppercase tracking-widest active:scale-95 transition-all text-xs">
<i data-lucide="send" class="w-5 h-5"></i> Generar PDF y Enviar por WhatsApp
</button>
<div class="pt-2">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-1.5"><i data-lucide="history" class="w-4 h-4"></i> Partes anteriores de este servicio</p>
<div id="wrHistoryListModal" class="space-y-2"></div>
</div>
</div>
</div>
<div id="wr-pdf-wrapper" class="hidden">
<div id="wr-pdf-content" style="background-color: white; color: #1e293b; font-family: Arial, Helvetica, sans-serif; width: 800px; min-height: 1120px; position: relative; padding: 30px 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="wrPdfLogo" src="" alt="Logo Empresa" style="max-width: 100%; max-height: 80px; object-fit: contain; display: none;">
<h2 id="wrPdfLogoFallback" style="margin:0; color:#1e3a8a; 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="wrPdfCompanyName" style="font-size: 14px; font-weight: 900; color: #0f172a; margin-bottom: 2px;">Empresa S.L.</div>
<div id="wrPdfCompanyDni"></div>
<div id="wrPdfCompanyAddress"></div>
<div id="wrPdfCompanyLocation"></div>
</div>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 20px;">
<div>
<h1 style="font-size: 24px; font-weight: 900; color: #1e3a8a; margin: 0 0 5px 0; letter-spacing: -1px;">PARTE DE TRABAJO</h1>
<div style="font-size: 12px; color: #64748b; line-height: 1.4;">
<strong>Expediente:</strong> <span id="wrPdfRef">#---</span><br>
<strong>Fecha:</strong> <span id="wrPdfDate">01/01/2026</span><br>
<strong>Atendido por:</strong> <span id="wrPdfOperatorName">-</span>
</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;">Cliente:</h3>
<p id="wrPdfClientName" 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="wrPdfClientPhone">Teléfono</span><br>
<span id="wrPdfClientAddress">Dirección</span>
</p>
</div>
</div>
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: #94a3b8; font-weight: 800;">Trabajo realizado</h3>
<div id="wrPdfText" style="font-size: 12px; color: #334155; line-height: 1.7; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px; white-space: pre-wrap;"></div>
</div>
<div style="position: absolute; bottom: 30px; left: 40px; right: 40px;">
<div style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px; margin-bottom: 15px;">
<p style="margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: #94a3b8; font-weight: 800;">Firma del cliente (conforme al trabajo realizado)</p>
<img id="wrPdfSignatureImg" src="" style="max-height: 90px; display: none;">
<p id="wrPdfSignaturePlaceholder" style="font-size: 11px; color: #94a3b8; font-style: italic; display: none;">Sin firma</p>
</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>
<div id="newServiceModal" class="translate-y-full transition-transform duration-300 fixed inset-0 z-[150] flex flex-col bg-slate-50" style="display:none;">
<div class="pt-safe bg-white border-b border-slate-200 flex justify-between items-center p-4 shrink-0 shadow-sm">
<button type="button" onclick="closeNewServiceModal()" class="bg-slate-100 p-2.5 rounded-full text-slate-600 active:scale-90 transition-transform"><i data-lucide="x" class="w-6 h-6"></i></button>
@@ -949,6 +1053,307 @@
setTimeout(() => modal.style.display = 'none', 300);
}
// ==========================================
// 🧾 PARTE DE TRABAJO (texto con IA + firma táctil + PDF + envío WhatsApp)
// ==========================================
let wrOriginalText = "";
let sigCtx = null, sigDrawing = false, sigHasContent = false;
function initSignatureCanvas() {
const canvas = document.getElementById('signatureCanvas');
const ratio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * ratio;
canvas.height = rect.height * ratio;
sigCtx = canvas.getContext('2d');
sigCtx.scale(ratio, ratio);
sigCtx.strokeStyle = '#0f172a';
sigCtx.lineWidth = 2.5;
sigCtx.lineCap = 'round';
sigCtx.lineJoin = 'round';
sigHasContent = false;
const getPos = (e) => {
const r = canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
return { x: clientX - r.left, y: clientY - r.top };
};
const start = (e) => { e.preventDefault(); sigDrawing = true; const p = getPos(e); sigCtx.beginPath(); sigCtx.moveTo(p.x, p.y); };
const move = (e) => { if (!sigDrawing) return; e.preventDefault(); const p = getPos(e); sigCtx.lineTo(p.x, p.y); sigCtx.stroke(); sigHasContent = true; };
const end = (e) => { if (e) e.preventDefault(); sigDrawing = false; };
canvas.onmousedown = start; canvas.onmousemove = move; canvas.onmouseup = end; canvas.onmouseleave = end;
canvas.ontouchstart = start; canvas.ontouchmove = move; canvas.ontouchend = end;
}
function clearSignature() {
const canvas = document.getElementById('signatureCanvas');
if (sigCtx) sigCtx.clearRect(0, 0, canvas.width, canvas.height);
sigHasContent = false;
}
async function openWorkReportModal() {
if (!currentServiceId) return;
document.getElementById('wrText').value = '';
wrOriginalText = '';
document.getElementById('wrExtraPhone').value = '';
const modal = document.getElementById('workReportModal');
modal.style.display = 'flex';
setTimeout(() => modal.classList.remove('translate-y-full'), 10);
setTimeout(() => { initSignatureCanvas(); safeLoadIcons(); }, 60);
loadWorkReportHistory(currentServiceId);
}
function closeWorkReportModal() {
const modal = document.getElementById('workReportModal');
modal.classList.add('translate-y-full');
setTimeout(() => modal.style.display = 'none', 300);
}
async function improveReportWithAI() {
const textarea = document.getElementById('wrText');
const text = textarea.value.trim();
if (!text) { showToast("Escribe primero unas notas del trabajo", true); return; }
wrOriginalText = text;
const btn = document.getElementById('btnImproveAI');
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i> Mejorando...';
safeLoadIcons();
try {
const res = await fetch(`${API_URL}/work-reports/improve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({ text })
});
const data = await res.json();
if (data.ok) {
textarea.value = data.text;
showToast("Texto mejorado. Puedes editarlo antes de enviar.");
} else {
showToast(data.error || "No se pudo mejorar el texto", true);
}
} catch (e) {
showToast("Error de conexión con la IA", true);
} finally {
btn.disabled = false; btn.innerHTML = original; safeLoadIcons();
}
}
async function generateAndSendWorkReport() {
const text = document.getElementById('wrText').value.trim();
if (!text) { showToast("Escribe el texto del parte de trabajo", true); return; }
if (!sigHasContent) {
if (!confirm("No hay firma del cliente. ¿Generar el parte sin firma?")) return;
}
const btn = document.getElementById('btnGenerateReport');
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i> Generando PDF...';
safeLoadIcons();
try {
// 1. Datos de la empresa (mismo patrón que en Presupuestos)
let empNombre = "IntegraRepara", empDni = "", empAddress = "", empCity = "", empState = "", empZip = "", empLogo = null;
try {
const confRes = await fetch(`${API_URL}/config/company`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } });
const confData = await confRes.json();
if (confData.ok && confData.config) {
empLogo = confData.config.company_logo || null;
if (confData.config.billing_settings) {
const b = confData.config.billing_settings;
empNombre = b.name || confData.config.full_name || empNombre;
empDni = b.dni ? `CIF/NIF: ${b.dni}` : "";
empAddress = b.address || "";
empCity = b.city || ""; empState = b.state || ""; empZip = b.zip || "";
} else {
empNombre = confData.config.full_name || empNombre;
}
}
} catch (e) {}
// 2. Nombre del operario logueado
let operatorName = "";
try {
const meRes = await fetch(`${API_URL}/auth/me`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } });
const meData = await meRes.json();
if (meData.ok && meData.user) operatorName = meData.user.full_name || "";
} catch (e) {}
// 3. Rellenar la plantilla oculta del PDF
document.getElementById('wrPdfCompanyName').innerText = empNombre;
document.getElementById('wrPdfCompanyDni').innerText = empDni;
document.getElementById('wrPdfCompanyAddress').innerText = empAddress;
document.getElementById('wrPdfCompanyLocation').innerText = [empZip, empCity, empState ? `(${empState})` : ''].filter(Boolean).join(' ');
const logoImg = document.getElementById('wrPdfLogo');
const logoTxt = document.getElementById('wrPdfLogoFallback');
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('wrPdfRef').innerText = document.getElementById('detRef').innerText || '#---';
document.getElementById('wrPdfDate').innerText = new Date().toLocaleDateString('es-ES');
document.getElementById('wrPdfOperatorName').innerText = operatorName || "-";
document.getElementById('wrPdfClientName').innerText = document.getElementById('detName').innerText || "Cliente";
document.getElementById('wrPdfClientPhone').innerText = document.getElementById('detPhoneRaw').value || "-";
document.getElementById('wrPdfClientAddress').innerText = document.getElementById('detAddress').innerText || "-";
document.getElementById('wrPdfText').innerText = text;
const sigCanvas = document.getElementById('signatureCanvas');
const sigImg = document.getElementById('wrPdfSignatureImg');
const sigPlaceholder = document.getElementById('wrPdfSignaturePlaceholder');
let signatureDataUrl = null;
if (sigHasContent) {
signatureDataUrl = sigCanvas.toDataURL('image/png');
sigImg.src = signatureDataUrl;
sigImg.style.display = 'block';
sigPlaceholder.style.display = 'none';
} else {
sigImg.style.display = 'none';
sigPlaceholder.style.display = 'block';
}
// 4. Generar el PDF en base64 a partir de la plantilla oculta
const wrapper = document.getElementById('wr-pdf-wrapper');
wrapper.classList.remove('hidden');
wrapper.style.position = 'absolute';
wrapper.style.left = '-9999px';
const element = document.getElementById('wr-pdf-content');
await new Promise(r => setTimeout(r, 400));
const pdfDataUri = await html2pdf().set({
margin: 0,
image: { type: 'jpeg', quality: 1 },
html2canvas: { scale: 2, useCORS: true, logging: false },
jsPDF: { unit: 'in', format: 'a4', orientation: 'portrait' }
}).from(element).outputPdf('datauristring');
wrapper.classList.add('hidden');
wrapper.style.position = '';
wrapper.style.left = '';
// 5. Guardar el parte en el backend
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i> Guardando...';
safeLoadIcons();
const saveRes = await fetch(`${API_URL}/services/${currentServiceId}/work-report`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({
raw_text: wrOriginalText || text,
final_text: text,
signature_data: signatureDataUrl,
pdf_base64: pdfDataUri
})
});
const saveData = await saveRes.json();
if (!saveData.ok) { showToast(saveData.error || "Error al guardar el parte", true); return; }
// 6. Enviarlo por WhatsApp: al cliente automáticamente + número extra si se indicó
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i> Enviando por WhatsApp...';
safeLoadIcons();
const extraPhone = document.getElementById('wrExtraPhone').value.trim();
const phones = [];
if (saveData.report.client_phone) phones.push(saveData.report.client_phone);
if (extraPhone) phones.push(extraPhone);
const sendRes = await fetch(`${API_URL}/work-reports/${saveData.report.id}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({ phones })
});
const sendData = await sendRes.json();
if (sendData.ok) {
showToast("✅ Parte generado y enviado por WhatsApp");
} else {
const firstFail = (sendData.results || []).find(r => !r.ok);
showToast("⚠️ Parte guardado, pero no se pudo enviar por WhatsApp" + (firstFail?.detail ? `: ${firstFail.detail}` : ""), true);
}
closeWorkReportModal();
sendLog("Parte de Trabajo", "Se ha generado un parte de trabajo para este servicio.");
} catch (e) {
console.error(e);
showToast("❌ Error generando el parte de trabajo", true);
} finally {
btn.disabled = false; btn.innerHTML = original; safeLoadIcons();
}
}
async function loadWorkReportHistory(serviceId) {
const list = document.getElementById('wrHistoryListModal');
list.innerHTML = '<p class="text-center text-[10px] text-slate-400 font-bold py-3">Cargando...</p>';
try {
const res = await fetch(`${API_URL}/services/${serviceId}/work-reports`, { headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } });
const data = await res.json();
if (!data.ok || !data.reports || data.reports.length === 0) {
list.innerHTML = '<p class="text-center text-[10px] text-slate-400 font-bold py-3">Sin partes anteriores.</p>';
return;
}
list.innerHTML = data.reports.map(r => {
const d = new Date(r.created_at).toLocaleString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' });
const wasSent = Array.isArray(r.sent_to) && r.sent_to.length > 0;
const sentBadge = wasSent
? `<span class="text-[8px] font-black bg-emerald-50 text-emerald-600 px-2 py-0.5 rounded uppercase border border-emerald-100">Enviado</span>`
: `<span class="text-[8px] font-black bg-amber-50 text-amber-600 px-2 py-0.5 rounded uppercase border border-amber-100">No enviado</span>`;
const preview = (r.final_text || '').substring(0, 60);
return `
<div class="bg-white border border-slate-200 rounded-xl p-3 flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5 mb-0.5">${sentBadge}<span class="text-[9px] font-bold text-slate-400">${d}</span></div>
<p class="text-[10px] font-bold text-slate-600 truncate">${preview}</p>
</div>
<div class="flex gap-1.5 shrink-0">
<button onclick="viewWorkReportPdf(${r.id})" class="w-8 h-8 bg-slate-50 border border-slate-200 rounded-full flex items-center justify-center active:scale-90"><i data-lucide="eye" class="w-3.5 h-3.5 text-slate-500"></i></button>
<button onclick="resendWorkReport(${r.id})" class="w-8 h-8 bg-slate-50 border border-slate-200 rounded-full flex items-center justify-center active:scale-90"><i data-lucide="send" class="w-3.5 h-3.5 text-blue-500"></i></button>
</div>
</div>`;
}).join('');
safeLoadIcons();
} catch (e) {
list.innerHTML = '<p class="text-center text-[10px] text-rose-400 font-bold py-3">Error al cargar el historial.</p>';
}
}
async function viewWorkReportPdf(reportId) {
try {
const res = await fetch(`${API_URL}/work-reports/${reportId}/pdf`, { headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } });
const data = await res.json();
if (!data.ok || !data.pdf_base64) return showToast("No se pudo abrir el PDF", true);
const base64 = data.pdf_base64.includes(',') ? data.pdf_base64.split(',').pop() : data.pdf_base64;
const byteChars = atob(base64);
const byteNumbers = new Array(byteChars.length);
for (let i = 0; i < byteChars.length; i++) byteNumbers[i] = byteChars.charCodeAt(i);
const blob = new Blob([new Uint8Array(byteNumbers)], { type: 'application/pdf' });
window.open(URL.createObjectURL(blob), '_blank');
} catch (e) {
showToast("Error al abrir el PDF", true);
}
}
async function resendWorkReport(reportId) {
if (!confirm("¿Reenviar este parte por WhatsApp al teléfono del cliente?")) return;
try {
const res = await fetch(`${API_URL}/work-reports/${reportId}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({})
});
const data = await res.json();
if (data.ok) { showToast("✅ Reenviado por WhatsApp"); loadWorkReportHistory(currentServiceId); }
else showToast("❌ No se pudo reenviar", true);
} catch (e) { showToast("Error de conexión", true); }
}
function sendLog(action, details) {
if(!currentServiceId) return;
fetch(`${API_URL}/services/${currentServiceId}/log`, {