Files
alro65 35d460b127 Initial commit — MarineInvoice v1.0
Multi-tenant marine invoicing system: Stripe payments, PDF generation,
digital signatures, QR codes, SMTP email, bilingual templates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 01:54:08 -04:00

984 lines
46 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ 'Invoices' if doc_type == 'invoice' else 'Cotizaciones' }} — MarineInvoice Pro{% endblock %}
{% block extra_css %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.5.31/jspdf.plugin.autotable.min.js"></script>
<style>
.inv-section-title { font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--gray);margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.08);font-weight:600; }
.number-display { font-family:'Playfair Display',serif;font-size:15px;font-weight:700;color:var(--gold);letter-spacing:0.5px; }
.auto-badge { background:rgba(46,204,113,0.12);color:#2ecc71;font-size:10px;padding:2px 7px;border-radius:8px;font-weight:600; }
.send-tab { background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);color:var(--gray);padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;transition:all 0.2s; }
.send-tab:hover { background:rgba(255,255,255,0.1);color:var(--white); }
.send-tab.active { background:rgba(201,168,76,0.15);border-color:rgba(201,168,76,0.4);color:var(--gold);font-weight:600; }
</style>
{% endblock %}
{% block content %}
{% set is_invoice = doc_type == 'invoice' %}
{% set page_title = 'Invoices' if is_invoice else 'Cotizaciones' %}
{% set icon = '📄' if is_invoice else '📋' %}
<div class="flex justify-between items-center mb-4">
<div>
<h1 class="page-title">{{ icon }} {{ page_title }}</h1>
<p class="page-subtitle">{{ 'Gestión de facturas' if is_invoice else 'Gestión de cotizaciones' }}</p>
</div>
<button class="btn btn-primary" onclick="openNewDoc()">+ Nuevo {{ 'Invoice' if is_invoice else 'Cotización' }}</button>
</div>
{% if docs %}
{% for doc in docs %}
{% set comp = doc.company %}
{% set client = doc.client %}
<div class="list-item" id="doc-row-{{ doc.id }}">
<div class="list-item-info">
<h4>
<span class="number-display">{{ doc.number }}</span>
{% if doc.status == 'draft' %}<span class="badge badge-gold">Borrador</span>
{% elif doc.status == 'sent' %}<span class="badge badge-blue">Enviado</span>
{% elif doc.status == 'paid' %}<span class="badge badge-green">Pagado</span>
{% elif doc.status == 'accepted' %}<span class="badge badge-green">Aceptado</span>
{% elif doc.status == 'rejected' %}<span class="badge" style="background:rgba(231,76,60,0.15);color:#e74c3c;">Rechazado</span>
{% elif doc.status == 'cancelled' %}<span class="badge" style="background:rgba(136,146,164,0.15);color:var(--gray);">Cancelado</span>
{% endif %}
<span class="badge badge-gray">{{ doc.language|upper }}</span>
{% if doc.pdf_path %}<span class="badge" style="background:rgba(46,204,113,0.12);color:#2ecc71;">💾 PDF</span>{% endif %}
</h4>
<p>{{ comp.name if comp }} → <strong>{{ client.name if client }}</strong>{% if client and client.yacht_name %} ⛵{{ client.yacht_name }}{% endif %} · {{ doc.date }}</p>
<p style="font-weight:700;color:#c9a84c;margin-top:3px;">${{ "%.2f"|format(doc.total) }}</p>
</div>
<div class="list-item-actions">
<button class="btn btn-success btn-sm" onclick="genAndSavePDF({{ doc.id }})">📄 Generar PDF</button>
{% if doc.pdf_path %}
<button class="btn btn-secondary btn-sm" onclick="previewPDF({{ doc.id }})">👁️ Preview</button>
<a href="/documents/{{ doc.id }}/download-pdf" class="btn btn-secondary btn-sm">⬇️</a>
<button class="btn btn-secondary btn-sm"
data-id="{{ doc.id }}"
data-number="{{ doc.number|e }}"
data-email="{{ (client.email if client else '')|e }}"
data-phone="{{ (client.phone if client else '')|e }}"
data-client="{{ (client.name if client else '')|e }}"
onclick="openSendModal(this)">📤 Enviar</button>
{% endif %}
<button class="btn btn-secondary btn-sm" onclick="editDoc({{ doc.id }})">✏️</button>
{% if doc.status == 'draft' or doc.status == 'sent' %}
{% if is_invoice %}
<button class="btn btn-secondary btn-sm" onclick="setStatus({{ doc.id }},'paid')">✅ Pagado</button>
{% else %}
<button class="btn btn-secondary btn-sm" onclick="setStatus({{ doc.id }},'accepted')">✅ Aceptado</button>
{% endif %}
{% endif %}
<button class="btn btn-danger btn-sm" onclick="delDoc({{ doc.id }})">🗑️</button>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state"><div class="emoji">{{ icon }}</div><h3>No hay {{ page_title }}</h3><p>Crea {{ 'tu primer invoice' if is_invoice else 'tu primera cotización' }}</p></div>
{% endif %}
<!-- ============ DOCUMENT MODAL ============ -->
<div class="modal-overlay" id="docModal" style="align-items:flex-start;padding-top:14px;">
<div class="modal modal-lg">
<h2 class="modal-title" id="docModalTitle">{{ icon }} Nuevo {{ 'Invoice' if is_invoice else 'Cotización' }}</h2>
<input type="hidden" id="doc-id">
<input type="hidden" id="doc-type" value="{{ doc_type }}">
<div class="grid-2">
<div class="form-group"><label>Compañía *</label>
<select id="doc-company" onchange="loadCompanyData()">
{% for c in companies %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</div>
<div class="form-group"><label>Cliente *</label><select id="doc-client"></select></div>
</div>
<!-- Auto number display -->
<div style="background:rgba(201,168,76,0.08);border:1px solid rgba(201,168,76,0.2);border-radius:10px;padding:12px 16px;margin-bottom:16px;">
<div class="flex justify-between items-center">
<div>
<div style="font-size:11px;color:var(--gray);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Número del documento</div>
<div class="number-display" id="auto-number-display">-</div>
<div style="font-size:11px;color:var(--gray);margin-top:3px;"><span class="auto-badge">AUTO</span> generado automáticamente</div>
</div>
<div>
<label style="margin-bottom:4px;">Ajuste manual (opcional)</label>
<input type="text" id="doc-number" placeholder="Solo si necesitas cambiar" style="width:200px;">
<div style="font-size:10px;color:var(--gray);margin-top:3px;">El contador interno no se altera</div>
</div>
</div>
</div>
<div class="grid-3">
<div class="form-group"><label>Fecha</label><input type="date" id="doc-date"></div>
<div class="form-group"><label>Vencimiento</label><input type="date" id="doc-due"></div>
<div class="form-group"><label>Estado</label>
<select id="doc-status">
<option value="draft">Borrador</option>
<option value="sent">Enviado</option>
{% if is_invoice %}
<option value="paid">Pagado</option>
<option value="cancelled">Cancelado</option>
{% else %}
<option value="accepted">Aceptado</option>
<option value="rejected">Rechazado</option>
{% endif %}
</select>
</div>
</div>
<div class="grid-2">
<div class="form-group"><label>Idioma del PDF</label>
<select id="doc-lang">
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
<div class="form-group" id="tax-display-group">
<label>Sales Tax</label>
<div style="padding:10px 14px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:10px;font-size:14px;color:var(--gold);">
<span id="doc-tax-pct">7</span>% (configurado en la compañía)
</div>
</div>
</div>
<div class="form-group"><label>Descripción del Trabajo</label><textarea id="doc-desc" placeholder="Marine electrical inspection and repair..."></textarea></div>
<div class="divider"></div>
<div class="flex justify-between items-center" style="margin-bottom:10px;">
<span style="font-weight:600;font-size:14px;">Líneas de Servicio / Producto</span>
<div class="flex gap-2">
<button class="btn btn-secondary btn-sm" onclick="addFromCatalog()">📋 Del catálogo</button>
<button class="btn btn-secondary btn-sm" onclick="addManualLine()">+ Manual</button>
</div>
</div>
<div id="line-items-container"><div style="text-align:center;padding:20px;color:var(--gray);font-size:13px;">Agrega líneas de servicio</div></div>
<div class="flex" style="justify-content:flex-end;margin-top:12px;">
<div class="totals-box">
<div class="totals-row"><span>Subtotal:</span><span id="doc-subtotal">$0.00</span></div>
<div class="totals-row"><span>Tax (<span id="doc-tax-pct2">7</span>%):</span><span id="doc-tax-amt">$0.00</span></div>
<div class="totals-row total"><span>TOTAL:</span><span id="doc-total">$0.00</span></div>
</div>
</div>
<div class="grid-2 mt-3">
<div class="form-group">
<label>Elaborado por</label>
<input type="text" id="doc-prepared-by" readonly style="background:var(--input-bg);opacity:0.7;cursor:not-allowed;">
</div>
<div class="form-group">
<label>Autorizado por</label>
<select id="doc-signed-by">
<option value="">— Seleccionar —</option>
</select>
</div>
</div>
<!-- Firma digital del usuario loggeado -->
<div class="form-group mt-3">
<label>Firma <span style="font-weight:400;color:var(--gray);font-size:12px;">(aparece en el PDF bajo "Elaborado por")</span></label>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px;">
<button type="button" class="btn btn-secondary btn-sm" onclick="openSignaturePad()">✏️ Dibujar firma</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="saveSignatureToProfile()" id="btn-save-sig" style="display:none;">💾 Guardar en mi perfil</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="clearSignature()" style="color:var(--danger);">🗑️ Limpiar</button>
</div>
<canvas id="sig-preview" width="300" height="80" style="border:1px dashed var(--border);border-radius:6px;background:#fff;display:block;"></canvas>
<input type="hidden" id="doc-signature">
</div>
<div class="form-group"><label>Notas</label><textarea id="doc-notes" placeholder="Thank you for your business!"></textarea></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('docModal')">Cancelar</button>
<button class="btn btn-primary" onclick="saveDoc()">💾 Guardar {{ 'Invoice' if is_invoice else 'Cotización' }}</button>
</div>
</div>
</div>
<!-- ============ SIGNATURE PAD MODAL ============ -->
<div id="sigPadModal" class="modal-overlay" style="display:none;z-index:1100;">
<div class="modal" style="max-width:500px;">
<h2 class="modal-title">✍️ Dibujar Firma</h2>
<p style="font-size:12px;color:var(--gray);margin-bottom:12px;">Dibuja tu firma con el mouse o el dedo:</p>
<canvas id="sig-pad" width="460" height="160"
style="border:2px solid var(--border);border-radius:8px;background:#fff;cursor:crosshair;touch-action:none;display:block;width:100%;"></canvas>
<div style="display:flex;gap:8px;margin-top:14px;justify-content:flex-end;">
<button class="btn btn-secondary btn-sm" onclick="clearSigPad()">🗑️ Borrar</button>
<button class="btn btn-secondary" onclick="document.getElementById('sigPadModal').style.display='none'">Cancelar</button>
<button class="btn btn-primary" onclick="saveSigPad()">✅ Usar esta firma</button>
</div>
</div>
</div>
<!-- ============ CATALOG PICKER ============ -->
<div class="modal-overlay" id="catalogModal">
<div class="modal" style="max-width:500px;">
<h2 class="modal-title">📋 Seleccionar del Catálogo</h2>
<div id="catalog-list"></div>
<div class="modal-footer"><button class="btn btn-secondary" onclick="closeModal('catalogModal')">Cerrar</button></div>
</div>
</div>
<!-- ============ SEND MODAL ============ -->
<div class="modal-overlay" id="sendModal">
<div class="modal" style="max-width:520px;">
<h2 class="modal-title">📤 Enviar Documento</h2>
<input type="hidden" id="send-doc-id">
<div style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:14px 16px;margin-bottom:20px;">
<div style="font-size:12px;color:var(--gray);margin-bottom:4px;">Documento</div>
<div class="number-display" id="send-doc-number">-</div>
<div style="font-size:12px;color:var(--gray);margin-top:4px;">Cliente: <span id="send-client-name" style="color:var(--white);"></span></div>
</div>
<!-- TABS -->
<div style="display:flex;gap:8px;margin-bottom:20px;">
<button class="send-tab active" onclick="switchTab('email')" id="tab-email">📧 Email</button>
<button class="send-tab" onclick="switchTab('whatsapp')" id="tab-whatsapp">💬 WhatsApp</button>
</div>
<!-- EMAIL TAB -->
<div id="tab-content-email">
<div class="form-group"><label>Email del cliente *</label><input type="email" id="email-to" placeholder="cliente@email.com"></div>
<div class="form-group"><label>Asunto</label><input type="text" id="email-subject"></div>
<div class="form-group"><label>Mensaje</label><textarea id="email-body" rows="4"></textarea></div>
<button class="btn btn-success" style="width:100%;" onclick="sendByEmail()">📧 Enviar Email con PDF adjunto</button>
</div>
<!-- WHATSAPP TAB -->
<div id="tab-content-whatsapp" style="display:none;">
<div class="form-group">
<label>Número de WhatsApp del cliente (con código de país)</label>
<input type="text" id="wa-phone" placeholder="+13055551234">
<small style="color:var(--gray);font-size:11px;">+1 USA · +57 Colombia · +34 España · +52 México</small>
</div>
<div class="form-group"><label>Mensaje</label><textarea id="wa-message" rows="4"></textarea></div>
<!-- Step by step instructions -->
<div style="background:rgba(37,211,102,0.07);border:1px solid rgba(37,211,102,0.25);border-radius:10px;padding:14px 16px;margin-bottom:16px;">
<div style="font-size:12px;font-weight:700;color:#25d366;margin-bottom:10px;">📋 Al hacer clic ocurrirá lo siguiente:</div>
<div style="display:flex;flex-direction:column;gap:8px;">
<div style="display:flex;gap:10px;align-items:flex-start;font-size:12px;">
<span style="background:#25d366;color:white;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0;">1</span>
<span style="color:var(--white);">El PDF se descarga automáticamente a tu carpeta de <strong>Descargas</strong></span>
</div>
<div style="display:flex;gap:10px;align-items:flex-start;font-size:12px;">
<span style="background:#25d366;color:white;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0;">2</span>
<span style="color:var(--white);">Se abre WhatsApp Web con el chat del cliente y el mensaje prellenado</span>
</div>
<div style="display:flex;gap:10px;align-items:flex-start;font-size:12px;">
<span style="background:#128c7e;color:white;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0;">3</span>
<span style="color:#25d366;"><strong>Tú:</strong> en WhatsApp haz clic en el clip 📎 → selecciona el PDF de tu carpeta Descargas → Enviar</span>
</div>
</div>
</div>
<button class="btn btn-success" style="width:100%;background:linear-gradient(135deg,#25d366,#128c7e);font-size:15px;padding:14px;" onclick="sendByWhatsApp()">
💬 Descargar PDF y Abrir WhatsApp
</button>
</div>
<div style="margin-top:16px;border-top:1px solid rgba(255,255,255,0.08);padding-top:14px;">
<button class="btn btn-secondary" style="width:100%;" onclick="closeModal('sendModal')">Cerrar</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const DOC_TYPE = '{{ doc_type }}';
const IS_INVOICE = DOC_TYPE === 'invoice';
let lineItems = [];
let catalogProducts = [];
let currentCompanyData = null;
let autoNumber = '';
// ---- LOAD COMPANY DATA ----
async function loadCompanyData() {
const cid = document.getElementById('doc-company').value;
if (!cid) return;
const [compR, clientR, prodR, numR] = await Promise.all([
fetch(`/api/company/${cid}`),
fetch(`/api/clients/${cid}`),
fetch(`/api/products/${cid}`),
fetch(`/api/next-number/${cid}/${DOC_TYPE}`)
]);
currentCompanyData = await compR.json();
const clients = await clientR.json();
catalogProducts = await prodR.json();
const numData = await numR.json();
autoNumber = numData.number;
// Update client dropdown
const sel = document.getElementById('doc-client');
sel.innerHTML = clients.map(c =>
`<option value="${c.id}" data-email="${c.email||''}">${c.name}${c.yacht?' (⛵'+c.yacht+')':''}</option>`
).join('');
// Update tax display
const tax = currentCompanyData.tax_rate || 7;
document.getElementById('doc-tax-pct').textContent = tax;
document.getElementById('doc-tax-pct2').textContent = tax;
// Show auto number
document.getElementById('auto-number-display').textContent = autoNumber;
document.getElementById('doc-number').placeholder = autoNumber + ' (opcional)';
updateTotals();
}
// ---- LOAD CURRENT USER ----
let currentUserData = null;
async function loadCurrentUser() {
if(currentUserData) return currentUserData;
const r = await fetch('/api/me');
currentUserData = await r.json();
return currentUserData;
}
// ---- POPULATE AUTHORIZED DROPDOWN ----
async function loadAuthorizedUsers(companyId, selectedId='') {
const sel = document.getElementById('doc-signed-by');
sel.innerHTML = '<option value="">— Seleccionar —</option>';
if(!companyId) return;
const users = await (await fetch(`/api/users/${companyId}`)).json();
users.forEach(u => {
const opt = document.createElement('option');
opt.value = u.id;
opt.textContent = u.full_name;
if(String(u.id) === String(selectedId)) opt.selected = true;
sel.appendChild(opt);
});
}
// ---- OPEN NEW ----
async function openNewDoc() {
document.getElementById('docModalTitle').textContent = (IS_INVOICE ? '📄 Nuevo Invoice' : '📋 Nueva Cotización');
document.getElementById('doc-id').value = '';
document.getElementById('doc-number').value = '';
document.getElementById('doc-status').value = 'draft';
document.getElementById('doc-lang').value = 'en';
document.getElementById('doc-desc').value = '';
document.getElementById('doc-notes').value = '';
lineItems = [];
const today = new Date().toISOString().split('T')[0];
const due = new Date(Date.now()+30*24*60*60*1000).toISOString().split('T')[0];
document.getElementById('doc-date').value = today;
document.getElementById('doc-due').value = due;
await loadCompanyData();
// Auto-fill elaborado por con usuario loggeado
const me = await loadCurrentUser();
document.getElementById('doc-prepared-by').value = me.full_name;
// Cargar dropdown de autorizado
const compId = document.getElementById('doc-company').value;
await loadAuthorizedUsers(compId);
// Cargar firma del usuario loggeado
clearSignature();
if(me.signature) setSignaturePreview(me.signature);
renderLines();
openModal('docModal');
}
// ---- EDIT ----
async function editDoc(id) {
const r = await fetch(`/documents/${id}/data`);
const doc = await r.json();
document.getElementById('doc-id').value = doc.id;
document.getElementById('doc-company').value = doc.company_id;
// Load company data first (resets client dropdown), then set client
await loadCompanyData();
// Set client after dropdown is populated
document.getElementById('doc-client').value = doc.client_id;
document.getElementById('doc-number').value = doc.number;
document.getElementById('auto-number-display').textContent = doc.number;
document.getElementById('doc-date').value = doc.date;
document.getElementById('doc-due').value = doc.due_date || '';
document.getElementById('doc-status').value = doc.status;
document.getElementById('doc-lang').value = doc.language;
document.getElementById('doc-desc').value = doc.description || '';
document.getElementById('doc-prepared-by').value = doc.prepared_by || '';
// Load authorized dropdown and select saved value
await loadAuthorizedUsers(doc.company_id, doc.signed_by || '');
// Restore signature — use saved signature or fall back to current user's
const me = await loadCurrentUser();
const sigVal = doc.signature || me.signature || '';
setSignaturePreview(sigVal);
document.getElementById('doc-notes').value = doc.notes || '';
lineItems = doc.line_items || [];
document.getElementById('docModalTitle').textContent = (IS_INVOICE ? '📄 Editar Invoice' : '📋 Editar Cotización');
renderLines();
openModal('docModal');
}
// ---- LINE ITEMS ----
function addManualLine() {
lineItems.push({desc:'',qty:1,price:0,unit:'ea'});
renderLines();
}
function addFromCatalog() {
if (!catalogProducts.length) { showToast('No hay productos para esta compañía','error'); return; }
document.getElementById('catalog-list').innerHTML = catalogProducts.map((p,i) => `
<div class="list-item" style="cursor:pointer;" onclick="addCatalogItem(${i})">
<div class="list-item-info">
<h4>${p.name}</h4>
<p>$${parseFloat(p.price).toFixed(2)} / ${p.unit}${p.desc?' · '+p.desc:''}</p>
</div>
<span style="color:var(--gold);font-size:22px;">+</span>
</div>`).join('');
openModal('catalogModal');
}
function addCatalogItem(idx) {
const p = catalogProducts[idx];
// Products and materials are taxable; services and labor are tax-exempt in Florida
const taxable = (p.item_type === 'product' || p.item_type === 'material');
lineItems.push({desc:p.name+(p.desc?' — '+p.desc:''),qty:1,price:parseFloat(p.price),unit:p.unit,taxable:taxable,item_type:p.item_type||'service'});
renderLines(); closeModal('catalogModal');
}
function removeLine(i) { lineItems.splice(i,1); renderLines(); }
function isTaxable(item) {
if (item.taxable === true) return true;
if (item.taxable === false) return false;
return (item.item_type === 'product' || item.item_type === 'material');
}
function renderLines() {
const c = document.getElementById('line-items-container');
if (!lineItems.length) {
c.innerHTML='<div style="text-align:center;padding:20px;color:var(--gray);font-size:13px;">Agrega líneas de servicio</div>';
updateTotals(); return;
}
c.innerHTML=`<table class="line-items-table"><thead><tr>
<th style="width:36%">Descripción</th><th style="width:9%">Cant.</th>
<th style="width:10%">Unidad</th><th style="width:14%">Precio Unit.</th>
<th style="width:13%">Total</th>
<th style="width:12%" title="Marcar si aplica Sales Tax (Florida: productos sí, servicios no)">Tax?</th>
<th style="width:6%"></th>
</tr></thead><tbody>${lineItems.map((item,i)=>`
<tr>
<td><input type="text" value="${item.desc}" onchange="lineItems[${i}].desc=this.value" placeholder="Descripción..."></td>
<td><input type="number" value="${item.qty}" min="0.01" step="0.01" onchange="lineItems[${i}].qty=parseFloat(this.value)||0;updateLineTotal(${i});updateTotals()"></td>
<td><select onchange="lineItems[${i}].unit=this.value">${['hr','ea','ft','job','day'].map(u=>`<option value="${u}"${item.unit===u?' selected':''}>${u}</option>`).join('')}</select></td>
<td><input type="number" value="${item.price}" min="0" step="0.01" onchange="lineItems[${i}].price=parseFloat(this.value)||0;updateLineTotal(${i});updateTotals()"></td>
<td id="line-total-${i}" style="font-weight:600;color:#c9a84c">$${(item.qty*item.price).toFixed(2)}</td>
<td style="text-align:center;">
<label style="display:flex;align-items:center;justify-content:center;gap:4px;font-size:11px;cursor:pointer;white-space:nowrap;">
<input type="checkbox" ${isTaxable(item)?'checked':''} onchange="lineItems[${i}].taxable=this.checked;updateTotals()" style="width:14px;height:14px;accent-color:#ffa500;">
<span style="color:${isTaxable(item)?'#ffa500':'var(--gray)'};">${isTaxable(item)?'Taxable':'Exempt'}</span>
</label>
</td>
<td><button class="btn btn-danger" style="padding:3px 7px;font-size:11px;" onclick="removeLine(${i})">✕</button></td>
</tr>`).join('')}</tbody></table>`;
updateTotals();
}
function updateLineTotal(i) {
const cell = document.getElementById(`line-total-${i}`);
if (cell) cell.textContent = '$' + (lineItems[i].qty * lineItems[i].price).toFixed(2);
}
function updateTotals() {
const sub = lineItems.reduce((s,i)=>s+(i.qty*i.price),0);
const taxPct = parseFloat(document.getElementById('doc-tax-pct2').textContent)||7;
const taxableAmt = lineItems.reduce((s,i)=>isTaxable(i)?s+(i.qty*i.price):s, 0);
const tax = taxableAmt*(taxPct/100);
const hasExempt = taxableAmt < sub && sub > 0;
document.getElementById('doc-subtotal').textContent='$'+sub.toFixed(2);
document.getElementById('doc-tax-amt').textContent='$'+tax.toFixed(2)+(hasExempt?' *':'');
document.getElementById('doc-total').textContent='$'+(sub+tax).toFixed(2);
}
// ---- SAVE ----
async function saveDoc() {
const cid = document.getElementById('doc-company').value;
const clientId = document.getElementById('doc-client').value;
if (!cid||!clientId) { showToast('Completa los campos requeridos','error'); return; }
const id = document.getElementById('doc-id').value;
const manualNumber = document.getElementById('doc-number').value.trim();
const data = {
doc_type: DOC_TYPE, company_id: cid, client_id: clientId,
number: manualNumber || autoNumber,
date: document.getElementById('doc-date').value,
due_date: document.getElementById('doc-due').value,
status: document.getElementById('doc-status').value,
language: document.getElementById('doc-lang').value,
description: document.getElementById('doc-desc').value,
prepared_by: document.getElementById('doc-prepared-by').value,
signed_by: (() => {
const sel = document.getElementById('doc-signed-by');
return sel.options[sel.selectedIndex]?.text || '';
})(),
signature: document.getElementById('doc-signature').value,
line_items: lineItems,
notes: document.getElementById('doc-notes').value
};
const url = id ? `/documents/${id}` : '/documents/new';
const method = id ? 'PUT' : 'POST';
const r = await fetch(url, {method, headers:{'Content-Type':'application/json'}, body:JSON.stringify(data)});
const res = await r.json();
if (res.success) { showToast(id?'✅ Actualizado':'✅ Guardado'); setTimeout(()=>location.reload(),1000); }
else showToast(res.error||'Error','error');
}
// ---- STATUS ----
async function setStatus(id, status) {
const r = await fetch(`/documents/${id}/status`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({status})});
const res = await r.json();
if (res.success) { showToast('✅ Estado actualizado'); setTimeout(()=>location.reload(),800); }
}
// ---- DELETE ----
async function delDoc(id) {
if (!confirm('¿Eliminar este documento?')) return;
const r = await fetch(`/documents/${id}`,{method:'DELETE'});
const res = await r.json();
if (res.success) { showToast('🗑️ Eliminado'); document.getElementById(`doc-row-${id}`).remove(); }
}
// ---- SEND MODAL ----
const TAILSCALE_BASE = 'http://100.96.43.86:5000';
function openSendModal(btn) {
const id = btn.dataset.id;
const number = btn.dataset.number;
const clientEmail = btn.dataset.email || '';
const clientPhone = btn.dataset.phone || '';
const clientName = btn.dataset.client || '';
document.getElementById('send-doc-id').value = id;
document.getElementById('send-doc-number').textContent = number;
document.getElementById('send-client-name').textContent = clientName;
// Pre-fill email
document.getElementById('email-to').value = clientEmail;
document.getElementById('email-subject').value = `${IS_INVOICE?'Invoice':'Quote'} ${number}`;
document.getElementById('email-body').value =
`Dear ${clientName||'Client'},\n\nPlease find attached ${IS_INVOICE?'invoice':'quotation'} ${number}.\n\nThank you for your business.\n\nBest regards`;
// Pre-fill WhatsApp
const phone = clientPhone.replace(/[^\d+]/g,'');
document.getElementById('wa-phone').value = phone;
const pdfLink = `${TAILSCALE_BASE}/documents/${id}/download-pdf`;
document.getElementById('wa-message').value =
`Hello ${clientName||''},\n\nPlease find your ${IS_INVOICE?'invoice':'quote'} *${number}*.\n\nDownload PDF: ${pdfLink}\n\nThank you for your business!`;
switchTab('email');
openModal('sendModal');
}
function switchTab(tab) {
['email','whatsapp'].forEach(t => {
document.getElementById(`tab-content-${t}`).style.display = t===tab ? 'block' : 'none';
document.getElementById(`tab-${t}`).classList.toggle('active', t===tab);
});
}
async function sendByEmail() {
const id = document.getElementById('send-doc-id').value;
const to = document.getElementById('email-to').value.trim();
if (!to) { showToast('Email del destinatario requerido','error'); return; }
const data = {
to_email: to,
subject: document.getElementById('email-subject').value,
body: document.getElementById('email-body').value
};
showToast('⏳ Enviando email...');
const r = await fetch(`/documents/${id}/send-email`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
const res = await r.json();
if (res.success) {
showToast('📧 Email enviado exitosamente');
closeModal('sendModal');
setTimeout(()=>location.reload(), 1200);
} else showToast(res.error||'Error al enviar','error');
}
async function sendByWhatsApp() {
let phone = document.getElementById('wa-phone').value.replace(/[^\d+]/g,'');
if (!phone) { showToast('Número de WhatsApp requerido','error'); return; }
const docId = document.getElementById('send-doc-id').value;
const msg = encodeURIComponent(document.getElementById('wa-message').value);
phone = phone.replace('+','');
// Step 1: Download PDF automatically
showToast('⏳ Descargando PDF...');
const dlLink = document.createElement('a');
dlLink.href = `/documents/${docId}/download-pdf`;
dlLink.download = '';
document.body.appendChild(dlLink);
dlLink.click();
document.body.removeChild(dlLink);
// Step 2: Wait 1.5 seconds then open WhatsApp
showToast('✅ PDF descargado — abriendo WhatsApp...');
setTimeout(() => {
window.open(`https://wa.me/${phone}?text=${msg}`, '_blank');
// Step 3: Show reminder toast
setTimeout(() => {
showToast('📎 En WhatsApp: clip → selecciona el PDF de Descargas → Enviar', 'info', 6000);
}, 1500);
}, 1500);
}
function previewPDF(id) {
window.open(`/documents/${id}/preview-pdf`, '_blank');
}
// ---- PDF GENERATION + SAVE ON SERVER (using Blob/FormData — no corruption) ----
async function genAndSavePDF(docId) {
showToast('⏳ Generando PDF...');
const r = await fetch(`/documents/${docId}/data`);
const doc = await r.json();
const compR = await fetch(`/api/company/${doc.company_id}`);
const comp = await compR.json();
const cliR = await fetch(`/api/clients/${doc.company_id}`);
const clientList = await cliR.json();
const client = clientList.find(c=>c.id==doc.client_id);
if (!comp||!client) { showToast('Datos incompletos','error'); return; }
const lang = doc.language||'en';
const T = {
en:{
header: IS_INVOICE?'INVOICE':'QUOTATION',
billTo:'Bill To:', num:(IS_INVOICE?'Invoice #':'Quote #'),
date:'Date:', due:(IS_INVOICE?'Due Date:':'Valid Until:'),
desc:'Description', qty:'Qty', unit:'Unit', price:'Unit Price', amount:'Amount',
subtotal:'Subtotal:', tax:'Sales Tax', total:(IS_INVOICE?'TOTAL DUE:':'TOTAL:'),
notes:'Notes:', ein:'EIN:', license:'License #:', manager:'Manager:',
authorized:'Authorized:', vessel:'Vessel:', workDesc:'Work Description:',
taxNote:'* Applied to taxable items only'
},
es:{
header: IS_INVOICE?'FACTURA':'COTIZACION',
billTo:'Facturar a:', num:(IS_INVOICE?'Factura #':'Cotizacion #'),
date:'Fecha:', due:(IS_INVOICE?'Vencimiento:':'Valida hasta:'),
desc:'Descripcion', qty:'Cant.', unit:'Unidad', price:'Precio Unit.', amount:'Total',
subtotal:'Subtotal:', tax:'Impuesto', total:(IS_INVOICE?'TOTAL A PAGAR:':'TOTAL:'),
notes:'Notas:', ein:'EIN:', license:'Licencia:', manager:'Gerente:',
authorized:'Autorizado:', vessel:'Embarcacion:', workDesc:'Descripcion del Trabajo:',
taxNote:'* Solo aplica en productos/materiales'
}
}[lang];
const {jsPDF} = window.jspdf;
const pd = new jsPDF({unit:'mm',format:'letter'});
const pw=215.9, mg=18;
const navy=[10,22,40], gold=[201,168,76], gray=[100,110,130], white=[255,255,255], lgray=[242,244,248];
const accentColor = gold; // Always gold for readability on navy background
// Header background — taller to fit all company info
pd.setFillColor(...navy); pd.rect(0,0,pw,64,'F');
pd.setFillColor(...gold); pd.rect(0,0,pw,3,'F');
// Logo
let lx=mg;
if (comp.logo_path) {
try {
const imgR=await fetch(`/static/${comp.logo_path}`);
const blob=await imgR.blob();
const b64=await new Promise(res=>{const fr=new FileReader();fr.onload=()=>res(fr.result);fr.readAsDataURL(blob);});
pd.addImage(b64,'JPEG',mg,8,42,36); lx=mg+48;
} catch(e){}
}
// Company info
pd.setTextColor(...gold); pd.setFontSize(16); pd.setFont('helvetica','bold');
pd.text(comp.name, lx, 18);
pd.setTextColor(...white); pd.setFontSize(7.5); pd.setFont('helvetica','normal');
let cy=25;
if(comp.address){pd.text(comp.address+(comp.city?', '+comp.city:'')+(comp.state?' '+comp.state:''),lx,cy);cy+=5;}
if(comp.phone){pd.text(comp.phone,lx,cy);cy+=5;}
if(comp.email){pd.text(comp.email,lx,cy);cy+=5;}
if(comp.ein){pd.text(T.ein+' '+comp.ein,lx,cy);cy+=5;}
if(comp.license_num){pd.text(T.license+' '+comp.license_num,lx,cy);}
// Document type + number — right side
pd.setTextColor(...white); pd.setFontSize(26); pd.setFont('helvetica','bold');
pd.text(T.header, pw-mg, 22, {align:'right'});
pd.setFontSize(8.5); pd.setFont('helvetica','normal');
pd.setTextColor(...gold);
pd.text(T.num+' '+doc.number, pw-mg, 33, {align:'right'});
pd.setTextColor(...white);
pd.text(T.date+' '+doc.date, pw-mg, 41, {align:'right'});
if(doc.due_date) pd.text(T.due+' '+doc.due_date, pw-mg, 49, {align:'right'});
let y=72; const hw=(pw-mg*2)/2;
// Bill to box
pd.setFillColor(...lgray); pd.rect(mg,y,hw-4,42,'F');
pd.setTextColor(...navy); pd.setFont('helvetica','bold'); pd.setFontSize(8);
pd.text(T.billTo, mg+4, y+7);
pd.setFont('helvetica','normal'); pd.setFontSize(9);
pd.text(client.name, mg+4, y+14);
pd.setFontSize(8); let by=y+20;
if(client.contact){pd.text(client.contact,mg+4,by);by+=5;}
if(client.address){pd.text(client.address,mg+4,by);by+=5;}
if(client.city){pd.text(client.city+(client.state?', '+client.state:''),mg+4,by);by+=5;}
if(client.phone){pd.text(client.phone,mg+4,by);by+=5;}
if(client.email){pd.text(client.email,mg+4,by);by+=5;}
if(client.yacht){pd.setFont('helvetica','bold');pd.text(T.vessel+' '+client.yacht+(client.yacht_info?' · '+client.yacht_info:''),mg+4,by);}
// Work description box
if(doc.description){
pd.setFillColor(228,235,248); pd.rect(mg+hw,y,hw-4,42,'F');
pd.setFont('helvetica','bold'); pd.setFontSize(8); pd.setTextColor(...navy);
pd.text(T.workDesc, mg+hw+4, y+7);
pd.setFont('helvetica','normal');
const dlines=pd.splitTextToSize(doc.description,hw-12);
pd.text(dlines.slice(0,7),mg+hw+4,y+13);
}
y+=50;
// Helper: strip accented/special chars that jsPDF helvetica can't render
const safeText = s => (s||'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/[^\x00-\x7F]/g,'');
// Line items table
pd.autoTable({
startY:y,
head:[[T.desc,T.qty,T.unit,T.price,T.amount]],
body:doc.line_items.map(item=>{
const taxable = (item.taxable===true||(item.taxable!==false&&(item.item_type==='product'||item.item_type==='material')));
return [safeText(item.desc)+(taxable?' *':''), item.qty.toString(), item.unit||'ea',
'$'+parseFloat(item.price).toFixed(2), '$'+(item.qty*item.price).toFixed(2)];
}),
margin:{left:mg,right:mg},
headStyles:{fillColor:navy,textColor:white,fontStyle:'bold',fontSize:8},
bodyStyles:{fontSize:8,textColor:[40,50,70]},
alternateRowStyles:{fillColor:lgray},
columnStyles:{0:{cellWidth:'auto'},1:{cellWidth:16,halign:'center'},2:{cellWidth:18,halign:'center'},3:{cellWidth:28,halign:'right'},4:{cellWidth:28,halign:'right',fontStyle:'bold'}}
});
y=pd.lastAutoTable.finalY+6;
// Recalculate totals with per-line taxable flag
const pdfSub = doc.line_items.reduce((s,i)=>s+(i.qty*i.price),0);
const pdfTaxableAmt = doc.line_items.reduce((s,i)=>{
const taxable=(i.taxable===true||(i.taxable!==false&&(i.item_type==='product'||i.item_type==='material')));
return taxable?s+(i.qty*i.price):s;
},0);
const pdfTaxAmt = pdfTaxableAmt*(doc.tax_rate/100);
const pdfTotal = pdfSub+pdfTaxAmt;
const hasTaxExempt = pdfTaxableAmt < pdfSub && pdfSub > 0;
// Totals box
const tw=82, tx=pw-mg-tw;
const totH = hasTaxExempt ? 40 : 30;
pd.setFillColor(...lgray); pd.rect(tx,y,tw,totH,'F');
pd.setFontSize(8); pd.setTextColor(...gray); pd.setFont('helvetica','normal');
pd.text(T.subtotal,tx+4,y+8); pd.text('$'+pdfSub.toFixed(2),tx+tw-4,y+8,{align:'right'});
pd.text(T.tax+' ('+doc.tax_rate+'%):',tx+4,y+15); pd.text('$'+pdfTaxAmt.toFixed(2),tx+tw-4,y+15,{align:'right'});
if(hasTaxExempt){
pd.setFontSize(6.5); pd.setTextColor(...gray);
pd.text(T.taxNote, tx+4, y+22);
}
const totalY = hasTaxExempt ? y+28 : y+19;
pd.setFillColor(...navy); pd.rect(tx,totalY,tw,12,'F');
pd.setTextColor(...gold); pd.setFont('helvetica','bold'); pd.setFontSize(9.5);
pd.text(T.total,tx+5,totalY+8); pd.text('$'+pdfTotal.toFixed(2),tx+tw-4,totalY+8,{align:'right'});
y+=totH+8;
// Notes + Signatures — add page 2 if needed
const preparedBy = doc.prepared_by || '';
const signedBy = doc.signed_by || '';
const SIG_HEIGHT = (preparedBy || signedBy) ? 20 : 0;
const FOOTER_Y = 263;
const MAX_NOTES_Y = FOOTER_Y - SIG_HEIGHT - 8; // leave room for sigs + footer
// Notes — invoice_notes o quote_notes según tipo, más doc.notes si es diferente
const compNotes = (IS_INVOICE ? (comp.invoice_notes || '') : (comp.quote_notes || '')).trim();
const docNotes = (doc.notes || '').trim();
const extraNotes = (docNotes && docNotes !== compNotes && !compNotes.includes(docNotes)) ? docNotes : '';
const noteText = [compNotes, extraNotes].filter(Boolean).join('\n');
if(noteText){
pd.setFontSize(8); pd.setFont('helvetica','bold'); pd.setTextColor(...navy);
// If no room on page 1 for even the Notes label, add page 2
if(y + 10 > MAX_NOTES_Y) {
pd.addPage();
y = 18;
}
pd.text(T.notes, mg, y); y+=6;
const noteLines = noteText.split('\n');
for(const line of noteLines){
// Add page 2 if we're running out of space (leave room for sigs)
if(y + SIG_HEIGHT + 12 > FOOTER_Y) {
pd.addPage();
y = 18;
}
const trimmed = line.trim();
const isDashes = /^-+$/.test(trimmed);
const isHeader = trimmed === trimmed.toUpperCase() && trimmed.length > 2 && /[A-Z]/.test(trimmed);
if(isDashes){
pd.setDrawColor(...gray); pd.setLineWidth(0.3);
pd.line(mg, y-1, mg+80, y-1); y+=3;
} else if(isHeader){
pd.setFont('helvetica','bold'); pd.setTextColor(...navy); pd.setFontSize(8);
pd.text(trimmed, mg, y); y+=5;
} else {
pd.setFont('helvetica','normal'); pd.setTextColor(...gray); pd.setFontSize(7.5);
const wrapped = pd.splitTextToSize(line, pw-mg*2);
pd.text(wrapped, mg, y); y+=wrapped.length*4.5;
}
}
}
// Signature area — always after notes, never overlapping
if(preparedBy || signedBy) {
if(y + 28 > FOOTER_Y) { pd.addPage(); y = 18; }
const sigY = y + 8;
const sigW = 72;
const sigImgData = doc.signature || '';
if(preparedBy) {
pd.setDrawColor(...gray); pd.setLineWidth(0.3);
pd.line(mg, sigY, mg+sigW, sigY);
pd.setFontSize(7.5); pd.setFont('helvetica','bold'); pd.setTextColor(...navy);
pd.text(preparedBy, mg, sigY+5);
pd.setFont('helvetica','normal'); pd.setTextColor(...gray); pd.setFontSize(7);
pd.text(lang==='es'?'Elaborado por':'Prepared by', mg, sigY+10);
}
if(signedBy) {
const sigX = pw-mg-sigW;
pd.setDrawColor(...gray); pd.setLineWidth(0.3);
pd.line(sigX, sigY, sigX+sigW, sigY);
// Draw signature image above the line if available
if(sigImgData) {
try {
let imgData = sigImgData;
// If it's a URL (company signature), fetch it first
if(sigImgData.startsWith('/')) {
const r = await fetch(sigImgData);
const blob = await r.blob();
imgData = await new Promise(res=>{const fr=new FileReader();fr.onload=()=>res(fr.result);fr.readAsDataURL(blob);});
}
pd.addImage(imgData, 'PNG', sigX, sigY-18, sigW, 16);
} catch(e) {}
}
pd.setFontSize(7.5); pd.setFont('helvetica','bold'); pd.setTextColor(...navy);
pd.text(signedBy, sigX+sigW/2, sigY+5, {align:'center'});
pd.setFont('helvetica','normal'); pd.setTextColor(...gray); pd.setFontSize(7);
pd.text(lang==='es'?'Autorizado por':'Authorized by', sigX+sigW/2, sigY+10, {align:'center'});
}
}
// Footer bar — fixed at bottom
pd.setFillColor(...navy); pd.rect(0,263,pw,14,'F');
pd.setFillColor(...gold); pd.rect(0,263,pw,1,'F');
pd.setTextColor(...gold); pd.setFontSize(7); pd.setFont('helvetica','normal');
const fp=[comp.name];
if(comp.website) fp.push(comp.website);
if(comp.phone) fp.push(comp.phone);
if(comp.email) fp.push(comp.email);
pd.text(fp.join(' · '), pw/2, 271, {align:'center'});
// ---- Save to server using Blob + FormData (no base64 corruption) ----
const pdfBlob = pd.output('blob');
// Open preview immediately from local blob (no server round-trip needed for preview)
const blobUrl = URL.createObjectURL(pdfBlob);
const previewWin = window.open(blobUrl, '_blank');
if (!previewWin) showToast('⚠️ Permite pop-ups para ver el preview', 'error');
// Save to server in background
showToast('⏳ Guardando PDF en servidor...');
const formData = new FormData();
formData.append('pdf', pdfBlob, `${doc.number}.pdf`);
try {
const saveR = await fetch(`/documents/${docId}/save-pdf`, {method:'POST', body: formData});
const saveRes = await saveR.json();
if (saveRes.success) {
showToast('✅ PDF guardado — usa ⬇️ descargar o 📤 Enviar');
setTimeout(()=>location.reload(), 1500);
} else {
showToast('⚠️ Preview OK pero no se pudo guardar: '+(saveRes.error||''), 'error');
}
} catch(e) {
showToast('⚠️ Preview OK pero error de red al guardar: '+e.message, 'error');
}
}
// ============ SIGNATURE PAD ============
let sigPadDrawing = false;
let sigPadCtx = null;
function setSignaturePreview(dataUrl) {
document.getElementById('doc-signature').value = dataUrl;
const prev = document.getElementById('sig-preview');
const ctx = prev.getContext('2d');
ctx.clearRect(0, 0, prev.width, prev.height);
if(dataUrl) {
const img = new Image();
img.onload = () => ctx.drawImage(img, 0, 0, prev.width, prev.height);
img.src = dataUrl;
document.getElementById('btn-save-sig').style.display = 'inline-flex';
}
}
function openSignaturePad() {
const modal = document.getElementById('sigPadModal');
modal.style.display = 'flex';
// Wait for layout so canvas has correct dimensions
requestAnimationFrame(() => {
const canvas = document.getElementById('sig-pad');
// Match canvas pixel size to its CSS display size to avoid coordinate mismatch
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width || 460;
canvas.height = 160;
sigPadCtx = canvas.getContext('2d');
sigPadCtx.clearRect(0, 0, canvas.width, canvas.height);
sigPadCtx.strokeStyle = '#1a2744';
sigPadCtx.lineWidth = 2.5;
sigPadCtx.lineCap = 'round';
sigPadCtx.lineJoin = 'round';
const getPos = (e) => {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
// Scale coordinates in case CSS size differs from canvas size
const scaleX = canvas.width / r.width;
const scaleY = canvas.height / r.height;
return { x: (src.clientX - r.left) * scaleX, y: (src.clientY - r.top) * scaleY };
};
canvas.onmousedown = canvas.ontouchstart = (e) => {
e.preventDefault(); sigPadDrawing = true;
const p = getPos(e); sigPadCtx.beginPath(); sigPadCtx.moveTo(p.x, p.y);
};
canvas.onmousemove = canvas.ontouchmove = (e) => {
e.preventDefault(); if (!sigPadDrawing) return;
const p = getPos(e); sigPadCtx.lineTo(p.x, p.y); sigPadCtx.stroke();
};
canvas.onmouseup = canvas.onmouseleave = canvas.ontouchend = () => { sigPadDrawing = false; };
});
}
function clearSigPad() {
const canvas = document.getElementById('sig-pad');
sigPadCtx && sigPadCtx.clearRect(0, 0, canvas.width, canvas.height);
}
function saveSigPad() {
const canvas = document.getElementById('sig-pad');
setSignaturePreview(canvas.toDataURL('image/png'));
document.getElementById('sigPadModal').style.display = 'none';
}
function clearSignature() {
document.getElementById('doc-signature').value = '';
const prev = document.getElementById('sig-preview');
prev.getContext('2d').clearRect(0, 0, prev.width, prev.height);
document.getElementById('btn-save-sig').style.display = 'none';
}
async function saveSignatureToProfile() {
const sig = document.getElementById('doc-signature').value;
if(!sig) return showToast('No hay firma para guardar', 'error');
const r = await fetch('/api/me/signature', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({signature: sig})
});
const res = await r.json();
if(res.success) {
currentUserData = null; // reset cache
showToast('✅ Firma guardada en tu perfil');
document.getElementById('btn-save-sig').style.display = 'none';
} else {
showToast('Error al guardar firma', 'error');
}
}</script>
{% endblock %}