feat: agregar servicio de cotización y componente de formulario de Google para feedback
This commit is contained in:
parent
68a3da8e3f
commit
581ce37449
125
src/components/POS/GoogleForm.vue
Normal file
125
src/components/POS/GoogleForm.vue
Normal file
@ -0,0 +1,125 @@
|
||||
<script setup>
|
||||
import Modal from '@Holos/Modal.vue';
|
||||
|
||||
/** Props */
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
formUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '¡Tu opinión nos importa!'
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: 'Cuéntanos cómo fue tu experiencia. Tu feedback nos ayuda a mejorar y solo te tomará <strong class="underline">menos de un minuto</strong>.'
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
default: 'Ir a la encuesta'
|
||||
},
|
||||
laterText: {
|
||||
type: String,
|
||||
default: 'Tal vez más tarde'
|
||||
},
|
||||
footerText: {
|
||||
type: String,
|
||||
default: 'MEJORA CONTINUA · EXPERIENCIA DEL CLIENTE'
|
||||
},
|
||||
openInNewTab: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
/** Emits */
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
/** Methods */
|
||||
const close = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const goToForm = () => {
|
||||
if (!props.formUrl) {
|
||||
window.Notify?.warning('No se encontro la URL del formulario.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.openInNewTab) {
|
||||
window.open(props.formUrl, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
window.location.href = props.formUrl;
|
||||
}
|
||||
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :show="show" max-width="sm" @close="close">
|
||||
<div class="relative bg-white dark:bg-gray-900 rounded-2xl p-8 flex flex-col items-center text-center">
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
@click="close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Star icon -->
|
||||
<div class="mb-5 flex items-center justify-center w-16 h-16 rounded-2xl bg-indigo-50 dark:bg-indigo-900/30">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
{{ title }}
|
||||
</h2>
|
||||
|
||||
<!-- Message -->
|
||||
<p
|
||||
class="text-sm text-indigo-500 dark:text-indigo-400 mb-7 leading-relaxed max-w-xs"
|
||||
v-html="message"
|
||||
/>
|
||||
|
||||
<!-- Primary button -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center justify-center gap-2 bg-indigo-500 hover:bg-indigo-600 active:bg-indigo-700 text-white font-semibold text-sm py-3 px-6 rounded-full transition-colors mb-4"
|
||||
@click="goToForm"
|
||||
>
|
||||
{{ buttonText }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Later link -->
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors mb-6"
|
||||
@click="close"
|
||||
>
|
||||
{{ laterText }}
|
||||
</button>
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="text-[10px] tracking-widest uppercase text-gray-400 dark:text-gray-500 font-medium">
|
||||
{{ footerText }}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@ -11,6 +11,7 @@ import Input from '@Holos/Form/Input.vue';
|
||||
import PrimaryButton from '@Holos/Button/Primary.vue';
|
||||
import SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
|
||||
import SelectRegimenFiscal from '@Components/POS/RegimenSelector.vue';
|
||||
import GoogleForm from '@Components/POS/GoogleForm.vue';
|
||||
|
||||
/** Definidores */
|
||||
const route = useRoute();
|
||||
@ -27,6 +28,7 @@ const formErrors = ref({});
|
||||
const searchingRfc = ref(false);
|
||||
const rfcSearch = ref('');
|
||||
const rfcSearchError = ref('');
|
||||
const showGoogleForm = ref(true);
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
@ -73,6 +75,8 @@ const canRequestInvoice = computed(() => {
|
||||
/** Helpers */
|
||||
const getRegimenFiscalLabel = (value) => regimenFiscalOptions.find(o => o.value === value)?.label ?? value;
|
||||
const getUsoCfdiLabel = (value) => usoCfdiOptions.find(o => o.value === value)?.label ?? value;
|
||||
const statusLabels = { pending: 'Pendiente', processed: 'Completada', rejected: 'Rechazada' };
|
||||
const getStatusLabel = (status) => statusLabels[status] ?? status;
|
||||
/** Métodos */
|
||||
const fetchSaleData = () => {
|
||||
loading.value = true;
|
||||
@ -186,6 +190,7 @@ const submitForm = () => {
|
||||
window.axios.post(apiURL(`facturacion/${invoiceNumber.value}`), form.value)
|
||||
.then(() => {
|
||||
submitted.value = true;
|
||||
showGoogleForm.value = true;
|
||||
})
|
||||
.catch(({ response }) => {
|
||||
if (response?.status === 422 && response.data?.errors) {
|
||||
@ -247,7 +252,7 @@ onMounted(() => {
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<span class="font-medium">Estado:</span>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-semibold">
|
||||
{{ existingInvoiceRequest.status }}
|
||||
{{ getStatusLabel(existingInvoiceRequest.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
@ -371,7 +376,7 @@ onMounted(() => {
|
||||
Solicitud #{{ request.id }}
|
||||
</span>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200">
|
||||
{{ request.status }}
|
||||
{{ getStatusLabel(request.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs space-y-1 text-gray-700 dark:text-gray-300">
|
||||
@ -658,8 +663,17 @@ onMounted(() => {
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>¿Tiene dudas? Contáctenos</p>
|
||||
<p>¿Tiene dudas? Visitanos para poder ayudarte.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GoogleForm
|
||||
:show="showGoogleForm"
|
||||
form-url="https://docs.google.com/forms/d/e/1FAIpQLSdhHLJ3SNbuIza9TZ6rQNAdI3sibmMjRJ8Udghiz08DnQvuSg/viewform?usp=publish-editor"
|
||||
title="¡Queremos escucharte!"
|
||||
message="Cuéntanos cómo fue tu experiencia. Tu feedback nos ayuda a mejorar y solo te tomará <strong class='underline'>menos de un minuto</strong>."
|
||||
button-text="Ir a la encuesta"
|
||||
:open-in-new-tab="true"
|
||||
@close="showGoogleForm = false"
|
||||
/>
|
||||
</template>
|
||||
@ -8,6 +8,7 @@ import { useBarcodeScanner } from '@/composables/useBarcodeScanner';
|
||||
import useCart from '@Stores/cart';
|
||||
import salesService from '@Services/salesService';
|
||||
import ticketService from '@Services/ticketService';
|
||||
import quoteService from '@Services/quoteService';
|
||||
import serialService from '@Services/serialService';
|
||||
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
@ -291,6 +292,25 @@ const handleBundleSerialConfirm = (serialConfig) => {
|
||||
closeBundleSerialSelector();
|
||||
};
|
||||
|
||||
const generateQuote = async () => {
|
||||
if (cart.isEmpty) {
|
||||
window.Notify.error('El carrito está vacío');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.Notify.info('Generando cotización...');
|
||||
await quoteService.generate(
|
||||
cart.items,
|
||||
{ subtotal: cart.subtotal, tax: cart.tax, total: cart.total },
|
||||
{ autoDownload: true }
|
||||
);
|
||||
window.Notify.success('Cotización generada correctamente');
|
||||
} catch (e) {
|
||||
console.error('Error generando cotización:', e);
|
||||
window.Notify.error('Error al generar la cotización');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCart = () => {
|
||||
if (confirm(t('cart.clearConfirm'))) {
|
||||
cart.clear();
|
||||
@ -913,6 +933,16 @@ watch(activeTab, (newTab) => {
|
||||
{{ $t('cart.checkout') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 disabled:text-gray-400 disabled:cursor-not-allowed text-sm font-semibold rounded-lg transition-colors"
|
||||
:disabled="cart.isEmpty"
|
||||
@click="generateQuote"
|
||||
>
|
||||
<GoogleIcon name="request_quote" class="text-lg" />
|
||||
Cotizar
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:text-gray-400 disabled:cursor-not-allowed text-sm font-semibold rounded-lg transition-colors"
|
||||
|
||||
216
src/services/quoteService.js
Normal file
216
src/services/quoteService.js
Normal file
@ -0,0 +1,216 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import { formatMoney } from '@/utils/formatters';
|
||||
|
||||
/**
|
||||
* Servicio para generar cotizaciones en formato PDF desde el carrito
|
||||
*/
|
||||
const quoteService = {
|
||||
|
||||
/**
|
||||
* Obtener información del negocio desde variables de entorno
|
||||
*/
|
||||
getBusinessInfo() {
|
||||
return {
|
||||
name: import.meta.env.VITE_APP_NAME || 'GOLSCONTROL',
|
||||
city: import.meta.env.VITE_BUSINESS_CITY || 'Villahermosa',
|
||||
state: import.meta.env.VITE_BUSINESS_STATE || 'Tabasco',
|
||||
country: import.meta.env.VITE_BUSINESS_COUNTRY || 'México',
|
||||
phone: import.meta.env.VITE_BUSINESS_PHONE || 'Tel: (52) 0000-0000'
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Genera un folio de cotización único basado en fecha y hora
|
||||
*/
|
||||
generateFolio() {
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `COT-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Genera el PDF de cotización a partir de los items del carrito
|
||||
*
|
||||
* @param {Array} cartItems - Items del carrito (cart.items)
|
||||
* @param {Object} totals - { subtotal, tax, total }
|
||||
* @param {Object} options
|
||||
* @param {string} options.businessName - Nombre del negocio (fallback a env)
|
||||
* @param {number} options.validDays - Días de vigencia (default 30)
|
||||
* @param {boolean} options.autoDownload - Descargar PDF automáticamente (default true)
|
||||
*/
|
||||
async generate(cartItems, totals, options = {}) {
|
||||
const business = this.getBusinessInfo();
|
||||
const {
|
||||
businessName = business.name,
|
||||
validDays = 30,
|
||||
autoDownload = true
|
||||
} = options;
|
||||
|
||||
const folio = this.generateFolio();
|
||||
const now = new Date();
|
||||
const expiry = new Date(now);
|
||||
expiry.setDate(expiry.getDate() + validDays);
|
||||
|
||||
const doc = new jsPDF({
|
||||
orientation: 'portrait',
|
||||
unit: 'mm',
|
||||
format: [80, 297]
|
||||
});
|
||||
|
||||
let y = 10;
|
||||
const left = 5;
|
||||
const right = 75;
|
||||
const center = 40;
|
||||
|
||||
const BLACK = [0, 0, 0];
|
||||
const DARK_GRAY = [80, 80, 80];
|
||||
const LIGHT_GRAY = [140, 140, 140];
|
||||
|
||||
const line = (yPos, width = 0.3) => {
|
||||
doc.setLineWidth(width);
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.line(left, yPos, right, yPos);
|
||||
};
|
||||
|
||||
// ===== ENCABEZADO =====
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.text(businessName, center, y, { align: 'center' });
|
||||
y += 6;
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...DARK_GRAY);
|
||||
doc.text(`${business.city}, ${business.state}, ${business.country}`, center, y, { align: 'center' });
|
||||
y += 4;
|
||||
doc.text(business.phone, center, y, { align: 'center' });
|
||||
y += 6;
|
||||
|
||||
line(y);
|
||||
y += 5;
|
||||
|
||||
// ===== TÍTULO =====
|
||||
doc.setFontSize(13);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.text('COTIZACIÓN', center, y, { align: 'center' });
|
||||
y += 7;
|
||||
|
||||
// ===== FOLIO Y FECHAS =====
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...DARK_GRAY);
|
||||
|
||||
doc.text(`Folio: ${folio}`, left, y);
|
||||
y += 4;
|
||||
|
||||
const fmtDate = (d) => d.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
doc.text(`Fecha: ${fmtDate(now)}`, left, y);
|
||||
y += 4;
|
||||
doc.text(`Vigencia: ${fmtDate(expiry)}`, left, y);
|
||||
y += 6;
|
||||
|
||||
line(y);
|
||||
y += 5;
|
||||
|
||||
// ===== CABECERA DE TABLA =====
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.text('ARTÍCULOS', center, y, { align: 'center' });
|
||||
y += 5;
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...DARK_GRAY);
|
||||
doc.text('DESCRIPCIÓN', left, y);
|
||||
doc.text('CANT', 52, y, { align: 'center' });
|
||||
doc.text('P.UNIT', right, y, { align: 'right' });
|
||||
y += 4;
|
||||
|
||||
line(y, 0.2);
|
||||
y += 4;
|
||||
|
||||
// ===== ITEMS =====
|
||||
cartItems.forEach((item) => {
|
||||
const name = item.product_name || item.name || 'Producto';
|
||||
const nameLines = doc.splitTextToSize(name, 44);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.text(nameLines, left, y);
|
||||
y += nameLines.length * 3.5;
|
||||
|
||||
if (item.sku) {
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...DARK_GRAY);
|
||||
doc.text(`SKU: ${item.sku}`, left, y);
|
||||
y += 3;
|
||||
}
|
||||
|
||||
const qty = item.quantity || 1;
|
||||
const unitPrice = item.unit_price || 0;
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.text(String(qty), 52, y, { align: 'center' });
|
||||
doc.text(`$${formatMoney(unitPrice)}`, right, y, { align: 'right' });
|
||||
y += 6;
|
||||
});
|
||||
|
||||
// ===== TOTALES =====
|
||||
line(y);
|
||||
y += 5;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...DARK_GRAY);
|
||||
|
||||
doc.text('Subtotal:', left, y);
|
||||
doc.text(`$${formatMoney(totals.subtotal)}`, right, y, { align: 'right' });
|
||||
y += 5;
|
||||
|
||||
doc.text('IVA (16%):', left, y);
|
||||
doc.text(`$${formatMoney(totals.tax)}`, right, y, { align: 'right' });
|
||||
y += 5;
|
||||
|
||||
line(y, 0.4);
|
||||
y += 5;
|
||||
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.text('TOTAL:', left, y);
|
||||
doc.text(`$${formatMoney(totals.total)}`, right, y, { align: 'right' });
|
||||
y += 10;
|
||||
|
||||
// ===== FOOTER =====
|
||||
line(y, 0.2);
|
||||
y += 5;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.text('¡Gracias por su preferencia!', center, y, { align: 'center' });
|
||||
y += 5;
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...LIGHT_GRAY);
|
||||
doc.text('Esta cotización no constituye una venta.', center, y, { align: 'center' });
|
||||
y += 3;
|
||||
doc.text(`Precios válidos por ${validDays} días a partir de la fecha de emisión.`, center, y, { align: 'center' });
|
||||
|
||||
if (autoDownload) {
|
||||
doc.save(`${folio}.pdf`);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
};
|
||||
|
||||
export default quoteService;
|
||||
Loading…
x
Reference in New Issue
Block a user