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 PrimaryButton from '@Holos/Button/Primary.vue';
|
||||||
import SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
|
import SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
|
||||||
import SelectRegimenFiscal from '@Components/POS/RegimenSelector.vue';
|
import SelectRegimenFiscal from '@Components/POS/RegimenSelector.vue';
|
||||||
|
import GoogleForm from '@Components/POS/GoogleForm.vue';
|
||||||
|
|
||||||
/** Definidores */
|
/** Definidores */
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -27,6 +28,7 @@ const formErrors = ref({});
|
|||||||
const searchingRfc = ref(false);
|
const searchingRfc = ref(false);
|
||||||
const rfcSearch = ref('');
|
const rfcSearch = ref('');
|
||||||
const rfcSearchError = ref('');
|
const rfcSearchError = ref('');
|
||||||
|
const showGoogleForm = ref(true);
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
name: '',
|
name: '',
|
||||||
@ -73,6 +75,8 @@ const canRequestInvoice = computed(() => {
|
|||||||
/** Helpers */
|
/** Helpers */
|
||||||
const getRegimenFiscalLabel = (value) => regimenFiscalOptions.find(o => o.value === value)?.label ?? value;
|
const getRegimenFiscalLabel = (value) => regimenFiscalOptions.find(o => o.value === value)?.label ?? value;
|
||||||
const getUsoCfdiLabel = (value) => usoCfdiOptions.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 */
|
/** Métodos */
|
||||||
const fetchSaleData = () => {
|
const fetchSaleData = () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@ -186,6 +190,7 @@ const submitForm = () => {
|
|||||||
window.axios.post(apiURL(`facturacion/${invoiceNumber.value}`), form.value)
|
window.axios.post(apiURL(`facturacion/${invoiceNumber.value}`), form.value)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
submitted.value = true;
|
submitted.value = true;
|
||||||
|
showGoogleForm.value = true;
|
||||||
})
|
})
|
||||||
.catch(({ response }) => {
|
.catch(({ response }) => {
|
||||||
if (response?.status === 422 && response.data?.errors) {
|
if (response?.status === 422 && response.data?.errors) {
|
||||||
@ -247,7 +252,7 @@ onMounted(() => {
|
|||||||
<div class="flex items-center justify-center gap-2">
|
<div class="flex items-center justify-center gap-2">
|
||||||
<span class="font-medium">Estado:</span>
|
<span class="font-medium">Estado:</span>
|
||||||
<span class="px-3 py-1 rounded-full text-xs font-semibold">
|
<span class="px-3 py-1 rounded-full text-xs font-semibold">
|
||||||
{{ existingInvoiceRequest.status }}
|
{{ getStatusLabel(existingInvoiceRequest.status) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
@ -371,7 +376,7 @@ onMounted(() => {
|
|||||||
Solicitud #{{ request.id }}
|
Solicitud #{{ request.id }}
|
||||||
</span>
|
</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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs space-y-1 text-gray-700 dark:text-gray-300">
|
<div class="text-xs space-y-1 text-gray-700 dark:text-gray-300">
|
||||||
@ -658,8 +663,17 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
<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>
|
</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>
|
</template>
|
||||||
@ -8,6 +8,7 @@ import { useBarcodeScanner } from '@/composables/useBarcodeScanner';
|
|||||||
import useCart from '@Stores/cart';
|
import useCart from '@Stores/cart';
|
||||||
import salesService from '@Services/salesService';
|
import salesService from '@Services/salesService';
|
||||||
import ticketService from '@Services/ticketService';
|
import ticketService from '@Services/ticketService';
|
||||||
|
import quoteService from '@Services/quoteService';
|
||||||
import serialService from '@Services/serialService';
|
import serialService from '@Services/serialService';
|
||||||
|
|
||||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
@ -291,6 +292,25 @@ const handleBundleSerialConfirm = (serialConfig) => {
|
|||||||
closeBundleSerialSelector();
|
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 = () => {
|
const handleClearCart = () => {
|
||||||
if (confirm(t('cart.clearConfirm'))) {
|
if (confirm(t('cart.clearConfirm'))) {
|
||||||
cart.clear();
|
cart.clear();
|
||||||
@ -913,6 +933,16 @@ watch(activeTab, (newTab) => {
|
|||||||
{{ $t('cart.checkout') }}
|
{{ $t('cart.checkout') }}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="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"
|
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