mirror of
https://github.com/marcogll/ap_pos.git
synced 2026-01-13 13:15:16 +00:00
feat: Implement robust discount/anticipo detection system for PNG receipts
- Added comprehensive discount detection logic in hasAnyDiscount() - Created extractDiscountInfo() to handle multiple data sources - Updated all discount rendering functions to use new extraction logic - Enhanced support for manual anticipos and applied discounts - Improved fallback detection using subtotal vs monto differences - Added Material Symbols icons to action buttons in table - Fixed discount display issues in PNG receipt generation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
881
receipt.js
Normal file
881
receipt.js
Normal file
@@ -0,0 +1,881 @@
|
||||
// PNG Receipt System - Based on broad_idea.png design
|
||||
// Phase 1: Setup with Assets
|
||||
|
||||
class PNGReceiptGenerator {
|
||||
constructor() {
|
||||
this.pngConfig = {
|
||||
scale: 2,
|
||||
width: 540, // 1080/2 for mobile format
|
||||
height: 960, // 1920/2 for mobile format
|
||||
backgroundColor: 'white',
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
ignoreElements: (element) => {
|
||||
return element.classList?.contains('no-png');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
hasAnyDiscount(movement) {
|
||||
// Check discountInfo.amount
|
||||
if (movement.discountInfo && parseFloat(movement.discountInfo.amount || '0') > 0) {
|
||||
console.log('🎯 Found discount in discountInfo.amount:', movement.discountInfo.amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for descuento field directly
|
||||
if (movement.descuento && parseFloat(movement.descuento) > 0) {
|
||||
console.log('🎯 Found discount in movement.descuento:', movement.descuento);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for anticipo aplicado in concepto text
|
||||
if (movement.concepto && movement.concepto.toLowerCase().includes('anticipo aplicado')) {
|
||||
console.log('🎯 Found anticipo in concepto text');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for subtotal vs monto difference (indicating discount)
|
||||
const subtotal = parseFloat(movement.subtotal || movement.monto || '0');
|
||||
const monto = parseFloat(movement.monto || '0');
|
||||
if (subtotal > monto && (subtotal - monto) > 0.01) {
|
||||
console.log('🎯 Found discount from subtotal/monto difference:', subtotal - monto);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('❌ No discount found in movement');
|
||||
return false;
|
||||
}
|
||||
|
||||
extractDiscountInfo(data) {
|
||||
// Priority 1: discountInfo structured data
|
||||
if (data.discountInfo && parseFloat(data.discountInfo.amount || '0') > 0) {
|
||||
return {
|
||||
amount: data.discountInfo.amount,
|
||||
label: data.tipoDescuento ? `Descuento (${data.tipoDescuento})` : 'Descuento',
|
||||
detail: data.motivoDescuento || null
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 2: direct descuento field
|
||||
if (data.descuento && parseFloat(data.descuento) > 0) {
|
||||
return {
|
||||
amount: data.descuento,
|
||||
label: data.tipoDescuento ? `Descuento (${data.tipoDescuento})` : 'Descuento',
|
||||
detail: data.motivoDescuento || null
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 3: Calculate from subtotal vs monto
|
||||
const subtotal = parseFloat(data.subtotal || data.monto || '0');
|
||||
const monto = parseFloat(data.monto || '0');
|
||||
if (subtotal > monto && (subtotal - monto) > 0.01) {
|
||||
const discountAmount = (subtotal - monto).toFixed(2);
|
||||
return {
|
||||
amount: discountAmount,
|
||||
label: 'Anticipo Aplicado',
|
||||
detail: 'Anticipo manual - no registrado previamente'
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 4: Extract from concepto text if it mentions anticipo
|
||||
if (data.concepto && data.concepto.toLowerCase().includes('anticipo aplicado')) {
|
||||
// Try to extract amount from text
|
||||
const match = data.concepto.match(/\$(\d+(?:\.\d{2})?)/);
|
||||
if (match) {
|
||||
return {
|
||||
amount: match[1],
|
||||
label: 'Anticipo Aplicado',
|
||||
detail: 'Anticipo manual'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return {
|
||||
amount: '0.00',
|
||||
label: 'Descuento',
|
||||
detail: null
|
||||
};
|
||||
}
|
||||
|
||||
analyzeTicketType(movement) {
|
||||
console.log('🔍 Analyzing movement for discounts:', {
|
||||
discountInfo: movement.discountInfo,
|
||||
descuento: movement.descuento,
|
||||
subtotal: movement.subtotal,
|
||||
monto: movement.monto,
|
||||
concepto: movement.concepto
|
||||
});
|
||||
|
||||
const analysis = {
|
||||
// Basic ticket analysis
|
||||
hasAppointment: !!(movement.fechaCita && movement.horaCita),
|
||||
isAnticipo: movement.tipo === 'Anticipo' ||
|
||||
movement.concepto?.toLowerCase().includes('anticipo'),
|
||||
hasAnticipoApplied: movement.discountInfo?.type === 'anticipo',
|
||||
hasConsent: movement.client?.consentimiento || movement.client?.esOncologico,
|
||||
isOncology: movement.client?.esOncologico,
|
||||
|
||||
// Service type detection
|
||||
isService: movement.tipo === 'service' || movement.tipo === 'Service',
|
||||
isCourse: movement.tipo === 'course' || movement.tipo === 'Curso',
|
||||
|
||||
// Payment analysis - check multiple fields for discounts/anticipos
|
||||
hasDiscount: this.hasAnyDiscount(movement),
|
||||
isWarriorDiscount: movement.discountInfo?.type === 'warrior',
|
||||
|
||||
// Client type analysis
|
||||
hasClientInfo: !!(movement.client && movement.client.nombre),
|
||||
hasClientPhone: !!(movement.client && movement.client.telefono),
|
||||
|
||||
// Medical info analysis
|
||||
hasMedicalInfo: function() {
|
||||
return this.isOncology && movement.client && (
|
||||
movement.client.nombreMedico ||
|
||||
movement.client.telefonoMedico ||
|
||||
movement.client.cedulaMedico
|
||||
);
|
||||
},
|
||||
|
||||
// Anticipo logic
|
||||
needsAnticipoNotes: function() {
|
||||
// Show anticipo notes only for pure anticipos (not applied to services)
|
||||
return this.isAnticipo && !this.hasAnticipoApplied;
|
||||
},
|
||||
|
||||
// Check if service is PMU related
|
||||
isPMUService: function() {
|
||||
const pmuKeywords = ['microblading', 'pmu', 'pigmentacion', 'cejas', 'labios', 'vanity brows', 'brows'];
|
||||
const concepto = movement.concepto?.toLowerCase() || '';
|
||||
return pmuKeywords.some(keyword => concepto.includes(keyword));
|
||||
},
|
||||
|
||||
// Determine main ticket category
|
||||
getTicketCategory: function() {
|
||||
if (this.isAnticipo && !this.hasAnticipoApplied) return 'anticipo-puro';
|
||||
if (this.isService && this.hasAnticipoApplied) return 'servicio-con-anticipo';
|
||||
if (this.isService && this.hasConsent && this.isOncology && this.isPMUService()) return 'servicio-oncologico';
|
||||
if (this.isService && this.hasConsent && this.isPMUService()) return 'servicio-con-consentimiento';
|
||||
if (this.isService && this.hasAppointment) return 'servicio-con-cita';
|
||||
if (this.isService) return 'servicio-simple';
|
||||
if (this.isCourse) return 'curso';
|
||||
return 'otros';
|
||||
}
|
||||
};
|
||||
|
||||
console.log('🔍 Analysis result - hasDiscount:', analysis.hasDiscount);
|
||||
console.log('🔍 Analysis result - discountInfo amount:', movement.discountInfo?.amount);
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
// Enhanced data mapping system
|
||||
mapMovementData(movement) {
|
||||
return {
|
||||
// Basic info
|
||||
folio: movement.folio || 'N/A',
|
||||
fecha: this.formatDate(movement.fecha) || new Date().toLocaleDateString('es-MX'),
|
||||
concepto: movement.concepto || movement.serviceName || 'Servicio',
|
||||
monto: this.formatAmount(movement.monto || movement.total || '0'),
|
||||
metodo: movement.metodo || movement.paymentMethod || 'No especificado',
|
||||
staff: movement.staff || movement.attendedBy || 'Ale Ponce',
|
||||
|
||||
// Client info
|
||||
cliente: this.getClientName(movement),
|
||||
telefonoCliente: this.getClientPhone(movement),
|
||||
|
||||
// Appointment info
|
||||
fechaCita: movement.fechaCita || movement.appointmentDate,
|
||||
horaCita: movement.horaCita || movement.appointmentTime,
|
||||
|
||||
// Discount/anticipo info
|
||||
subtotal: this.formatAmount(movement.subtotal || movement.monto),
|
||||
descuento: this.formatAmount(movement.discountInfo?.amount || '0'),
|
||||
tipoDescuento: movement.discountInfo?.type || null,
|
||||
motivoDescuento: movement.discountInfo?.reason || null,
|
||||
|
||||
// Client object
|
||||
client: movement.client || {},
|
||||
|
||||
// Original movement for reference
|
||||
originalMovement: movement
|
||||
};
|
||||
}
|
||||
|
||||
// Helper functions for data formatting
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return dateStr; // Invalid date
|
||||
return date.toLocaleDateString('es-MX', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
formatAmount(amount) {
|
||||
if (!amount) return '0.00';
|
||||
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||
return numAmount.toFixed(2);
|
||||
}
|
||||
|
||||
getClientName(movement) {
|
||||
if (movement.cliente) return movement.cliente;
|
||||
if (movement.client?.nombre) return movement.client.nombre;
|
||||
if (movement.clientName) return movement.clientName;
|
||||
return 'Cliente General';
|
||||
}
|
||||
|
||||
getClientPhone(movement) {
|
||||
if (movement.telefonoCliente) return movement.telefonoCliente;
|
||||
if (movement.client?.telefono) return movement.client.telefono;
|
||||
if (movement.clientPhone) return movement.clientPhone;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dynamic template generation based on ticket type
|
||||
generateReceiptHTML(movement, ticketType) {
|
||||
const data = this.mapMovementData(movement);
|
||||
const category = ticketType.getTicketCategory();
|
||||
|
||||
return `
|
||||
<div class="receipt-wrapper" style="width: 540px; min-height: 960px; background: url('./assets/receipt/background.png') center/cover; position: relative; font-family: 'Montserrat', sans-serif; color: #333; padding: 40px;">
|
||||
<div class="receipt-card" style="position: relative; z-index: 2; background: rgba(255,255,255,0.95); border-radius: 20px; padding: 40px 30px; min-height: calc(100% - 80px); box-sizing: border-box; box-shadow: 0 10px 30px rgba(0,0,0,0.3);">
|
||||
|
||||
${this.generateMobileHeader()}
|
||||
${this.generateMobileTitle()}
|
||||
${this.generateMobileBasicInfo(data)}
|
||||
${this.generateMobileServicesTable(data, ticketType)}
|
||||
${this.generateMobileTotalsSection(data, ticketType)}
|
||||
${this.generateCategorySpecificSections(data, ticketType, category)}
|
||||
${this.generateMobileFooter()}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateMobileHeader() {
|
||||
return `
|
||||
<div class="mobile-header" style="text-align: center; margin-bottom: 25px; padding-top: 15px;">
|
||||
<img src="./assets/receipt/isotipo.svg" alt="ap" style="width: 22%; max-width: 140px; height: auto; margin-bottom: 15px;" />
|
||||
<div class="logotipo" style="text-align: center;">
|
||||
<div class="business-name" style="font-size: 14px; font-weight: 700; color: #000; margin-bottom: 5px; letter-spacing: 1px;">ALEJANDRA PONCE</div>
|
||||
<div class="business-tagline" style="font-size: 10px; color: #666; font-weight: 400; letter-spacing: 1px;">BEAUTY EXPERT • MASTER TRAINER</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateHeader() {
|
||||
return `
|
||||
<div class="receipt-header">
|
||||
<div class="logo-section">
|
||||
<div class="logo-text">ap</div>
|
||||
</div>
|
||||
<div class="business-info">
|
||||
<div class="business-name">ALEJANDRA PONCE</div>
|
||||
<div class="business-tagline">BEAUTY EXPERT • MASTER TRAINER</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateMobileTitle() {
|
||||
return `
|
||||
<div class="mobile-title" style="text-align: center; margin-bottom: 25px;">
|
||||
<img src="./assets/receipt/comprobante-title.svg" alt="COMPROBANTE DE PAGO" style="width: 51%; max-width: 238px; height: auto;" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateTitle() {
|
||||
return `
|
||||
<div class="receipt-title">
|
||||
<h1>COMPROBANTE DE PAGO</h1>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateMobileBasicInfo(data) {
|
||||
return `
|
||||
<div class="mobile-client-info" style="margin-bottom: 20px;">
|
||||
<div style="margin-bottom: 15px;">
|
||||
<span style="font-size: 14px; color: #666; font-weight: 400;">Cliente: </span>
|
||||
<span style="font-size: 16px; color: #000; font-weight: 700;">${data.cliente}</span>
|
||||
</div>
|
||||
${data.telefonoCliente ? `
|
||||
<div style="margin-bottom: 15px;">
|
||||
<span style="font-size: 14px; color: #666; font-weight: 400;">Contacto: </span>
|
||||
<span style="font-size: 14px; color: #000; font-weight: 500;">${data.telefonoCliente}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="display: flex; justify-content: space-between; gap: 20px;">
|
||||
<div style="flex: 1;">
|
||||
<div style="font-size: 14px; color: #666; font-weight: 400; margin-bottom: 3px;">Folio</div>
|
||||
<div style="font-size: 16px; color: #000; font-weight: 700;">${data.folio}</div>
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-size: 14px; color: #666; font-weight: 400; margin-bottom: 3px;">Fecha</div>
|
||||
<div style="font-size: 16px; color: #000; font-weight: 700;">${data.fecha}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateBasicInfo(data) {
|
||||
return `
|
||||
<div class="receipt-info">
|
||||
<div class="info-row">
|
||||
<span class="label">Folio</span>
|
||||
<span class="value">${data.folio}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Fecha</span>
|
||||
<span class="value">${data.fecha}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateMobileClientInfo(data, ticketType) {
|
||||
return ``; // Esta función ya no se usa, la info del cliente se movió a generateMobileBasicInfo
|
||||
}
|
||||
|
||||
generateClientInfo(data, ticketType) {
|
||||
return `
|
||||
<div class="client-info">
|
||||
<div class="client-row">
|
||||
<span class="client-label">Cliente:</span>
|
||||
<span class="client-name">${data.cliente}</span>
|
||||
</div>
|
||||
${data.telefonoCliente ? `
|
||||
<div class="client-row">
|
||||
<span class="client-label">Contacto:</span>
|
||||
<span class="client-contact">${data.telefonoCliente}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateMobileServicesTable(data, ticketType) {
|
||||
console.log('🔍 Services table - hasDiscount:', ticketType.hasDiscount);
|
||||
console.log('🔍 Services table - descuento amount:', data.descuento);
|
||||
console.log('🔍 Services table - discountInfo:', data.originalMovement?.discountInfo);
|
||||
return `
|
||||
<div class="mobile-services" style="margin-bottom: 30px;">
|
||||
<table style="width: 100%; border-collapse: collapse; border: 2px solid #000;">
|
||||
<thead>
|
||||
<tr style="background: #f8f8f8;">
|
||||
<th style="padding: 10px 8px; text-align: left; font-size: 14px; font-weight: 700; border-right: 2px solid #000; width: 70%;">Descripción</th>
|
||||
<th style="padding: 10px 8px; text-align: right; font-size: 14px; font-weight: 700; width: 30%;">Monto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding: 12px 8px; border-right: 2px solid #000; border-top: 2px solid #000; font-size: 14px; color: #333; vertical-align: top; line-height: 1.4;">${data.concepto}</td>
|
||||
<td style="padding: 12px 8px; border-top: 2px solid #000; text-align: right; font-size: 16px; color: #000; font-weight: 700;">$${data.monto}</td>
|
||||
</tr>
|
||||
${ticketType.hasDiscount ? this.generateMobileDiscountTableRow(data) : ''}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateServicesTable(data, ticketType) {
|
||||
return `
|
||||
<div class="services-table">
|
||||
<div class="table-header">
|
||||
<div class="description-header">Description</div>
|
||||
<div class="amount-header">Amount</div>
|
||||
</div>
|
||||
<div class="table-content">
|
||||
<div class="service-row">
|
||||
<div class="service-description">${data.concepto}</div>
|
||||
<div class="service-amount">$${data.monto}</div>
|
||||
</div>
|
||||
${ticketType.hasDiscount ? this.generateDiscountRow(data) : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateMobileDiscountTableRow(data) {
|
||||
const discountInfo = this.extractDiscountInfo(data);
|
||||
console.log('🎨 Generating discount row with extracted info:', discountInfo);
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td style="padding: 12px 8px; border-right: 2px solid #000; border-top: 2px solid #000; font-size: 14px; color: #999; vertical-align: top; line-height: 1.4;">
|
||||
${discountInfo.label}
|
||||
${discountInfo.detail ? `<br><span style="font-size: 12px;">${discountInfo.detail}</span>` : ''}
|
||||
</td>
|
||||
<td style="padding: 12px 8px; border-top: 2px solid #000; text-align: right; font-size: 16px; color: #e74c3c; font-weight: 600;">-$${discountInfo.amount}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
generateAppointmentInTotals(data) {
|
||||
const fechaCita = data.fechaCita ? this.formatDate(data.fechaCita) : null;
|
||||
const citaText = fechaCita && data.horaCita ? `${fechaCita} - ${data.horaCita}` : 'Por confirmar';
|
||||
return `
|
||||
<div style="margin-top: 6px; padding-top: 4px; border-top: 1px dashed #ccc;">
|
||||
<div style="font-size: 11px; color: #666; text-align: center;">🗓️ Tu cita es: ${citaText}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateMobileDiscountRow(data) {
|
||||
const discountInfo = this.extractDiscountInfo(data);
|
||||
return `
|
||||
<div style="border-top: 1px dashed #ddd; padding-top: 15px; margin-top: 15px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div style="font-size: 14px; color: #999; flex: 1; line-height: 1.4;">
|
||||
${discountInfo.label}
|
||||
${discountInfo.detail ? `<br><span style="font-size: 12px;">${discountInfo.detail}</span>` : ''}
|
||||
</div>
|
||||
<div style="font-size: 16px; color: #e74c3c; font-weight: 600; margin-left: 15px;">-$${discountInfo.amount}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateDiscountRow(data) {
|
||||
const discountInfo = this.extractDiscountInfo(data);
|
||||
return `
|
||||
<div class="service-row discount-row">
|
||||
<div class="service-description">
|
||||
${discountInfo.label}
|
||||
${discountInfo.detail ? `<br><small>${discountInfo.detail}</small>` : ''}
|
||||
</div>
|
||||
<div class="service-amount discount-amount">-$${discountInfo.amount}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateMobileTotalsSection(data, ticketType) {
|
||||
return `
|
||||
<div class="mobile-totals" style="margin-bottom: 20px; margin-top: 15px; padding: 12px; background: rgba(0,0,0,0.05); border-radius: 8px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 2px solid #000;">
|
||||
<span style="font-size: 16px; color: #000; font-weight: 700;">Total Pagado</span>
|
||||
<span style="font-size: 20px; color: #000; font-weight: 700;">$${data.monto}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span style="font-size: 12px; color: #000; font-weight: 700;">Método de Pago:</span>
|
||||
<span style="font-size: 12px; color: #000; font-weight: 500;">${data.metodo}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span style="font-size: 12px; color: #000; font-weight: 700;">Te atendió:</span>
|
||||
<span style="font-size: 12px; color: #000; font-weight: 500;">${data.staff}</span>
|
||||
</div>
|
||||
${ticketType.hasAppointment ? this.generateAppointmentInTotals(data) : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateTotalsSection(data, ticketType) {
|
||||
return `
|
||||
<div class="totals-section">
|
||||
<div class="total-row">
|
||||
<span class="total-label">Total Paid</span>
|
||||
<span class="total-amount">$${data.monto}</span>
|
||||
</div>
|
||||
<div class="payment-row">
|
||||
<span class="payment-label">Payment Method:</span>
|
||||
<span class="payment-method">${data.metodo}</span>
|
||||
</div>
|
||||
<div class="staff-row">
|
||||
<span class="staff-label">Te atendió:</span>
|
||||
<span class="staff-name">${data.staff}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateCategorySpecificSections(data, ticketType, category) {
|
||||
let sections = '';
|
||||
|
||||
// Add category-specific content
|
||||
switch (category) {
|
||||
case 'anticipo-puro':
|
||||
sections += this.generateAnticipoSection();
|
||||
break;
|
||||
case 'servicio-oncologico':
|
||||
sections += this.generateOncologySection(data);
|
||||
break;
|
||||
case 'servicio-con-consentimiento':
|
||||
sections += this.generateConsentSection();
|
||||
break;
|
||||
case 'curso':
|
||||
sections += this.generateCourseSection();
|
||||
break;
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
generateAppointmentSection(data) {
|
||||
return `
|
||||
<div class="appointment-section" style="background: rgba(240,248,255,0.8); padding: 15px; border-left: 4px solid #007bff; margin: 15px 0; border-radius: 5px;">
|
||||
<h4 style="margin: 0 0 10px 0; font-size: 14px; font-weight: 700; color: #0056b3;">📅 Información de Cita</h4>
|
||||
<p style="margin: 5px 0; font-size: 12px; line-height: 1.4; color: #333;"><strong>Fecha:</strong> ${data.fechaCita || 'Por confirmar'}</p>
|
||||
<p style="margin: 5px 0; font-size: 12px; line-height: 1.4; color: #333;"><strong>Hora:</strong> ${data.horaCita || 'Por confirmar'}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateAnticipoSection() {
|
||||
return `
|
||||
<div class="anticipo-section" style="background: rgba(255,248,220,0.8); padding: 15px; border-left: 4px solid #ffc107; margin: 15px 0; border-radius: 5px;">
|
||||
<h4 style="margin: 0 0 10px 0; font-size: 14px; font-weight: 700; color: #b8860b;">💰 Notas del Anticipo</h4>
|
||||
<p style="margin: 8px 0; font-size: 12px; line-height: 1.4; color: #333;">Al dejar tu anticipo, te agradecemos tu compromiso con nuestro tiempo, de la misma forma en que nosotros respetamos el tuyo.</p>
|
||||
<p style="margin: 8px 0; font-size: 12px; line-height: 1.4; color: #333;">Las cancelaciones con menos de 48 horas no son reembolsables.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateConsentSection() {
|
||||
return `
|
||||
<div class="consent-section" style="background: rgba(255,248,220,0.8); padding: 15px; border-left: 4px solid #ffc107; margin: 15px 0; border-radius: 5px;">
|
||||
<h4 style="margin: 0 0 10px 0; font-size: 14px; font-weight: 700; color: #b8860b;">📋 Consentimiento Médico</h4>
|
||||
<p style="margin: 5px 0; font-size: 12px; line-height: 1.4;">✅ Cliente ha proporcionado consentimiento médico informado completo</p>
|
||||
<p style="margin: 5px 0; font-size: 12px; line-height: 1.4;">Se han explicado los procedimientos, riesgos y cuidados post-tratamiento</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateOncologySection(data) {
|
||||
const client = data.client;
|
||||
return `
|
||||
<div class="oncology-section" style="background: rgba(255,240,245,0.8); padding: 12px; border-left: 4px solid #dc3545; margin: 15px 0; border-radius: 5px;">
|
||||
<h4 style="margin: 0 0 8px 0; font-size: 13px; font-weight: 700; color: #dc3545;">🎗️ Consentimiento Oncológico</h4>
|
||||
<p style="margin: 4px 0; font-size: 11px; line-height: 1.3; color: #333;">El cliente declara ser paciente oncológico y que la información de su médico es veraz.</p>
|
||||
${client.nombreMedico ? `<p style="margin: 3px 0; font-size: 11px; line-height: 1.3; color: #333;"><strong>Médico:</strong> ${client.nombreMedico}</p>` : ''}
|
||||
${client.cedulaMedico ? `<p style="margin: 3px 0; font-size: 11px; line-height: 1.3; color: #333;"><strong>Cédula:</strong> ${client.cedulaMedico}</p>` : ''}
|
||||
<p style="margin: 4px 0 0 0; font-size: 10px; line-height: 1.3; color: #666;">Al consentir el servicio, declara que la información médica proporcionada es veraz.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateCourseSection() {
|
||||
return `
|
||||
<div class="course-section">
|
||||
<h4>🎓 Información del Curso</h4>
|
||||
<p>✅ Inscripción confirmada al programa educativo</p>
|
||||
<p>Términos y condiciones del curso aceptados</p>
|
||||
<p>Para consultas sobre fechas y material, contactar directamente</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateMobileFooter() {
|
||||
return `
|
||||
<div class="mobile-footer" style="text-align: center; padding: 15px 10px; margin-top: 10px;">
|
||||
<div style="font-size: 12px; color: #666; margin-bottom: 8px; font-weight: 300;">Si tienes alguna duda o pregunta</div>
|
||||
<div style="display: flex; justify-content: center; gap: 12px; margin-bottom: 12px;">
|
||||
<span style="font-size: 16px;">📱</span>
|
||||
<span style="font-size: 16px;">📞</span>
|
||||
</div>
|
||||
<div style="font-size: 14px; line-height: 1.3; color: #333;">
|
||||
<div style="font-weight: 300; margin-bottom: 2px;">Muchas gracias por confiar tu</div>
|
||||
<div style="font-weight: 600;">belleza en mí <span style="color: #e74c3c;">🤍</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateFooter() {
|
||||
return `
|
||||
<div class="receipt-footer">
|
||||
<div class="contact-text">Si tienes alguna duda o pregunta</div>
|
||||
<div class="contact-icons">
|
||||
<span class="whatsapp-icon">📱</span>
|
||||
<span class="phone-icon">📞</span>
|
||||
</div>
|
||||
<div class="thank-you">
|
||||
<span class="thank-text">Muchas gracias por confiar tu</span>
|
||||
<span class="beauty-text">belleza en mí</span>
|
||||
<span class="heart-icon">🤍</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async downloadReceiptPNG(movementId, movementData = null) {
|
||||
try {
|
||||
console.log('🎨 PNG Receipt Generator - Starting generation for:', movementId);
|
||||
console.log('📋 Movement data provided:', movementData);
|
||||
|
||||
// Check dependencies
|
||||
if (typeof html2canvas === 'undefined') {
|
||||
throw new Error('html2canvas library not loaded');
|
||||
}
|
||||
if (typeof saveAs === 'undefined') {
|
||||
throw new Error('FileSaver library not loaded');
|
||||
}
|
||||
|
||||
// Get movement data - use provided data or fetch test data
|
||||
const movement = movementData || await this.getMovementById(movementId);
|
||||
console.log('📄 Final movement data:', movement);
|
||||
|
||||
// Analyze ticket type
|
||||
const ticketType = this.analyzeTicketType(movement);
|
||||
console.log('🔍 Ticket type analysis:', ticketType);
|
||||
|
||||
// Generate HTML
|
||||
const receiptHTML = this.generateReceiptHTML(movement, ticketType);
|
||||
console.log('🏗️ Generated HTML length:', receiptHTML.length);
|
||||
|
||||
// Create temporary DOM element with mobile dimensions
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = receiptHTML;
|
||||
tempDiv.style.position = 'absolute';
|
||||
tempDiv.style.left = '-9999px';
|
||||
tempDiv.style.top = '-9999px';
|
||||
tempDiv.style.width = '540px';
|
||||
tempDiv.style.height = '960px';
|
||||
tempDiv.style.backgroundColor = 'white';
|
||||
tempDiv.style.fontFamily = "'Montserrat', sans-serif";
|
||||
tempDiv.style.overflow = 'hidden';
|
||||
|
||||
// Apply receipt styles and ensure CSS is loaded
|
||||
tempDiv.className = 'receipt-container';
|
||||
|
||||
// Create and inject CSS link to ensure styles are applied
|
||||
const cssLink = document.createElement('link');
|
||||
cssLink.rel = 'stylesheet';
|
||||
cssLink.href = 'receipt.css';
|
||||
if (!document.querySelector('link[href="receipt.css"]')) {
|
||||
document.head.appendChild(cssLink);
|
||||
}
|
||||
|
||||
// Ensure child element has proper mobile dimensions too
|
||||
const tempElement = tempDiv.firstElementChild;
|
||||
if (tempElement) {
|
||||
tempElement.style.width = '540px';
|
||||
tempElement.style.height = '960px';
|
||||
tempElement.style.backgroundColor = 'white';
|
||||
tempElement.style.fontFamily = "'Montserrat', sans-serif";
|
||||
tempElement.style.display = 'block';
|
||||
tempElement.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
document.body.appendChild(tempDiv);
|
||||
console.log('📐 Temp div created and added to DOM with explicit dimensions');
|
||||
|
||||
// Wait for DOM to settle, CSS to load, and images to load
|
||||
console.log('⏳ Waiting for DOM to settle, CSS to load, and images to load...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Longer wait for CSS
|
||||
await this.waitForImages(tempDiv);
|
||||
console.log('✅ Images loaded and DOM settled');
|
||||
|
||||
// Log element dimensions before canvas creation
|
||||
const rect = tempDiv.getBoundingClientRect();
|
||||
const childRect = tempElement?.getBoundingClientRect();
|
||||
console.log('📏 Temp div dimensions:', rect?.width, 'x', rect?.height);
|
||||
console.log('📏 Child element dimensions:', childRect?.width, 'x', childRect?.height);
|
||||
|
||||
// Force a reflow to ensure all dimensions are calculated
|
||||
tempDiv.offsetHeight;
|
||||
if (tempElement) tempElement.offsetHeight;
|
||||
|
||||
// Use the element with the most reliable dimensions
|
||||
const elementToCapture = tempElement || tempDiv;
|
||||
const finalRect = elementToCapture.getBoundingClientRect();
|
||||
console.log('📏 Final element to capture dimensions:', finalRect?.width, 'x', finalRect?.height);
|
||||
|
||||
// Convert to PNG with mobile config
|
||||
console.log('🖼️ Converting to PNG with html2canvas...');
|
||||
const adjustedConfig = {
|
||||
scale: 2,
|
||||
width: 540,
|
||||
height: 960,
|
||||
backgroundColor: 'white',
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
removeContainer: false
|
||||
};
|
||||
console.log('🔧 Using canvas config:', adjustedConfig);
|
||||
|
||||
const canvas = await html2canvas(elementToCapture, adjustedConfig);
|
||||
console.log('✅ Canvas created, dimensions:', canvas.width, 'x', canvas.height);
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(tempDiv);
|
||||
console.log('🧹 Temporary DOM element removed');
|
||||
|
||||
// Download
|
||||
console.log('💾 Creating blob and downloading...');
|
||||
canvas.toBlob(blob => {
|
||||
if (blob) {
|
||||
saveAs(blob, `Ticket_${movement.folio}.png`);
|
||||
console.log('🎉 PNG receipt downloaded successfully:', `Ticket_${movement.folio}.png`);
|
||||
} else {
|
||||
throw new Error('Failed to create blob from canvas');
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating receipt:', error);
|
||||
alert('Error al generar el recibo PNG: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async waitForImages(container) {
|
||||
const images = container.querySelectorAll('img');
|
||||
const imagePromises = Array.from(images).map(img => {
|
||||
return new Promise((resolve) => {
|
||||
if (img.complete) {
|
||||
resolve();
|
||||
} else {
|
||||
img.onload = resolve;
|
||||
img.onerror = resolve; // Continue even if image fails to load
|
||||
}
|
||||
});
|
||||
});
|
||||
await Promise.all(imagePromises);
|
||||
}
|
||||
|
||||
async getMovementById(movementId) {
|
||||
// Placeholder - will integrate with actual movement retrieval
|
||||
// For now, return different test scenarios based on movementId
|
||||
|
||||
const testScenarios = {
|
||||
'TEST-001': {
|
||||
// Servicio simple
|
||||
id: movementId,
|
||||
folio: 'AP-001',
|
||||
fecha: '2025-09-14',
|
||||
cliente: 'Ana García',
|
||||
telefonoCliente: '+52 614 123 4567',
|
||||
concepto: 'Pestañas Volumen Ruso',
|
||||
monto: '1200.00',
|
||||
metodo: 'Efectivo',
|
||||
staff: 'Ale Ponce',
|
||||
tipo: 'service',
|
||||
client: { nombre: 'Ana García', telefono: '+52 614 123 4567' }
|
||||
},
|
||||
|
||||
'TEST-002': {
|
||||
// Servicio con cita
|
||||
id: movementId,
|
||||
folio: 'AP-002',
|
||||
fecha: '2025-09-14',
|
||||
cliente: 'María López',
|
||||
concepto: 'Microblading Completo',
|
||||
monto: '2500.00',
|
||||
metodo: 'Tarjeta',
|
||||
staff: 'Ale Ponce',
|
||||
fechaCita: '2025-09-20',
|
||||
horaCita: '10:00',
|
||||
tipo: 'service',
|
||||
client: { nombre: 'María López' }
|
||||
},
|
||||
|
||||
'TEST-003': {
|
||||
// Anticipo puro
|
||||
id: movementId,
|
||||
folio: 'AP-003',
|
||||
fecha: '2025-09-14',
|
||||
cliente: 'Carmen Ruiz',
|
||||
concepto: 'Anticipo - Pestañas',
|
||||
monto: '500.00',
|
||||
metodo: 'Transferencia',
|
||||
staff: 'Ale Ponce',
|
||||
fechaCita: '2025-09-25',
|
||||
horaCita: '14:00',
|
||||
tipo: 'Anticipo',
|
||||
client: { nombre: 'Carmen Ruiz' }
|
||||
},
|
||||
|
||||
'TEST-004': {
|
||||
// Servicio con anticipo aplicado
|
||||
id: movementId,
|
||||
folio: 'AP-004',
|
||||
fecha: '2025-09-14',
|
||||
cliente: 'Laura Pérez',
|
||||
concepto: 'Pestañas Mega Volumen',
|
||||
monto: '800.00',
|
||||
subtotal: '1300.00',
|
||||
metodo: 'Efectivo',
|
||||
staff: 'Ale Ponce',
|
||||
tipo: 'service',
|
||||
discountInfo: {
|
||||
type: 'anticipo',
|
||||
amount: '500.00',
|
||||
reason: 'Anticipo aplicado'
|
||||
},
|
||||
client: { nombre: 'Laura Pérez' }
|
||||
},
|
||||
|
||||
'TEST-005': {
|
||||
// Paciente oncológico
|
||||
id: movementId,
|
||||
folio: 'AP-005',
|
||||
fecha: '2025-09-14',
|
||||
cliente: 'Elena Martínez',
|
||||
concepto: 'Microblading Oncológico',
|
||||
monto: '0.00',
|
||||
metodo: 'Cortesía',
|
||||
staff: 'Ale Ponce',
|
||||
fechaCita: '2025-09-18',
|
||||
horaCita: '11:00',
|
||||
tipo: 'service',
|
||||
discountInfo: {
|
||||
type: 'warrior',
|
||||
amount: '2500.00',
|
||||
reason: 'Programa Vanity Warriors'
|
||||
},
|
||||
client: {
|
||||
nombre: 'Elena Martínez',
|
||||
telefono: '+52 614 987 6543',
|
||||
consentimiento: true,
|
||||
esOncologico: true,
|
||||
nombreMedico: 'Dr. Carlos Hernández',
|
||||
telefonoMedico: '+52 614 555 0123',
|
||||
cedulaMedico: 'CED-789456'
|
||||
}
|
||||
},
|
||||
|
||||
'TEST-006': {
|
||||
// Curso
|
||||
id: movementId,
|
||||
folio: 'AP-006',
|
||||
fecha: '2025-09-14',
|
||||
cliente: 'Sofia Vargas',
|
||||
concepto: 'Curso Básico de Pestañas',
|
||||
monto: '3500.00',
|
||||
metodo: 'Transferencia',
|
||||
staff: 'Ale Ponce',
|
||||
tipo: 'course',
|
||||
client: {
|
||||
nombre: 'Sofia Vargas',
|
||||
telefono: '+52 614 456 7890',
|
||||
consentimiento: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Return test scenario or default
|
||||
return testScenarios[movementId] || testScenarios['TEST-001'];
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
window.pngReceiptGenerator = new PNGReceiptGenerator();
|
||||
|
||||
// Global function for easy access
|
||||
window.downloadReceiptPNG = function(movementId) {
|
||||
window.pngReceiptGenerator.downloadReceiptPNG(movementId);
|
||||
};
|
||||
|
||||
console.log('PNG Receipt Generator initialized successfully (based on broad_idea.png design)');
|
||||
Reference in New Issue
Block a user