ADD: Plantilla Doc WIP

This commit is contained in:
Juan Felipe Zapata Moreno 2025-10-03 16:30:33 -06:00
parent d7887d028c
commit f277c3677a
8 changed files with 665 additions and 314 deletions

View File

@ -0,0 +1,244 @@
<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

@ -1,42 +0,0 @@
<script setup>
import { RouterLink } from 'vue-router';
import GoogleIcon from '@Shared/GoogleIcon.vue';
defineProps({
template: {
type: Object,
required: true
}
});
</script>
<template>
<div class="bg-white rounded-lg border border-gray-200 p-6 hover:shadow-lg transition-shadow dark:bg-primary-d dark:border-primary/20">
<!-- Icono -->
<div class="flex items-center justify-center w-16 h-16 rounded-lg mb-4 bg-blue-100 dark:bg-blue-900/30">
<GoogleIcon
:name="template.icono"
class="text-3xl text-blue-600 dark:text-blue-400"
/>
</div>
<!-- Información -->
<h3 class="text-lg font-semibold text-gray-900 mb-2 dark:text-primary-dt">
{{ template.nombre }}
</h3>
<p class="text-sm text-gray-600 mb-4 dark:text-primary-dt/70">
{{ template.descripcion }}
</p>
<!-- Botón CON router -->
<RouterLink
:to="{ name: 'admin.templates.form', params: { id: template.id } }"
class="block"
>
<button class="w-full px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
Usar Plantilla
</button>
</RouterLink>
</div>
</template>

View File

@ -1,44 +0,0 @@
import { ref } from 'vue';
import ConfigCotizacion from '../Configs/ConfigCotizacion'
const STORAGE_KEY = 'holos_templates';
export function useTemplateStorage() {
const templates = ref([]);
const loadTemplates = () => {
const stored = localStorage.getItem(STORAGE_KEY);
templates.value = stored ? JSON.parse(stored) : getDefaultTemplates();
console.log('Templates cargados:', templates.value); // DEBUG
return templates.value;
};
const getDefaultTemplates = () => {
const defaults = [
{
id: 'temp-cot-001',
nombre: 'Cotización IP',
descripcion: 'Plantilla de cotización para productos y servicios tecnológicos',
componente: 'TempCot',
config: ConfigCotizacion,
icono: 'description',
color: 'blue',
activa: true,
fechaCreacion: new Date().toISOString()
}
];
console.log('Default templates:', defaults);
return defaults;
};
const getTemplateById = (id) => {
if (templates.value.length === 0) loadTemplates();
return templates.value.find(t => t.id === id);
};
return {
templates,
loadTemplates,
getTemplateById
};
}

View File

@ -1,69 +1,174 @@
export default {
templateId: 'temp-cot-001',
nombre: 'Cotización IP',
nombre: 'Cotización',
branding: {
logo: null,
primaryColor: '#dc2626',
secondaryColor: '#1e40af',
logoPartA: 'GOL',
logoPartB: 'SYSTEMS'
slogan: ' ',
},
campos: [
{
seccion: 'Información del Documento',
campos: [
{
key: 'folio',
label: 'Número de Folio',
tipo: 'text',
required: true,
placeholder: 'COT-2025-001'
},
{
key: 'fechaRealizacion',
label: 'Fecha de Realización',
tipo: 'date',
required: true
},
{
key: 'vigencia',
label: 'Vigencia',
tipo: 'text',
required: true,
placeholder: '30 días'
}
]
},
{
seccion: 'Datos de la Empresa',
campos: [
{
key: 'empresaNombre',
label: 'Nombre',
label: 'Nombre de la Empresa',
tipo: 'text',
required: true,
defaultValue: 'GOLSYSTEMS'
placeholder: 'Ej: GOLSYSTEMS',
},
{
key: 'empresaWeb',
label: 'Sitio Web',
tipo: 'url',
defaultValue: 'www.golsystems.com'
required: false,
placeholder: 'www.ejemplo.com',
},
{
key: 'empresaEmail',
label: 'Email',
tipo: 'email',
required: true,
defaultValue: 'contacto@golsystems.com'
placeholder: 'contacto@ejemplo.com',
},
{
key: 'empresaTelefono',
label: 'Teléfono',
tipo: 'tel',
required: true
required: true,
placeholder: '+52 999 123 4567',
},
{
key: 'empresaRFC',
label: 'RFC',
tipo: 'text',
required: true,
placeholder: 'GME111116GJA',
},
{
key: 'empresaDireccion',
label: 'Dirección',
tipo: 'textarea',
required: true,
placeholder: 'Dirección completa',
}
]
},
{
seccion: 'Datos Bancarios',
campos: [
{
key: 'bancoBanco',
label: 'Banco',
tipo: 'text',
required: false,
placeholder: 'Banco Nacional',
},
{
key: 'bancoTipoCuenta',
label: 'Tipo de Cuenta',
tipo: 'text',
required: false,
placeholder: 'Cuenta de cheques',
},
{
key: 'bancoCuenta',
label: 'Número de Cuenta',
tipo: 'text',
required: false,
placeholder: '1234567890',
}
]
},
{
seccion: 'Datos del Cliente',
campos: [
{
key: 'clienteNombre',
label: 'Nombre del Cliente',
tipo: 'text',
required: true,
placeholder: 'Nombre completo',
},
{
key: 'clienteRFC',
label: 'RFC',
tipo: 'text',
required: false,
placeholder: 'RFC del cliente',
},
{
key: 'clienteDomicilio',
label: 'Domicilio',
tipo: 'text',
required: false,
placeholder: 'Dirección',
},
{
key: 'clienteTelefono',
label: 'Teléfono',
tipo: 'tel',
required: false,
placeholder: '+52 999 123 4567',
}
]
},
{
seccion: 'Datos del Ejecutivo',
campos: [
{
key: 'ejecutivoNombre',
label: 'Nombre del Ejecutivo',
tipo: 'text',
required: true,
placeholder: 'Nombre completo',
},
{
key: 'ejecutivoCorreo',
label: 'Correo',
tipo: 'email',
required: true,
placeholder: 'ejecutivo@ejemplo.com',
},
{
key: 'ejecutivoCelular',
label: 'Celular',
tipo: 'tel',
required: true,
placeholder: '+52 999 123 4567',
}
]
},
{
seccion: 'Detalles del Documento',
campos: [
{
key: 'folio',
label: 'Número de Folio',
tipo: 'text',
required: true,
placeholder: '17016',
},
{
key: 'fechaRealizacion',
label: 'Fecha de Realización',
tipo: 'date',
required: true,
},
{
key: 'vigencia',
label: 'Vigencia',
tipo: 'date',
required: false,
},
{
key: 'observaciones',
label: 'Observaciones',
tipo: 'textarea',
required: false,
placeholder: 'Observaciones adicionales',
}
]
}

View File

@ -1,120 +1,203 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useTemplateStorage } from '@Pages/Templates/Composables/useTemplateStorage';
import { usePDFExport } from '@Pages/Templates/Configs/usePDFExport';
import ConfigCotizacion from '@Pages/Templates/Configs/ConfigCotizacion';
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';
/** Composables */
const route = useRoute();
const router = useRouter();
const { getTemplateById } = useTemplateStorage();
const { exportToPDF, isExporting } = usePDFExport();
/** Usar configuración importada */
const templateConfig = ConfigCotizacion;
/** Estado */
const template = ref(null);
const formData = ref({});
const branding = ref({
documentType: 'COTIZACION',
...templateConfig.branding
});
const productos = ref([]);
const totales = ref({
subtotal1: 0,
descuentoTotal: 0,
subtotal2: 0,
iva: 0,
total: 0
});
/** Computed */
const cotizacionData = computed(() => ({
...formData.value,
template: template.value?.config.branding,
// Mock de productos para ejemplo
productos: [
{
lote: 1,
cantidad: 10,
unidad: 'PZA',
codigo: 'SW-001',
descripcion: 'Licencia de Software Empresarial',
precioUnitario: 1500,
descuento: 100
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
},
{
lote: 2,
cantidad: 5,
unidad: 'PZA',
codigo: 'HW-002',
descripcion: 'Servidor Dell PowerEdge',
precioUnitario: 2500,
descuento: 200
}
],
// Cálculos automáticos
subtotal1: 27500,
descuentoTotal: 300,
subtotal2: 27200,
iva: 4352,
total: 31552
}));
productos: productos.value,
...totales.value
};
});
/** Métodos */
const initializeForm = () => {
if (!template.value) return;
const data = {};
template.value.config.campos.forEach(seccion => {
templateConfig.campos.forEach(seccion => {
seccion.campos.forEach(campo => {
data[campo.key] = campo.defaultValue || '';
});
});
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;
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 };
};
const handleExport = () => {
exportToPDF('template-preview', `${template.value.nombre}.pdf`);
exportToPDF('template-preview', `${branding.value.documentType}-${formData.value.folio || 'documento'}.pdf`);
};
/** Ciclos */
onMounted(() => {
template.value = getTemplateById(route.params.id);
console.log('Template cargado:', template.value); // DEBUG
if (template.value) {
initializeForm();
} else {
Notify.error('Plantilla no encontrada');
router.push({ name: 'admin.templates.index' });
}
initializeForm();
});
</script>
<template>
<div class="p-6">
<!-- Breadcrumb -->
<div class="mb-6">
<RouterLink
:to="{ name: 'admin.templates.index' }"
class="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-blue-600 transition-colors dark:text-primary-dt/70"
>
<GoogleIcon name="arrow_back" class="text-lg" />
Volver a plantillas
</RouterLink>
<!-- Header -->
<div class="mb-6 flex items-center justify-between 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">
Personaliza y genera documentos profesionales
</p>
</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>
<option value="NOTA DE VENTA">Nota de Venta</option>
<option value="PRESUPUESTO">Presupuesto</option>
<option value="ORDEN DE COMPRA">Orden de Compra</option>
</select>
</div>
</div>
<div v-if="template" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- FORMULARIO (Columna Izquierda) -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- FORMULARIO -->
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-primary-dt">
{{ template.nombre }}
</h2>
<p class="text-sm text-gray-600 dark:text-primary-dt/70">
{{ template.descripcion }}
</p>
<!-- 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>
<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 template.config.campos"
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"
>
@ -147,6 +230,12 @@ onMounted(() => {
</div>
</div>
<!-- Tabla de Productos -->
<ProductTable
v-model="productos"
@update:totals="handleTotalsUpdate"
/>
<!-- Botón de exportar (móvil) -->
<div class="lg:hidden">
<PrimaryButton
@ -160,14 +249,13 @@ onMounted(() => {
</div>
</div>
<!-- VISTA PREVIA (Columna Derecha) -->
<!-- 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>
<!-- Botón de exportar (desktop) -->
<div class="hidden lg:block">
<PrimaryButton
@click="handleExport"
@ -180,7 +268,6 @@ onMounted(() => {
</div>
</div>
<!-- Plantilla renderizada -->
<div
class="border rounded-lg overflow-auto bg-gray-50 dark:bg-gray-900 flex justify-center p-4"
style="max-height: 80vh;"

View File

@ -1,54 +0,0 @@
<script setup>
import { onMounted } from 'vue';
import { useTemplateStorage } from './Composables/useTemplateStorage';
import TemplateCard from '@Holos/TemplateCard.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Composables */
const { templates, loadTemplates } = useTemplateStorage();
/** Ciclos */
onMounted(() => {
loadTemplates();
});
</script>
<template>
<div class="p-6 max-w-7xl mx-auto">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-3 mb-2">
<GoogleIcon name="description" class="text-3xl text-blue-600" />
<h1 class="text-3xl font-bold text-gray-900 dark:text-primary-dt">
Plantillas de Documentos
</h1>
</div>
<p class="text-gray-600 dark:text-primary-dt/70">
Selecciona una plantilla para generar tu documento
</p>
</div>
<!-- Grid de plantillas -->
<div v-if="templates.length > 0"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<TemplateCard
v-for="template in templates"
:key="template.id"
:template="template"
/>
</div>
<!-- Estado vacío -->
<div v-else
class="flex flex-col items-center justify-center py-16 text-center">
<GoogleIcon name="insert_drive_file" class="text-6xl text-gray-300 mb-4" />
<h3 class="text-lg font-medium text-gray-900 mb-2 dark:text-primary-dt">
No hay plantillas disponibles
</h3>
<p class="text-gray-600 dark:text-primary-dt/70">
Las plantillas se cargarán automáticamente
</p>
</div>
</div>
</template>

View File

@ -1,41 +1,66 @@
<script setup>
defineProps({
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;"
style="width: 100%; font-family: Arial, sans-serif; box-sizing: border-box"
>
<!-- Header -->
<div
class="flex justify-between items-start border-b-2 border-blue-700 pb-2 mb-3"
class="flex justify-between items-start pb-2 mb-3 border-b-2"
:style="{ borderColor: template.primaryColor }"
>
<div>
<h1 class="text-2xl font-bold">
<span :style="{ color: template?.primaryColor }">{{
template?.logoPartA
}}</span>
<span :style="{ color: template?.secondaryColor }">{{
template?.logoPartB
}}</span>
</h1>
<img v-if="template?.logo" :src="template.logo" alt="Logo" class="h-8" />
<p class="text-gray-600">
Optimizando lasTIC's en las empresas
</p>
<!-- 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>
@ -46,18 +71,25 @@ const formatCurrency = (value) => {
</div>
<div class="text-right">
<h2 class="text-xl font-bold text-blue-600 mb-1">COTIZACION IP</h2>
<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">Numero de Folio:</span>
<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>
<span class="font-semibold">Vigencia de Cotización:</span>
<p v-if="template.documentType !== 'FACTURA'">
<span class="font-semibold">Vigencia:</span>
{{ cotizacion.vigencia }}
</p>
</div>
@ -81,7 +113,10 @@ const formatCurrency = (value) => {
<!-- Cliente -->
<div class="mb-3">
<div class="bg-blue-600 text-white font-semibold px-2 py-0.5">
<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">
@ -103,10 +138,13 @@ const formatCurrency = (value) => {
</div>
</div>
<!-- Ejecutivo -->
<!-- Ejecutivo y Observaciones -->
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<div class="bg-blue-600 text-white font-semibold px-2 py-0.5">
<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">
@ -126,7 +164,10 @@ const formatCurrency = (value) => {
</div>
<div>
<div class="bg-blue-600 text-white font-semibold px-2 py-0.5">
<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]">
@ -138,7 +179,10 @@ const formatCurrency = (value) => {
<!-- Tabla de Productos -->
<table class="w-full border-collapse mb-3">
<thead>
<tr class="bg-blue-600 text-white">
<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>
@ -156,15 +200,9 @@ const formatCurrency = (value) => {
: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 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">
@ -177,11 +215,7 @@ const formatCurrency = (value) => {
{{ 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
)
}}
{{ formatCurrency(producto.cantidad * producto.precioUnitario - producto.descuento) }}
</td>
</tr>
</tbody>
@ -189,40 +223,73 @@ const formatCurrency = (value) => {
<!-- Totales -->
<div class="flex justify-end mb-3">
<div class="border border-blue-600 w-56">
<div
class="border w-56"
:style="{ borderColor: template.primaryColor }"
>
<div class="grid grid-cols-2">
<div class="bg-blue-600 text-white font-semibold px-2 py-1 text-right">
<div
class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }"
>
SUBTOTAL 1:
</div>
<div class="border-l border-blue-600 px-2 py-1 text-right whitespace-nowrap">
<div
class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }"
>
{{ formatCurrency(cotizacion.subtotal1) }}
</div>
<div class="bg-blue-600 text-white font-semibold px-2 py-1 text-right">
<div
class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }"
>
DESCUENTO:
</div>
<div class="border-l border-blue-600 px-2 py-1 text-right whitespace-nowrap">
<div
class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }"
>
{{ formatCurrency(cotizacion.descuentoTotal) }}
</div>
<div class="bg-blue-600 text-white font-semibold px-2 py-1 text-right">
<div
class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }"
>
SUBTOTAL 2:
</div>
<div class="border-l border-blue-600 px-2 py-1 text-right whitespace-nowrap">
<div
class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }"
>
{{ formatCurrency(cotizacion.subtotal2) }}
</div>
<div class="bg-blue-600 text-white font-semibold px-2 py-1 text-right">
<div
class="text-white font-semibold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }"
>
I.V.A.
</div>
<div class="border-l border-blue-600 px-2 py-1 text-right whitespace-nowrap">
<div
class="px-2 py-1 text-right whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }"
>
{{ formatCurrency(cotizacion.iva) }}
</div>
<div class="bg-blue-600 text-white font-bold px-2 py-1 text-right">
<div
class="text-white font-bold px-2 py-1 text-right"
:style="{ backgroundColor: template.primaryColor }"
>
TOTAL
</div>
<div class="border-l border-blue-600 px-2 py-1 text-right font-bold whitespace-nowrap">
<div
class="px-2 py-1 text-right font-bold whitespace-nowrap border-l"
:style="{ borderColor: template.primaryColor }"
>
{{ formatCurrency(cotizacion.total) }}
</div>
</div>
@ -235,16 +302,16 @@ const formatCurrency = (value) => {
</p>
<div
class="bg-blue-600 text-white font-semibold px-2 py-0.5 text-center mb-1"
class="text-white font-semibold px-2 py-0.5 text-center mb-1"
:style="{ backgroundColor: template.primaryColor }"
>
CERTIFICACIONES Y PARTNERS
</div>
<!-- Logos de Partners (puedes agregar imágenes reales) -->
<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>
</template>

View File

@ -374,24 +374,12 @@ const router = createRouter({
},
{
path: 'templates',
name: 'admin.template',
name: 'admin.templates.index',
meta: {
title: 'Plantillas',
icon: 'templates',
},
redirect: '/admin/templates',
children: [
{
path: '',
name: 'admin.templates.index',
component: () => import('@Pages/Templates/Index.vue'),
},
{
path: ':id/fill',
name: 'admin.templates.form',
component: () => import('@Pages/Templates/Form.vue'),
}
]
component: () => import('@Pages/Templates/Form.vue'),
},
]
},