ADD: Factura pdf
This commit is contained in:
parent
f277c3677a
commit
47764891d2
44
src/components/Holos/DocumentSection/ClientSection.vue
Normal file
44
src/components/Holos/DocumentSection/ClientSection.vue
Normal 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>
|
||||
57
src/components/Holos/DocumentSection/CompanyInfoSection.vue
Normal file
57
src/components/Holos/DocumentSection/CompanyInfoSection.vue
Normal 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>
|
||||
320
src/components/Holos/DocumentSection/CotizacionTable.vue
Normal file
320
src/components/Holos/DocumentSection/CotizacionTable.vue
Normal 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' (Sí 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>
|
||||
37
src/components/Holos/DocumentSection/ExecutiveSection.vue
Normal file
37
src/components/Holos/DocumentSection/ExecutiveSection.vue
Normal 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>
|
||||
469
src/components/Holos/DocumentSection/FacturacionTable.vue
Normal file
469
src/components/Holos/DocumentSection/FacturacionTable.vue
Normal 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' (Sí 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>
|
||||
35
src/components/Holos/DocumentSection/FooterSection.vue
Normal file
35
src/components/Holos/DocumentSection/FooterSection.vue
Normal 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>
|
||||
87
src/components/Holos/DocumentSection/HeaderSection.vue
Normal file
87
src/components/Holos/DocumentSection/HeaderSection.vue
Normal 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>
|
||||
26
src/components/Holos/DocumentSection/ObservationsSection.vue
Normal file
26
src/components/Holos/DocumentSection/ObservationsSection.vue
Normal 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>
|
||||
65
src/components/Holos/DocumentSection/ProductsTableView.vue
Normal file
65
src/components/Holos/DocumentSection/ProductsTableView.vue
Normal 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>
|
||||
117
src/components/Holos/DocumentSection/TotalsSection.vue
Normal file
117
src/components/Holos/DocumentSection/TotalsSection.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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',
|
||||
|
||||
140
src/pages/Templates/Configs/ConfigFacturacion.js
Normal file
140
src/pages/Templates/Configs/ConfigFacturacion.js
Normal 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' },
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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;
|
||||
|
||||
66
src/pages/Templates/DocumentTemplate.vue
Normal file
66
src/pages/Templates/DocumentTemplate.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user