From 21b28b5bff7b31e96868dac13a8ce1766a81cf3c Mon Sep 17 00:00:00 2001
From: Juan Felipe Zapata Moreno
Date: Tue, 3 Feb 2026 17:02:28 -0600
Subject: [PATCH] =?UTF-8?q?WIP:=20agregar=20configuraci=C3=B3n=20de=20impr?=
=?UTF-8?q?esora=20y=20funcionalidad=20de=20impresi=C3=B3n=20autom=C3=A1ti?=
=?UTF-8?q?ca=20de=20tickets?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/POS/PrinterConfigModal.vue | 227 ++++++++++++++
src/pages/POS/Point.vue | 53 +++-
src/services/printService.js | 349 ++++++++++++++++++++++
src/services/ticketService.js | 38 ++-
4 files changed, 647 insertions(+), 20 deletions(-)
create mode 100644 src/components/POS/PrinterConfigModal.vue
create mode 100644 src/services/printService.js
diff --git a/src/components/POS/PrinterConfigModal.vue b/src/components/POS/PrinterConfigModal.vue
new file mode 100644
index 0000000..73647a8
--- /dev/null
+++ b/src/components/POS/PrinterConfigModal.vue
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Configuración de Impresora
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ isConnected ? 'QZ Tray conectado' : 'QZ Tray no está activo' }}
+
+
+ {{ isConnected ? 'Listo para imprimir' : 'Por favor, inicia la aplicación QZ Tray' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No se encontraron impresoras
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ¿Cómo activar QZ Tray?
+
+
+ - Descarga QZ Tray desde qz.io/download
+ - Instala la aplicación en tu computadora
+ - Inicia QZ Tray (debería aparecer en la bandeja del sistema)
+ - Haz clic en el botón de actualizar arriba
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/POS/Point.vue b/src/pages/POS/Point.vue
index 5c71373..854ee64 100644
--- a/src/pages/POS/Point.vue
+++ b/src/pages/POS/Point.vue
@@ -17,6 +17,7 @@ import CheckoutModal from '@Components/POS/CheckoutModal.vue';
import ClientModal from '@Components/POS/ClientModal.vue';
import QRscan from '@Components/POS/QRscan.vue';
import SerialSelector from '@Components/POS/SerialSelector.vue';
+import PrinterConfigModal from '@Components/POS/PrinterConfigModal.vue';
/** i18n */
const { t } = useI18n();
@@ -37,6 +38,9 @@ const lastSaleData = ref(null);
const showSerialSelector = ref(false);
const serialSelectorProduct = ref(null);
+// Estado para configuración de impresora
+const showPrinterConfig = ref(false);
+
/** Buscador de productos */
const searcher = useSearcher({
url: apiURL('inventario'),
@@ -293,14 +297,15 @@ const handleConfirmSale = async (paymentData) => {
// Mostrar notificación de que se está generando el ticket
window.Notify.info('Generando ticket...');
- // Generar ticket PDF con descarga automática
+ // Generar ticket PDF con descarga automática e impresión
await ticketService.generateSaleTicket(response, {
businessName: 'HIKVISION DISTRIBUIDOR',
- autoDownload: true
+ autoDownload: true,
+ autoPrint: true // Activar impresión automática con QZ Tray
});
// Notificación de éxito
- window.Notify.success('Ticket descargado correctamente');
+ window.Notify.success('Ticket generado e impreso correctamente');
} catch (ticketError) {
console.error('Error generando ticket:', ticketError);
window.Notify.warning('Venta registrada, pero hubo un error al generar el ticket');
@@ -394,19 +399,29 @@ onUnmounted(() => {
-
+
+
+
+
@@ -583,5 +598,11 @@ onUnmounted(() => {
@close="closeSerialSelector"
@confirm="handleSerialConfirm"
/>
+
+
+
diff --git a/src/services/printService.js b/src/services/printService.js
new file mode 100644
index 0000000..500d994
--- /dev/null
+++ b/src/services/printService.js
@@ -0,0 +1,349 @@
+/**
+ * Servicio de impresión usando QZ Tray
+ * Maneja la conexión con impresoras térmicas y de tickets
+ *
+ * Requiere que QZ Tray esté cargado globalmente (window.qz)
+ * Se carga automáticamente desde CDN si no está disponible
+ */
+class PrintService {
+ constructor() {
+ this.isConnected = false;
+ this.defaultPrinter = null;
+ this.qz = null;
+ this.loadQZ();
+ }
+
+ /**
+ * Cargar QZ Tray desde CDN si no está disponible
+ */
+ async loadQZ() {
+ // Si ya está cargado globalmente, usarlo
+ if (window.qz) {
+ this.qz = window.qz;
+ return;
+ }
+
+ // Cargar desde CDN
+ try {
+ await this.loadScript('https://cdn.jsdelivr.net/npm/qz-tray@2.2/qz-tray.min.js');
+ this.qz = window.qz;
+ console.log('QZ Tray cargado desde CDN');
+ } catch (error) {
+ console.error('Error cargando QZ Tray:', error);
+ }
+ }
+
+ /**
+ * Cargar script dinámicamente
+ */
+ loadScript(src) {
+ return new Promise((resolve, reject) => {
+ // Verificar si ya está cargado
+ if (document.querySelector(`script[src="${src}"]`)) {
+ resolve();
+ return;
+ }
+
+ const script = document.createElement('script');
+ script.src = src;
+ script.onload = resolve;
+ script.onerror = reject;
+ document.head.appendChild(script);
+ });
+ }
+
+ /**
+ * Conectar con QZ Tray
+ * @returns {Promise}
+ */
+ async connect() {
+ try {
+ // Asegurar que QZ esté cargado
+ if (!this.qz) {
+ await this.loadQZ();
+ }
+
+ if (!this.qz) {
+ throw new Error('QZ Tray no se pudo cargar. Verifica tu conexión a internet.');
+ }
+
+ if (this.isConnected) {
+ return true;
+ }
+
+ // Verificar si QZ Tray está corriendo
+ if (!this.qz.websocket.isActive()) {
+ await this.qz.websocket.connect();
+ }
+
+ this.isConnected = true;
+ console.log('QZ Tray conectado exitosamente');
+ return true;
+ } catch (error) {
+ console.error('Error conectando con QZ Tray:', error);
+ this.isConnected = false;
+
+ // Mensaje de error más amigable
+ if (error.message?.includes('Unable to establish connection')) {
+ throw new Error('QZ Tray no está ejecutándose. Por favor, inicia la aplicación QZ Tray.');
+ }
+
+ throw error;
+ }
+ }
+
+ /**
+ * Desconectar de QZ Tray
+ */
+ async disconnect() {
+ try {
+ if (this.qz && this.qz.websocket.isActive()) {
+ await this.qz.websocket.disconnect();
+ }
+ this.isConnected = false;
+ console.log('QZ Tray desconectado');
+ } catch (error) {
+ console.error('Error desconectando QZ Tray:', error);
+ }
+ }
+
+ /**
+ * Obtener lista de impresoras disponibles
+ * @returns {Promise}
+ */
+ async getPrinters() {
+ try {
+ await this.connect();
+ const printers = await this.qz.printers.find();
+ return printers;
+ } catch (error) {
+ console.error('Error obteniendo impresoras:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Obtener la impresora predeterminada
+ * @returns {Promise}
+ */
+ async getDefaultPrinter() {
+ try {
+ await this.connect();
+ const printer = await this.qz.printers.getDefault();
+ this.defaultPrinter = printer;
+ return printer;
+ } catch (error) {
+ console.error('Error obteniendo impresora predeterminada:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Establecer impresora predeterminada
+ * @param {string} printerName - Nombre de la impresora
+ */
+ setDefaultPrinter(printerName) {
+ this.defaultPrinter = printerName;
+ // Guardar en localStorage para persistencia
+ localStorage.setItem('pos_default_printer', printerName);
+ }
+
+ /**
+ * Obtener impresora guardada en localStorage
+ * @returns {string|null}
+ */
+ getSavedPrinter() {
+ return localStorage.getItem('pos_default_printer');
+ }
+
+ /**
+ * Imprimir un PDF desde base64
+ * @param {string} base64Data - PDF en base64 o data URI
+ * @param {string} printerName - Nombre de la impresora (opcional)
+ * @returns {Promise}
+ */
+ async printPDF(base64Data, printerName = null) {
+ try {
+ await this.connect();
+
+ const printer = printerName || this.getSavedPrinter() || await this.getDefaultPrinter();
+
+ const config = this.qz.configs.create(printer, {
+ scaleContent: false,
+ rasterize: true
+ });
+
+ // Asegurar que tenemos el data URI completo
+ let pdfDataUri = base64Data;
+ if (!pdfDataUri.startsWith('data:')) {
+ pdfDataUri = `data:application/pdf;base64,${base64Data}`;
+ }
+
+ const data = [
+ {
+ type: 'pixel',
+ format: 'pdf',
+ flavor: 'base64',
+ data: pdfDataUri
+ }
+ ];
+
+ await this.qz.print(config, data);
+ console.log('Impresión enviada exitosamente');
+ return true;
+ } catch (error) {
+ console.error('Error imprimiendo PDF:', error);
+
+ // Si falla con PDF, mostrar error descriptivo
+ if (error.message?.includes('parse')) {
+ throw new Error('La impresora no puede procesar PDFs. Verifica la configuración de la impresora.');
+ }
+
+ throw error;
+ }
+ }
+
+ /**
+ * Imprimir usando comandos ESC/POS (para impresoras térmicas)
+ * @param {Array} commands - Array de comandos ESC/POS
+ * @param {string} printerName - Nombre de la impresora (opcional)
+ * @returns {Promise}
+ */
+ async printRaw(commands, printerName = null) {
+ try {
+ await this.connect();
+
+ const printer = printerName || this.getSavedPrinter() || await this.getDefaultPrinter();
+
+ const config = this.qz.configs.create(printer);
+
+ const data = [
+ {
+ type: 'raw',
+ format: 'command',
+ data: commands
+ }
+ ];
+
+ await this.qz.print(config, data);
+ console.log('Impresión enviada exitosamente');
+ return true;
+ } catch (error) {
+ console.error('Error imprimiendo comandos raw:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Imprimir HTML (convertido a imagen)
+ * @param {string} html - HTML a imprimir
+ * @param {string} printerName - Nombre de la impresora (opcional)
+ * @returns {Promise}
+ */
+ async printHTML(html, printerName = null) {
+ try {
+ await this.connect();
+
+ const printer = printerName || this.getSavedPrinter() || await this.getDefaultPrinter();
+
+ const config = this.qz.configs.create(printer);
+
+ const data = [
+ {
+ type: 'pixel',
+ format: 'html',
+ flavor: 'plain',
+ data: html
+ }
+ ];
+
+ await this.qz.print(config, data);
+ console.log('Impresión enviada exitosamente');
+ return true;
+ } catch (error) {
+ console.error('Error imprimiendo HTML:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Verificar si QZ Tray está disponible y conectado
+ * @returns {boolean}
+ */
+ isAvailable() {
+ return this.qz && this.qz.websocket.isActive();
+ }
+
+ /**
+ * Imprimir PDF usando window.print() (fallback)
+ * Abre el PDF en una nueva ventana e invoca el diálogo de impresión
+ * @param {string} pdfDataUri - PDF como data URI
+ * @returns {Promise}
+ */
+ async printPDFWithDialog(pdfDataUri) {
+ return new Promise((resolve, reject) => {
+ try {
+ // Asegurar que tenemos el data URI completo
+ let dataUri = pdfDataUri;
+ if (!dataUri.startsWith('data:')) {
+ dataUri = `data:application/pdf;base64,${pdfDataUri}`;
+ }
+
+ // Convertir data URI a Blob
+ const byteString = atob(dataUri.split(',')[1]);
+ const mimeString = dataUri.split(',')[0].split(':')[1].split(';')[0];
+ const ab = new ArrayBuffer(byteString.length);
+ const ia = new Uint8Array(ab);
+
+ for (let i = 0; i < byteString.length; i++) {
+ ia[i] = byteString.charCodeAt(i);
+ }
+
+ const blob = new Blob([ab], { type: mimeString });
+ const blobUrl = URL.createObjectURL(blob);
+
+ // Abrir ventana nueva con el PDF
+ const printWindow = window.open(blobUrl, '_blank', 'width=800,height=600');
+
+ if (!printWindow) {
+ URL.revokeObjectURL(blobUrl);
+ reject(new Error('No se pudo abrir la ventana de impresión. Verifica que los popups estén permitidos.'));
+ return;
+ }
+
+ // Esperar a que cargue y luego imprimir
+ printWindow.onload = () => {
+ setTimeout(() => {
+ try {
+ printWindow.print();
+
+ // Cerrar ventana después de imprimir (opcional)
+ setTimeout(() => {
+ printWindow.close();
+ URL.revokeObjectURL(blobUrl);
+ resolve();
+ }, 500);
+ } catch (error) {
+ printWindow.close();
+ URL.revokeObjectURL(blobUrl);
+ reject(error);
+ }
+ }, 250);
+ };
+
+ printWindow.onerror = () => {
+ printWindow.close();
+ URL.revokeObjectURL(blobUrl);
+ reject(new Error('Error cargando el PDF en la ventana'));
+ };
+ } catch (error) {
+ reject(error);
+ }
+ });
+ }
+}
+
+// Exportar instancia singleton
+const printService = new PrintService();
+
+export default printService;
diff --git a/src/services/ticketService.js b/src/services/ticketService.js
index 910512c..ace083c 100644
--- a/src/services/ticketService.js
+++ b/src/services/ticketService.js
@@ -1,6 +1,7 @@
import jsPDF from 'jspdf';
import QRCode from 'qrcode';
import { formatMoney, PAYMENT_METHODS } from '@/utils/formatters';
+import printService from '@Services/printService';
/**
* Servicio para generar tickets de venta en formato PDF
@@ -22,11 +23,16 @@ const ticketService = {
* Generar ticket de venta
* @param {Object} saleData - Datos de la venta
* @param {Object} options - Opciones de configuración
+ * @param {boolean} options.autoDownload - Descargar automáticamente el PDF
+ * @param {boolean} options.autoPrint - Imprimir automáticamente con QZ Tray
+ * @param {string} options.printerName - Nombre de la impresora (opcional)
*/
async generateSaleTicket(saleData, options = {}) {
const {
businessName = 'HIKVISION DISTRIBUIDOR',
- autoDownload = true
+ autoDownload = true,
+ autoPrint = false,
+ printerName = null
} = options;
// Detectar ubicación del usuario
@@ -356,10 +362,34 @@ const ticketService = {
if (autoDownload) {
const fileName = `ticket-${folio}.pdf`;
doc.save(fileName);
- return doc;
- } else {
- return doc;
}
+
+ // Imprimir automáticamente si se solicita
+ if (autoPrint) {
+ try {
+ // Convertir PDF a base64 data URI
+ const pdfBase64 = doc.output('datauristring');
+
+ // Intentar imprimir con QZ Tray
+ try {
+ await printService.printPDF(pdfBase64, printerName);
+ console.log('Ticket enviado a la impresora con QZ Tray');
+ } catch (qzError) {
+ console.warn('QZ Tray falló, usando diálogo de impresión:', qzError.message);
+
+ // Fallback: usar window.print()
+ await printService.printPDFWithDialog(pdfBase64);
+ window.Notify.info('Se abrió el diálogo de impresión');
+ }
+ } catch (error) {
+ console.error('Error imprimiendo ticket:', error);
+
+ // Mostrar error al usuario
+ window.Notify.warning('No se pudo imprimir automáticamente. Usa el PDF descargado.');
+ }
+ }
+
+ return doc;
},
/**