35d460b127
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>
984 lines
46 KiB
HTML
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 %}
|