ADD: Plantillas WIP

This commit is contained in:
Juan Felipe Zapata Moreno 2025-10-02 16:38:45 -06:00
parent 4518be3887
commit d7887d028c
14 changed files with 1018 additions and 31 deletions

87
package-lock.json generated
View File

@ -12,6 +12,10 @@
"@tailwindcss/postcss": "^4.0.9",
"@tailwindcss/vite": "^4.0.9",
"@tiptap/extension-color": "^3.5.2",
"@tiptap/extension-table": "^3.6.2",
"@tiptap/extension-table-cell": "^3.6.2",
"@tiptap/extension-table-header": "^3.6.2",
"@tiptap/extension-table-row": "^3.6.2",
"@tiptap/extension-text-align": "^3.5.2",
"@tiptap/extension-text-style": "^3.5.2",
"@tiptap/extension-underline": "^3.5.2",
@ -21,6 +25,8 @@
"@vuepic/vue-datepicker": "^11.0.2",
"apexcharts": "^5.3.5",
"axios": "^1.8.1",
"html2canvas-pro": "^1.5.11",
"html2pdf.js": "^0.12.1",
"jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2",
"laravel-echo": "^2.0.2",
@ -1684,6 +1690,59 @@
"@tiptap/core": "^3.6.1"
}
},
"node_modules/@tiptap/extension-table": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.6.2.tgz",
"integrity": "sha512-ozRPpxTXrYABTU/zQq3JlytUUXvQDaEcl19YUR1mL/7Ctf4zRBvSnBHCuP/1Cu+4oHX4zdako/G++Z5qJxa65A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.6.2",
"@tiptap/pm": "^3.6.2"
}
},
"node_modules/@tiptap/extension-table-cell": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.6.2.tgz",
"integrity": "sha512-0mYEVy8YtHVRD781SA6pdQN3ICnRfUaVNwLLP8BJv32qLKUX4akK4Xtd+h3XQ5PY6uqBtiVKLiEfpuLeBVpvZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "^3.6.2"
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.6.2.tgz",
"integrity": "sha512-D9J0fzVBgZQQds8BQXb1dm02u9CLG90NGuLWz42kWoddViNmXkzZFJeUmsksGiQmM9s1Uqq87m3KpXcFgy8uvw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "^3.6.2"
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.6.2.tgz",
"integrity": "sha512-2PxDZ0DjopWUoxgP9BaMy7v86DMHB158KA/QktBt976zpLUiZ9JPLHezbSqADjt+vaWXesnjZk2iHw2Kgd+zFQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "^3.6.2"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.6.1.tgz",
@ -2181,7 +2240,6 @@
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
@ -2447,7 +2505,6 @@
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
@ -3169,7 +3226,6 @@
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
@ -3178,6 +3234,29 @@
"node": ">=8.0.0"
}
},
"node_modules/html2canvas-pro": {
"version": "1.5.11",
"resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.11.tgz",
"integrity": "sha512-W4pEeKLG8+9a54RDOSiEKq7gRXXDzt0ORMaLXX+l6a3urSKbmnkmyzcRDCtgTOzmHLaZTLG2wiTQMJqKLlSh3w==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/html2pdf.js": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/html2pdf.js/-/html2pdf.js-0.12.1.tgz",
"integrity": "sha512-3rBWQ96H5oOU9jtoz3MnE/epGi27ig9h8aonBk4JTpvUERM3lMRxhIRckhJZEi4wE0YfRINoYOIDY0hLY0CHgQ==",
"license": "MIT",
"dependencies": {
"html2canvas": "^1.0.0",
"jspdf": "^3.0.0"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
@ -4470,7 +4549,6 @@
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
@ -4655,7 +4733,6 @@
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}

View File

@ -14,6 +14,10 @@
"@tailwindcss/postcss": "^4.0.9",
"@tailwindcss/vite": "^4.0.9",
"@tiptap/extension-color": "^3.5.2",
"@tiptap/extension-table": "^3.6.2",
"@tiptap/extension-table-cell": "^3.6.2",
"@tiptap/extension-table-header": "^3.6.2",
"@tiptap/extension-table-row": "^3.6.2",
"@tiptap/extension-text-align": "^3.5.2",
"@tiptap/extension-text-style": "^3.5.2",
"@tiptap/extension-underline": "^3.5.2",
@ -23,6 +27,8 @@
"@vuepic/vue-datepicker": "^11.0.2",
"apexcharts": "^5.3.5",
"axios": "^1.8.1",
"html2canvas-pro": "^1.5.11",
"html2pdf.js": "^0.12.1",
"jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2",
"laravel-echo": "^2.0.2",

View File

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

@ -0,0 +1,155 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useTemplateStorage } from '@Pages/Templates/Composables/useTemplateStorage';
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';
/** Props */
const props = defineProps({
templateId: {
type: String,
required: true
}
});
/** Composables */
const router = useRouter();
const { getTemplateById } = useTemplateStorage();
/** Estado */
const template = ref(null);
const formData = ref({});
/** Computed */
const allFieldsFilled = computed(() => {
if (!template.value) return false;
const requiredFields = template.value.config.campos
.flatMap(seccion => seccion.campos)
.filter(campo => campo.required);
return requiredFields.every(campo => formData.value[campo.key]);
});
/** Métodos */
const initializeForm = () => {
if (!template.value) return;
const data = {};
template.value.config.campos.forEach(seccion => {
seccion.campos.forEach(campo => {
data[campo.key] = campo.defaultValue || '';
});
});
formData.value = data;
};
const handleSubmit = () => {
if (!allFieldsFilled.value) {
Notify.warning('Por favor completa todos los campos requeridos');
return;
}
console.log('Datos del formulario:', formData.value);
Notify.success('Formulario completado');
router.push({
name: 'admin.templates.preview',
params: { id: template.value.id },
query: { data: JSON.stringify(formData.value) }
});
};
/** Ciclos */
onMounted(() => {
template.value = getTemplateById(props.templateId);
if (template.value) {
initializeForm();
} else {
Notify.error('Plantilla no encontrada');
router.push({ name: 'admin.templates.index' });
}
});
</script>
<template>
<div v-if="template" class="max-w-4xl mx-auto">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
<GoogleIcon :name="template.icono" class="text-2xl text-blue-600" />
<h2 class="text-2xl font-bold text-gray-900 dark:text-primary-dt">
{{ template.nombre }}
</h2>
</div>
<p class="text-sm text-gray-600 dark:text-primary-dt/70">
{{ template.descripcion }}
</p>
</div>
<!-- Formulario -->
<form @submit.prevent="handleSubmit" class="space-y-8">
<!-- Secciones dinámicas -->
<div
v-for="seccion in template.config.campos"
:key="seccion.seccion"
class="bg-white rounded-lg border border-gray-200 p-6 dark:bg-primary-d dark:border-primary/20"
>
<h3 class="text-lg font-semibold text-gray-900 mb-4 dark:text-primary-dt">
{{ seccion.seccion }}
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Campos individuales -->
<template v-for="campo in seccion.campos" :key="campo.key">
<!-- Input de texto -->
<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"
:class="campo.tipo === 'date' ? 'col-span-1' : ''"
/>
<!-- Textarea -->
<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>
<!-- Botones de acción -->
<div class="flex gap-4 justify-end">
<button
type="button"
@click="router.push({ name: 'admin.templates.index' })"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors dark:border-primary/20 dark:text-primary-dt dark:hover:bg-primary/10"
>
Cancelar
</button>
<PrimaryButton
type="submit"
:disabled="!allFieldsFilled"
class="px-6 py-2"
>
Generar Documento
</PrimaryButton>
</div>
</form>
</div>
</template>

View File

@ -1,6 +1,5 @@
@import "tailwindcss";
@import "../../colors.css";
@import "./univer.css";
@custom-variant dark (&:where(.dark, .dark *));
@theme {

View File

@ -161,6 +161,12 @@ onMounted(() => {
name="Maquetador de Documentos"
to="admin.maquetador.index"
/>
<Link
v-if="hasPermission('activities.index')"
icon="event"
name="Plantillas"
to="admin.templates.index"
/>
</Section>
</template>
<!-- Contenido -->

View File

@ -1,22 +0,0 @@
<script setup>
import UniverDoc from '@Holos/Editor/UniverDoc.vue';
</script>
<template>
<div class="p-6">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-primary-dt">
Editor de Documentos
</h1>
<p class="text-gray-600 dark:text-primary-dt/70">
Editor avanzado de documentos con Univer
</p>
</div>
<div class="bg-white dark:bg-primary-d rounded-lg shadow-sm border border-gray-200 dark:border-primary/20">
<div class="h-[600px] p-4">
<UniverDoc />
</div>
</div>
</div>
</template>

View File

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

@ -0,0 +1,71 @@
export default {
templateId: 'temp-cot-001',
nombre: 'Cotización IP',
branding: {
logo: null,
primaryColor: '#dc2626',
secondaryColor: '#1e40af',
logoPartA: 'GOL',
logoPartB: 'SYSTEMS'
},
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',
tipo: 'text',
required: true,
defaultValue: 'GOLSYSTEMS'
},
{
key: 'empresaWeb',
label: 'Sitio Web',
tipo: 'url',
defaultValue: 'www.golsystems.com'
},
{
key: 'empresaEmail',
label: 'Email',
tipo: 'email',
required: true,
defaultValue: 'contacto@golsystems.com'
},
{
key: 'empresaTelefono',
label: 'Teléfono',
tipo: 'tel',
required: true
}
]
}
]
};

View File

@ -0,0 +1,85 @@
import { ref } from 'vue';
import html2canvas from 'html2canvas-pro';
import { jsPDF } from 'jspdf';
export function usePDFExport() {
const isExporting = ref(false);
const exportToPDF = async (elementId, filename = 'documento.pdf') => {
isExporting.value = true;
try {
const element = document.getElementById(elementId);
if (!element) {
throw new Error('Elemento no encontrado');
}
// Capturar el elemento exacto (210mm x 297mm)
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
allowTaint: false,
imageTimeout: 30000,
width: element.offsetWidth,
height: element.offsetHeight,
ignoreElements: (element) => {
return element.classList?.contains('no-pdf');
},
onclone: (clonedDoc) => {
const allElements = clonedDoc.querySelectorAll('*');
allElements.forEach(el => {
if (!(el instanceof Element)) return;
const computedStyle = window.getComputedStyle(el);
// Aplicar colores como inline styles
if (computedStyle.color) {
el.style.color = computedStyle.color;
}
if (computedStyle.backgroundColor) {
el.style.backgroundColor = computedStyle.backgroundColor;
}
if (computedStyle.borderColor) {
el.style.borderColor = computedStyle.borderColor;
}
});
}
});
const imgData = canvas.toDataURL('image/png', 1.0);
// Orientación vertical A4
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
compress: true
});
const pdfWidth = 210; // A4 portrait width
const pdfHeight = 297; // A4 portrait height
// Ajustar imagen al tamaño completo de la página (sin márgenes)
// para que ocupe toda la hoja A4
pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, pdfHeight, '', 'FAST');
// Guardar PDF
pdf.save(filename);
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;
}
};
return {
exportToPDF,
isExporting
};
}

View File

@ -0,0 +1,199 @@
<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 TempCot from './Temp-Cot.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();
/** Estado */
const template = ref(null);
const formData = ref({});
/** 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
},
{
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
}));
/** Métodos */
const initializeForm = () => {
if (!template.value) return;
const data = {};
template.value.config.campos.forEach(seccion => {
seccion.campos.forEach(campo => {
data[campo.key] = campo.defaultValue || '';
});
});
formData.value = data;
};
const handleExport = () => {
exportToPDF('template-preview', `${template.value.nombre}.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' });
}
});
</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>
</div>
<div v-if="template" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- FORMULARIO (Columna Izquierda) -->
<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>
</div>
</div>
<!-- Secciones del formulario -->
<div
v-for="seccion in template.config.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>
<!-- 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 (Columna Derecha) -->
<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"
:disabled="isExporting"
class="px-4 py-2"
>
<GoogleIcon name="picture_as_pdf" class="mr-2 text-sm" />
{{ isExporting ? 'Generando...' : 'Exportar PDF' }}
</PrimaryButton>
</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;"
>
<div
id="template-preview"
class="shadow-lg"
style="width: 210mm; height: 297mm; overflow: hidden;"
>
<TempCot :cotizacion="cotizacionData" />
</div>
</div>
</div>
</div>
</div>
</template>

View File

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

@ -0,0 +1,250 @@
<script setup>
defineProps({
cotizacion: {
type: Object,
required: true,
},
});
const formatCurrency = (value) => {
return new Intl.NumberFormat("es-MX", {
style: "currency",
currency: "MXN",
}).format(value);
};
</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 border-b-2 border-blue-700 pb-2 mb-3"
>
<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>
<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 text-blue-600 mb-1">COTIZACION IP</h2>
<div class="space-y-0.5">
<p>
<span class="font-semibold">Numero de Folio:</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>
{{ 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="bg-blue-600 text-white font-semibold px-2 py-0.5">
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 -->
<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">
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="bg-blue-600 text-white font-semibold px-2 py-0.5">
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="bg-blue-600 text-white">
<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 border-blue-600 w-56">
<div class="grid grid-cols-2">
<div class="bg-blue-600 text-white font-semibold px-2 py-1 text-right">
SUBTOTAL 1:
</div>
<div class="border-l border-blue-600 px-2 py-1 text-right whitespace-nowrap">
{{ formatCurrency(cotizacion.subtotal1) }}
</div>
<div class="bg-blue-600 text-white font-semibold px-2 py-1 text-right">
DESCUENTO:
</div>
<div class="border-l border-blue-600 px-2 py-1 text-right whitespace-nowrap">
{{ formatCurrency(cotizacion.descuentoTotal) }}
</div>
<div class="bg-blue-600 text-white font-semibold px-2 py-1 text-right">
SUBTOTAL 2:
</div>
<div class="border-l border-blue-600 px-2 py-1 text-right whitespace-nowrap">
{{ formatCurrency(cotizacion.subtotal2) }}
</div>
<div class="bg-blue-600 text-white font-semibold px-2 py-1 text-right">
I.V.A.
</div>
<div class="border-l border-blue-600 px-2 py-1 text-right whitespace-nowrap">
{{ formatCurrency(cotizacion.iva) }}
</div>
<div class="bg-blue-600 text-white font-bold px-2 py-1 text-right">
TOTAL
</div>
<div class="border-l border-blue-600 px-2 py-1 text-right font-bold whitespace-nowrap">
{{ 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="bg-blue-600 text-white font-semibold px-2 py-0.5 text-center mb-1"
>
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>

View File

@ -357,13 +357,13 @@ const router = createRouter({
]
},
{
path: 'documentation',
name: 'admin.documentation',
path: 'maquetador',
name: 'admin.maquetador',
meta: {
title: 'Maquetador de Documentos',
icon: 'documents',
},
redirect: '/admin/documentation',
redirect: '/admin/maquetador',
children: [
{
path: '',
@ -372,6 +372,27 @@ const router = createRouter({
},
]
},
{
path: 'templates',
name: 'admin.template',
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'),
}
]
},
]
},
{