ADD: Factura pdf

This commit is contained in:
Juan Felipe Zapata Moreno 2025-10-09 13:27:54 -06:00
parent f277c3677a
commit 47764891d2
17 changed files with 1806 additions and 800 deletions

View File

@ -0,0 +1,44 @@
<script setup>
const props = defineProps({
template: {
type: Object,
required: true,
},
data: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="mb-3">
<div
class="text-white font-semibold px-2 py-0.5"
:style="{ backgroundColor: template.primaryColor }"
>
DATOS FISCALES CLIENTE
</div>
<div class="border border-gray-300 p-1.5 grid grid-cols-2 gap-x-4">
<div>
<span class="font-semibold">Nombre:</span>
{{ data.clienteNombre }}
</div>
<div>
<span class="font-semibold">Domicilio:</span>
{{ data.clienteDomicilio }}
</div>
<div>
<span class="font-semibold">RFC:</span> {{ data.clienteRFC }}
</div>
<div v-if="template.documentType === 'COTIZACION'">
<span class="font-semibold">Telefono:</span>
{{ data.clienteTelefono }}
</div>
<div v-if="template.documentType === 'FACTURA'">
<span class="font-semibold">Régimen Fiscal:</span> {{ data.clienteRegimen }}
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,57 @@
<script setup>
const props = defineProps({
template: {
type: Object,
required: true,
},
data: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="mb-3">
<!-- Cotización -->
<div
v-if="template.documentType === 'COTIZACION'"
class="grid grid-cols-2 gap-1 mb-2"
>
<!-- Datos Fiscales -->
<div class="border border-gray-300 p-1.5">
<p class="font-semibold">{{ data.empresaNombre }}</p>
<p class="font-semibold">{{ data.empresaWeb }}</p>
<p>{{ data.empresaEmail }}</p>
<p>{{ data.empresaTelefono }}</p>
<p>RFC: {{ data.empresaRFC }}</p>
<p>{{ data.empresaDireccion }}</p>
</div>
<!-- Datos Bancarios -->
<div class="border border-gray-300 p-1.5">
<p class="font-semibold">{{ data.bancoBanco }}</p>
<p>{{ data.bancoTipoCuenta }}</p>
<p>Cuenta: {{ data.bancoCuenta }}</p>
</div>
</div>
<!-- Factura -->
<div
v-else-if="template.documentType === 'FACTURA'"
>
<div class="border border-gray-300 p-1.5 grid grid-cols-2 gap-4">
<div>
<p class="font-semibold">{{ data.empresaNombre }}</p>
<p>{{ data.empresaWeb }}</p>
<p>{{ data.empresaDireccion }}</p>
</div>
<div>
<p>RFC: {{ data.empresaRFC }}</p>
<p>Lugar de Expedición: {{ data.empresaLugar }}</p>
<p>Régimen Fiscal: {{ data.empresaRegimen }}</p>
<p>Uso CFDI: {{ data.empresaCfdi }}</p>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,320 @@
<script setup>
import { ref, computed, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'update:totals']);
const productos = ref([...props.modelValue]);
const unidades = [
{ codigo: 'H87', nombre: 'Pieza' },
{ codigo: 'E48', nombre: 'Unidad de servicio' },
{ codigo: 'ACT', nombre: 'Actividad' },
{ codigo: 'KGM', nombre: 'Kilogramo' },
{ codigo: 'LTR', nombre: 'Litro' },
{ codigo: 'MTR', nombre: 'Metro' },
{ codigo: 'SET', nombre: 'Conjunto' }
];
// Agregar producto vacío
const agregarProducto = () => {
const nuevoLote = productos.value.length + 1;
productos.value.push({
lote: nuevoLote,
cantidad: 1,
unidad: 'H87',
codigo: '',
descripcion: '',
precioUnitario: 0,
descuento: 0,
tasaIVA: 0.16,
});
actualizarProductos();
};
// Eliminar producto
const eliminarProducto = (index) => {
productos.value.splice(index, 1);
// Reordenar lotes
productos.value.forEach((producto, i) => {
producto.lote = i + 1;
});
actualizarProductos();
};
// Calcular importe de un producto
const calcularImporte = (producto) => {
return producto.cantidad * producto.precioUnitario;
};
/**
* Calcular subtotal (Importe - Descuento)
*/
const calcularSubtotal = (producto) => {
return calcularImporte(producto) - (producto.descuento || 0);
};
/**
* Calcular impuesto de la partida
* Solo si objetoImpuesto === '02' ( objeto de impuesto)
*/
const calcularImpuesto = (producto) => {
if (producto.objetoImpuesto !== '02') return 0;
const subtotal = calcularSubtotal(producto);
return subtotal * (producto.tasaIVA || 0);
};
/**
* Total de la partida (Subtotal + Impuesto)
*/
const calcularTotalPartida = (producto) => {
return calcularSubtotal(producto) + calcularImpuesto(producto);
};
// Cálculos generales
const calculos = computed(() => {
const subtotal1 = productos.value.reduce((sum, p) => sum + calcularImporte(p), 0);
const descuentoTotal = productos.value.reduce((sum, p) => sum + (p.descuento || 0), 0);
const subtotal2 = subtotal1 - descuentoTotal;
const iva = productos.value.reduce((sum, p) => sum + calcularImpuesto(p), 0);
const total = subtotal2 + iva;
return {
subtotal1,
descuentoTotal,
subtotal2,
iva,
total
};
});
// Actualizar productos y emitir cambios
const actualizarProductos = () => {
emit('update:modelValue', productos.value);
emit('update:totals', calculos.value);
};
// Watch para actualizar cuando cambian los productos
watch(productos, () => {
actualizarProductos();
}, { deep: true });
// Watch para sincronizar con el v-model externo
watch(
() => props.modelValue,
(newVal) => {
if (JSON.stringify(newVal) !== JSON.stringify(productos.value)) {
productos.value = [...newVal];
}
},
{ deep: true }
);
// Formatear moneda
const formatCurrency = (value) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(value || 0);
};
</script>
<template>
<div class="bg-white rounded-lg border border-gray-200 p-4 dark:bg-primary-d dark:border-primary/20">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900 dark:text-primary-dt">
Productos / Servicios
</h3>
<button
@click="agregarProducto"
type="button"
class="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
>
<GoogleIcon name="add" class="text-lg" />
Agregar Producto
</button>
</div>
<!-- Productos -->
<div v-if="productos.length > 0" class="space-y-3">
<div v-for="(producto, index) in productos" :key="producto.lote" class="border border-gray-200 rounded-lg p-4 dark:border-primary/20">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="bg-blue-100 text-blue-700 font-bold px-3 py-1 rounded text-sm dark:bg-blue-900/30 dark:text-blue-300">
#{{ producto.lote }}
</span>
</div>
<button
@click="eliminarProducto(index)"
type="button"
class="text-red-600 hover:text-red-800 transition-colors"
>
<GoogleIcon name="delete" class="text-xl" />
</button>
</div>
<!-- CANTIDAD -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Cantidad <span class="text-red-500">*</span>
</label>
<input
v-model.number="producto.cantidad"
type="number"
min="0"
step="1.00"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
>
</div>
<!-- UNIDAD -->
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Unidad <span class="text-red-500">*</span>
</label>
<select
v-model="producto.unidad"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
>
<option v-for="unidad in unidades" :key="unidad.codigo" :value="unidad.codigo">
{{ unidad.nombre }}
</option>
</select>
</div>
<!-- CÓDIGO INTERNO -->
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Código Interno
</label>
<input
v-model="producto.codigo"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
placeholder="SKU-001"
/>
</div>
</div>
<!-- DESCRIPCIÓN -->
<div class="mt-4">
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Descripción del Concepto <span class="text-red-500">*</span>
</label>
<textarea
v-model="producto.descripcion"
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500 resize-y"
placeholder="Descripción detallada del producto o servicio..."
></textarea>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mt-4">
<!-- PRECIO UNITARIO -->
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Precio Unit. <span class="text-red-500">*</span>
</label>
<input
v-model.number="producto.precioUnitario"
type="number"
min="0"
step="0.01"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-right dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
placeholder="0.00"
/>
</div>
<!-- IMPORTE -->
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Importe
</label>
<div class="px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm text-right font-medium dark:bg-primary/5 dark:border-primary/20 dark:text-primary-dt">
{{ formatCurrency(calcularImporte(producto)) }}
</div>
</div>
<!-- DESCUENTO -->
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Descuento
</label>
<input
v-model.number="producto.descuento"
type="number"
min="0"
step="0.01"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-right dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
placeholder="0.00"
/>
</div>
<!-- SUBTOTAL -->
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Subtotal
</label>
<div class="px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm text-right font-semibold dark:bg-primary/5 dark:border-primary/20 dark:text-primary-dt">
{{ formatCurrency(calcularSubtotal(producto)) }}
</div>
</div>
<!-- TASA IVA -->
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Tasa IVA
</label>
<select
v-model.number="producto.tasaIVA"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
>
<option :value="0.16">16%</option>
</select>
</div>
<!-- TOTAL PARTIDA -->
<div>
<label class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt">
Total Partida
</label>
<div class="px-3 py-2 bg-blue-50 border-2 border-blue-200 rounded-lg text-sm text-right font-bold text-blue-700 dark:bg-blue-900/20 dark:border-blue-700 dark:text-blue-400">
{{ formatCurrency(calcularTotalPartida(producto)) }}
</div>
</div>
</div>
</div>
</div>
<!-- Resumen de totales -->
<div v-if="productos.length > 0" class="mt-4 flex justify-end">
<div class="w-64 space-y-2 border-t border-gray-200 dark:border-primary/20 pt-3">
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-primary-dt/70">Subtotal 1:</span>
<span class="font-semibold dark:text-primary-dt">{{ formatCurrency(calculos.subtotal1) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-primary-dt/70">Descuento:</span>
<span class="font-semibold dark:text-primary-dt">{{ formatCurrency(calculos.descuentoTotal) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-primary-dt/70">Subtotal 2:</span>
<span class="font-semibold dark:text-primary-dt">{{ formatCurrency(calculos.subtotal2) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-primary-dt/70">IVA (16%):</span>
<span class="font-semibold dark:text-primary-dt">{{ formatCurrency(calculos.iva) }}</span>
</div>
<div class="flex justify-between text-base border-t border-gray-200 dark:border-primary/20 pt-2">
<span class="font-bold text-gray-900 dark:text-primary-dt">Total:</span>
<span class="font-bold text-blue-600 dark:text-blue-400">{{ formatCurrency(calculos.total) }}</span>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,37 @@
<script setup>
const props = defineProps({
template: {
type: Object,
required: true,
},
data: {
type: Object,
required: true,
},
});
</script>
<template>
<div>
<div
class="text-white font-semibold px-2 py-0.5"
:style="{ backgroundColor: template.primaryColor }"
>
DATOS DEL EJECUTIVO DE CUENTAS
</div>
<div class="border border-gray-300 p-1.5">
<p>
<span class="font-semibold">NOMBRE:</span>
{{ data.ejecutivoNombre }}
</p>
<p>
<span class="font-semibold">CORREO:</span>
{{ data.ejecutivoCorreo }}
</p>
<p>
<span class="font-semibold">CELULAR:</span>
{{ data.ejecutivoCelular }}
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,469 @@
<script setup>
import { ref, computed, watch } from "vue";
import GoogleIcon from "@Shared/GoogleIcon.vue";
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["update:modelValue", "update:totals"]);
const productos = ref([...props.modelValue]);
// CATÁLOGOS SAT COMUNES
const unidadesSAT = [
{ codigo: "H87", nombre: "Pieza" },
{ codigo: "E48", nombre: "Unidad de servicio" },
{ codigo: "ACT", nombre: "Actividad" },
{ codigo: "KGM", nombre: "Kilogramo" },
{ codigo: "LTR", nombre: "Litro" },
{ codigo: "MTR", nombre: "Metro" },
{ codigo: "SET", nombre: "Conjunto" },
];
const objetosImpuesto = [
{ codigo: "01", nombre: "No objeto de impuesto" },
{ codigo: "02", nombre: "Sí objeto de impuesto" },
{ codigo: "03", nombre: "Sí objeto, no obligado" },
{ codigo: "04", nombre: "Sí objeto, tasa 0%" },
];
/**
* Agregar nuevo producto con estructura CFDI
*/
const agregarProducto = () => {
const nuevoLote = productos.value.length + 1;
productos.value.push({
// Datos básicos
lote: nuevoLote,
cantidad: 1,
// Catálogos SAT
unidadSAT: "H87", // Código de unidad SAT
claveProdServ: "", // Clave producto/servicio SAT (8 dígitos)
// Descripción
codigo: "", // Código interno del producto
descripcion: "",
// Precios
precioUnitario: 0,
descuento: 0,
// Impuestos
objetoImpuesto: "02", // Sí objeto de impuesto
tasaIVA: 0.16, // 16%
});
actualizarProductos();
};
/**
* Eliminar producto y reordenar lotes
*/
const eliminarProducto = (index) => {
productos.value.splice(index, 1);
// Reordenar lotes
productos.value.forEach((producto, i) => {
producto.lote = i + 1;
});
actualizarProductos();
};
/**
* Calcular importe bruto (Cantidad × Precio Unitario)
*/
const calcularImporte = (producto) => {
return producto.cantidad * producto.precioUnitario;
};
/**
* Calcular subtotal (Importe - Descuento)
*/
const calcularSubtotal = (producto) => {
return calcularImporte(producto) - (producto.descuento || 0);
};
/**
* Calcular impuesto de la partida
* Solo si objetoImpuesto === '02' ( objeto de impuesto)
*/
const calcularImpuesto = (producto) => {
if (producto.objetoImpuesto !== "02") return 0;
const subtotal = calcularSubtotal(producto);
return subtotal * (producto.tasaIVA || 0);
};
/**
* Total de la partida (Subtotal + Impuesto)
*/
const calcularTotalPartida = (producto) => {
return calcularSubtotal(producto) + calcularImpuesto(producto);
};
const calculos = computed(() => {
// Suma de todos los importes
const importeTotal = productos.value.reduce(
(sum, p) => sum + calcularImporte(p),
0
);
// Suma de descuentos
const descuentoTotal = productos.value.reduce(
(sum, p) => sum + (p.descuento || 0),
0
);
// Subtotal antes de impuestos
const subtotal = importeTotal - descuentoTotal;
// Total de impuestos trasladados (IVA)
const impuestosTrasladados = productos.value.reduce(
(sum, p) => sum + calcularImpuesto(p),
0
);
// Total a pagar
const total = subtotal + impuestosTrasladados;
return {
importeTotal, // Para validaciones
descuentoTotal, // Total descuentos
subtotal, // Subtotal sin impuestos
impuestosTrasladados, // Total IVA
total, // Total final
};
});
const actualizarProductos = () => {
emit("update:modelValue", productos.value);
emit("update:totals", calculos.value);
};
// Watch profundo para detectar cambios en productos
watch(
productos,
() => {
actualizarProductos();
},
{ deep: true }
);
// Sincronizar con v-model externo
watch(
() => props.modelValue,
(newVal) => {
if (JSON.stringify(newVal) !== JSON.stringify(productos.value)) {
productos.value = [...newVal];
}
},
{ deep: true }
);
const formatCurrency = (value) => {
return new Intl.NumberFormat("es-MX", {
style: "currency",
currency: "MXN",
}).format(value || 0);
};
</script>
<template>
<div
class="bg-white rounded-lg border border-gray-200 p-4 dark:bg-primary-d dark:border-primary/20"
>
<!-- ENCABEZADO -->
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-primary-dt">
Conceptos CFDI
</h3>
<p class="text-xs text-gray-500 dark:text-primary-dt/60">
Productos/Servicios conforme a catálogos SAT
</p>
</div>
<button
@click="agregarProducto"
type="button"
class="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
>
<GoogleIcon name="add" class="text-lg" />
Agregar Concepto
</button>
</div>
<!-- Productos -->
<div v-if="productos.length > 0" class="space-y-3">
<div
v-for="(producto, index) in productos"
:key="producto.lote"
class="border-2 border-gray-200 rounded-lg p-4 hover:border-blue-300 transition-colors dark:border-primary/20 dark:hover:border-blue-500"
>
<!-- Header de la tarjeta -->
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span
class="bg-blue-100 text-blue-700 font-bold px-3 py-1 rounded text-sm dark:bg-blue-900/30 dark:text-blue-300"
>
#{{ producto.lote }}
</span>
</div>
<button
@click="eliminarProducto(index)"
type="button"
class="text-red-500 hover:text-red-700 p-2 hover:bg-red-50 rounded-lg transition-colors dark:text-red-400 dark:hover:bg-red-900/20"
title="Eliminar concepto"
>
<GoogleIcon name="delete" class="text-xl" />
</button>
</div>
<!-- Grid de campos -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- CANTIDAD -->
<div>
<label
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
>
Cantidad <span class="text-red-500">*</span>
</label>
<input
v-model.number="producto.cantidad"
type="number"
min="0"
step="1.00"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
/>
</div>
<!-- UNIDAD SAT -->
<div>
<label
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
>
Unidad SAT <span class="text-red-500">*</span>
</label>
<select
v-model="producto.unidadSAT"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
>
<option
v-for="unidad in unidadesSAT"
:key="unidad.codigo"
:value="unidad.codigo"
>
{{ unidad.codigo }} - {{ unidad.nombre }}
</option>
</select>
</div>
<!-- CLAVE PROD/SERV SAT -->
<div>
<label
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
>
Clave Prod/Serv SAT <span class="text-red-500">*</span>
</label>
<input
v-model="producto.claveProdServ"
type="text"
maxlength="8"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
placeholder="12345678"
/>
</div>
<!-- CÓDIGO INTERNO -->
<div>
<label
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
>
Código Interno
</label>
<input
v-model="producto.codigo"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
placeholder="SKU-001"
/>
</div>
</div>
<!-- DESCRIPCIÓN -->
<div class="mt-4">
<label
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
>
Descripción del Concepto <span class="text-red-500">*</span>
</label>
<textarea
v-model="producto.descripcion"
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500 resize-y"
placeholder="Descripción detallada del producto o servicio..."
></textarea>
</div>
<!-- Grid de precios e impuestos -->
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mt-4">
<!-- PRECIO UNITARIO -->
<div>
<label
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
>
Precio Unit. <span class="text-red-500">*</span>
</label>
<input
v-model.number="producto.precioUnitario"
type="number"
min="0"
step="0.01"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-right dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
placeholder="0.00"
/>
</div>
<!-- IMPORTE -->
<div>
<label
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
>
Importe
</label>
<div
class="px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm text-right font-medium dark:bg-primary/5 dark:border-primary/20 dark:text-primary-dt"
>
{{ formatCurrency(calcularImporte(producto)) }}
</div>
</div>
<!-- DESCUENTO -->
<div>
<label
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
>
Descuento
</label>
<input
v-model.number="producto.descuento"
type="number"
min="0"
step="0.01"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-right dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
placeholder="0.00"
/>
</div>
<!-- SUBTOTAL -->
<div>
<label
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
>
Subtotal
</label>
<div
class="px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm text-right font-semibold dark:bg-primary/5 dark:border-primary/20 dark:text-primary-dt"
>
{{ formatCurrency(calcularSubtotal(producto)) }}
</div>
</div>
<!-- TASA IVA -->
<div>
<label
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
>
Tasa IVA
</label>
<select
v-model.number="producto.tasaIVA"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt focus:ring-2 focus:ring-blue-500"
>
<option :value="0">0%</option>
<option :value="0.08">8%</option>
<option :value="0.16">16%</option>
</select>
</div>
<!-- TOTAL PARTIDA -->
<div>
<label
class="block text-xs font-semibold text-gray-700 mb-1 dark:text-primary-dt"
>
Total Partida
</label>
<div
class="px-3 py-2 bg-blue-50 border-2 border-blue-200 rounded-lg text-sm text-right font-bold text-blue-700 dark:bg-blue-900/20 dark:border-blue-700 dark:text-blue-400"
>
{{ formatCurrency(calcularTotalPartida(producto)) }}
</div>
</div>
</div>
</div>
</div>
<!-- Estado vacío -->
<div v-else class="text-center py-12 text-gray-500 dark:text-primary-dt/70">
<GoogleIcon name="receipt_long" class="text-5xl mb-3 opacity-50" />
<p class="text-sm font-semibold">No hay conceptos agregados</p>
<p class="text-xs">Haz clic en "Agregar Concepto" para comenzar</p>
</div>
<!-- RESUMEN DE TOTALES -->
<div v-if="productos.length > 0" class="mt-6 flex justify-end">
<div
class="w-80 border-2 border-gray-300 rounded-lg overflow-hidden dark:border-primary/20"
>
<!-- Subtotal -->
<div
class="flex justify-between bg-gray-100 dark:bg-primary/10 px-4 py-3 border-b dark:border-primary/20"
>
<span class="font-semibold text-gray-700 dark:text-primary-dt"
>Subtotal:</span
>
<span class="font-bold text-gray-900 dark:text-primary-dt text-lg">
{{ formatCurrency(calculos.subtotal) }}
</span>
</div>
<!-- Descuento Total -->
<div
v-if="calculos.descuentoTotal > 0"
class="flex justify-between px-4 py-2 border-b dark:border-primary/20"
>
<span class="text-sm text-gray-600 dark:text-primary-dt/70"
>Descuentos:</span
>
<span class="text-sm text-red-600 dark:text-red-400 font-semibold">
- {{ formatCurrency(calculos.descuentoTotal) }}
</span>
</div>
<!-- Impuestos Trasladados (IVA) -->
<div
class="flex justify-between bg-green-50 dark:bg-green-900/20 px-4 py-3 border-b dark:border-primary/20"
>
<span
class="text-sm font-semibold text-gray-700 dark:text-primary-dt"
>
Impuestos Trasladados:
</span>
<span class="font-bold text-green-700 dark:text-green-400 text-lg">
{{ formatCurrency(calculos.impuestosTrasladados) }}
</span>
</div>
<!-- Total -->
<div
class="flex justify-between bg-blue-600 dark:bg-blue-700 px-4 py-4"
>
<span class="font-bold text-white text-lg">TOTAL:</span>
<span class="font-bold text-white text-2xl">
{{ formatCurrency(calculos.total) }}
</span>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,35 @@
<script setup>
const props = defineProps({
template: {
type: Object,
required: true,
},
totalEnLetras: {
type: String,
default: '',
},
});
</script>
<template>
<div>
<!-- Total en letras -->
<p v-if="totalEnLetras" class="text-center font-bold mb-1">
{{ totalEnLetras }}
</p>
<!-- Certificaciones -->
<div
class="text-white font-semibold px-2 py-0.5 text-center mb-1"
:style="{ backgroundColor: template.primaryColor }"
>
CERTIFICACIONES Y PARTNERS
</div>
<div class="flex justify-center items-center gap-4 flex-wrap">
<div class="text-gray-500">
Huawei | Lenovo | Hikvision | Fortinet | Panduit | Honeywell
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,87 @@
<script setup>
const props = defineProps({
template: {
type: Object,
required: true,
},
data: {
type: Object,
required: true,
},
});
const getTipoFolioLabel = (documentType) => {
const labels = {
COTIZACION: "Número de Folio:",
FACTURA: "Folio:",
REMISION: "Número de Remisión:",
};
return labels[documentType] || "Número de Folio:";
};
</script>
<template>
<div
class="flex justify-between items-start pb-2 mb-3 border-b-2"
:style="{ borderColor: template.primaryColor }"
>
<div>
<!-- Logo -->
<img
v-if="template.logo"
:src="template.logo"
alt="Logo"
class="h-12 mb-1"
/>
<!-- Placeholder si no hay logo -->
<div
v-else
class="h-12 mb-1 flex items-center justify-center bg-gray-100 rounded text-gray-400 text-xs px-3"
>
Sin logo
</div>
<!-- Slogan -->
<p class="text-gray-600 mt-1">{{ template.slogan }}</p>
</div>
<div class="text-right">
<h2
class="text-xl font-bold mb-1"
:style="{ color: template.primaryColor }"
>
{{ template.documentType || "COTIZACION" }}
</h2>
<div class="space-y-0.5">
<p v-if="template.documentType === 'FACTURA'">
<span class="font-semibold"> Serie: </span>
{{ data.serie }}
</p>
<p>
<span class="font-semibold">{{
getTipoFolioLabel(template.documentType)
}}</span>
{{ data.folio }}
</p>
<p v-if="template.documentType === 'FACTURA'">
<span class="font-semibold">Fecha de Emisión:</span>
{{ data.fechaEmision }}
</p>
<p v-else>
<span class="font-semibold">Fecha de Realización:</span>
{{ data.fechaRealizacion }}
</p>
<p v-if="template.documentType !== 'FACTURA'">
<span class="font-semibold">Vigencia::</span>
{{ data.vigencia }}
</p>
<p v-if="template.documentType === 'FACTURA'">
<span class="font-semibold">Tipo de Comprobante:</span>
{{ data.tipoComprobante }}
</p>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,26 @@
<script setup>
const props = defineProps({
template: {
type: Object,
required: true,
},
data: {
type: Object,
required: true,
},
});
</script>
<template>
<div>
<div
class="text-white font-semibold px-2 py-0.5"
:style="{ backgroundColor: template.primaryColor }"
>
OBSERVACIONES
</div>
<div class="border border-gray-300 p-1.5 min-h-[50px]">
{{ data.observaciones }}
</div>
</div>
</template>

View File

@ -0,0 +1,65 @@
<script setup>
const props = defineProps({
template: {
type: Object,
required: true,
},
productos: {
type: Array,
required: true,
},
});
const formatCurrency = (value) => {
return new Intl.NumberFormat("es-MX", {
style: "currency",
currency: "MXN",
}).format(value);
};
</script>
<template>
<table class="w-full border-collapse mb-3">
<thead>
<tr
class="text-white"
:style="{ backgroundColor: template.primaryColor }"
>
<th class="border border-white px-1 py-0.5">LOTE</th>
<th class="border border-white px-1 py-0.5">CANT</th>
<th class="border border-white px-1 py-0.5">UNIDAD</th>
<th class="border border-white px-1 py-0.5">CODIGO</th>
<th class="border border-white px-1 py-0.5">DESCRIPCION</th>
<th class="border border-white px-1 py-0.5">P. UNIT.</th>
<th class="border border-white px-1 py-0.5">IMPORTE</th>
<th class="border border-white px-1 py-0.5">DESC</th>
<th class="border border-white px-1 py-0.5">TOTAL</th>
</tr>
</thead>
<tbody>
<tr
v-for="producto in productos"
:key="producto.lote"
class="odd:bg-blue-100"
>
<td class="border border-gray-300 px-1 py-0.5 text-center">{{ producto.lote }}</td>
<td class="border border-gray-300 px-1 py-0.5 text-center">{{ producto.cantidad }}</td>
<td class="border border-gray-300 px-1 py-0.5 text-center">{{ producto.unidad || producto.unidadSAT }}</td>
<td class="border border-gray-300 px-1 py-0.5">{{ producto.codigo }}</td>
<td class="border border-gray-300 px-1 py-0.5">{{ producto.descripcion }}</td>
<td class="border border-gray-300 px-1 py-0.5 text-right whitespace-nowrap">
{{ formatCurrency(producto.precioUnitario) }}
</td>
<td class="border border-gray-300 px-1 py-0.5 text-right whitespace-nowrap">
{{ formatCurrency(producto.cantidad * producto.precioUnitario) }}
</td>
<td class="border border-gray-300 px-1 py-0.5 text-right whitespace-nowrap">
{{ formatCurrency(producto.descuento || 0) }}
</td>
<td class="border border-gray-300 px-1 py-0.5 text-right font-semibold whitespace-nowrap">
{{ formatCurrency(producto.cantidad * producto.precioUnitario - (producto.descuento || 0)) }}
</td>
</tr>
</tbody>
</table>
</template>

View File

@ -0,0 +1,117 @@
<script setup>
const props = defineProps({
template: {
type: Object,
required: true,
},
totales: {
type: Object,
required: true,
},
});
const formatCurrency = (value) => {
return new Intl.NumberFormat("es-MX", {
style: "currency",
currency: "MXN",
}).format(value);
};
</script>
<template>
<div class="flex justify-end mb-3">
<div class="border w-56" :style="{ borderColor: template.primaryColor }">
<div class="grid grid-cols-2">
<!-- COTIZACIÓN -->
<template v-if="template.documentType === 'COTIZACION'">
<!-- SUBTOTAL 1 -->
<div class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }">
SUBTOTAL 1:
</div>
<div class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }">
{{ formatCurrency(totales.subtotal1) }}
</div>
<!-- DESCUENTO -->
<div class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }">
DESCUENTO:
</div>
<div class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }">
{{ formatCurrency(totales.descuentoTotal) }}
</div>
<!-- SUBTOTAL 2 -->
<div class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }">
SUBTOTAL 2:
</div>
<div class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }">
{{ formatCurrency(totales.subtotal2) }}
</div>
<!-- I.V.A. -->
<div class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }">
I.V.A.
</div>
<div class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }">
{{ formatCurrency(totales.iva) }}
</div>
</template>
<!-- FACTURA-->
<template v-else-if="template.documentType === 'FACTURA'">
<!-- SUBTOTAL -->
<div class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }">
SUBTOTAL:
</div>
<div class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }">
{{ formatCurrency(totales.subtotal) }}
</div>
<!-- DESCUENTO (si existe) -->
<template v-if="totales.descuentoTotal > 0">
<div class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }">
DESCUENTO:
</div>
<div class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }">
{{ formatCurrency(totales.descuentoTotal) }}
</div>
</template>
<!-- IMPUESTOS TRASLADADOS -->
<div class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }">
IMP. TRASLADADOS:
</div>
<div class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }">
{{ formatCurrency(totales.impuestosTrasladados) }}
</div>
</template>
<!-- TOTAL (común para ambos) -->
<div class="text-white font-bold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }">
TOTAL
</div>
<div class="px-2 py-1 text-right font-bold whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }">
{{ formatCurrency(totales.total) }}
</div>
</div>
</div>
</div>
</template>

View File

@ -1,244 +0,0 @@
<script setup>
import { ref, computed, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'update:totals']);
const productos = ref([...props.modelValue]);
// Agregar producto vacío
const agregarProducto = () => {
const nuevoLote = productos.value.length + 1;
productos.value.push({
lote: nuevoLote,
cantidad: 0,
unidad: 'PZA',
codigo: '',
descripcion: '',
precioUnitario: 0,
descuento: 0
});
actualizarProductos();
};
// Eliminar producto
const eliminarProducto = (index) => {
productos.value.splice(index, 1);
// Reordenar lotes
productos.value.forEach((producto, i) => {
producto.lote = i + 1;
});
actualizarProductos();
};
// Calcular importe de un producto
const calcularImporte = (producto) => {
return producto.cantidad * producto.precioUnitario;
};
// Calcular total de un producto (importe - descuento)
const calcularTotal = (producto) => {
return calcularImporte(producto) - producto.descuento;
};
// Cálculos generales
const calculos = computed(() => {
const subtotal1 = productos.value.reduce((sum, p) => sum + calcularImporte(p), 0);
const descuentoTotal = productos.value.reduce((sum, p) => sum + p.descuento, 0);
const subtotal2 = subtotal1 - descuentoTotal;
const iva = subtotal2 * 0.16; // 16% IVA
const total = subtotal2 + iva;
return {
subtotal1,
descuentoTotal,
subtotal2,
iva,
total
};
});
// Actualizar productos y emitir cambios
const actualizarProductos = () => {
emit('update:modelValue', productos.value);
emit('update:totals', calculos.value);
};
// Watch para actualizar cuando cambian los productos
watch(productos, () => {
actualizarProductos();
}, { deep: true });
// Watch para sincronizar con el v-model externo
watch(() => props.modelValue, (newVal) => {
productos.value = [...newVal];
}, { deep: true });
// Formatear moneda
const formatCurrency = (value) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(value || 0);
};
</script>
<template>
<div class="bg-white rounded-lg border border-gray-200 p-4 dark:bg-primary-d dark:border-primary/20">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900 dark:text-primary-dt">
Productos / Servicios
</h3>
<button
@click="agregarProducto"
type="button"
class="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
>
<GoogleIcon name="add" class="text-lg" />
Agregar Producto
</button>
</div>
<!-- Tabla de productos -->
<div class="overflow-x-auto -mx-4 px-4">
<table class="w-full text-sm" v-if="productos.length > 0">
<thead>
<tr class="border-b border-gray-200 dark:border-primary/20">
<th class="text-left py-2 px-2 font-semibold text-gray-700 dark:text-primary-dt">Lote</th>
<th class="text-left py-2 px-2 font-semibold text-gray-700 dark:text-primary-dt">Cant.</th>
<th class="text-left py-2 px-2 font-semibold text-gray-700 dark:text-primary-dt">Unidad</th>
<th class="text-left py-2 px-2 font-semibold text-gray-700 dark:text-primary-dt">Código</th>
<th class="text-left py-2 px-2 font-semibold text-gray-700 dark:text-primary-dt">Descripción</th>
<th class="text-right py-2 px-2 font-semibold text-gray-700 dark:text-primary-dt">P. Unit.</th>
<th class="text-right py-2 px-2 font-semibold text-gray-700 dark:text-primary-dt">Importe</th>
<th class="text-right py-2 px-2 font-semibold text-gray-700 dark:text-primary-dt">Desc.</th>
<th class="text-right py-2 px-2 font-semibold text-gray-700 dark:text-primary-dt">Total</th>
<th class="text-center py-2 px-2 font-semibold text-gray-700 dark:text-primary-dt">Acción</th>
</tr>
</thead>
<tbody>
<tr
v-for="(producto, index) in productos"
:key="index"
class="border-b border-gray-100 dark:border-primary/10 hover:bg-gray-50 dark:hover:bg-primary/5"
>
<td class="py-2 px-2">
<span class="text-gray-600 dark:text-primary-dt/70">{{ producto.lote }}</span>
</td>
<td class="py-2 px-2">
<input
v-model.number="producto.cantidad"
type="number"
min="0"
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
/>
</td>
<td class="py-2 px-2">
<select
v-model="producto.unidad"
class="w-20 px-2 py-1 border border-gray-300 rounded text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
>
<option value="PZA">PZA</option>
<option value="KG">KG</option>
<option value="LT">LT</option>
<option value="M">M</option>
<option value="SRV">SRV</option>
</select>
</td>
<td class="py-2 px-2">
<input
v-model="producto.codigo"
type="text"
class="w-24 px-2 py-1 border border-gray-300 rounded text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
placeholder="Código"
/>
</td>
<td class="py-2 px-2">
<input
v-model="producto.descripcion"
type="text"
class="w-full px-2 py-1 border border-gray-300 rounded text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
placeholder="Descripción del producto"
/>
</td>
<td class="py-2 px-2">
<input
v-model.number="producto.precioUnitario"
type="number"
min="0"
step="0.01"
class="w-24 px-2 py-1 border border-gray-300 rounded text-sm text-right dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
placeholder="0.00"
/>
</td>
<td class="py-2 px-2 text-right text-gray-600 dark:text-primary-dt/70 whitespace-nowrap">
{{ formatCurrency(calcularImporte(producto)) }}
</td>
<td class="py-2 px-2">
<input
v-model.number="producto.descuento"
type="number"
min="0"
step="0.01"
class="w-24 px-2 py-1 border border-gray-300 rounded text-sm text-right dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
placeholder="0.00"
/>
</td>
<td class="py-2 px-2 text-right font-semibold text-gray-900 dark:text-primary-dt whitespace-nowrap">
{{ formatCurrency(calcularTotal(producto)) }}
</td>
<td class="py-2 px-2 text-center">
<button
@click="eliminarProducto(index)"
type="button"
class="text-red-500 hover:text-red-700 dark:text-red-400"
>
<GoogleIcon name="delete" class="text-lg" />
</button>
</td>
</tr>
</tbody>
</table>
<!-- Estado vacío -->
<div v-else class="text-center py-8 text-gray-500 dark:text-primary-dt/70">
<GoogleIcon name="inventory_2" class="text-4xl mb-2 opacity-50" />
<p class="text-sm">No hay productos agregados</p>
<p class="text-xs">Haz clic en "Agregar Producto" para comenzar</p>
</div>
</div>
<!-- Resumen de totales -->
<div v-if="productos.length > 0" class="mt-4 flex justify-end">
<div class="w-64 space-y-2 border-t border-gray-200 dark:border-primary/20 pt-3">
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-primary-dt/70">Subtotal 1:</span>
<span class="font-semibold dark:text-primary-dt">{{ formatCurrency(calculos.subtotal1) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-primary-dt/70">Descuento:</span>
<span class="font-semibold dark:text-primary-dt">{{ formatCurrency(calculos.descuentoTotal) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-primary-dt/70">Subtotal 2:</span>
<span class="font-semibold dark:text-primary-dt">{{ formatCurrency(calculos.subtotal2) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-primary-dt/70">IVA (16%):</span>
<span class="font-semibold dark:text-primary-dt">{{ formatCurrency(calculos.iva) }}</span>
</div>
<div class="flex justify-between text-base border-t border-gray-200 dark:border-primary/20 pt-2">
<span class="font-bold text-gray-900 dark:text-primary-dt">Total:</span>
<span class="font-bold text-blue-600 dark:text-blue-400">{{ formatCurrency(calculos.total) }}</span>
</div>
</div>
</div>
</div>
</template>

View File

@ -4,7 +4,7 @@ export default {
branding: {
logo: null,
primaryColor: '#dc2626',
primaryColor: '#2c50dd',
slogan: ' ',
},
@ -90,7 +90,7 @@ export default {
label: 'Nombre del Cliente',
tipo: 'text',
required: true,
placeholder: 'Nombre completo',
placeholder: 'Ej: Juan Pérez',
},
{
key: 'clienteRFC',

View File

@ -0,0 +1,140 @@
export default{
templateId: 'temp-cot-002',
nombre: 'Factura',
branding: {
logo: null,
primaryColor: '#2c50dd',
slogan: ' ',
},
campos:[
{
seccion: 'Datos de la Empresa',
campos: [
{
key: 'empresaNombre',
label: 'Nombre de la Empresa',
tipo: 'text',
required: true,
placeholder: 'Ej: GOLSYSTEMS',
},
{
key: 'empresaWeb',
label: 'Sitio Web',
tipo: 'url',
required: false,
placeholder: 'www.ejemplo.com',
},
{
key: 'empresaDireccion',
label: 'Dirección',
tipo: 'textarea',
required: true,
placeholder: 'Dirección completa',
},
{
key: 'empresaRFC',
label: 'RFC',
tipo: 'text',
required: true,
placeholder: 'GME111116GJA',
},
{
key: 'empresaLugar',
label: 'Lugar de Expedición',
tipo: 'text',
required: true,
placeholder: '8000',
},
{
key: 'empresaCfdi',
label: 'Uso de CFDI',
tipo: 'text',
required: true,
placeholder: 'G03 - Gastos en general',
},
{
key: 'empresaRegimen',
label: 'Régimen Fiscal',
tipo: 'select',
required: true,
opciones: [
{ value: 'Régimen Simplificado de Confianza', label: 'Régimen Simplificado de Confianza' },
{ value: 'Personas Físicas con Actividades Empresariales y Profesionales', label: 'Personas Físicas con Actividades Empresariales y Profesionales' },
]
},
]
},
{
seccion: 'Datos del Cliente',
campos: [
{
key: 'clienteNombre',
label: 'Nombre del Cliente',
tipo: 'text',
required: true,
placeholder: 'Ej: Juan Pérez',
},
{
key: 'clienteRFC',
label: 'RFC',
tipo: 'text',
required: false,
placeholder: 'RFC del cliente',
},
{
key: 'clienteDomicilio',
label: 'Domicilio',
tipo: 'textarea',
required: false,
placeholder: 'Domicilio completo',
},
{
key: 'clienteRegimen',
label: 'Régimen Fiscal',
tipo: 'select',
required: true,
opciones: [
{ value: 'Régimen Simplificado de Confianza', label: 'Régimen Simplificado de Confianza' },
{ value: 'Personas Físicas con Actividades Empresariales y Profesionales', label: 'Personas Físicas con Actividades Empresariales y Profesionales' },
]
}
]
},
{
seccion: 'Datos del Documento',
campos: [
{
key: 'serie',
label: 'Número de Serie',
tipo: 'text',
required: true,
},
{
key: 'folio',
label: 'Número de Folio',
tipo: 'text',
required: true,
},
{
key: 'fechaEmision',
label: 'Fecha de Emisión',
tipo: 'date',
required: true,
},
{
key: 'tipoComprobante',
label: 'Tipo de Comprobante',
tipo: 'select',
required: true,
opciones: [
{ value: 'Ingreso', label: 'Ingreso' },
{ value: 'Egreso', label: 'Egreso' },
{ value: 'Traslado', label: 'Traslado' },
]
}
]
}
]
}

View File

@ -1,7 +1,8 @@
import { ref } from 'vue';
import html2canvas from 'html2canvas-pro';
import { jsPDF } from 'jspdf';
import html2canvas from 'html2canvas-pro';
export function usePDFExport() {
const isExporting = ref(false);
@ -59,10 +60,10 @@ export function usePDFExport() {
compress: true
});
const pdfWidth = 210; // A4 portrait width
const pdfHeight = 297; // A4 portrait height
const pdfWidth = 210; // A4 width
const pdfHeight = 297; // A4 height
// Ajustar imagen al tamaño completo de la página (sin márgenes)
// Ajustar imagen al tamaño completo de la página
// para que ocupe toda la hoja A4
pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, pdfHeight, '', 'FAST');
@ -71,7 +72,6 @@ export function usePDFExport() {
Notify.success('PDF generado exitosamente');
} catch (error) {
console.error('Error al generar PDF:', error);
Notify.error(`Error al generar el PDF: ${error.message}`);
} finally {
isExporting.value = false;

View File

@ -0,0 +1,66 @@
<script setup>
import { computed } from 'vue';
import HeaderSection from '@Holos/DocumentSection/HeaderSection.vue';
import CompanyInfoSection from '@Holos/DocumentSection/CompanyInfoSection.vue';
import ClientSection from '@Holos/DocumentSection/ClientSection.vue';
import ExecutiveSection from '@Holos/DocumentSection/ExecutiveSection.vue';
import ObservationsSection from '@Holos/DocumentSection/ObservationsSection.vue';
import ProductsTableView from '@Holos/DocumentSection/ProductsTableView.vue';
import TotalsSection from '@Holos/DocumentSection/TotalsSection.vue';
import FooterSection from '@Holos/DocumentSection/FooterSection.vue';
const props = defineProps({
documentData: {
type: Object,
required: true,
},
});
const template = computed(() => props.documentData?.template || {
documentType: 'COTIZACION',
primaryColor: '#2c50dd',
logo: null,
slogan: 'Optimizando lasTIC\'s en las empresas'
});
</script>
<template>
<div
class="bg-white p-4 text-[9px] leading-tight h-full"
style="width: 100%; font-family: Arial, sans-serif; box-sizing: border-box"
>
<!-- Header -->
<HeaderSection :template="template" :data="documentData" />
<!-- Datos Fiscales y Bancarios (solo cotización) -->
<CompanyInfoSection
:template="template"
:data="documentData"
/>
<!-- Cliente -->
<ClientSection :template="template" :data="documentData" />
<!-- Ejecutivo y Observaciones (solo cotización) -->
<div
v-if="template.documentType === 'COTIZACION'"
class="grid grid-cols-2 gap-3 mb-3"
>
<ExecutiveSection :template="template" :data="documentData" />
<ObservationsSection :template="template" :data="documentData" />
</div>
<!-- Tabla de Productos -->
<ProductsTableView
:template="template"
:productos="documentData.productos || []"
/>
<!-- Totales -->
<TotalsSection :template="template" :totales="documentData" />
<!-- Footer -->
<FooterSection :template="template" />
</div>
</template>

View File

@ -1,286 +1,390 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { usePDFExport } from '@Pages/Templates/Configs/usePDFExport';
import ConfigCotizacion from '@Pages/Templates/Configs/ConfigCotizacion';
import { ref, computed, onMounted, watch } from "vue";
import { usePDFExport } from "@Pages/Templates/Configs/usePDFExport";
import TempCot from './Temp-Cot.vue';
import ProductTable from '@Holos/ProductTable.vue';
import Input from '@Holos/Form/Input.vue';
import Textarea from '@Holos/Form/Textarea.vue';
import PrimaryButton from '@Holos/Button/Primary.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import ConfigCotizacion from "@Pages/Templates/Configs/ConfigCotizacion.js";
import ConfigFacturacion from "@Pages/Templates/Configs/ConfigFacturacion.js";
import Document from "./DocumentTemplate.vue";
import ProductTable from "@Holos/DocumentSection/CotizacionTable.vue";
import FacturaTable from "@Holos/DocumentSection/FacturacionTable.vue";
import Input from "@Holos/Form/Input.vue";
import Textarea from "@Holos/Form/Textarea.vue";
import PrimaryButton from "@Holos/Button/Primary.vue";
import GoogleIcon from "@Shared/GoogleIcon.vue";
/** Composables */
const { exportToPDF, isExporting } = usePDFExport();
/** Usar configuración importada */
const templateConfig = ConfigCotizacion;
const templateConfig = ref(ConfigCotizacion);
const showPreview = ref(true);
/** Estado */
const formData = ref({});
const branding = ref({
documentType: 'COTIZACION',
...templateConfig.branding
documentType: "COTIZACION",
...templateConfig.value.branding,
});
const productos = ref([]);
const totales = ref({
subtotal1: 0,
descuentoTotal: 0,
subtotal2: 0,
iva: 0,
total: 0
subtotal1: 0,
descuentoTotal: 0,
subtotal2: 0,
iva: 0,
subtotal: 0,
impuestosTrasladados: 0,
total: 0,
});
/** Computed */
const cotizacionData = computed(() => {
return {
...formData.value,
template: {
documentType: branding.value.documentType,
primaryColor: branding.value.primaryColor,
secondaryColor: branding.value.secondaryColor,
logo: branding.value.logoPreview,
slogan: branding.value.slogan
},
productos: productos.value,
...totales.value
};
const documentData = computed(() => {
return {
...formData.value,
template: {
documentType: branding.value.documentType,
primaryColor: branding.value.primaryColor,
secondaryColor: branding.value.secondaryColor,
logo: branding.value.logoPreview,
slogan: branding.value.slogan,
},
productos: productos.value,
...totales.value,
};
});
/** Métodos */
const initializeForm = () => {
const data = {};
templateConfig.campos.forEach(seccion => {
seccion.campos.forEach(campo => {
data[campo.key] = campo.defaultValue || '';
});
const data = {};
templateConfig.value.campos.forEach((seccion) => {
seccion.campos.forEach((campo) => {
data[campo.key] = campo.defaultValue || "";
});
formData.value = data;
});
formData.value = data;
};
const handleLogoUpload = (file) => {
if (!file) {
console.warn('No se seleccionó archivo');
return;
}
console.log('Archivo seleccionado:', file.name, file.type);
branding.value.logo = file;
if (!file) {
console.warn("No se seleccionó archivo");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
branding.value.logoPreview = e.target.result;
};
reader.onerror = (error) => {
};
reader.readAsDataURL(file);
console.log("Archivo seleccionado:", file.name, file.type);
branding.value.logo = file;
const reader = new FileReader();
reader.onload = (e) => {
branding.value.logoPreview = e.target.result;
};
reader.onerror = (error) => {};
reader.readAsDataURL(file);
};
const handleTotalsUpdate = (newTotals) => {
totales.value = { ...newTotals };
totales.value = { ...newTotals };
};
const handleExport = () => {
exportToPDF('template-preview', `${branding.value.documentType}-${formData.value.folio || 'documento'}.pdf`);
exportToPDF(
"template-preview",
`${branding.value.documentType}-${formData.value.folio || "documento"}.pdf`
);
};
watch(
() => branding.value.documentType,
(newType) => {
if (newType === "FACTURA") {
templateConfig.value = ConfigFacturacion;
} else {
templateConfig.value = ConfigCotizacion;
}
initializeForm();
productos.value = [];
}
);
/** Ciclos */
onMounted(() => {
initializeForm();
initializeForm();
});
</script>
<template>
<div class="p-6">
<!-- Header -->
<div class="mb-6 flex items-center justify-between gap-4">
<div class="p-6">
<!-- Header -->
<div class="mb-6 flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-primary-dt">
{{ templateConfig.nombre }}
</h2>
<p class="text-sm text-gray-600 dark:text-primary-dt/70">
Genera documento PDF
</p>
</div>
<!-- Botones de acción (desktop) -->
<div class="hidden lg:flex items-center gap-3">
<button
@click="showPreview = !showPreview"
class="flex items-center gap-2 px-3 py-2 rounded-lg border-2"
:class="
showPreview
? 'bg-blue-50 border-blue-500 text-blue-700 hover:bg-blue-100 dark:bg-blue-900/30 dark:border-blue-500 dark:text-blue-300 dark:hover:bg-blue-900/50'
: 'bg-gray-50 border-gray-300 text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700'
"
>
<GoogleIcon
:name="showPreview ? 'visibility_off' : 'visibility'"
class="text-base"
/>
<span class="text-sm font-medium">
{{ showPreview ? "Ocultar" : "Mostrar" }} Vista Previa
</span>
</button>
<PrimaryButton
@click="handleExport"
:disabled="isExporting"
class="px-4 py-2"
>
<GoogleIcon name="picture_as_pdf" class="mr-2 text-sm" />
{{ isExporting ? "Generando..." : "Exportar PDF" }}
</PrimaryButton>
</div>
</div>
<!-- Selector de Tipo de Documento -->
<div class="min-w-[250px]">
<label
class="block text-sm font-medium text-gray-700 dark:text-primary-dt mb-1"
>
Tipo de Documento
</label>
<select
v-model="branding.documentType"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 font-medium dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
>
<option value="COTIZACION">Cotización</option>
<option value="FACTURA">Factura</option>
<option value="REMISION">Remisión</option>
</select>
</div>
</div>
<div
class="grid gap-5"
:class="showPreview ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'"
>
<!-- FORMULARIO -->
<div class="space-y-6">
<!-- SECCIÓN DE BRANDING -->
<div
class="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border-2 border-blue-200 p-4 dark:from-blue-900/20 dark:to-indigo-900/20 dark:border-blue-700"
>
<div class="flex items-center gap-2 mb-3">
<GoogleIcon
name="palette"
class="text-blue-600 dark:text-blue-400"
/>
<h3
class="text-base font-semibold text-gray-900 dark:text-primary-dt"
>
Personalización del Documento
</h3>
</div>
<div class="space-y-3">
<!-- Slogan -->
<Input
v-model="branding.slogan"
title="Slogan de la Empresa"
type="text"
placeholder="Optimizando lasTIC's en las empresas"
/>
<!-- Logo de Imagen -->
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-primary-dt">
{{ templateConfig.nombre }}
</h2>
<p class="text-sm text-gray-600 dark:text-primary-dt/70">
Personaliza y genera documentos profesionales
</p>
<label
class="block text-sm font-medium text-gray-700 dark:text-primary-dt mb-2"
>
Logo de la Empresa
</label>
<!-- Preview del logo -->
<div
v-if="branding.logoPreview"
class="mb-3 flex items-center gap-3 p-3 bg-white rounded border dark:bg-primary-d dark:border-primary/20"
>
<img
:src="branding.logoPreview"
alt="Logo"
class="h-16 object-contain"
/>
<button
@click="
branding.logoPreview = null;
branding.logo = null;
"
type="button"
class="ml-auto text-red-500 hover:text-red-700 dark:text-red-400"
>
<GoogleIcon name="delete" class="text-lg" />
</button>
</div>
<!-- Input para subir logo -->
<input
type="file"
accept="image/png, image/jpeg, image/jpg"
@change="(e) => handleLogoUpload(e.target.files[0])"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-blue-900/30 dark:file:text-blue-300"
/>
</div>
<!-- Selector de Tipo de Documento -->
<div class="min-w-[250px]">
<label class="block text-sm font-medium text-gray-700 dark:text-primary-dt mb-1">
Tipo de Documento
<!-- Colores -->
<div class="grid grid-cols-2 gap-2">
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-primary-dt mb-1"
>
Color Primario
</label>
<div class="flex gap-2">
<input
v-model="branding.primaryColor"
type="color"
class="h-10 w-16 rounded border border-gray-300 cursor-pointer"
/>
<input
v-model="branding.primaryColor"
type="text"
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
placeholder="#2563eb"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Secciones del formulario -->
<div
v-for="seccion in templateConfig.campos"
:key="seccion.seccion"
class="bg-white rounded-lg border border-gray-200 p-4 dark:bg-primary-d dark:border-primary/20"
>
<h3
class="text-base font-semibold text-gray-900 mb-3 dark:text-primary-dt"
>
{{ seccion.seccion }}
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<template v-for="campo in seccion.campos" :key="campo.key">
<Input
v-if="
['text', 'email', 'url', 'tel', 'number', 'date'].includes(
campo.tipo
)
"
v-model="formData[campo.key]"
:id="campo.key"
:title="campo.label"
:type="campo.tipo"
:required="campo.required"
:placeholder="campo.placeholder"
/>
<Textarea
v-else-if="campo.tipo === 'textarea'"
v-model="formData[campo.key]"
:id="campo.key"
:title="campo.label"
:required="campo.required"
:placeholder="campo.placeholder"
class="md:col-span-2"
/>
<div v-else-if="campo.tipo === 'select'" class="md:col-span-2">
<label
:for="campo.key"
class="block text-sm font-medium text-gray-700 dark:text-primary-dt mb-1"
>
{{ campo.label }}
<span v-if="campo.required" class="text-red-500">*</span>
</label>
<select
v-model="branding.documentType"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 font-medium dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
v-model="formData[campo.key]"
:id="campo.key"
:required="campo.required"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
>
<option value="COTIZACION">Cotización</option>
<option value="FACTURA">Factura</option>
<option value="REMISION">Remisión</option>
<option value="NOTA DE VENTA">Nota de Venta</option>
<option value="PRESUPUESTO">Presupuesto</option>
<option value="ORDEN DE COMPRA">Orden de Compra</option>
<option value="">Seleccione una opción</option>
<option
v-for="opcion in campo.opciones"
:key="opcion.value"
:value="opcion.value"
>
{{ opcion.label }}
</option>
</select>
</div>
</div>
</template>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- FORMULARIO -->
<div class="space-y-6">
<!-- SECCIÓN DE BRANDING -->
<div class="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border-2 border-blue-200 p-4 dark:from-blue-900/20 dark:to-indigo-900/20 dark:border-blue-700">
<div class="flex items-center gap-2 mb-3">
<GoogleIcon name="palette" class="text-blue-600 dark:text-blue-400" />
<h3 class="text-base font-semibold text-gray-900 dark:text-primary-dt">
Personalización del Documento
</h3>
</div>
<!-- Tabla de Productos -->
<FacturaTable
v-if="branding.documentType === 'FACTURA'"
v-model="productos"
@update:totals="handleTotalsUpdate"
/>
<div class="space-y-3">
<!-- Slogan -->
<Input
v-model="branding.slogan"
title="Slogan de la Empresa"
type="text"
placeholder="Optimizando lasTIC's en las empresas"
/>
<ProductTable
v-else
v-model="productos"
@update:totals="handleTotalsUpdate"
/>
</div>
<!-- Logo de Imagen -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-primary-dt mb-2">
Logo de la Empresa
</label>
<!-- Preview del logo -->
<div v-if="branding.logoPreview" class="mb-3 flex items-center gap-3 p-3 bg-white rounded border dark:bg-primary-d dark:border-primary/20">
<img :src="branding.logoPreview" alt="Logo" class="h-16 object-contain" />
<button
@click="branding.logoPreview = null; branding.logo = null"
type="button"
class="ml-auto text-red-500 hover:text-red-700 dark:text-red-400"
>
<GoogleIcon name="delete" class="text-lg" />
</button>
</div>
<!-- Input para subir logo -->
<input
type="file"
accept="image/png, image/jpeg, image/jpg"
@change="(e) => handleLogoUpload(e.target.files[0])"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-blue-900/30 dark:file:text-blue-300"
/>
</div>
<!-- Colores -->
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-primary-dt mb-1">
Color Primario
</label>
<div class="flex gap-2">
<input
v-model="branding.primaryColor"
type="color"
class="h-10 w-16 rounded border border-gray-300 cursor-pointer"
/>
<input
v-model="branding.primaryColor"
type="text"
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
placeholder="#2563eb"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Secciones del formulario -->
<div
v-for="seccion in templateConfig.campos"
:key="seccion.seccion"
class="bg-white rounded-lg border border-gray-200 p-4 dark:bg-primary-d dark:border-primary/20"
>
<h3 class="text-base font-semibold text-gray-900 mb-3 dark:text-primary-dt">
{{ seccion.seccion }}
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<template v-for="campo in seccion.campos" :key="campo.key">
<Input
v-if="['text', 'email', 'url', 'tel', 'number', 'date'].includes(campo.tipo)"
v-model="formData[campo.key]"
:id="campo.key"
:title="campo.label"
:type="campo.tipo"
:required="campo.required"
:placeholder="campo.placeholder"
/>
<Textarea
v-else-if="campo.tipo === 'textarea'"
v-model="formData[campo.key]"
:id="campo.key"
:title="campo.label"
:required="campo.required"
:placeholder="campo.placeholder"
class="md:col-span-2"
/>
</template>
</div>
</div>
<!-- Tabla de Productos -->
<ProductTable
v-model="productos"
@update:totals="handleTotalsUpdate"
/>
<!-- Botón de exportar (móvil) -->
<div class="lg:hidden">
<PrimaryButton
@click="handleExport"
:disabled="isExporting"
class="w-full px-6 py-3"
>
<GoogleIcon name="picture_as_pdf" class="mr-2" />
{{ isExporting ? 'Generando PDF...' : 'Exportar a PDF' }}
</PrimaryButton>
</div>
</div>
<!-- VISTA PREVIA -->
<div class="sticky top-6 h-fit">
<div class="mb-4 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-primary-dt">
Vista Previa
</h3>
<div class="hidden lg:block">
<PrimaryButton
@click="handleExport"
:disabled="isExporting"
class="px-4 py-2"
>
<GoogleIcon name="picture_as_pdf" class="mr-2 text-sm" />
{{ isExporting ? 'Generando...' : 'Exportar PDF' }}
</PrimaryButton>
</div>
</div>
<div
class="border rounded-lg overflow-auto bg-gray-50 dark:bg-gray-900 flex justify-center p-4"
style="max-height: 80vh;"
>
<div
id="template-preview"
class="shadow-lg"
style="width: 210mm; height: 297mm; overflow: hidden;"
>
<TempCot :cotizacion="cotizacionData" />
</div>
</div>
</div>
<!-- VISTA PREVIA -->
<div v-if="showPreview" class="sticky top-6 h-fit">
<div class="mb-4">
<h3 class="font-semibold text-gray-900 dark:text-primary-dt">
Vista Previa
</h3>
</div>
<div
class="border rounded-lg overflow-auto bg-gray-50 dark:bg-gray-900 flex justify-center p-4"
style="max-height: 80vh"
>
<div
id="template-preview"
class="shadow-lg"
style="width: 210mm; height: 297mm; overflow: hidden"
>
<Document :documentData="documentData" />
</div>
</div>
</div>
<div v-else style="position: absolute; left: -9999px; top: 0">
<div
id="template-preview"
class="shadow-lg"
style="width: 210mm; height: 297mm; overflow: hidden"
>
<Document :documentData="documentData" />
</div>
</div>
</div>
</template>
</div>
</template>

View File

@ -1,317 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
cotizacion: {
type: Object,
required: true,
},
});
const template = computed(() => props.cotizacion?.template || {
documentType: 'COTIZACION',
primaryColor: '#2563eb',
logo: null,
slogan: 'Optimizando lasTIC\'s en las empresas'
});
const formatCurrency = (value) => {
return new Intl.NumberFormat("es-MX", {
style: "currency",
currency: "MXN",
}).format(value);
};
const getTipoFolioLabel = (documentType) => {
const labels = {
'FACTURA': 'Número de Factura:',
'REMISION': 'Número de Remisión:',
'NOTA DE VENTA': 'Número de Nota:',
'ORDEN DE COMPRA': 'Número de Orden:',
'PRESUPUESTO': 'Número de Presupuesto:',
'COTIZACION': 'Número de Folio:'
};
return labels[documentType] || 'Número de Folio:';
};
</script>
<template>
<div
class="bg-white p-4 text-[9px] leading-tight h-full"
style="width: 100%; font-family: Arial, sans-serif; box-sizing: border-box"
>
<!-- Header -->
<div
class="flex justify-between items-start pb-2 mb-3 border-b-2"
:style="{ borderColor: template.primaryColor }"
>
<div>
<!-- Logo -->
<img
v-if="template.logo"
:src="template.logo"
alt="Logo"
class="h-12 mb-1"
/>
<!-- Placeholder si no hay logo -->
<div v-else class="h-12 mb-1 flex items-center justify-center bg-gray-100 rounded text-gray-400 text-xs px-3">
Sin logo
</div>
<!-- Slogan -->
<p class="text-gray-600 mt-1">{{ template.slogan }}</p>
<div class="mt-2 space-y-0.5">
<p class="font-semibold">{{ cotizacion.empresaNombre }}</p>
<p>{{ cotizacion.empresaWeb }}</p>
<p>{{ cotizacion.empresaEmail }}</p>
<p>{{ cotizacion.empresaTelefono }}</p>
</div>
</div>
<div class="text-right">
<h2
class="text-xl font-bold mb-1"
:style="{ color: template.primaryColor }"
>
{{ template.documentType || "COTIZACION" }} IP
</h2>
<div class="space-y-0.5">
<p>
<span class="font-semibold">
{{ getTipoFolioLabel(template.documentType) }}
</span>
{{ cotizacion.folio }}
</p>
<p>
<span class="font-semibold">Fecha de Realización:</span>
{{ cotizacion.fechaRealizacion }}
</p>
<p v-if="template.documentType !== 'FACTURA'">
<span class="font-semibold">Vigencia:</span>
{{ cotizacion.vigencia }}
</p>
</div>
</div>
</div>
<!-- Datos Fiscales y Bancarios -->
<div class="grid grid-cols-2 gap-3 mb-3">
<div class="border border-gray-300 p-1.5">
<p class="font-semibold">{{ cotizacion.empresaNombre }}</p>
<p>RFC: {{ cotizacion.empresaRFC }}</p>
<p>{{ cotizacion.empresaDireccion }}</p>
</div>
<div class="border border-gray-300 p-1.5">
<p class="font-semibold">{{ cotizacion.bancoBanco }}</p>
<p>{{ cotizacion.bancoTipoCuenta }}</p>
<p>{{ cotizacion.bancoCuenta }}</p>
</div>
</div>
<!-- Cliente -->
<div class="mb-3">
<div
class="text-white font-semibold px-2 py-0.5"
:style="{ backgroundColor: template.primaryColor }"
>
DATOS FISCALES CLIENTE
</div>
<div class="border border-gray-300 p-1.5 grid grid-cols-2 gap-x-4">
<div>
<span class="font-semibold">Nombre:</span>
{{ cotizacion.clienteNombre }}
</div>
<div>
<span class="font-semibold">Domicilio:</span>
{{ cotizacion.clienteDomicilio }}
</div>
<div>
<span class="font-semibold">RFC:</span> {{ cotizacion.clienteRFC }}
</div>
<div>
<span class="font-semibold">Telefono:</span>
{{ cotizacion.clienteTelefono }}
</div>
</div>
</div>
<!-- Ejecutivo y Observaciones -->
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<div
class="text-white font-semibold px-2 py-0.5"
:style="{ backgroundColor: template.primaryColor }"
>
DATOS DEL EJECUTIVO DE CUENTAS
</div>
<div class="border border-gray-300 p-1.5">
<p>
<span class="font-semibold">NOMBRE:</span>
{{ cotizacion.ejecutivoNombre }}
</p>
<p>
<span class="font-semibold">CORREO:</span>
{{ cotizacion.ejecutivoCorreo }}
</p>
<p>
<span class="font-semibold">CELULAR:</span>
{{ cotizacion.ejecutivoCelular }}
</p>
</div>
</div>
<div>
<div
class="text-white font-semibold px-2 py-0.5"
:style="{ backgroundColor: template.primaryColor }"
>
OBSERVACIONES
</div>
<div class="border border-gray-300 p-1.5 min-h-[50px]">
{{ cotizacion.observaciones }}
</div>
</div>
</div>
<!-- Tabla de Productos -->
<table class="w-full border-collapse mb-3">
<thead>
<tr
class="text-white"
:style="{ backgroundColor: template.primaryColor }"
>
<th class="border border-white px-1 py-0.5">LOTE</th>
<th class="border border-white px-1 py-0.5">CANT</th>
<th class="border border-white px-1 py-0.5">UNIDAD</th>
<th class="border border-white px-1 py-0.5">CODIGO</th>
<th class="border border-white px-1 py-0.5">DESCRIPCION</th>
<th class="border border-white px-1 py-0.5">P. UNIT.</th>
<th class="border border-white px-1 py-0.5">IMPORTE</th>
<th class="border border-white px-1 py-0.5">DESC</th>
<th class="border border-white px-1 py-0.5">TOTAL</th>
</tr>
</thead>
<tbody>
<tr
v-for="producto in cotizacion.productos"
:key="producto.lote"
class="odd:bg-blue-100"
>
<td class="border border-gray-300 px-1 py-0.5 text-center">{{ producto.lote }}</td>
<td class="border border-gray-300 px-1 py-0.5 text-center">{{ producto.cantidad }}</td>
<td class="border border-gray-300 px-1 py-0.5 text-center">{{ producto.unidad }}</td>
<td class="border border-gray-300 px-1 py-0.5">{{ producto.codigo }}</td>
<td class="border border-gray-300 px-1 py-0.5">{{ producto.descripcion }}</td>
<td class="border border-gray-300 px-1 py-0.5 text-right whitespace-nowrap">
{{ formatCurrency(producto.precioUnitario) }}
</td>
<td class="border border-gray-300 px-1 py-0.5 text-right whitespace-nowrap">
{{ formatCurrency(producto.cantidad * producto.precioUnitario) }}
</td>
<td class="border border-gray-300 px-1 py-0.5 text-right whitespace-nowrap">
{{ formatCurrency(producto.descuento) }}
</td>
<td class="border border-gray-300 px-1 py-0.5 text-right font-semibold whitespace-nowrap">
{{ formatCurrency(producto.cantidad * producto.precioUnitario - producto.descuento) }}
</td>
</tr>
</tbody>
</table>
<!-- Totales -->
<div class="flex justify-end mb-3">
<div
class="border w-56"
:style="{ borderColor: template.primaryColor }"
>
<div class="grid grid-cols-2">
<div
class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }"
>
SUBTOTAL 1:
</div>
<div
class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }"
>
{{ formatCurrency(cotizacion.subtotal1) }}
</div>
<div
class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }"
>
DESCUENTO:
</div>
<div
class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }"
>
{{ formatCurrency(cotizacion.descuentoTotal) }}
</div>
<div
class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }"
>
SUBTOTAL 2:
</div>
<div
class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }"
>
{{ formatCurrency(cotizacion.subtotal2) }}
</div>
<div
class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }"
>
I.V.A.
</div>
<div
class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }"
>
{{ formatCurrency(cotizacion.iva) }}
</div>
<div
class="text-white font-bold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }"
>
TOTAL
</div>
<div
class="px-2 py-1 text-right font-bold whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }"
>
{{ formatCurrency(cotizacion.total) }}
</div>
</div>
</div>
</div>
<!-- Footer -->
<p class="text-center font-bold mb-1">
DIECIOCHO MIL NOVENTA Y SEIS PESOS 00/100 M.N.
</p>
<div
class="text-white font-semibold px-2 py-0.5 text-center mb-1"
:style="{ backgroundColor: template.primaryColor }"
>
CERTIFICACIONES Y PARTNERS
</div>
<div class="flex justify-center items-center gap-4 flex-wrap">
<div class="text-gray-500">
Huawei | Lenovo | Hikvision | Fortinet | Panduit | Honeywell
</div>
</div>
</div>
</template>